Chapter 11 · Subcollections, Collection Groups, Hierarchies, Deletion, and Data Lifecycle

Archiving, Soft Delete, Legal Retention, Tenant Deletion, Export, and Lifecycle State Machines

Design AtlasMart soft-delete, archive, retention-hold and purge states with explicit query behavior and cloud-export boundaries.

Intermediate140–170 minutesArchive · retention · lifecycleFirebase JS 12.19.0 · Admin 14.4.0 · CLI 15.30.0Last reviewed: September 2026

Learning outcomes

01

Distinguish hard delete, soft delete, archive, retention hold, purge eligibility and verified purge as separate lifecycle states.

02

Design normal queries so soft-deleted data cannot silently leak back into application screens.

03

Explain why managed export/import is a billed cloud recovery/processing tool rather than a local emulator feature or exact transaction snapshot.

04

Create a retention-exception manifest that makes legal/business holds explicit before tenant or subject purge.

Execution and safety note

Use the Emulator Suite, a Firebase demo project, or an isolated test project for destructive, security-sensitive, billing-sensitive, migration, backup/restore, or write-heavy exercises unless the lesson explicitly marks managed verification as required. Treat shown output as expected evidence unless it is explicitly identified as captured output, and re-check current Firebase/Google Cloud edition, mode, quota, pricing, and security documentation before production execution.

Chapter 11 reproducibility baseline · reviewed 16 September 2026

AtlasMart continues the same mandatory environment used in Chapters 01–10: project ID demo-atlasmart-firestore, Standard edition / Native mode / (default) database, Firestore emulator 127.0.0.1:8080, Authentication emulator 127.0.0.1:9099, Emulator UI 127.0.0.1:4000, Firebase CLI 15.30.0, Firebase JavaScript SDK 12.19.0, Firebase Admin Node SDK 14.4.0 with @google-cloud/firestore 9.1.0, and Node.js 22+. Mandatory deletion/lifecycle work remains local and isolated. Managed bulk delete, managed export/import, PITR, production IAM, Cloud Storage and legal/compliance procedures are discussed but are not falsely claimed to have run in the emulator.

Evidence boundary

The Emulator Suite is appropriate for proving path structure, document-delete versus surviving-subcollection behavior, collection-group query results, Security Rules behavior, recursive-cleanup logic and deterministic post-delete verification. It does not establish production delete throughput, billed read/delete cost, managed bulk-delete progress, export consistency, IAM behavior, backup/PITR recovery, legal-retention compliance or p95/p99 latency. Recursive deletion is multi-operation work rather than one atomic cascade; every production run needs a scoped target, project/database guard, durable audit evidence and post-delete verification.

1. The AtlasMart problem: “delete” can conflict with retention

A tenant closes its store and requests deletion. AtlasMart also has invoices that finance policy says must be retained, a fraud case under a temporary hold, and ordinary chat/activity data that can be purged. A one-bit deleted=true field cannot represent these different obligations safely. Lifecycle must be a state machine with explicit query behavior and transition authority.

State Meaning Query behavior Deletion consequence
ACTIVE Normal application data Included in normal reads No purge action
SOFT_DELETED Hidden from normal UX but retained Every normal query must exclude it Still exists and is billable/queryable unless constrained
ARCHIVED Moved or copied to an archive contract Separate operational query surface Original may be purged only after verified archive policy
LEGAL_HOLD Deletion suspended by organization policy Usually excluded from normal UX; retained for authorized workflows Must not be hard-deleted until hold is released
PURGE_ELIGIBLE Retention/hold checks passed Should be absent from normal UX May enter controlled deletion job
PURGED Target data removed and verified No application result expected Audit evidence remains according to its own policy

2. Soft delete is a query contract, not a disappearance

soft-delete transition
await db.doc("tenants/t-acme/orders/o-1001").update({  lifecycleState: "SOFT_DELETED",  deletedAt: FieldValue.serverTimestamp(),  deletionReason: "TENANT_CLOSURE",  schemaVersion: 4});

Soft-deleted data still exists, can be read by trusted code, participates in indexes according to configuration, and may be included by any query that forgets the lifecycle predicate. Every normal access pattern needs a test that excludes non-active states. For sensitive data on client caches, a server-side soft delete also does not instantly erase bytes from an already-populated local persistent cache; offline/cache policy must be handled separately.

Wrong approach: add deleted=true and trust developers to remember it

A forgotten filter can expose logically deleted data. Repair the model by centralizing query builders or materialized active collections, testing lifecycle predicates, tightening client rules where possible, and minimizing client access to retained/held records.

3. Retention holds override purge transitions

retention hold document
{  "targetType": "ORDER",  "targetPath": "tenants/t-acme/orders/o-1002",  "tenantId": "t-acme",  "reasonCode": "FINANCE_RETENTION",  "state": "ACTIVE",  "releaseAfter": "2027-09-16T00:00:00Z",  "policyVersion": "finance-retention-v3",  "schemaVersion": 4}

This is an application governance pattern, not legal advice or a Firestore compliance feature. Your organization must define retention periods, legal bases, hold authority, export requirements and audit retention with appropriate counsel/policy owners. The database implementation should make those decisions enforceable and reviewable rather than inventing them.

