You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

13 KiB

Engineering Directive v2 — Active Pointer + Smart Deletion Pattern (hardened)

Author: Matthew Raymer
Date: 2025-01-27
Status: 🎯 ACTIVE - Production-grade engineering directive for implementing smart deletion patterns

Overview

This supersedes the previous draft and is copy-pasteable for any <model>. It keeps UX smooth, guarantees data integrity, and adds production-grade safeguards (bootstrapping, races, soft deletes, bulk ops, and testability). Built on your prior pattern.

0) Objectives (non-negotiable)

  1. Exactly one active <model> pointer (or NULL during first-run).
  2. Block deletion when it would leave zero <models>.
  3. If deleting the active item, atomically re-point to a deterministic next item before delete.
  4. Enforce with app logic + FK RESTRICT (and ON UPDATE CASCADE if ref can change).

1) Schema / Migration (SQLite)

-- <timestamp>__active_<model>.sql
PRAGMA foreign_keys = ON;

-- Stable external key on <models> (e.g., did/slug/uuid)
-- ALTER TABLE <models> ADD COLUMN ref TEXT UNIQUE NOT NULL; -- if missing

CREATE TABLE IF NOT EXISTS active_<model> (
  id INTEGER PRIMARY KEY CHECK (id = 1),
  activeRef TEXT UNIQUE, -- allow NULL on first run
  lastUpdated TEXT NOT NULL DEFAULT (datetime('now')),
  FOREIGN KEY (activeRef) REFERENCES <models>(ref)
    ON UPDATE CASCADE
    ON DELETE RESTRICT
);

-- Seed singleton row (idempotent)
INSERT INTO active_<model> (id, activeRef)
SELECT 1, NULL
WHERE NOT EXISTS (SELECT 1 FROM active_<model> WHERE id = 1);

Rules

  • Never default activeRef to ''—use NULL for "no selection yet".
  • Ensure PRAGMA foreign_keys = ON for every connection.

2) Data Access API (TypeScript)

// Required DAL
async function getAllRefs(): Promise<string[]> { /* SELECT ref FROM <models> ORDER BY created_at, ref */ }
async function getRefById(id: number): Promise<string> { /* SELECT ref FROM <models> WHERE id=? */ }
async function getActiveRef(): Promise<string|null> { /* SELECT activeRef FROM active_<model> WHERE id=1 */ }
async function setActiveRef(ref: string|null): Promise<void> { /* UPDATE active_<model> SET activeRef=?, lastUpdated=datetime('now') WHERE id=1 */ }
async function deleteById(id: number): Promise<void> { /* DELETE FROM <models> WHERE id=? */ }
async function countModels(): Promise<number> { /* SELECT COUNT(*) FROM <models> */ }

// Deterministic "next"
function pickNextRef(all: string[], current?: string): string {
  const sorted = [...all].sort();
  if (!current) return sorted[0];
  const i = sorted.indexOf(current);
  return sorted[(i + 1) % sorted.length];
}

3) Smart Delete (Atomic, Race-safe)

async function smartDeleteModelById(id: number, notify: (m: string) => void) {
  await db.transaction(async trx => {
    const total = await countModels();
    if (total <= 1) {
      notify("Cannot delete the last item. Keep at least one.");
      throw new Error("blocked:last-item");
    }

    const refToDelete = await getRefById(id);
    const activeRef   = await getActiveRef();

    if (activeRef === refToDelete) {
      const all = (await getAllRefs()).filter(r => r !== refToDelete);
      const next = pickNextRef(all, refToDelete);
      await setActiveRef(next);
      notify(`Switched active to ${next} before deletion.`);
    }

    await deleteById(id); // RESTRICT prevents orphaning if we forgot to switch
  });

  // Post-tx: emit events / refresh UI
}

4) Bootstrapping & Repair

async function ensureActiveSelected() {
  const active = await getActiveRef();
  const all = await getAllRefs();
  if (active === null && all.length > 0) {
    await setActiveRef(pickNextRef(all)); // first stable choice
  }
}

Invoke after migrations and after bulk imports.


5) Concurrency & Crash Safety

  • Always wrap "switch → delete" inside a single transaction.
  • Treat any FK violation as a logic regression; surface telemetry (fk:restrict).

6) Soft Deletes (if applicable)

If <models> uses deleted_at:

  • Replace DELETE with UPDATE <models> SET deleted_at = datetime('now') WHERE id=?.

  • Add a partial uniqueness strategy for ref:

    • SQLite workaround: make ref unique globally and never reuse; or maintain a shadow refs ledger to prevent reuse.
  • Adjust getAllRefs() to filter WHERE deleted_at IS NULL.


7) Bulk Ops & Imports

  • For batch deletes:

    1. Compute survivors.
    2. If a batch would remove all survivors → refuse.
    3. If the active is included, precompute a deterministic new active and set it once before deleting.
  • After imports, run ensureActiveSelected().


8) Multi-Scope Actives (optional)

To support one active per workspace/tenant:

  • Replace singleton with scoped pointer:

    CREATE TABLE active_<model> (
      scope TEXT NOT NULL,        -- e.g., workspace_id
      activeRef TEXT,
      lastUpdated TEXT NOT NULL DEFAULT (datetime('now')),
      PRIMARY KEY (scope),
      FOREIGN KEY (activeRef) REFERENCES <models>(ref) ON UPDATE CASCADE ON DELETE RESTRICT
    );
    
  • All APIs gain scope parameter; transactions remain unchanged in spirit.


9) UX Contract

  • Delete confirmation must state:

    • Deleting the active item will auto-switch.
    • Deleting the last item is not allowed.
  • Keep list ordering aligned with pickNextRef strategy for predictability.


10) Observability

  • Log categories:

    • blocked:last-item
    • fk:restrict
    • repair:auto-selected-active
    • active:switch:pre-delete
  • Emit metrics counters; attach <model> and (if used) scope.


11) Test Matrix (must pass)

  1. Non-active delete (≥2): deleted; active unchanged.
  2. Active delete (≥2): active switches deterministically, then delete succeeds.
  3. Last item delete (==1): blocked with message.
  4. First-run: 0 items → activeRef stays NULL; add first → ensureActiveSelected() selects it.
  5. Ref update (if allowed): activeRef follows via ON UPDATE CASCADE.
  6. Soft delete mode: filters respected; invariants preserved.
  7. Bulk delete that includes active but not all: pre-switch then delete set.
  8. Foreign keys disabled (fault injection): tests must fail to surface missing PRAGMA.

12) Rollout & Rollback

  • Feature-flag the new deletion path.
  • Migrations are idempotent; ship ensureActiveSelected() with them.
  • Keep a pre-migration backup for <models> on first rollout.
  • Rollback leaves active_<model> table harmlessly present.

13) Replace-Me Cheatsheet

  • <model> → singular (e.g., project)
  • <models> → plural table (e.g., projects)
  • ref → stable external key (did | slug | uuid)

Outcome: You get predictable UX, atomic state changes, and hard integrity guarantees across single- or multi-scope actives, with clear tests and telemetry to keep it honest.


TimeSafari Implementation Guide

Current State Analysis (2025-01-27)

Status: ⚠️ PARTIAL COMPLIANCE - Smart deletion logic implemented correctly, but critical security issues remain.

Compliance Score: 67% (4/6 components compliant)

What's Already Working

  • Smart Deletion Logic: IdentitySwitcherView.vue implements atomic transaction-safe deletion
  • Data Access API: All required DAL methods exist in PlatformServiceMixin.ts
  • Schema Structure: active_identity table follows singleton pattern correctly
  • Bootstrapping: $ensureActiveSelected() method implemented

Critical Issues Requiring Fix

  1. Foreign Key Constraint: Currently ON DELETE SET NULL (allows accidental deletion)
  2. Settings Table Cleanup: Orphaned records with accountDid=null exist

Updated Implementation Plan

Note: Smart deletion logic is already implemented correctly. Focus on fixing security issues and cleanup.

1) Critical Security Fix (Migration 005)

Fix Foreign Key Constraint:

-- Migration 005: Fix foreign key constraint to ON DELETE RESTRICT
{
  name: "005_active_identity_constraint_fix",
  sql: `
    PRAGMA foreign_keys = ON;
    
    -- Recreate table with ON DELETE RESTRICT constraint (SECURITY FIX)
    CREATE TABLE active_identity_new (
      id INTEGER PRIMARY KEY CHECK (id = 1),
      activeDid TEXT REFERENCES accounts(did) ON DELETE RESTRICT,
      lastUpdated TEXT NOT NULL DEFAULT (datetime('now'))
    );
    
    -- Copy existing data
    INSERT INTO active_identity_new (id, activeDid, lastUpdated)
    SELECT id, activeDid, lastUpdated FROM active_identity;
    
    -- Replace old table
    DROP TABLE active_identity;
    ALTER TABLE active_identity_new RENAME TO active_identity;
    
    -- Recreate indexes
    CREATE UNIQUE INDEX IF NOT EXISTS idx_active_identity_single_record ON active_identity(id);
  `
}

2) Settings Table Cleanup (Migration 006)

Remove Orphaned Records:

-- Migration 006: Settings cleanup
{
  name: "006_settings_cleanup",
  sql: `
    -- Remove orphaned settings records (accountDid is null)
    DELETE FROM settings WHERE accountDid IS NULL;
    
    -- Clear any remaining activeDid values in settings
    UPDATE settings SET activeDid = NULL;
  `
}

3) Optional Future Enhancement (Migration 007)

Remove Legacy activeDid Column:

-- Migration 007: Remove activeDid column entirely (future task)
{
  name: "007_remove_activeDid_column",
  sql: `
    -- Remove the legacy activeDid column from settings table
    ALTER TABLE settings DROP COLUMN activeDid;
  `
}

Current Implementation Status

Already Implemented Correctly

  • Smart Deletion Logic: IdentitySwitcherView.vue lines 285-315
  • Data Access API: All methods exist in PlatformServiceMixin.ts
  • Transaction Safety: Uses $withTransaction() for atomicity
  • Last Account Protection: Blocks deletion when total <= 1
  • Deterministic Selection: $pickNextAccountDid() method
  • Bootstrapping: $ensureActiveSelected() method

Requires Immediate Fix

  1. Foreign Key Constraint: Change from ON DELETE SET NULL to ON DELETE RESTRICT
  2. Settings Cleanup: Remove orphaned records with accountDid=null

Implementation Priority

Phase 1: Critical Security Fix (IMMEDIATE)

  • Migration 005: Fix foreign key constraint to ON DELETE RESTRICT
  • Impact: Prevents accidental account deletion
  • Risk: HIGH - Current implementation allows data loss

Phase 2: Settings Cleanup (HIGH PRIORITY)

  • Migration 006: Remove orphaned settings records
  • Impact: Cleaner architecture, reduced confusion
  • Risk: LOW - Only removes obsolete data

Phase 3: Future Enhancement (OPTIONAL)

  • Migration 007: Remove activeDid column from settings
  • Impact: Complete separation of concerns
  • Risk: LOW - Architectural cleanup

Updated Compliance Assessment

Current Status: ⚠️ PARTIAL COMPLIANCE (67%)

Component Status Compliance
Smart Deletion Logic Complete 100%
Data Access API Complete 100%
Schema Structure Complete 100%
Foreign Key Constraint Wrong (SET NULL) 0%
Settings Cleanup Missing 0%
Overall ⚠️ Partial 67%

After Fixes: FULL COMPLIANCE (100%)

Component Status Compliance
Smart Deletion Logic Complete 100%
Data Access API Complete 100%
Schema Structure Complete 100%
Foreign Key Constraint Fixed (RESTRICT) 100%
Settings Cleanup Cleaned 100%
Overall Complete 100%

Implementation Benefits

Current implementation already provides:

  • Atomic Operations: Transaction-safe account deletion
  • Last Account Protection: Prevents deletion of final account
  • Smart Switching: Auto-switches active account before deletion
  • Deterministic Behavior: Predictable "next account" selection
  • NULL Handling: Proper empty state management

After fixes will add:

  • Data Integrity: Foreign key constraints prevent orphaned references
  • Clean Architecture: Complete separation of identity vs. settings
  • Production Safety: No accidental account deletion possible

Next Steps

  1. IMMEDIATE: Implement Migration 005 (foreign key fix)
  2. HIGH PRIORITY: Implement Migration 006 (settings cleanup)
  3. OPTIONAL: Implement Migration 007 (remove legacy column)
  4. TEST: Run directive test matrix to verify compliance

This updated plan focuses on fixing the critical security issue while preserving the already-working smart deletion logic.