lifecycle

package
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 18, 2026 License: Apache-2.0 Imports: 31 Imported by: 0

Documentation

Overview

Package lifecycle is where data stops existing: the hard delete, and the rules that decide when it is allowed (ADR-0020, data-retention.md).

Trashing and archiving live in the work context, because they are changes to an entry - a stamp on the aggregate, reversible, with the entry still there afterwards. What lives here is the operation that has no aggregate left when it is done, and whose rules come from somewhere else entirely: a tenant's configured period, a legal obligation, and the state of every device that ever held a copy.

Index

Constants

View Source
const (
	PlaceLegalHoldName   = "PlaceLegalHold"
	ReleaseLegalHoldName = "ReleaseLegalHold"
	ListLegalHoldsName   = "ListLegalHolds"

	// HoldPlacedAction and HoldReleasedAction are the two entries §4.1 asks for. Both warnings:
	// placing one overrides the workspace's own configured periods, and lifting one is the moment
	// the data under it becomes deletable again. Neither is the machinery doing what it was told -
	// they are both somebody deciding something.
	HoldPlacedAction   audit.Action = "lifecycle.hold_placed"
	HoldReleasedAction audit.Action = "lifecycle.hold_released"
)
View Source
const (
	BlockedByLegalHold       = domain.BlockedByLegalHold
	BlockedByTombstoneWindow = domain.BlockedByTombstoneWindow
)

The reasons a removal can be refused, kept here as the names this package has always used and defined where the precedence of data-retention.md §4 is - in the domain, because "which reason wins" is a rule about the data rather than a detail of the engine.

View Source
const (
	PurgeWorkItemName = "PurgeWorkItem"
	EmptyTrashName    = "EmptyTrash"

	// The audit codes. Both are warnings where the rest of the lifecycle is an info or a notice:
	// nothing else in this system destroys data, and this is the entry somebody is looking for when
	// work has gone and nobody knows where (audit.md §2).
	ItemPurgedAction   audit.Action = "item.purged"
	TrashEmptiedAction audit.Action = "trash.emptied"
)
View Source
const (
	ReplaceLegalHoldsName = "ReplaceLegalHolds"

	// HoldReplacedAction is the entry a hold placed again after a point-in-time recovery leaves in
	// its workspace's trail. Its own action rather than `lifecycle.hold_placed`: the hold was placed
	// by its owner, at its own moment, and that entry was rewound away with it - this one records
	// that the operator put it back, and from which recovery.
	HoldReplacedAction audit.Action = "lifecycle.hold_replaced"
)
View Source
const (
	RetainItemName = "RetainItem"

	// RetainedAction is somebody taking an entry out of a running retention period. A warning
	// rather than an info: it is a decision that keeps data the workspace's own rule said should
	// go, and data-retention.md §5 makes taking an object out part of the procedure rather than an
	// exception to it - which means it has to be visible.
	RetainedAction audit.Action = "lifecycle.retained"
)
View Source
const (
	UpdateRetentionPolicyName = "UpdateRetentionPolicy"
	DeleteRetentionPolicyName = "DeleteRetentionPolicy"

	// RuleWithdrawnAction is its own code beside `rule_changed`, for the reason a removal always
	// is: "somebody corrected the rule" and "somebody withdrew it" are two different findings, and
	// the second is the one a reader of the trail looks for after data stopped disappearing.
	RuleWithdrawnAction audit.Action = "lifecycle.rule_withdrawn"
)

A rule that deletes data has to be correctable and withdrawable. Until this, one could be written and then only read.

View Source
const (
	CreateRetentionPolicyName  = "CreateRetentionPolicy"
	ListRetentionPoliciesName  = "ListRetentionPolicies"
	PreviewRetentionPolicyName = "PreviewRetentionPolicy"

	// RuleChangedAction is the entry data-retention.md §4.4 asks for and more besides. A warning
	// rather than an info: a rule is a standing instruction to delete somebody's work, and "who
	// decided that things go after ninety days" is a question with an answer.
	RuleChangedAction audit.Action = "lifecycle.rule_changed"
)
View Source
const BroadFirstRunShare = 0.05

BroadFirstRunShare is §5's five per cent: "A new rule always starts in NOTIFY_ONLY mode if its first run would affect more than 5% of the holdings - with a clear notice rather than a silent mass deletion."

View Source
const DefaultReadBudgetPages = 10

DefaultReadBudgetPages is how many batches one read judges per pass by default: ten thousand rows, which the slowest read measured (a condition naming `item`) judges in a few seconds - well inside the job's deadline with every reader of a pass beside it.

View Source
const DefaultSweepBatch = 1000

DefaultSweepBatch is the thousand objects per transaction data-retention.md §5 asks for.

View Source
const RetentionRunAction audit.Action = "retention.executed"

RetentionRunAction is the audit code of a run that removed something.

View Source
const SampleSize = 10

SampleSize is how many objects a preview shows. Enough to recognise what a rule has caught, few enough that the answer is a page rather than an export.

Variables

This section is empty.

Functions

This section is empty.

Types

type ArchiveSource

type ArchiveSource struct {
	TargetID shared.ID
	Path     string
}

ArchiveSource names one archive at one target.

type ArchivedHolds

type ArchivedHolds struct {
	Holds []HoldRecord
	Path  string
	// SnapshotAt is when the archive was taken: a hold placed between it and the loss is not in
	// it, which is the gap the operator reports per workspace.
	SnapshotAt time.Time
	// AuditTrail is whether the archive includes the trail, whose entries name the holds its own
	// copy of the workspace no longer carries.
	AuditTrail bool
	// UnreadableEntries counts the records of a hold, in the archive's copy or its trail, that name
	// too little to place it again. Left out rather than refused: one damaged record must not cost
	// the other holds.
	UnreadableEntries int
}

ArchivedHolds is what an archive gave back, and how far it reaches.

type Authorizer

type Authorizer interface {
	Authorize(ctx context.Context, actor appshared.ActorContext, request access.Request) error
}

Authorizer is the slice of the authorisation service these use cases need. Declared here rather than imported from the work context, so that a use case in this package depends on the question it asks and not on a neighbouring context's interface list.

type CreateRetentionPolicy

type CreateRetentionPolicy struct{ Rules Rules }

CreateRetentionPolicy writes down what this workspace deletes and when.

func (CreateRetentionPolicy) Descriptor

func (h CreateRetentionPolicy) Descriptor() usecase.Descriptor

Descriptor registers writing a rule down in all three channels.

func (CreateRetentionPolicy) Execute

Execute writes the rule down.

§5's switch is applied here rather than at the first run, and that is what "starts in NOTIFY_ONLY" means: the rule that gets stored is the safe one, so an installation that never runs the engine still cannot have a broad rule waiting to fire. What a caller gets back says which mode it is in, which is the "clear notice" the document asks for.

type CreateRetentionPolicyCommand

type CreateRetentionPolicyCommand struct {
	Scope          domain.Scope
	DataKind       domain.DataKind
	Condition      string
	RetainDays     int
	Action         domain.Action
	ThenAfterDays  int
	ThenAction     domain.Action
	GraceDays      *int
	Notify         *domain.Notify
	Justification  string
	Enabled        *bool
	ExportTargetID shared.ID
}

CreateRetentionPolicyCommand is the input, typed.

type DeleteRetentionPolicy

type DeleteRetentionPolicy struct{ Rules Rules }

DeleteRetentionPolicy withdraws a rule.

func (DeleteRetentionPolicy) Descriptor

func (h DeleteRetentionPolicy) Descriptor() usecase.Descriptor

func (DeleteRetentionPolicy) Execute

Execute withdraws it, and takes what it had marked out of its period.

What the rule already did stands: retention deletes, and a deletion is not undone by withdrawing the instruction that caused it. What is undone is only the countdown - an entry waiting for a rule nobody holds any more would otherwise be deleted by a rule that does not exist.

type DispatchedEvents

type DispatchedEvents interface {
	DeleteExpired(ctx context.Context, cutoff time.Time, batch int) (int, error)
	CountExpired(ctx context.Context, cutoff time.Time, ceiling int) (int, error)

	// DeleteExpiredConsumption removes the record of who has already consumed what. The outbox's
	// twin table, swept at the same period and by the same pass: a record whose event has been
	// swept can say nothing about an event nobody can deliver again (ADR-0007).
	DeleteExpiredConsumption(ctx context.Context, cutoff time.Time, batch int) (int, error)
}

DispatchedEvents is the slice of the outbox this run removes through (ADR-0007's second countermeasure). The same two methods as the notification history, and deliberately the same shape: the engine treats a third kind exactly as it treats the second.

What the interface does not offer is a way to remove an *undispatched* event. That guard lives in the query rather than in a parameter, because it is not a policy an engine could get wrong once and a tenant could configure away - a row nobody has consumed is never due.

type EffectiveRule

type EffectiveRule struct {
	Rule domain.Rule
	// InForce is whether this is the rule that would act on an object in the container asked
	// about. False for one a narrower rule beats, and for one that is switched off.
	InForce bool
}

EffectiveRule is one rule and where it came from, which is what §6's question needs: "which rules actually apply here?", including where each came from.

type EmptyTrash

type EmptyTrash struct {
	Purger     Purger
	Authorizer Authorizer
	UnitOfWork persistence.UnitOfWork
}

EmptyTrash removes everything in the trash for good, in one pass.

The owner's right to delete a container, because that is what it may reach: a trash holds hubs and collections as readily as entries, and somebody who may not delete a hub must not be able to delete one by emptying a trash it happens to be in (domain-model.md §3.2).

func (EmptyTrash) Descriptor

func (h EmptyTrash) Descriptor() usecase.Descriptor

func (EmptyTrash) Execute

func (h EmptyTrash) Execute(
	ctx context.Context, actor appshared.ActorContext,
) (Outcome, error)

Execute empties the trash and reports what it removed.

type ErasureRemainders

type ErasureRemainders interface {
	// Seed queues the rest of every erasure the hold kept part of, in the caller's transaction.
	Seed(ctx context.Context, tenantID, holdID shared.ID) error
	// Reconcile queues the rest of every erasure kept under a hold no longer in force.
	Reconcile(ctx context.Context, tenantID shared.ID, active domain.Holds) error
}

ErasureRemainders is the erasure's side of a hold being lifted (data-protection.md §4.1): an erasure a hold kept part of continues once the hold no longer keeps it. Declared here so this package knows that much and no more; the privacy context implements it.

type ExpiredDevices

type ExpiredDevices interface {
	DeleteExpired(ctx context.Context, cutoff time.Time, batch int) (int, error)
	CountExpired(ctx context.Context, cutoff time.Time, ceiling int) (int, error)
}

ExpiredDevices is the slice of the device repository this run removes through (offline-sync.md §6): the session's two methods, and the same shape on purpose.

type ExpiredIdempotencyKeys

type ExpiredIdempotencyKeys interface {
	DeleteExpired(ctx context.Context, cutoff time.Time, batch int) (int, error)
	CountExpired(ctx context.Context, cutoff time.Time, ceiling int) (int, error)
}

ExpiredIdempotencyKeys is the slice of the idempotency store this run removes through (api-guidelines.md §5): the session's two methods, and the same shape on purpose.

type ExpiredSessions

type ExpiredSessions interface {
	DeleteExpired(ctx context.Context, cutoff time.Time, batch int) (int, error)
	CountExpired(ctx context.Context, cutoff time.Time, ceiling int) (int, error)
}

ExpiredSessions is the slice of the session repository this run removes through. The same two methods as the notification history, and deliberately the same shape: the engine treats a fifth kind exactly as it treats the second.

Which sessions are due is the query's business rather than a parameter, for the reason the outbox's dispatched guard is: only a session that is already over - run out or revoked - is ever removed, whatever the period says, because ending sign-ins is revocation's job and the engine's job is forgetting.

type ExpiredSyncLog

type ExpiredSyncLog interface {
	DeleteExpired(ctx context.Context, cutoff time.Time, batch int) (int, error)
	CountExpired(ctx context.Context, cutoff time.Time, ceiling int) (int, error)
}

ExpiredSyncLog is the slice of the synchronisation's log this run removes through (offline-sync.md §7): the operation log rows and the tombstones past the window, in one pass per batch, the device's two methods and the same shape on purpose.

type ExpiringDeliveries

type ExpiringDeliveries interface{ ExpiringRows }

ExpiringDeliveries is the slice of the webhook delivery log this run removes through: the jumble's two methods, and which attempts are due is the statement's business - a pending attempt a living job still names is never due.

type ExpiringRows

type ExpiringRows interface {
	DeleteExpired(ctx context.Context, cutoff time.Time, batch int) (int, error)
	CountExpired(ctx context.Context, cutoff time.Time, ceiling int) (int, error)
}

ExpiringRows is a kind removed by count and batch, whose due rows its statement decides.

type ExpiringSuggestions

type ExpiringSuggestions interface {
	DeleteExpired(ctx context.Context, cutoff time.Time, batch int) (int, error)
	CountExpired(ctx context.Context, cutoff time.Time, ceiling int) (int, error)
}

ExpiringSuggestions is the slice of the suggestion store this run removes through.

The same two methods as the jumble, the notification history and the outbox, and deliberately the same shape: the engine treats a fifth kind exactly as it treats the second.

type ExportBeforeDelete

type ExportBeforeDelete interface {
	// Export writes one archive of the tenant to the target and answers the run it produced.
	//
	// The whole tenant rather than the objects going: the archive format's scope is a tenant or a
	// container (backup-restore.md §3), and an archive of "these forty entries" is not a thing it
	// can write. What §6 asks for is that the data exists somewhere before it stops existing here,
	// and a tenant archive is that with room to spare.
	Export(ctx context.Context, targetID shared.ID) (shared.ID, error)
}

ExportBeforeDelete writes an archive to a backup target before a rule removes anything (data-retention.md §6).

A seam rather than the backup performer itself, so that the retention engine does not depend on the backup context to run - an installation with no target configured refuses `EXPORT_THEN_DELETE` rather than failing to sweep anything at all.

type HoldArchive

type HoldArchive interface {
	// Read answers the holds the archive carries, under the workspace's scope. An archive that is
	// not the workspace's own is refused.
	Read(ctx context.Context, scope persistence.Scope, source ArchiveSource) (ArchivedHolds, error)
}

HoldArchive reads the fallback source of a point-in-time recovery: a workspace archive at one of the workspace's backup targets (backup-restore.md §8.5 step 5). The backup service answers it, because it holds what opening an archive takes - the target's credential and the key derived from the installation's master key.

type HoldRecord

type HoldRecord struct {
	ID       shared.ID
	Scope    domain.HoldScope
	ScopeID  shared.ID
	Reason   string
	PlacedBy shared.ID
	PlacedAt time.Time
	// ReleasedAt is when it was released in the rewound period; zero if it was not.
	ReleasedAt time.Time
}

HoldRecord is one hold as it stood before the rewind.

type Holds

type Holds struct {
	Holds repository.HoldWriter
	// Remainders is what lifting a hold seeds. Optional: an installation wired without it lifts
	// holds as before, and the retention pass seeds what it would have.
	Remainders ErasureRemainders
	Authorizer Authorizer
	Audit      audit.Sink
	UnitOfWork persistence.UnitOfWork
	Clock      clock.Clock
	IDs        clock.IDGenerator
	// Text brings a reason to normal form C on the way in (i18n-l10n.md §5).
	Text text.Normalizer
}

Holds is what the three legal hold use cases share.

type JumbleInbox

type JumbleInbox interface {
	DeleteExpired(ctx context.Context, cutoff time.Time, batch int) (int, error)
	CountExpired(ctx context.Context, cutoff time.Time, ceiling int) (int, error)
}

JumbleInbox is the slice of the jumble repository this run removes through.

The same two methods as the notification history and the outbox, and deliberately the same shape: the engine treats a fourth kind exactly as it treats the second. Declared here rather than imported, so that what the retention engine can do to the jumble is visible in one place - it can count what is due and remove a batch of it, and it cannot read an entry, write one or convert one.

Which entries are due is the query's business rather than a parameter, for the reason the outbox's dispatched guard is: an entry that became a work item is that item's provenance, and "never converted" is a correctness rule rather than a period a tenant could configure away.

type ListLegalHolds

type ListLegalHolds struct{ Holds Holds }

ListLegalHolds answers what is frozen, and what has been.

func (ListLegalHolds) Descriptor

func (h ListLegalHolds) Descriptor() usecase.Descriptor

Descriptor registers the listing.

func (ListLegalHolds) Execute

func (h ListLegalHolds) Execute(
	ctx context.Context, actor appshared.ActorContext, includeReleased bool,
) ([]domain.LegalHold, error)

Execute answers the tenant's holds.

type ListRetentionPolicies

type ListRetentionPolicies struct{ Rules Rules }

ListRetentionPolicies answers which rules exist, and which of them is in force where.

func (ListRetentionPolicies) Descriptor

func (h ListRetentionPolicies) Descriptor() usecase.Descriptor

Descriptor registers the listing.

func (ListRetentionPolicies) Execute

func (h ListRetentionPolicies) Execute(
	ctx context.Context, actor appshared.ActorContext, containerID shared.ID, effectiveOnly bool,
) ([]EffectiveRule, error)

Execute answers the rules, and which of them is in force in a container when one is named.

type NotificationHistory

type NotificationHistory interface {
	DeleteExpired(ctx context.Context, cutoff time.Time, batch int) (int, error)
	CountExpired(ctx context.Context, cutoff time.Time, ceiling int) (int, error)
}

NotificationHistory is the slice of the notification repository this run removes through.

Declared here rather than imported, so that what the retention engine can do to notifications is visible in one place: it can count what is due and remove a batch of it, and it cannot read one, write one or send one.

type OrphanedHubs

type OrphanedHubs interface {
	Sweep(ctx context.Context, tenantID shared.ID) (int, error)
}

OrphanedHubs is the work context's catch-all for private hubs without a member.

type Outcome

type Outcome struct {
	// Matched is how many rows were past their period, whether or not they were removed.
	Matched int
	// Held is how many of the matched rows a pass could not act on because something will still
	// keep them on the next one: in the trash pass a legal hold or the offline window, in the rule
	// sweep a legal hold or, in its second phase, a retained descendant. Coming back at once would
	// find them again, so they do not count towards a full batch (RunRetention.Exhausted).
	Held int
	// Removed is how many actually went.
	Removed int
	// Read is how many rows the rule sweep judged, matched or not: what reading past entries it
	// cannot act on costs, set beside what it removed.
	Read int
	// Blocked counts what was refused, by reason. The reasons are the closed set above, so this is
	// a report an operator can read and a metric can be labelled from.
	Blocked map[string]int
	// SetAside are the rules the rule sweep set aside for this pass because their condition could
	// not be evaluated on a candidate, each with why (data-retention.md §2). Judged are the rules it
	// evaluated against at least one candidate without that: their failure, if they showed one,
	// is over.
	SetAside map[shared.ID]domain.RuleFailure
	Judged   map[shared.ID]bool
	// Unfinished is true when a read of the rule sweep stopped at its budget: the rows past its place
	// are still unread in this cycle, so the job comes straight back (RunRetention.Exhausted).
	Unfinished bool
}

Outcome is what one pass did.

func (Outcome) Clean

func (o Outcome) Clean() []shared.ID

Clean is the rules the pass judged and did not set aside.

type PlaceLegalHold

type PlaceLegalHold struct{ Holds Holds }

PlaceLegalHold stops anything under it being deleted.

func (PlaceLegalHold) Descriptor

func (h PlaceLegalHold) Descriptor() usecase.Descriptor

Descriptor registers placing a hold in all three channels.

func (PlaceLegalHold) Execute

Execute places the hold.

The owner's right, and it is the sharpest case for that line in the whole system: a hold overrides the tenant's own configured retention periods *and* a person emptying their own trash. Somebody who can freeze a workspace's data against the workspace's own decisions is exercising the owner's authority, not an administrator's (domain-model.md §3.2).

type PlaceLegalHoldCommand

type PlaceLegalHoldCommand struct {
	Scope   domain.HoldScope
	ScopeID shared.ID
	Reason  string
}

PlaceLegalHoldCommand is the input, typed.

type Preview

type Preview struct {
	// Matched is how many entries are past the rule's period in its scope. Exact rather than a
	// page: the switch below is about a proportion, and a count that stopped at a batch would
	// under-report exactly the runs it exists to catch.
	Matched int
	// Blocked is what would be kept back and why, from the sample rather than from the whole set:
	// deciding a legal hold against a million rows to draw a preview would cost more than the run.
	Blocked map[string]int
	// ShareOfScope is Matched over what the scope holds, which is what §5's switch reads.
	ShareOfScope float64
	// Samples are a handful of the objects, so that "four thousand entries" is a sentence somebody
	// can check.
	Samples []Sample
	// Broad reports a rule whose first run would touch more than five per cent of the holdings.
	Broad bool
}

Preview is what a rule would do (data-retention.md §5).

type PreviewRetentionPolicy

type PreviewRetentionPolicy struct{ Rules Rules }

PreviewRetentionPolicy answers what a rule would do, before it does it.

func (PreviewRetentionPolicy) Descriptor

func (h PreviewRetentionPolicy) Descriptor() usecase.Descriptor

Descriptor registers the preview.

func (PreviewRetentionPolicy) Execute

Execute answers what a rule would do, and changes nothing.

type PurgeWorkItem

type PurgeWorkItem struct {
	Items      workrepo.Items
	Containers workrepo.Containers
	Purger     Purger
	Authorizer Authorizer
	UnitOfWork persistence.UnitOfWork
}

PurgeWorkItem removes one entry and everything under it for good, before its period is up.

The right to write entries, not the owner's right to delete a container. The trash already accepted this deletion under that right, and purging only skips the waiting - what it does not do is take anything with it that the deletion did not already take. Emptying a whole trash is a different question and is asked differently below.

func (PurgeWorkItem) Descriptor

func (h PurgeWorkItem) Descriptor() usecase.Descriptor

Descriptor is the catalogue entry.

func (PurgeWorkItem) Execute

Execute removes the entry and its subtree, and reports how many rows went.

type PurgeWorkItemCommand

type PurgeWorkItemCommand struct {
	ItemID shared.ID
}

PurgeWorkItemCommand is the input, typed.

type Purger

type Purger struct {
	Trash    workrepo.Trash
	Expired  repository.Expired
	Holds    repository.LegalHolds
	Removals repository.Removals
	Events   outbox.Events
	Audit    audit.Sink
	Clock    clock.Clock
	IDs      clock.IDGenerator
	// TombstoneWindow is the maximum offline window: how long the marker of a removal has to
	// survive it, and the lower bound an automatic run observes before removing at all.
	TombstoneWindow time.Duration
	// BatchSize is how many rows one sweep reads. A deletion run works in batches so that a large
	// one does not hold a single transaction open across the whole of it (data-retention.md §5).
	BatchSize int
}

Purger removes trashed rows for good.

One engine behind every path that removes: a person purging one entry, a person emptying a trash, and the retention job. They differ in what they select and in whether a refusal is an error or a number in a report - not in what removing owes, which is the whole point of it being one thing. A second implementation would be a second place for a purge to forget its tombstone.

func (Purger) RecordAudit

func (p Purger) RecordAudit(
	ctx context.Context, actor appshared.ActorContext, action audit.Action, target string,
	targetID shared.ID, outcome Outcome, reason domain.DeletionReason, now time.Time,
) error

RecordAudit writes the summary a removal owes the trail.

A summary rather than an entry per row, which is what data-retention.md §5 asks for: an audit that grew with every deleted object would grow faster than the payload data it is about. What an auditor needs is who removed how much, when, and why - and the journal is where the individual rows are, if it ever comes to that.

func (Purger) Subtree

func (p Purger) Subtree(
	ctx context.Context, actor appshared.ActorContext, item work.WorkItem, hub shared.ID,
	reason domain.DeletionReason, now time.Time,
) (int, error)

Subtree removes one entry and everything under it for good, and reports how many rows went.

Called inside the caller's transaction, like every repository method here: the rows, the journal, the tombstones and the events commit together or not at all. A purge that reached the tables and not the journal would come back on the next restore from backup (ADR-0020 §6).

A legal hold is an error here rather than a number, because the caller named this entry: somebody asked for one thing, and "it was skipped" is not an answer they can act on.

func (Purger) Sweep

func (p Purger) Sweep(
	ctx context.Context, actor appshared.ActorContext, selection Selection, now time.Time,
) (Outcome, error)

Sweep removes everything the selection covers, in one batch, and reports what it did.

One batch per call. The caller decides whether to come back for another - the retention job loops until a pass finds nothing, and a person emptying a trash gets one pass and a count - because that decision belongs with whoever holds the transaction, and a loop in here would hold one open across the whole of a large deletion (data-retention.md §5).

A refusal is counted rather than raised. This selects rather than names, so "one of these is held" is a fact about the run and not a failure of it.

type ReleaseLegalHold

type ReleaseLegalHold struct{ Holds Holds }

ReleaseLegalHold lifts one, and records who and why.

func (ReleaseLegalHold) Descriptor

func (h ReleaseLegalHold) Descriptor() usecase.Descriptor

Descriptor registers lifting one.

func (ReleaseLegalHold) Execute

func (h ReleaseLegalHold) Execute(
	ctx context.Context, actor appshared.ActorContext, id shared.ID, reason string,
) (domain.LegalHold, error)

Execute lifts the hold.

type ReplaceLegalHolds

type ReplaceLegalHolds struct {
	Holds      Holds
	Workspaces Workspace
	Archive    HoldArchive
}

ReplaceLegalHolds places the legal holds of a rewound period again, after a point-in-time recovery and before traffic is admitted (backup-restore.md §8.5 step 5, data-protection.md §5).

The holds arrive from outside the recovered database - with the request, read by the operator from the cluster recovered to the end of the WAL archive, or read here from a workspace archive where that end is unusable - and each is placed as it was: the same identifier, scope, reason, placer and moment. One released in the rewound period is placed in force all the same, and reported so that its owner releases it again. One the workspace already has is left alone, so a second run changes nothing. One whose target the recovery removed is placed anyway and reported: it protects nothing until the target comes back, and refusing it would drop a hold a later restore may need.

func (ReplaceLegalHolds) Descriptor

func (h ReplaceLegalHolds) Descriptor() usecase.Descriptor

Descriptor is the catalogue entry.

func (ReplaceLegalHolds) Execute

Execute places the holds again and answers what happened to each, in the order given.

type ReplaceLegalHoldsCommand

type ReplaceLegalHoldsCommand struct {
	TenantID      shared.ID
	RecoveryPoint time.Time
	// Holds or Archive, never both.
	Holds   []HoldRecord
	Archive *ArchiveSource
}

ReplaceLegalHoldsCommand is the input, typed.

type ReplaceResult

type ReplaceResult struct {
	Holds   []Replacement
	Archive *ArchivedHolds
}

ReplaceResult is what one run did, and - when the holds came from an archive - how far that archive reaches.

type Replacement

type Replacement struct {
	ID               shared.ID
	Outcome          ReplacementOutcome
	TargetPresent    bool
	ReleasedInPeriod bool
}

Replacement is what happened to one hold.

type ReplacementOutcome

type ReplacementOutcome string

ReplacementOutcome says what this run did with one hold.

const (
	// HoldPlacedAgain is a hold this run placed.
	HoldPlacedAgain ReplacementOutcome = "PLACED"
	// HoldAlreadyPresent is a hold the workspace already had; it was left as it is.
	HoldAlreadyPresent ReplacementOutcome = "PRESENT"
)

type RetainItem

type RetainItem struct {
	Items      workrepo.Items
	Containers workrepo.Containers
	Marking    repository.Marking
	Authorizer Authorizer
	Audit      audit.Sink
	Changes    syncrepo.ChangeLog
	UnitOfWork persistence.UnitOfWork
	Clock      clock.Clock
	HLC        clock.HLCSource
}

RetainItem takes one entry out of the retention period running against it (data-retention.md §5).

The third of the three ways §5 names - "anyone with permission can take the object out by editing it, moving it, or issuing a `:retain` command" - and the only one that says so out loud. The other two fall out of the anchor: an entry somebody completes again has a new completion date, and one moved into another collection meets that collection's rule.

func (RetainItem) Descriptor

func (h RetainItem) Descriptor() usecase.Descriptor

Descriptor registers taking an entry out in all three channels.

func (RetainItem) Execute

func (h RetainItem) Execute(
	ctx context.Context, actor appshared.ActorContext, itemID shared.ID,
) (work.WorkItem, error)

Execute takes the entry out and answers it as it now stands.

type RetentionSignals

type RetentionSignals interface {
	RetentionDeleted(ctx context.Context, dataKind string, count int64)
	RetentionBlocked(ctx context.Context, reason string, count int64)
	RetentionRun(ctx context.Context, dataKind string, seconds float64)
	// RetentionRead counts the rows the rule sweep judged, which is what reading past what it
	// cannot act on costs (data-retention.md §5).
	RetentionRead(ctx context.Context, dataKind string, count int64)
}

RetentionSignals is the slice of the metrics adapter a run reports through (data-retention.md §5, observability-reliability.md §3.2).

The labels are the closed sets this package defines - the data kind and the block reasons - so there is no way for an unbounded label to reach a metric from here.

type RetentionWarner

type RetentionWarner interface {
	Warn(ctx context.Context, warning notification.RetentionWarning) error
}

RetentionWarner is the slice of the notification context the sweep needs (R-1): tell the people a rule's audience names that this entry is about to be acted on.

Declared here rather than imported, so that what the retention engine can do to somebody's inbox is visible in one place: it can warn about a marked entry, and it cannot read a notification, send one, or write one about anything else.

type Rules

type Rules struct {
	Rules repository.Rules
	// Policies is the old table, read for one thing only: the upper bound an operator has set for
	// a kind (§4.4). The periods in it are carried into rules by the sweep; the bounds stay where
	// an operator writes them.
	Policies   repository.Policies
	Marking    repository.Marking
	Holds      repository.LegalHolds
	Authorizer Authorizer
	Audit      audit.Sink
	UnitOfWork persistence.UnitOfWork
	Clock      clock.Clock
	IDs        clock.IDGenerator
	// Conditions compiles a rule's expression when it is written. A port, so this layer
	// never learns which engine evaluates one - and the same port the automation rules use, which is
	// what makes "the same expression language" one statement rather than two.
	Conditions expression.Compiler
	// Text brings a justification to normal form C on the way in (i18n-l10n.md §5).
	Text text.Normalizer
}

Rules is what the three rule use cases share.

type RunRetention

type RunRetention struct {
	Policies repository.Policies
	Runs     repository.Runs
	Purger   Purger
	// History is the notification record's remover. A second kind rather than a second job,
	// because a tenant's periods are one thing to evaluate: two schedules would mean two leases,
	// two logs and two ways for one of them to quietly stop running.
	History NotificationHistory
	// Events is the outbox's own remover. Optional, like Rules and Sweeper below: an installation
	// wired without it sweeps exactly what it did before, which is what lets the two land in
	// separate releases.
	Events DispatchedEvents
	// Inbox is the jumble's remover. Required, not optional, on the notification history's
	// reasoning exactly: an inbox holds the least trusted text in the system - raw subject, raw
	// body and the sender's address, all of it PERSONAL_CONTENT - and one that silently stops
	// being swept is personal data kept past its period (risk R-09) in the one place nobody
	// looks at afterwards.
	Inbox JumbleInbox
	// Deliveries is the webhook delivery log's remover. Optional for the outbox's reason: an
	// installation wired without it sweeps exactly what it did before.
	Deliveries ExpiringDeliveries
	// Proposals is the suggestion store's remover. Optional for the outbox's reason: an
	// installation wired without it sweeps exactly what it did before, and what it would have
	// removed is a working note rather than personal content nobody looks at - a suggestion's
	// payload is a *copy* of fields the entry itself holds, and the entry has its own period.
	Proposals ExpiringSuggestions
	// Sessions is the sign-in rows' remover. Optional for the outbox's reason: an
	// installation wired without it sweeps exactly what it did before, and the rows it would
	// have removed are already unusable - the sweep forgets, revocation ends.
	Sessions ExpiredSessions
	// Devices is the synchronising devices' remover. Optional for the session's reason,
	// and with the same shape: a device silent past its period has its sign-in revoked and its
	// row removed, and an installation wired without it sweeps exactly what it did before.
	Devices ExpiredDevices
	// SyncLog is the remover of the synchronisation's records past the offline window:
	// the operation log and the tombstones. Optional for the device's reason. The change log is
	// not swept here - its months fall as partitions, the leader's duty.
	SyncLog ExpiredSyncLog
	// IdempotencyKeys is the remover of requests' stored answers past their day. Optional for the
	// session's reason: an installation wired without it sweeps exactly what it did before.
	IdempotencyKeys ExpiredIdempotencyKeys
	// KeptTexts are the deleted comments whose text a legal hold kept; the pass clears each text
	// once no hold covers it (data-retention.md §4). Optional for the session's reason: wired
	// without it, a kept text stays until a pass that has it.
	KeptTexts repository.KeptCommentTexts
	Clock     clock.Clock
	IDs       clock.IDGenerator
	// Signals is the observability slice. Optional: a run without it still runs, which is what keeps
	// a metrics adapter from being a dependency of the deletion path.
	Signals RetentionSignals
	// Rules and Sweeper are the rule-driven half of the engine. Optional together: an
	// installation wired without them sweeps the trash and the notification history exactly as
	// before, which is what makes the two halves independently deployable.
	Rules   repository.Rules
	Sweeper Sweeper
	// RuleFailures is where a pass leaves the rules it set aside and the ones it evaluated cleanly
	// (data-retention.md §2), written by Independent in a transaction of its own: the record has to
	// outlive a pass that rolls back afterwards. Optional together: wired without them, the rules
	// are set aside all the same and only the log line says so.
	RuleFailures repository.RuleFailures
	Independent  persistence.Independent
	// Remainders seeds the rest of an erasure kept under a hold no longer in force that nothing
	// seeded (data-protection.md §4.1). Optional: wired without it, a release still seeds its own.
	Remainders ErasureRemainders
	// Orphans trashes a private hub nobody is left in that the revocation or the erasure did not
	// catch - a cascade, a pod older than the rule (ADR-0073 §5). Optional: those paths trash their
	// own.
	Orphans OrphanedHubs
	// DeletedTemplates are the templates deleted while a legal hold covered them; the pass removes
	// each once no hold does (data-retention.md §4). Optional: wired without it, they stay until a
	// pass that has it.
	DeletedTemplates repository.DeletedTemplates
}

RunRetention removes what one tenant's periods say may go.

Internal, and deliberately not in the use case catalogue. The catalogue is the list of things a person, an agent or a rule can ask for (arc42 §4), and this is not one of them: it is what the installation does on its own, and the way to influence it is to change the period rather than to call the sweep. Registering it would put a "delete everything that is due, now" action on three channels, which is a button nobody should be given.

One pass per call. The queue's handler runs inside the transaction the runner opened (core/port/queue), so a run that looped would hold one transaction open across a whole deletion - and the batching data-retention.md §5 asks for is exactly the opposite of that. The job comes back for the next pass instead, which is also what makes a large deletion stoppable between passes rather than only by killing it.

func (RunRetention) Execute

func (h RunRetention) Execute(
	ctx context.Context, actor appshared.ActorContext,
) (Outcome, error)

Execute runs one pass for the tenant the transaction is bound to, and reports what it did.

The whole of it inside the caller's transaction, which is the queue's contract: what the pass removed, the records it left and the log entry saying it happened commit together or not at all. A pass whose log was written separately could say it removed nothing while the rows were gone.

func (RunRetention) Exhausted

func (h RunRetention) Exhausted(outcome Outcome) bool

Exhausted reports whether this pass found less than a full batch, which is how the job knows there is nothing left to come back for straight away.

Not while a read of the rule sweep is unfinished (Outcome.Unfinished): it continues where it stopped, and a cycle through the workspace's rows runs back to back (data-retention.md §5).

Read off what was matched rather than off what was removed, less what is held: a row a legal hold or the offline window keeps would be found again by a pass that came straight back, so a batch of them is the end for now - the trash pass reads past held rows to the due ones behind them, so a full batch is never made of held rows while due ones wait. A matched row that went or could have gone still counts: a pass that filled its batch with them has not reached the end.

type Sample

type Sample struct {
	ID    shared.ID
	Title string
	// EffectiveAt is when the act would fall due for this one: its own anchor plus the period and
	// the grace, rather than one date for the whole set.
	EffectiveAt time.Time
}

Sample is one object a preview shows.

type Selection

type Selection struct {
	// Cutoff is the deletion date a row has to be older than. The retention job passes the tenant's
	// period; a person emptying a trash passes now, which is everything in it.
	Cutoff time.Time
	// Reason is why, for the journal, the events and the metric.
	Reason domain.DeletionReason
	// ObserveTombstoneWindow holds back rows deleted more recently than the maximum offline window
	// (offline-sync.md §7).
	//
	// True for the automatic paths and false for an explicit one, which is the distinction the two
	// documents together draw. A retention rule removing an object a device has not yet heard about
	// would let that device recreate it on its next push, and nobody asked for the removal to happen
	// today. A person emptying their own trash did ask, and the tombstone the purge writes is what
	// stops the resurrection there - the marker outlives the row by the whole window either way.
	ObserveTombstoneWindow bool
}

Selection is what one sweep is to remove.

type Sweeper

type Sweeper struct {
	Rules   repository.Rules
	Marking repository.Marking
	Holds   repository.LegalHolds
	Items   workrepo.Items
	// Entries reads a page's entries in one statement, the first time a condition on that page
	// asks for `item`. Optional: without it every candidate is read on its own through Items, which
	// is what a page of a thousand candidates costs a thousand statements for (data-retention.md §5).
	Entries workrepo.ItemBatch
	// Purger is the one engine behind every removal, which is what keeps a retention hard delete
	// owing exactly what a person's purge owes: a journal entry, a tombstone, and an event per row.
	Purger Purger
	// Warnings sends the advance warning of data-retention.md §6 (R-1). Optional: a build
	// without it marks and acts exactly as before, which is what an installation that has switched
	// its notifications off already looks like - and the marking is the visibility §6 asks for
	// first, so silence here loses the message rather than the warning.
	Warnings RetentionWarner
	// Conditions compiles a rule's expression, and it is the same port and the same language the
	// automation rules use (ADR-0009). Optional: a build with none refuses to act on a
	// conditioned rule rather than acting on all of it, which is the safe direction for a pass
	// whose job is deleting.
	Conditions expression.Compiler
	Changes    syncrepo.ChangeLog
	// Export is optional. A rule that asks for one when there is nothing to write it with is
	// refused at the act rather than silently downgraded to a deletion.
	Export ExportBeforeDelete
	Clock  clock.Clock
	IDs    clock.IDGenerator
	HLC    clock.HLCSource
	// Batch is how many objects one pass judges. A thousand by default (§5).
	Batch int
	// Cursors keeps where each read stopped, so the next pass continues there (§5). Optional: a
	// build without it reads from the start on every pass and without a budget, which is what it
	// did before - a budget without a place to continue from would starve what lies past it.
	Cursors repository.Cursors
	// ReadBudget is how many rows one read judges in a pass before it stops and keeps its place:
	// what keeps a pass inside the job's deadline however many entries the read has to pass over.
	// DefaultReadBudgetPages batches by default.
	ReadBudget int
}

Sweeper is the engine data-retention.md §5 describes: two phases, in batches, per tenant.

It is the rule-driven half. The trash and the notification history keep their own sweeps, and that is not a leftover: both are kinds with no marking phase - the trash is its own grace period and nobody can take a notification record out - so they are a cutoff and a delete, which is what they already were.

func (Sweeper) Pass

func (s Sweeper) Pass(ctx context.Context, actor appshared.ActorContext) (Outcome, error)

Pass runs one pass of both phases for the tenant the transaction is bound to.

Phase one first and phase two after it, in that order and in one pass. A rule with no grace period then announces and acts in the same pass, which is what "grace_days: 0" means - and one with a grace period announces now and acts on a pass some days later, which is what everything in between is for.

type UpdateRetentionPolicy

type UpdateRetentionPolicy struct{ Rules Rules }

UpdateRetentionPolicy corrects a rule, or takes it out of enforcement.

func (UpdateRetentionPolicy) Descriptor

func (h UpdateRetentionPolicy) Descriptor() usecase.Descriptor

func (UpdateRetentionPolicy) Execute

Execute applies the patch and writes the corrected rule.

§5's five per cent is **not** re-applied here, and the omission is deliberate. That switch is about a rule nobody has looked at yet - "a new rule always starts in NOTIFY_ONLY" - and a correction is the opposite situation: somebody is looking at this rule now, has the preview, and is deciding. Re-applying it would silently undo an operator's considered `action` every time they touched anything else about the rule.

type UpdateRetentionPolicyCommand

type UpdateRetentionPolicyCommand struct {
	ID              shared.ID
	Condition       *string
	RetainDays      *int
	Action          *domain.Action
	ThenAfterDays   *int
	ThenAction      *domain.Action
	GraceDays       *int
	Notify          *domain.Notify
	Justification   *string
	Enabled         *bool
	ExportTargetID  *shared.ID
	ExpectedVersion int
}

UpdateRetentionPolicyCommand is the merge-patch, typed. A nil pointer is a key the caller did not send. The kind and the scope are absent by construction: neither may move.

type Workspace

type Workspace interface {
	Find(ctx context.Context) (adminrepo.TenantRecord, error)
}

Workspace is the one question the re-placement asks of the workspace itself: whether it is there. The admin context's tenant store answers it.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL