Back to flin
flin

Destroy and Restore: Soft Deletes Done Right

How FLIN implements three tiers of data deletion -- soft delete, hard delete (destroy), and restore -- with GDPR compliance built into the language.

Thales & Claude | March 25, 2026 11 min flin
flinsoft-deletedestroyrestoredata-recovery

Delete is the most dangerous operation in any application. One wrong click, one missing WHERE clause, one overeager cron job, and data is gone. In most systems, "gone" means gone forever. Recovery requires backups, point-in-time restores, or divine intervention from a DBA.

FLIN's temporal model makes deletion a spectrum, not a binary. There are three tiers: delete (soft), destroy (hard), and restore (undo). Each has clear semantics, clear use cases, and clear implications for data lifecycle management. Session 077 implemented the full destroy/restore lifecycle in two hours, bringing FLIN's delete/restore implementation to ninety-two percent completion.

The Three Tiers of Deletion

Tier 1: Soft Delete

The delete keyword marks an entity as deleted without removing any data. The entity and its full history remain in the database, but it no longer appears in standard queries.

user = User { name: "Juste", email: "[email protected]" }
save user

user.name = "Juste Gnimavo" save user

delete user ```

After delete: - User.all no longer returns this user. - User.find(id) returns none. - The entity's history is fully preserved. - The deletion itself becomes a version in the history (version number incremented, deleted_at timestamp set).

Soft delete is the default and the safe option. It is what you use when a user clicks "Delete" in your application. The data can always be recovered.

Tier 2: Hard Delete (Destroy)

The destroy keyword permanently removes an entity and all its version history. This is irrecoverable.

destroy user

After destroy: - The entity is removed from the database entirely. - All version history is purged. - .history returns an empty list. - There is no recovery path.

Hard delete exists for one primary use case: regulatory compliance. GDPR's "right to be forgotten" requires the ability to permanently erase a person's data -- not just hide it, but remove it so that no trace remains. destroy is that mechanism.

Tier 3: Restore

The restore() function reverses a soft delete, bringing a deleted entity back into active queries.

delete user           // Soft deleted
restore(user)         // Undeleted -- entity is active again

After restore: - The entity reappears in standard queries. - The deleted_at timestamp is cleared. - A new version is created (the restore itself is recorded in history). - All previous history remains intact.

The version timeline for a full delete-restore cycle looks like this:

v1: Created     v2: Updated     v3: Deleted     v4: Restored
   (active)       (active)       (deleted)        (active)

Each transition is a version. Each version is immutable. The complete lifecycle is traceable.

Implementation: What Was Already Done

When we started Session 077, the goal was to implement destroy and restore from scratch. What we found, echoing the pattern from Session 068, was that most of the work was already done.

The following were already implemented in earlier sessions: - ZeroCore destroy() method (hard delete from database). - ZeroCore restore() method (undelete -- clear deleted_at). - destroy statement parsing (AST node, parser, type checker). - Destroy opcode emission (code generator). - Destroy opcode execution (VM). - restore() as VM built-in function (index 60).

What was missing were bugs and integration gaps that prevented these features from working end-to-end.

Bug 1: Opcode Validation Range

The Destroy opcode was assigned value 0x9A, but the opcode validation range only covered 0x90..=0x99. The bytecode deserializer rejected 0x9A as invalid, meaning destroy compiled successfully but crashed at runtime with an "invalid opcode" error.

// BEFORE: Range didn't include 0x9A
0x90..=0x99 => { /* valid opcodes */ }

// AFTER: Extended to include Destroy 0x90..=0x9A => { / valid opcodes / } ```

One character. One byte. The difference between a feature that works and a feature that crashes.

Bug 2: Ghost Entities After Destroy

After destroy, the entity object still existed in the VM's heap -- it had been loaded into memory before the destroy operation. When .history was called on this "ghost" entity, the VM dutifully looked up its history in the database (empty, because destroy purges everything) but then appended the current version from the heap, returning [current] instead of [].

