# Implementation Plan: Policy Hot Reload, Follow List Whitelisting, and Web UI

**Issue:** https://git.nostrdev.com/mleku/next.orly.dev/issues/6

## Overview

This plan implements three interconnected features for ORLY's policy system:
1. **Dynamic Policy Configuration** via kind 12345 events (hot reload)
2. **Administrator Follow List Whitelisting** within the policy system
3. **Web Interface** for policy management with JSON editing

## Architecture Summary

### Current System Analysis

**Policy System** ([pkg/policy/policy.go](pkg/policy/policy.go)):
- Policy loaded from `~/.config/ORLY/policy.json` at startup
- `P` struct with unexported `rules` field (map[int]Rule)
- `PolicyManager` manages script runners for external policy scripts
- `LoadFromFile()` method exists for loading policy from disk
- No hot reload mechanism currently exists

**ACL System** ([pkg/acl/follows.go](pkg/acl/follows.go)):
- Separate from policy system
- Manages admin/owner/follows lists for write access control
- Fetches kind 3 events from relays
- Has callback mechanism for updates

**Event Handling** ([app/handle-event.go](app/handle-event.go)):213-226
- Special handling for NIP-43 events (join/leave requests)
- Pattern: Check kind early, process, return early

**Web UI**:
- Svelte-based component architecture
- Tab-based navigation in [app/web/src/App.svelte](app/web/src/App.svelte)
- API endpoints follow `/api/<feature>/<action>` pattern

## Feature 1: Dynamic Policy Configuration (Kind 12345)

### Design

**Event Kind:** 12345 (Relay Policy Configuration)
**Purpose:** Allow admins/owners to update policy configuration via Nostr event
**Security:** Only admins/owners can publish; only visible to admins/owners
**Process Flow:**
1. Admin/owner creates kind 12345 event with JSON policy in `content` field
2. Relay receives event via WebSocket
3. Validate sender is admin/owner
4. Pause policy manager (stop script runners)
5. Parse and validate JSON configuration
6. Apply new policy configuration
7. Persist to `~/.config/ORLY/policy.json`
8. Resume policy manager (restart script runners)
9. Send OK response

### Implementation Steps

#### Step 1.1: Define Kind Constant
**File:** Create `pkg/protocol/policyconfig/policyconfig.go`
```go
package policyconfig

const (
    // KindPolicyConfig is a relay-internal event for policy configuration updates
    // Only visible to admins and owners
    KindPolicyConfig uint16 = 12345
)
```

#### Step 1.2: Add Policy Hot Reload Methods
**File:** [pkg/policy/policy.go](pkg/policy/policy.go)

Add methods to `P` struct:
```go
// Reload loads policy from JSON bytes and applies it to the existing policy instance
// This pauses the policy manager, updates configuration, and resumes
func (p *P) Reload(policyJSON []byte) error

// Pause pauses the policy manager and stops all script runners
func (p *P) Pause() error

// Resume resumes the policy manager and restarts script runners
func (p *P) Resume() error

// SaveToFile persists the current policy configuration to disk
func (p *P) SaveToFile(configPath string) error
```

**Implementation Details:**
- `Reload()` should:
  - Call `Pause()` to stop all script runners
  - Unmarshal JSON into policy struct (using shadow struct pattern)
  - Validate configuration
  - Populate binary caches
  - Call `SaveToFile()` to persist
  - Call `Resume()` to restart scripts
  - Return error if any step fails

- `Pause()` should:
  - Iterate through `p.manager.runners` map
  - Call `Stop()` on each runner
  - Set a paused flag on the manager

- `Resume()` should:
  - Clear paused flag
  - Call `startPolicyIfExists()` to restart default script
  - Restart any rule-specific scripts that were running

- `SaveToFile()` should:
  - Marshal policy to JSON (using pJSON shadow struct)
  - Write atomically to config path (write to temp file, then rename)

#### Step 1.3: Handle Kind 12345 Events
**File:** [app/handle-event.go](app/handle-event.go)

Add handling after NIP-43 special events (after line 226):
```go
// Handle policy configuration update events (kind 12345)
case policyconfig.KindPolicyConfig:
    // Process policy config update and return early
    if err = l.HandlePolicyConfigUpdate(env.E); chk.E(err) {
        log.E.F("failed to process policy config update: %v", err)
        if err = Ok.Error(l, env, err.Error()); chk.E(err) {
            return
        }
        return
    }
    // Send OK response
    if err = Ok.Ok(l, env, "policy configuration updated"); chk.E(err) {
        return
    }
    return
```

Create new file: `app/handle-policy-config.go`
```go
// HandlePolicyConfigUpdate processes kind 12345 policy configuration events
// Only admins and owners can update policy configuration
func (l *Listener) HandlePolicyConfigUpdate(ev *event.E) error {
    // 1. Verify sender is admin or owner
    // 2. Parse JSON from event content
    // 3. Validate JSON structure
    // 4. Call l.policyManager.Reload(jsonBytes)
    // 5. Log success/failure
    return nil
}
```

**Security Checks:**
- Verify `ev.Pubkey` is in admins or owners list
- Validate JSON syntax before applying
- Catch all errors and return descriptive messages
- Log all policy update attempts (success and failure)

#### Step 1.4: Query Filtering (Optional)
**File:** [app/handle-req.go](app/handle-req.go)

Add filter to hide kind 12345 from non-admins:
```go
// In handleREQ, after ACL checks:
// Filter out policy config events (kind 12345) for non-admin users
if !isAdminOrOwner(l.authedPubkey.Load(), l.Admins, l.Owners) {
    // Remove kind 12345 from filter
    for _, f := range filters {
        f.Kinds.Remove(policyconfig.KindPolicyConfig)
    }
}
```

## Feature 2: Administrator Follow List Whitelisting

### Design

**Purpose:** Enable policy-based follow list whitelisting (separate from ACL follows)
**Use Case:** Policy admins can designate follows who get special policy privileges
**Configuration:**
```json
{
  "policy_admins": ["admin_pubkey_hex_1", "admin_pubkey_hex_2"],
  "policy_follow_whitelist_enabled": true,
  "rules": {
    "1": {
      "write_allow_follows": true  // Allow writes from policy admin follows
    }
  }
}
```

### Implementation Steps

#### Step 2.1: Extend Policy Configuration Structure
**File:** [pkg/policy/policy.go](pkg/policy/policy.go)

Extend `P` struct:
```go
type P struct {
    Kind          Kinds  `json:"kind"`
    rules         map[int]Rule
    Global        Rule   `json:"global"`
    DefaultPolicy string `json:"default_policy"`

    // New fields for follow list whitelisting
    PolicyAdmins                   []string `json:"policy_admins,omitempty"`
    PolicyFollowWhitelistEnabled   bool     `json:"policy_follow_whitelist_enabled,omitempty"`

    // Unexported cached data
    policyAdminsBin  [][]byte     // Binary cache for admin pubkeys
    policyFollows    [][]byte     // Cached follow list from policy admins
    policyFollowsMx  sync.RWMutex // Protect follows list

    manager *PolicyManager
}
```

Extend `Rule` struct:
```go
type Rule struct {
    // ... existing fields ...

    // New field for follow-based whitelisting
    WriteAllowFollows bool `json:"write_allow_follows,omitempty"`
    ReadAllowFollows  bool `json:"read_allow_follows,omitempty"`
}
```

Update `pJSON` shadow struct to include new fields.

#### Step 2.2: Add Follow List Fetching
**File:** [pkg/policy/policy.go](pkg/policy/policy.go)

Add methods:
```go
// FetchPolicyFollows fetches follow lists (kind 3) from database for policy admins
// This is called during policy load and can be called periodically
func (p *P) FetchPolicyFollows(db database.D) error {
    p.policyFollowsMx.Lock()
    defer p.policyFollowsMx.Unlock()

    // Clear existing follows
    p.policyFollows = nil

    // For each policy admin, query kind 3 events
    for _, adminPubkey := range p.policyAdminsBin {
        // Build filter for kind 3 from this admin
        // Query database for latest kind 3 event
        // Extract p-tags from event
        // Add to p.policyFollows list
    }

    return nil
}

// IsPolicyFollow checks if pubkey is in policy admin follows
func (p *P) IsPolicyFollow(pubkey []byte) bool {
    p.policyFollowsMx.RLock()
    defer p.policyFollowsMx.RUnlock()

    for _, follow := range p.policyFollows {
        if utils.FastEqual(pubkey, follow) {
            return true
        }
    }
    return false
}
```

#### Step 2.3: Integrate Follow Checking in Policy Rules
**File:** [pkg/policy/policy.go](pkg/policy/policy.go)

Update `checkRulePolicy()` method (around line 1062):
```go
// In write access checks, after checking write_allow list:
if access == "write" {
    // Check if follow-based whitelisting is enabled for this rule
    if rule.WriteAllowFollows && p.PolicyFollowWhitelistEnabled {
        if p.IsPolicyFollow(loggedInPubkey) {
            return true, nil  // Allow write from policy admin follow
        }
    }

    // Continue with existing write_allow checks...
}

// Similar for read access:
if access == "read" {
    if rule.ReadAllowFollows && p.PolicyFollowWhitelistEnabled {
        if p.IsPolicyFollow(loggedInPubkey) {
            return true, nil  // Allow read from policy admin follow
        }
    }
    // Continue with existing read_allow checks...
}
```

#### Step 2.4: Periodic Follow List Refresh
**File:** [pkg/policy/policy.go](pkg/policy/policy.go)

Add to `NewWithManager()`:
```go
// Start periodic follow list refresh for policy admins
if len(policy.PolicyAdmins) > 0 && policy.PolicyFollowWhitelistEnabled {
    go policy.startPeriodicFollowRefresh(ctx)
}
```

Add method:
```go
// startPeriodicFollowRefresh periodically fetches policy admin follow lists
func (p *P) startPeriodicFollowRefresh(ctx context.Context) {
    ticker := time.NewTicker(15 * time.Minute) // Refresh every 15 minutes
    defer ticker.Stop()

    // Fetch immediately on startup
    if err := p.FetchPolicyFollows(p.db); err != nil {
        log.E.F("failed to fetch policy follows: %v", err)
    }

    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            if err := p.FetchPolicyFollows(p.db); err != nil {
                log.E.F("failed to fetch policy follows: %v", err)
            } else {
                log.I.F("refreshed policy admin follow lists")
            }
        }
    }
}
```

**Note:** Need to pass database reference to policy manager. Update `NewWithManager()` signature:
```go
func NewWithManager(ctx context.Context, appName string, enabled bool, db *database.D) *P
```

## Feature 3: Web Interface for Policy Management

### Design

**Components:**
1. `PolicyView.svelte` - Main policy management UI
2. API endpoints for policy CRUD operations
3. JSON editor with validation
4. Follow list viewer

**UI Features:**
- View current policy configuration (read-only JSON display)
- Edit policy JSON with syntax highlighting
- Validate JSON before publishing
- Publish kind 12345 event to update policy
- View policy admin pubkeys
- View follow lists for each policy admin
- Add/remove policy admin pubkeys (updates and republishes config)

### Implementation Steps

#### Step 3.1: Create Policy View Component
**File:** `app/web/src/PolicyView.svelte`

Structure:
```svelte
<script>
    export let isLoggedIn = false;
    export let userRole = "";
    export let policyConfig = null;
    export let policyAdmins = [];
    export let policyFollows = [];
    export let isLoadingPolicy = false;
    export let policyMessage = "";
    export let policyMessageType = "info";
    export let policyEditJson = "";

    import { createEventDispatcher } from "svelte";
    const dispatch = createEventDispatcher();

    // Event handlers
    function loadPolicy() { dispatch("loadPolicy"); }
    function savePolicy() { dispatch("savePolicy"); }
    function validatePolicy() { dispatch("validatePolicy"); }
    function addPolicyAdmin() { dispatch("addPolicyAdmin"); }
    function removePolicyAdmin(pubkey) { dispatch("removePolicyAdmin", pubkey); }
    function refreshFollows() { dispatch("refreshFollows"); }
</script>

<div class="policy-view">
    <h2>Policy Configuration Management</h2>

    {#if isLoggedIn && (userRole === "owner" || userRole === "admin")}
        <!-- Policy JSON Editor Section -->
        <div class="policy-section">
            <h3>Policy Configuration</h3>
            <div class="policy-controls">
                <button on:click={loadPolicy}>🔄 Reload</button>
                <button on:click={validatePolicy}>✓ Validate</button>
                <button on:click={savePolicy}>📤 Publish Update</button>
            </div>

            <textarea
                class="policy-editor"
                bind:value={policyEditJson}
                spellcheck="false"
                placeholder="Policy JSON configuration..."
            />
        </div>

        <!-- Policy Admins Section -->
        <div class="policy-admins-section">
            <h3>Policy Administrators</h3>
            <p class="section-description">
                Policy admins can update configuration and their follows get whitelisted
                (if policy_follow_whitelist_enabled is true)
            </p>

            <div class="admin-list">
                {#each policyAdmins as admin}
                    <div class="admin-item">
                        <span class="admin-pubkey">{admin}</span>
                        <button
                            class="remove-btn"
                            on:click={() => removePolicyAdmin(admin)}
                        >
                            Remove
                        </button>
                    </div>
                {/each}
            </div>

            <div class="add-admin">
                <input
                    type="text"
                    placeholder="npub or hex pubkey"
                    id="new-admin-input"
                />
                <button on:click={addPolicyAdmin}>Add Admin</button>
            </div>
        </div>

        <!-- Follow List Section -->
        <div class="policy-follows-section">
            <h3>Policy Follow Whitelist</h3>
            <button on:click={refreshFollows}>🔄 Refresh Follows</button>

            <div class="follows-list">
                {#if policyFollows.length === 0}
                    <p class="no-follows">No follows loaded</p>
                {:else}
                    <p class="follows-count">
                        {policyFollows.length} pubkey(s) in whitelist
                    </p>
                    <div class="follows-grid">
                        {#each policyFollows as follow}
                            <div class="follow-item">{follow}</div>
                        {/each}
                    </div>
                {/if}
            </div>
        </div>

        <!-- Message Display -->
        {#if policyMessage}
            <div class="policy-message {policyMessageType}">
                {policyMessage}
            </div>
        {/if}
    {:else}
        <div class="access-denied">
            <p>Policy management is only available to relay administrators and owners.</p>
            {#if !isLoggedIn}
                <button on:click={() => dispatch("openLoginModal")}>
                    Login
                </button>
            {/if}
        </div>
    {/if}
</div>

<style>
    /* Policy-specific styling */
    .policy-view { /* ... */ }
    .policy-editor {
        width: 100%;
        min-height: 400px;
        font-family: 'Monaco', 'Courier New', monospace;
        font-size: 0.9em;
        padding: 1em;
        border: 1px solid var(--border-color);
        border-radius: 4px;
        background: var(--code-bg);
        color: var(--code-text);
    }
    /* ... more styles ... */
</style>
```

#### Step 3.2: Add Policy Tab to Main App
**File:** [app/web/src/App.svelte](app/web/src/App.svelte)

Add state variables (around line 94):
```javascript
// Policy management state
let policyConfig = null;
let policyAdmins = [];
let policyFollows = [];
let isLoadingPolicy = false;
let policyMessage = "";
let policyMessageType = "info";
let policyEditJson = "";
```

Add tab definition in `tabs` array (look for export/import/sprocket tabs):
```javascript
if (isLoggedIn && (userRole === "owner" || userRole === "admin")) {
    tabs.push({
        id: "policy",
        label: "Policy",
        icon: "🛡️",
        isSearchTab: false
    });
}
```

Add component import:
```javascript
import PolicyView from "./PolicyView.svelte";
```

Add view in main content area (look for {#if selectedTab === "sprocket"}):
```svelte
{:else if selectedTab === "policy"}
    <PolicyView
        {isLoggedIn}
        {userRole}
        {policyConfig}
        {policyAdmins}
        {policyFollows}
        {isLoadingPolicy}
        {policyMessage}
        {policyMessageType}
        bind:policyEditJson
        on:loadPolicy={handleLoadPolicy}
        on:savePolicy={handleSavePolicy}
        on:validatePolicy={handleValidatePolicy}
        on:addPolicyAdmin={handleAddPolicyAdmin}
        on:removePolicyAdmin={handleRemovePolicyAdmin}
        on:refreshFollows={handleRefreshFollows}
        on:openLoginModal={() => (showLoginModal = true)}
    />
```

Add event handlers:
```javascript
async function handleLoadPolicy() {
    isLoadingPolicy = true;
    policyMessage = "";

    try {
        const response = await fetch("/api/policy/config", {
            credentials: "include"
        });

        if (!response.ok) {
            throw new Error(`Failed to load policy: ${response.statusText}`);
        }

        const data = await response.json();
        policyConfig = data.config;
        policyEditJson = JSON.stringify(data.config, null, 2);
        policyAdmins = data.config.policy_admins || [];

        policyMessage = "Policy loaded successfully";
        policyMessageType = "success";
    } catch (error) {
        policyMessage = `Error loading policy: ${error.message}`;
        policyMessageType = "error";
        console.error("Error loading policy:", error);
    } finally {
        isLoadingPolicy = false;
    }
}

async function handleSavePolicy() {
    isLoadingPolicy = true;
    policyMessage = "";

    try {
        // Validate JSON first
        const config = JSON.parse(policyEditJson);

        // Publish kind 12345 event via websocket with auth
        const event = {
            kind: 12345,
            content: policyEditJson,
            tags: [],
            created_at: Math.floor(Date.now() / 1000)
        };

        const result = await publishEventWithAuth(event, userSigner);

        if (result.success) {
            policyMessage = "Policy updated successfully";
            policyMessageType = "success";
            // Reload to get updated config
            await handleLoadPolicy();
        } else {
            throw new Error(result.message || "Failed to publish policy update");
        }
    } catch (error) {
        policyMessage = `Error updating policy: ${error.message}`;
        policyMessageType = "error";
        console.error("Error updating policy:", error);
    } finally {
        isLoadingPolicy = false;
    }
}

function handleValidatePolicy() {
    try {
        JSON.parse(policyEditJson);
        policyMessage = "Policy JSON is valid ✓";
        policyMessageType = "success";
    } catch (error) {
        policyMessage = `Invalid JSON: ${error.message}`;
        policyMessageType = "error";
    }
}

async function handleRefreshFollows() {
    isLoadingPolicy = true;
    policyMessage = "";

    try {
        const response = await fetch("/api/policy/follows", {
            credentials: "include"
        });

        if (!response.ok) {
            throw new Error(`Failed to load follows: ${response.statusText}`);
        }

        const data = await response.json();
        policyFollows = data.follows || [];

        policyMessage = `Loaded ${policyFollows.length} follows`;
        policyMessageType = "success";
    } catch (error) {
        policyMessage = `Error loading follows: ${error.message}`;
        policyMessageType = "error";
        console.error("Error loading follows:", error);
    } finally {
        isLoadingPolicy = false;
    }
}

async function handleAddPolicyAdmin(event) {
    // Get input value
    const input = document.getElementById("new-admin-input");
    const pubkey = input.value.trim();

    if (!pubkey) {
        policyMessage = "Please enter a pubkey";
        policyMessageType = "error";
        return;
    }

    try {
        // Convert npub to hex if needed (implement or use nostr library)
        // Add to policy_admins array in config
        const config = JSON.parse(policyEditJson);
        if (!config.policy_admins) {
            config.policy_admins = [];
        }
        if (!config.policy_admins.includes(pubkey)) {
            config.policy_admins.push(pubkey);
            policyEditJson = JSON.stringify(config, null, 2);
            input.value = "";
            policyMessage = "Admin added (click Publish to save)";
            policyMessageType = "info";
        } else {
            policyMessage = "Admin already in list";
            policyMessageType = "warning";
        }
    } catch (error) {
        policyMessage = `Error adding admin: ${error.message}`;
        policyMessageType = "error";
    }
}

async function handleRemovePolicyAdmin(event) {
    const pubkey = event.detail;

    try {
        const config = JSON.parse(policyEditJson);
        if (config.policy_admins) {
            config.policy_admins = config.policy_admins.filter(p => p !== pubkey);
            policyEditJson = JSON.stringify(config, null, 2);
            policyMessage = "Admin removed (click Publish to save)";
            policyMessageType = "info";
        }
    } catch (error) {
        policyMessage = `Error removing admin: ${error.message}`;
        policyMessageType = "error";
    }
}
```

#### Step 3.3: Add API Endpoints
**File:** [app/server.go](app/server.go)

Add to route registration (around line 245):
```go
// Policy management endpoints (admin/owner only)
s.mux.HandleFunc("/api/policy/config", s.handlePolicyConfig)
s.mux.HandleFunc("/api/policy/follows", s.handlePolicyFollows)
```

Create new file: `app/handle-policy-api.go`
```go
package app

import (
    "encoding/json"
    "net/http"
    "lol.mleku.dev/log"
    "git.mleku.dev/mleku/nostr/encoders/hex"
)

// handlePolicyConfig returns the current policy configuration
// GET /api/policy/config
func (s *Server) handlePolicyConfig(w http.ResponseWriter, r *http.Request) {
    // Verify authentication
    session, err := s.getSession(r)
    if err != nil || session == nil {
        http.Error(w, "Unauthorized", http.StatusUnauthorized)
        return
    }

    // Verify user is admin or owner
    role := s.getUserRole(session.Pubkey)
    if role != "admin" && role != "owner" {
        http.Error(w, "Forbidden", http.StatusForbidden)
        return
    }

    // Get current policy configuration from policy manager
    // This requires adding a method to get the raw config
    config := s.policyManager.GetConfig() // Need to implement this

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]interface{}{
        "config": config,
    })
}

// handlePolicyFollows returns the policy admin follow lists
// GET /api/policy/follows
func (s *Server) handlePolicyFollows(w http.ResponseWriter, r *http.Request) {
    // Verify authentication
    session, err := s.getSession(r)
    if err != nil || session == nil {
        http.Error(w, "Unauthorized", http.StatusUnauthorized)
        return
    }

    // Verify user is admin or owner
    role := s.getUserRole(session.Pubkey)
    if role != "admin" && role != "owner" {
        http.Error(w, "Forbidden", http.StatusForbidden)
        return
    }

    // Get policy follows from policy manager
    follows := s.policyManager.GetPolicyFollows() // Need to implement this

    // Convert to hex strings for JSON response
    followsHex := make([]string, len(follows))
    for i, f := range follows {
        followsHex[i] = hex.Enc(f)
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]interface{}{
        "follows": followsHex,
    })
}
```

**Note:** Need to add getter methods to policy manager:
```go
// GetConfig returns the current policy configuration as a map
// File: pkg/policy/policy.go
func (p *P) GetConfig() map[string]interface{} {
    // Marshal to JSON and back to get map representation
    jsonBytes, _ := json.Marshal(p)
    var config map[string]interface{}
    json.Unmarshal(jsonBytes, &config)
    return config
}

// GetPolicyFollows returns the current policy follow list
func (p *P) GetPolicyFollows() [][]byte {
    p.policyFollowsMx.RLock()
    defer p.policyFollowsMx.RUnlock()

    follows := make([][]byte, len(p.policyFollows))
    copy(follows, p.policyFollows)
    return follows
}
```

## Testing Strategy

### Unit Tests

1. **Policy Reload Tests** (`pkg/policy/policy_test.go`):
   - Test `Reload()` with valid JSON
   - Test `Reload()` with invalid JSON
   - Test `Pause()` and `Resume()` functionality
   - Test `SaveToFile()` atomic write

2. **Follow List Tests** (`pkg/policy/follows_test.go`):
   - Test `FetchPolicyFollows()` with mock database
   - Test `IsPolicyFollow()` with various inputs
   - Test follow list caching and expiry

3. **Handler Tests** (`app/handle-policy-config_test.go`):
   - Test kind 12345 handling with admin pubkey
   - Test kind 12345 rejection from non-admin
   - Test JSON validation errors

### Integration Tests

1. **End-to-End Policy Update**:
   - Publish kind 12345 event as admin
   - Verify policy reloaded
   - Verify new policy enforced
   - Verify policy persisted to disk

2. **Follow Whitelist E2E**:
   - Configure policy with follow whitelist enabled
   - Add admin pubkey to policy_admins
   - Publish kind 3 follow list for admin
   - Verify follows can write/read per policy rules

3. **Web UI E2E**:
   - Load policy via API
   - Edit and publish via UI
   - Verify changes applied
   - Check follow list display

## Security Considerations

1. **Authorization**:
   - Only admins/owners can publish kind 12345
   - Only admins/owners can access policy API endpoints
   - Policy events only visible to admins/owners in queries

2. **Validation**:
   - Strict JSON schema validation before applying
   - Rollback mechanism if policy fails to load
   - Catch all parsing errors

3. **Audit Trail**:
   - Log all policy update attempts
   - Store kind 12345 events in database for audit
   - Include who changed what and when

4. **Atomic Operations**:
   - Pause-update-resume must be atomic
   - File writes must be atomic (temp file + rename)
   - No partial updates on failure

## Migration Path

### Phase 1: Backend Foundation
1. Implement kind 12345 constant
2. Add policy reload methods
3. Add follow list support to policy
4. Test hot reload mechanism

### Phase 2: Event Handling
1. Add kind 12345 handler
2. Add API endpoints
3. Test event flow end-to-end

### Phase 3: Web UI
1. Create PolicyView component
2. Integrate into App.svelte
3. Add JSON editor
4. Test user workflows

### Phase 4: Testing & Documentation
1. Write comprehensive tests
2. Update CLAUDE.md
3. Create user documentation
4. Add examples to docs/

## Open Questions / Decisions Needed

1. **Policy Admin vs Relay Admin**:
   - Should policy_admins be separate from ORLY_ADMINS?
   - **Recommendation:** Yes, separate. Policy admins manage policy, relay admins manage relay.

2. **Follow List Refresh Frequency**:
   - How often to refresh policy admin follows?
   - **Recommendation:** 15 minutes (configurable via ORLY_POLICY_FOLLOW_REFRESH)

3. **Backward Compatibility**:
   - What happens to relays without policy_admins field?
   - **Recommendation:** Fall back to empty list, disabled by default

4. **Database Reference in Policy**:
   - Policy needs database reference for follow queries
   - **Recommendation:** Pass database to NewWithManager()

5. **Error Handling on Reload Failure**:
   - Should failed reload keep old policy or disable policy?
   - **Recommendation:** Keep old policy, log error, return error to client

## Success Criteria

1. ✅ Admin can publish kind 12345 event with new policy JSON
2. ✅ Relay receives event, validates sender, reloads policy without restart
3. ✅ Policy persisted to `~/.config/ORLY/policy.json`
4. ✅ Script runners paused during reload, resumed after
5. ✅ Policy admins can be configured in policy JSON
6. ✅ Policy admin follow lists fetched from database
7. ✅ Follow-based whitelisting enforced in policy rules
8. ✅ Web UI displays current policy configuration
9. ✅ Web UI allows editing and validation of policy JSON
10. ✅ Web UI shows policy admin follows
11. ✅ Only admins/owners can access policy management
12. ✅ All tests pass
13. ✅ Documentation updated

## Estimated Effort

- **Backend (Policy + Event Handling):** 8-12 hours
  - Policy reload methods: 3-4 hours
  - Follow list support: 3-4 hours
  - Event handling: 2-3 hours
  - Testing: 2-3 hours

- **API Endpoints:** 2-3 hours
  - Route setup: 1 hour
  - Handler implementation: 1-2 hours
  - Testing: 1 hour

- **Web UI:** 6-8 hours
  - PolicyView component: 3-4 hours
  - App integration: 2-3 hours
  - Styling and UX: 2-3 hours
  - Testing: 2 hours

- **Documentation & Testing:** 4-6 hours
  - Unit tests: 2-3 hours
  - Integration tests: 2-3 hours
  - Documentation: 2 hours

**Total:** 20-29 hours

## Dependencies

- No external dependencies required
- Uses existing ORLY infrastructure
- Compatible with current policy system

## Next Steps

1. Review and approve this plan
2. Clarify open questions/decisions
3. Begin implementation in phases
4. Iterative testing and refinement