4. Archive can mean several different things

“Archive” might mean a separate Firestore collection with stricter access, a managed export in Cloud Storage, a warehouse/archive system, or merely a lifecycle state in the same document. Each has different query, security, cost and deletion implications. Copy-then-delete is not atomic across arbitrary destinations, so record archive verification before marking source data purge-eligible.

Archive form Benefit Risk/limitation Verification evidence
Same Firestore document, state=ARCHIVED Simple transition Still in same storage/query surface State + rules/query tests
Separate archive collection/database Operational isolation Copy/delete workflow and duplicate risk Source/target counts + checksum/IDs
Managed Firestore export to Cloud Storage Recovery/offline processing Billing required; export is not an exact start-time snapshot Completed operation + output URI + collection groups
External warehouse/object archive Long-term analytics/retention flexibility Separate IAM/encryption/retention system Destination manifest + access/retention policy

5. Managed export/import: cloud-only and billed

Firestore managed export/import requires billing and Cloud Storage. Exporting data incurs document-read charges; import incurs writes, and the export is not an exact database snapshot taken at the operation start. You can export selected collection groups, but exporting a parent collection group does not automatically include its subcollections unless those collection groups are also selected. Therefore “archive tenant” cannot be implemented safely by assuming one parent collection export contains every descendant.

OPTIONAL PRODUCTION-ONLY export example
# Requires a real billing-enabled project, Cloud Storage bucket and appropriate IAM.gcloud config set project REAL_PROJECT_IDgcloud firestore export gs://REAL_BUCKET/atlasmart-archive-2026-09-16 \  --collection-ids=orders,messages \  --database='(default)'# Verify operation completion and manifest before source purge.

Do not run this against the demo/emulator project as if it were a local feature. For the mandatory no-cost exercise, create a deterministic JSON manifest from the emulator instead.

trusted emulator initialization
process.env.FIRESTORE_EMULATOR_HOST = "127.0.0.1:8080";process.env.GCLOUD_PROJECT = "demo-atlasmart-firestore";import { initializeApp } from "firebase-admin/app";import { getFirestore, FieldValue, Timestamp } from "firebase-admin/firestore";initializeApp({ projectId: "demo-atlasmart-firestore" });const db = getFirestore();
seed deterministic AtlasMart hierarchy
async function seedHierarchy() {  const batch = db.batch();  batch.set(db.doc("tenants/t-acme"), {    name: "Acme Seller", lifecycleState: "ACTIVE", schemaVersion: 4  });  batch.set(db.doc("tenants/t-acme/users/u-1001"), {    tenantId: "t-acme", uid: "u-1001", displayName: "Ava", lifecycleState: "ACTIVE", schemaVersion: 4  });  batch.set(db.doc("tenants/t-acme/orders/o-1001"), {    tenantId: "t-acme", customerId: "u-1001", status: "PAID", totalMinor: 12990,    lifecycleState: "ACTIVE", schemaVersion: 4  });  batch.set(db.doc("tenants/t-acme/orders/o-1002"), {    tenantId: "t-acme", customerId: "u-1002", status: "PAID", totalMinor: 4950,    lifecycleState: "ACTIVE", schemaVersion: 4  });  batch.set(db.doc("tenants/t-acme/orders/o-1001/messages/m-001"), {    tenantId: "t-acme", orderId: "o-1001", authorId: "u-1001",    text: "Please leave at reception", sentAt: Timestamp.fromMillis(1789570800000), schemaVersion: 4  });  batch.set(db.doc("tenants/t-acme/orders/o-1002/messages/m-002"), {    tenantId: "t-acme", orderId: "o-1002", authorId: "u-1002",    text: "Thanks", sentAt: Timestamp.fromMillis(1789570860000), schemaVersion: 4  });  batch.set(db.doc("userDirectory/u-1001"), {    uid: "u-1001", tenantId: "t-acme", lifecycleState: "ACTIVE", schemaVersion: 4  });  batch.set(db.doc("activity/a-001"), {    subjectUid: "u-1001", tenantId: "t-acme", type: "ORDER_CREATED", schemaVersion: 4  });  await batch.commit();}
local archive manifest simulation
const orderSnap = await db.collection("tenants/t-acme/orders").get();const messageSnap = await db.collectionGroup("messages").where("tenantId","==","t-acme").get();const manifest = {  kind: "LOCAL_TRAINING_MANIFEST",  tenantId: "t-acme",  orders: orderSnap.docs.map(d => d.ref.path).sort(),  messages: messageSnap.docs.map(d => d.ref.path).sort()};console.log(JSON.stringify(manifest, null, 2));

6. Lifecycle transition guard

server-side transition policy
const allowed = {  ACTIVE: new Set(["SOFT_DELETED", "LEGAL_HOLD"]),  SOFT_DELETED: new Set(["ACTIVE", "ARCHIVED", "LEGAL_HOLD", "PURGE_ELIGIBLE"]),  ARCHIVED: new Set(["LEGAL_HOLD", "PURGE_ELIGIBLE"]),  LEGAL_HOLD: new Set(["ACTIVE", "SOFT_DELETED", "ARCHIVED"]),  PURGE_ELIGIBLE: new Set(["PURGED"]),  PURGED: new Set()};function assertLifecycleTransition(from, to) {  if (!allowed[from]?.has(to)) throw new Error(`INVALID_LIFECYCLE_${from}_TO_${to}`);}

Keep lifecycle transitions in trusted code and preserve who/why/correlation evidence. A hard-delete command should require a prior eligibility decision rather than determining policy while deleting.

7. Reproducible AtlasMart lab

package.json
{  "name": "atlasmart-firestore-ch11",  "private": true,  "type": "module",  "engines": { "node": ">=22" },  "dependencies": {    "firebase": "12.19.0",    "firebase-admin": "14.4.0"  },  "devDependencies": {    "firebase-tools": "15.30.0",    "@firebase/rules-unit-testing": "5.0.2"  }}
firebase.json
{  "firestore": {    "rules": "firestore.rules",    "indexes": "firestore.indexes.json"  },  "emulators": {    "firestore": { "port": 8080 },    "auth": { "port": 9099 },    "ui": { "enabled": true, "port": 4000 }  }}
firestore.rules
rules_version = '2';service cloud.firestore {  match /databases/{database}/documents {    function signedIn() { return request.auth != null; }    function sameTenant(tenantId) {      return signedIn() && request.auth.token.tenantId == tenantId;    }    match /tenants/{tenantId} {      allow read: if sameTenant(tenantId);      allow write: if false;      match /users/{uid} {        allow read: if sameTenant(tenantId);        allow write: if sameTenant(tenantId) && request.auth.uid == uid;      }      match /orders/{orderId} {        allow read: if sameTenant(tenantId);        allow write: if false;        match /messages/{messageId} {          allow read: if sameTenant(tenantId);          allow write: if false;        }      }    }    // Required for collection-group queries over every messages collection.    match /{path=**}/messages/{messageId} {      allow read: if signedIn() && resource.data.tenantId == request.auth.token.tenantId;      allow write: if false;    }    match /deletionAudits/{id} { allow read, write: if false; }    match /retentionHolds/{id} { allow read, write: if false; }    match /{document=**} { allow read, write: if false; }  }}
firestore.indexes.json
{  "indexes": [    {      "collectionGroup": "messages",      "queryScope": "COLLECTION_GROUP",      "fields": [        { "fieldPath": "tenantId", "order": "ASCENDING" },        { "fieldPath": "sentAt", "order": "DESCENDING" }      ]    }  ],  "fieldOverrides": []}
local setup
mkdir atlasmart-firestore-ch11 && cd atlasmart-firestore-ch11npm init -ynpm install firebase@12.19.0 firebase-admin@14.4.0npm install --save-dev firebase-tools@15.30.0 @firebase/rules-unit-testing@5.0.2# Save firebase.json, firestore.rules and firestore.indexes.json from this lesson.npx firebase-tools@15.30.0 emulators:start --project demo-atlasmart-firestore --only firestore,auth

Seed the tenant. Soft-delete o-1001 and prove an “active orders” query excludes it. Put o-1002 under a retention hold and prove the purge planner returns it as an exception. Generate the local archive manifest. Transition only eligible paths to PURGE_ELIGIBLE; do not hard-delete held data.

purge planner output
const plan = {  tenantId: "t-acme",  delete: ["tenants/t-acme/orders/o-1001/messages/m-001", "tenants/t-acme/orders/o-1001"],  retain: [{ path:"tenants/t-acme/orders/o-1002", reason:"FINANCE_RETENTION" }],  externalCopiesToReview: ["userDirectory/u-1001", "activity/a-001"],  schemaVersion: 4};console.log(JSON.stringify(plan, null, 2));

Production judgment

Lifecycle design is a policy-to-data contract. Make normal-query behavior explicit, centralize transition authority, separate retention exceptions from ordinary data, and require verified archive/hold evidence before purge. Backups, PITR and exports are recovery/governance tools but do not automatically satisfy a data-subject deletion or legal-retention program; later chapters treat backup/recovery in detail.

Lesson 5 turns the lifecycle policy into an executable tenant/data-subject deletion manifest that searches every known copy and verifies both removals and documented exceptions.

Knowledge check

  1. Does soft delete remove stored data?
  2. Why is a retention hold a separate state?
  3. Is managed export a free emulator capability?
  4. Does exporting orders automatically export messages subcollections?
  5. What should precede hard deletion?
Review the answers

1. No. The document remains stored and must be excluded by query/rules/application policy.

2. It must block ordinary purge progression and preserve an explicit reason/policy/release process.

3. No. It is a billing-enabled production service using Cloud Storage and IAM.

4. Not simply by naming the parent collection group; required subcollection groups must be included explicitly.

5. A verified lifecycle decision showing the data is purge-eligible and not subject to an active retention exception.

Summary and next step

Hard delete is only one terminal lifecycle action. AtlasMart now distinguishes active, soft-deleted, archived, held and purge-eligible data. Next we implement deletion as a manifest that can prove what was removed and what was intentionally retained.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.