The fix added an existence check before appending the current version:

// Check if entity still exists in database
let still_exists = is_saved
    && self.database.find(&type_name, entity_id).ok().flatten().is_some();

if is_saved && still_exists { versions.push(entity_val); } ```

This was a subtle interaction between the VM's memory model (entities live on the heap) and the database's persistence model (destroyed entities are purged). The VM cannot assume that a heap object corresponds to a database record -- it must verify.

Bug 3: Restore as Keyword vs. Function

restore was originally defined as a keyword in the lexer, like delete and destroy. But unlike those keywords, restore takes an argument: restore(user). The keyword-based parsing expected a statement form (restore user), which conflicted with the function call syntax that the rest of the language used.

The fix was to remove restore from the keyword list and register it as a built-in function:

// Removed from lexer keywords
// "restore" => Some(Keyword::Restore)  -- REMOVED

// Added to type checker as built-in function "restore" => Some(FlinType::Function { params: vec![FlinType::Unknown], ret: Box::new(FlinType::Unit), min_arity: 1, has_rest: false, }), ```

Now restore(user) is parsed as a function call, which is consistent with how developers expect to use it and consistent with FLIN's general syntax rules.

The Test Suite: Ten New Integration Tests

Session 077 added ten comprehensive integration tests covering every aspect of the delete/restore lifecycle.

Destroy Tests

test_destroy_removes_entity_permanently: Verifies that .history returns an empty list after destroy. No "ghost" versions remain.

test_destroy_vs_delete_difference: The critical distinction test. Soft delete preserves history and allows @ -1 access. Hard delete purges everything. This test creates an entity, saves it twice, then runs both operations on separate instances to verify the behavioral difference.

test_destroy_with_multiple_versions: An entity with three saves is destroyed. After destroy, .history count is zero -- all three versions are purged, not just the current one.

Restore Tests

test_restore_soft_deleted_entity: Delete, verify history count is one (the pre-delete version), restore, verify history count is three (original, delete version, restore version).

test_restore_preserves_history: Three saves, delete, restore. History before delete: three versions. History after restore: five versions (three original, the delete, and the restore). Every lifecycle event is recorded.

test_restore_creates_new_version: Verifies that each restore operation increments the version counter and adds a new entry to the history.

test_multiple_delete_restore_cycles: Delete, restore, delete, restore. Five total versions (one create, two deletes, two restores). Tests that the system handles repeated cycles without data corruption or version counter issues.

Combined Lifecycle Tests

test_cannot_restore_destroyed_entity: Documents the expected behavior when attempting to restore a hard-deleted entity. The entity no longer exists in the database, so the restore operation has nothing to act on.

test_delete_restore_then_destroy: The complete lifecycle in one test: create, delete, restore (entity is active again), destroy (entity is permanently removed). History grows during the soft-delete/restore phase, then drops to zero after destroy.

All thirty-six temporal integration tests passing. All one thousand and ten library tests passing. Zero regressions.

Why Three Tiers Matter

The three-tier deletion model is not complexity for its own sake. Each tier serves a distinct use case in real applications.

Soft delete (delete) is for user-facing deletion. When a user removes a document, a contact, or an order, the data should be recoverable. Accidental deletion is one of the most common support tickets in any application. With soft delete, recovery is a single restore() call instead of a database restore from backup.

// User clicks "Delete" in the UI
delete document

// User clicks "Undo" within 30 seconds restore(document) ```

Hard delete (destroy) is for regulatory compliance and data hygiene. GDPR Article 17 grants individuals the "right to erasure" -- the right to have their personal data permanently deleted. A soft delete does not satisfy this requirement because the data still exists in the database. destroy does.

// GDPR erasure request
fn handle_erasure_request(user_id) {
    user = User.find(user_id)
    if user {
        destroy user    // Permanent. No trace remains.
    }
}

Restore is the safety net. It enables undo functionality, data recovery, and operational resilience. Combined with the temporal model, it means that data deleted yesterday can be recovered today, as long as it was soft-deleted and not destroyed.

The Version Timeline in Detail

Consider a product entity that goes through a complete lifecycle:

entity Product {
    name: text
    price: number
}

// v1: Creation product = Product { name: "Widget", price: 10 } save product

// v2: Price update product.price = 15 save product

// v3: Soft delete delete product

// v4: Restore restore(product)

// v5: Another update product.price = 20 save product

// v6: Hard delete destroy product ```

At each stage, the version count and .history behavior change:

AfterVersions`.history` countQueryable?
v1: Save11Yes
v2: Save22Yes
v3: Delete32 (past versions only)No
v4: Restore44Yes
v5: Save55Yes
v6: Destroy00No (permanently)

The distinction between v3 (soft delete, history preserved, restorable) and v6 (hard delete, history purged, irrecoverable) is fundamental. One is a status change. The other is annihilation.

Design Decisions

Why destroy Instead of hard_delete?

We chose destroy over hard_delete for two reasons. First, brevity: in a language that values concise syntax, a seven-character keyword is better than an eleven-character one. Second, semantic weight: "destroy" conveys the permanence and gravity of the operation better than "hard delete," which sounds like a technical detail rather than a consequential action.

Why restore() as a Function Instead of a Statement?

delete and destroy are statements because they do not return values. restore() is a function call because it conceptually "returns" the entity to its active state and because function call syntax naturally accommodates the argument parentheses. The inconsistency is intentional -- it signals that restore is a recovery operation (something you do to an entity) rather than a lifecycle transition (something that happens to it).

Why Does Delete Create a Version?

Soft delete increments the version counter and creates a history entry. This was a deliberate choice, not an accident. The deletion event is part of the entity's history. If you ask "what happened to this product?" the answer should include "it was deleted at 3:47 PM" -- not just "it stopped having updates at some point."

The delete version also enables a specific query pattern: "show me everything that was deleted last week." By recording delete as a version with a timestamp, time-range queries naturally capture deletion events.

GDPR and the Right to Be Forgotten

FLIN's three-tier deletion model maps directly onto GDPR requirements:

  • Soft delete satisfies "account deactivation" -- the user's data is no longer visible but can be reactivated.
  • Destroy satisfies "right to erasure" (Article 17) -- all personal data and its history are permanently removed.
  • The temporal model itself satisfies "right of access" (Article 15) -- the complete history of data changes is available for export.

Building these compliance mechanisms into the language means that every FLIN application gets GDPR-compatible data lifecycle management without any additional code. A developer building a SaaS product in FLIN does not need to implement a separate "data deletion pipeline" -- the primitives are already there.

Crossing the 50% Milestone

Session 077 completed ten tasks and brought the temporal model to eighty-one out of one hundred sixty tasks -- fifty point six percent. It was the session that crossed the halfway mark.

More significantly, it completed the delete/restore category to ninety-two percent. The remaining eight percent were advanced features (permission guards, confirmation requirements, UI for viewing deleted entities) that could be deferred without affecting the core functionality.

The destroy/restore implementation was also a validation of a pattern we had seen repeatedly: the hardest part was not writing new code, but finding and fixing integration bugs in code that already existed. Three bugs fixed, ten tests added, and a feature that had been "implemented" for weeks finally worked end-to-end.

---

This is Part 4 of the "How We Built FLIN" temporal model series, documenting the three-tier deletion model and its GDPR implications.

Series Navigation: - [046] Every Entity Remembers Everything: The Temporal Model - [047] Version History and Time Travel Queries - [048] Temporal Integration: From Bugs to 100% Test Coverage - [049] Destroy and Restore: Soft Deletes Done Right (you are here) - [050] Temporal Filtering and Ordering - [051] Temporal Comparison Helpers - [052] Version Metadata Access - [053] Time Arithmetic: Adding Days, Comparing Dates - [054] Tracking Accuracy and Validation - [055] The Temporal Model Complete: What No Other Language Has

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles