@joelouf/lifecycle-manager
v1.0.0
Published
A zero-dependency, tenant-isolated record-lifecycle engine — trash, archive, legal hold, retention, and GDPR redaction enforced as auditable state transitions over any entity.
Maintainers
Readme
@joelouf/lifecycle-manager
A zero-dependency, tenant-isolated record-lifecycle engine that models trash, archive, legal hold, retention, and GDPR erasure as auditable, reversible state transitions over any entity.
"Delete" is three different acts that must never share a mechanism: voiding a fact, archiving an entity, and trashing a mistake. This engine encodes that trichotomy as first-class operations and enforces the guarantees a system of record is held to — retention windows, legal hold, right-to-erasure, immutable audit, multi-tenant isolation, and optimistic concurrency. One LifecycleService serves every entity kind by reading a registry, and every side effect — storage, time, ids, audit, authorization — is a port, so the core is pure and the same engine runs against memory or a database by swapping adapters.
Features
- Zero runtime dependencies - pure JavaScript, no transitive packages, nothing to audit but the source
- One trichotomy, never conflated - voiding a fact, archiving an entity, and trashing a mistake are distinct operations; the engine rejects the wrong pairing rather than guessing
- A fact is never hard-deleted - fact-bearing records are un-purgeable by construction, so purge and the retention sweep can erase a mistake without ever destroying financial history
- Cascade with an exact cohort - trashing a parent stamps its descendants with the cascade root, and restoring the root clears precisely that set — atomically, never a child alone
- Legal hold overrides everything - a hold on a record, or anywhere in its cascade tree, suspends void, trash, purge, redaction, and the retention sweep
- Right-to-erasure without losing the fact - redaction scrubs declared PII on the entity and on every referencing fact's snapshot while the monetary fields stay intact
- Strict multi-tenant isolation - every read is tenant-scoped, cross-tenant access is refused before lookup, and the retention sweep authorizes per record
- Optimistic concurrency - both writes and hard-deletes are version-conditional, so a hold or restore landing mid-purge aborts the purge instead of being overrun
- Tamper-evident audit - one append-only event per mutation, hash-chained from its predecessor, with a verifier that pinpoints any break
- Ports, not ambient authority - the core reads no global state and performs no I/O; storage, clock, ids, audit, and authorization are all injected
Architecture
core/
types.js # Domain shapes (ManagedRecord, Deletion, LegalHold, Redaction, AuditEvent) and enums
errors.js # LifecycleError and the typed error codes every block maps to
ports.js # Dependency-inverted seams: Clock, IdGenerator, RecordStore, AuditSink, Authorizer
registry.js # Per-kind config: factBearing? retentionDays? piiFields? states? transitions? guards?
authz.js # Role-based authorization over the lifecycle operations
retention.js # Purge eligibility from the trash timestamp and the kind's retention window
transitions.js # Legal status moves: which states are active, which archived, what may follow what
lifecycle-service.js # The orchestrator — the only thing that mutates
adapters/
in-memory.js # Reference Store/Audit/Clock/Id implementations of the portsThe engine is registry-driven: one LifecycleService serves every entity kind by reading its config, so deletion is cross-cutting infrastructure, not per-model code. Persistence, time, ids, audit, and authorization are ports, so the core is pure and the same service runs against in-memory adapters (tests) or your database (production) by swapping the implementations.
Install
npm install @joelouf/lifecycle-managerQuick Start
Construct the Service
import {
LifecycleService,
InMemoryRecordStore,
InMemoryAuditSink,
SystemClock,
SequentialIdGenerator,
SequentialCorrelationSource,
createRoleAuthorizer,
createDefaultRegistry,
Role
} from '@joelouf/lifecycle-manager';
const store = new InMemoryRecordStore();
const audit = new InMemoryAuditSink();
const service = new LifecycleService({
store,
audit,
authz: createRoleAuthorizer(),
registry: createDefaultRegistry(),
clock: new SystemClock(),
ids: new SequentialIdGenerator(),
correlation: new SequentialCorrelationSource()
});
const owner = { id: 'u1', tenantId: 't1', roles: [Role.owner] };
const ctx = { principal: owner, reason: 'tenant moved out' };Trash a Mistake, Then Restore It
// Trashing an entity cascades: entity children are trashed, fact children are voided,
// and every descendant is stamped with the cascade root.
await service.trash({ tenantId: 't1', id: 'p1' }, ctx);
// Restoring the root clears exactly that cohort — atomically, never a child on its own.
const result = await service.restore({ tenantId: 't1', id: 'p1' }, ctx);
result.affected.length; // the whole cohort the trash removedPreview Before You Act
import { LifecycleOp } from '@joelouf/lifecycle-manager';
// previewImpact runs the same assessment the real op does — the dry-run can never disagree.
const preview = await service.previewImpact(
{ tenantId: 't1', id: 'p1', op: LifecycleOp.purge },
{ principal: owner, reason: 'audit', stepUp: true }
);
preview.allowed; // false if retention has not elapsed, a hold exists, or a guard objects
preview.blocks; // the typed reasons it would be refused
preview.affected; // the cohort it would touchErase PII, Keep the Fact
import { REDACTED } from '@joelouf/lifecycle-manager';
// Redaction scrubs declared PII on the contact and on every referencing fact's snapshot,
// while the monetary fields of those facts stay intact.
await service.redact(
{ tenantId: 't1', id: 'c1' },
{ principal: owner, reason: 'GDPR erasure', stepUp: true }
);
// contact.data.name === REDACTED, but the transaction's amountCents is untouched.The Trichotomy
"Delete" is three acts that must never share a mechanism.
| Act | Applies to | Means | Effect on the books | Reversible | Purgeable |
|---|---|---|---|---|---|
| Void | a fact (e.g. a transaction) | "this event shouldn't count" | excluded from every fold | yes (restore) | never |
| Archive | an entity (property, contact, lease) | "real, but the relationship ended" | unchanged | yes (unarchive) | never |
| Trash | an entity created by mistake | "this shouldn't exist" | cascades out | yes (restore, within retention) | after retention |
Deletion.mode: 'void' | 'trash' is the single discriminator; archive is the orthogonal status axis and is not a deletion at all. A fact can only be voided; an entity can only be archived or trashed; the engine refuses the wrong pairing with WRONG_DELETION_MODE.
Operations
Every mutating call on LifecycleService follows the same spine — authorize, load, tenant-check, version-check, assess (guards / hold / retention / step-up), mutate atomically, audit, return the affected cohort — and takes an ActingContext (principal, optional reason, expectedVersion, correlationId, idempotencyKey, stepUp).
| Operation | Effect |
|---|---|
| archive(input, ctx) | Move an entity to an archived status; never a deletion |
| unarchive(input, ctx) | Return an entity to its kind's default active state |
| void(input, ctx) | Exclude a fact from every fold while keeping the record |
| trash(input, ctx) | Soft-delete an entity and cascade to its live descendants |
| restore(input, ctx) | Un-stamp exactly the cascade cohort of a trashed root |
| purge(input, ctx) | Hard-delete a trashed cohort after retention; facts survive by their snapshots |
| redact(input, ctx) | Scrub declared PII on the entity and on referencing fact snapshots |
| placeHold(input, ctx) / releaseHold(input, ctx) | Apply or lift a legal hold that suspends deletion and erasure |
| previewImpact(input, ctx) | Dry-run the assessment for any op — blocks and cohort, no mutation |
| listTrash(tenantId) | The current trash, with per-record purge eligibility |
| sweepRetention(ctx) | Purge every eligible root, skipping facts, archived, and held records |
| trashMany / restoreMany / purgeMany | Bulk variants returning typed multi-status results |
previewImpact and every executor call the same private assessment, so a preview can never disagree with the effect it predicts.
Threat Model & Data Governance
This engine sits on the deletion path of a system of record, where the failure modes are compliance incidents — losing audited history, erasing evidence under litigation, leaking data across tenants, or silently overwriting a concurrent decision. Each guarantee below is a control against one of those, and each is pinned by a test.
- A financial fact is never hard-deleted. Fact-bearing kinds have no retention window and short-circuit purge eligibility, so
purgeandsweepRetentionremove only non-fact cohort members. A mistake can be erased; the audited event it touched cannot. This is enforced by construction, not by a policy flag that can be misconfigured. - Erasure honors litigation hold.
redactrefuses when the target or any referencing fact is under hold, so identity frozen for litigation is never altered — closing a spoliation hole where erasing a contact would have scrubbed a held transaction's snapshot. - Right-to-erasure preserves the books. Redaction scrubs only declared PII fields, on the entity and on the frozen snapshots of facts that reference it, while monetary fields remain. The identity is crypto-shreddable; the transaction stays auditable.
- Tenant isolation with no existence oracle. Every read is tenant-scoped; a record belonging to another tenant is refused as
CROSS_TENANTbefore lookup, while a genuine miss in your own tenant isNOT_FOUND— so the error never reveals whether another tenant's id exists. - Concurrency closes the load-to-commit window. Both
putandhardDeleteare version-conditional (validate-then-apply), so a hold or restore that lands between a purge's assessment and its commit aborts the purge withCONFLICTrather than being overrun. - Irreversible operations require step-up.
purge,redact, andreleaseHolddemand an elevatedstepUpcontext in addition to an owner/admin role; the retention sweep self-elevates only under its system principal. - Least authority by default. Authorization is role-based and checked first; a member may trash, archive, restore, and void, but not purge, redact, or hold; an auditor is read-only. Force-purge that bypasses retention is owner-only and still never touches a fact or a hold.
- Tamper-evident history. Every mutation appends one audit event carrying actor, reason, correlation id, before/after, and cohort, hash-chained from its predecessor;
verifyChain()recomputes the chain and reports the first broken link.
Because the core is pure and every effect is a port, none of this depends on ambient authority: the engine reads no global state, opens no connection, and executes no input as code. Its behavior is a deterministic function of the store and context you pass in, which makes every control above reproducible and testable in isolation.
Security
Run the suite and the type-check:
npm test # node:test (38 cases across the trichotomy, cascade, retention, hold, redaction, authz)
npm run typecheck # tsc --checkJs (strict JSDoc verification, dev-only; not shipped)To report a vulnerability, see SECURITY.md.
Supply Chain
- Zero runtime dependencies — installing this package adds nothing to your dependency tree.
- No peer dependencies — it runs anywhere Node >= 18 does.
- Dev toolchain is type-checking only — the sole
devDependenciesaretypescriptand@types/node(which pullsundici-types): three packages, all compiler/type tooling that is never shipped and never installed by consumers.pnpm auditreports no known vulnerabilities. - Deny-by-default packaging — the published tarball is an explicit
filesallowlist, so only vetted source, declarations, andSECURITY.mdship; tests, config, and CI never leave the repository. - Reproducible and pinned — a committed
pnpm-lock.yamllocks the dev toolchain by integrity hash, thepackageManagerfield pins pnpm itself, and CI runspnpm install --frozen-lockfile, the test suite,tsc --checkJs, andpnpm auditon every push. - Verifiable provenance — releases are published from CI with
pnpm publish --provenance, attaching a signed Sigstore / SLSA build attestation that ties each tarball to this repository and commit.
License
MIT
