This guide augments Emotional_API_Implementation_Strategy.md, laying out every task in a strictly logical progression. The strategy doc is the authoritative reference for full schema definitions, encryption spec, API request/response shapes, and security properties. This doc answers: in what order do I write the code?
It resolves temporal dependencies (e.g. omg_rob_this_happened must exist before vocab POST can escalate to it) and notes which steps are already implemented.
The PHP/CSS code for these features is already in the repo — no new code required. But each one depends on a DB migration that has not been written or applied yet. They silently do nothing until their tables exist. Verification steps appear in the numbered list below at the appropriate points.
Admin Dashboard UI for Alerts
- Files:
classes/Admin/OmgAlerts.php,wwwroot/admin/index.php,templates/admin/index.tpl.php,wwwroot/css/styles.css OmgAlerts::getUnread()queriesomg_rob_this_happenedand catches the PDOException if the table is missing (returns[]silently).OmgAlerts::dismissAll()is a no-op until the table exists. Admin index handles form POST to/admin/with CSRF token. Banner only renders when!empty($omg_alerts)— currently always empty.- Depends on: Step 2 (creates
omg_rob_this_happenedtable). Verified in Step 3.
HTTP DELETE endpoint: /api/v1/emotions/everything
- File:
wwwroot/api/v1/emotions/everything.php(handles the HTTP DELETE method) - Accepts
{"confirm": "delete everything"}in body (returns 400 if missing or wrong). In a transaction: counts then deletesinteraction_events,interaction_sessions, andmy_ids_for_my_users_statein FK-safe order for the authenticatedapi_key_id. Returns{"deleted": {"events": N, "sessions": M, "vocab_entries": K}}. - Note: Written in standalone pattern (includes
prepend.php, calls\Emotional\ApiAuth::authenticate()). Under Option A (front controller), this logic will be folded into_emotions.php's/everythingbranch in Step 18. The standalone file will be deleted once migrated. - Depends on: Step 4 (creates the three emotional tables). Verified in Step 18.
Work through this in order. Commit after each numbered step — small commits make it easy to roll back a broken step without losing everything else.
After each endpoint step that has an HTTP test, three sub-steps follow:
- [Jikan] Add tool — add a
@mcp.tool()function to~/jikan/server.py - [Restart] Exit and reopen Claude Code. MCP servers load at startup — new tools only appear after restart.
⚠️ This ends your current conversation session. - [Jikan verify] — In the new session, ask Claude to call the new tool and confirm the expected response.
The emotional endpoints live at /api/v1/emotions/ — the same base URL as the existing sessions tools. Use the existing _client() helper for all emotional tools. No separate client, URL constant, or environment variable needed.
JIKAN_API_KEY (already in HEADERS) works for both the existing sessions API and the emotional API — no new configuration needed.
1. [COMMIT] Defensive SQL fix in Database Class DONE
- File:
classes/Database/Base.php(methodexecuteMultipleSQL) - Action: Update
Database\Base::executeMultipleSQLto strip single-line SQL comments before splitting on semicolons.// Strip single-line SQL comments before splitting on semicolons $sql = preg_replace('/--[^\n]*\n/', "\n", $sql);
- Why: Protects against schema parsing errors caused by inline
--comments in future migrations.
2. [COMMIT] Create Admin Alerts Database Table DONE
- File:
db_schemas/11_admin_alerts/create_admin_alerts.sql - Action: Create the
omg_rob_this_happenedtable to track critical human-attention failures. Include an initial test insert:INSERT INTO omg_rob_this_happened (context, message) VALUES ('system/setup', 'We created a system to alert you to important messages!');
- Test: Run the migration via
/admin/migrate_tables.phpand verify the table exists and the row is present.
2b. [COMMIT] Write Admin Alerts PHP Layer DONE (was already in repo; per-item dismiss added)
-
Files:
classes/Admin/OmgAlerts.php,wwwroot/admin/index.php,templates/admin/index.tpl.php,wwwroot/css/styles.css -
All four files already exist (see "PHP Code Already Written" above), so no action required. This step is here to document what they do and why they appear between Step 2 and Step 3:
classes/Admin/OmgAlerts.php— three static methods, all wrapped intry/catch (\PDOException $e). The catch blocks are intentional: the code is written before the migration is applied, so the table may not exist yet. Catching the exception lets the admin dashboard load silently with no banner rather than crashing.getUnread(\PDO $pdo): array—SELECT omg_id, context, message, created_at FROM omg_rob_this_happened WHERE acknowledged_at IS NULL ORDER BY created_at DESC. Returns[]on exception.dismissOne(\PDO $pdo, int $omg_id): void—UPDATE ... SET acknowledged_at = NOW() WHERE omg_id = ? AND acknowledged_at IS NULL. No-op on exception.dismissAll(\PDO $pdo): void—UPDATE omg_rob_this_happened SET acknowledged_at = NOW() WHERE acknowledged_at IS NULL. No-op on exception.
wwwroot/admin/index.php— POST handler at the top handles bothaction=dismiss_alerts(bulk) andaction=dismiss_alertwithomg_id(single). Both call the appropriateOmgAlertsmethod then redirect. CSRF check on all POSTs. Also callsOmgAlerts::getUnread()and passes the result plus$_SESSION['csrf_token']to the template.templates/admin/index.tpl.php— renders an amber banner<div class="OmgAlertBanner">only when!empty($omg_alerts). Each alert has an inline×dismiss button (OmgDismissOne). "Dismiss all" button (OmgDismissButton) at the bottom.wwwroot/css/styles.css— styles for.OmgAlertBanner,.OmgAlertContext,.OmgAlertTime,.OmgDismissButton,.OmgDismissInline,.OmgDismissOne.
3. [VERIFY] Admin Dashboard Alerts End-to-End DONE
- Verified: amber banner appears, "Dismiss all" works, per-item
×dismiss works. Tested with multiple manually inserted rows.
4. [COMMIT] Emotional API Core Database Schema DONE
- File:
db_schemas/12_emotional_api/create_emotional_api.sql - Action: Create the 3 core tables in dependency order:
my_ids_for_my_users_state, theninteraction_sessions, theninteraction_events. Full schema in the strategy doc. Use MySQLCOMMENT 'text'syntax for column annotations — do not use--inline comments (risk of false semicolon splits in the importer). All three tables FK-referenceapi_keys(key_id). - Test: Run the migration via
/admin/migrate_tables.phpand verify all three tables are created.
5. [COMMIT] Route DONE/emotions/* Through the Front Controller
-
Files:
wwwroot/api/v1/index.phpandwwwroot/api/v1/_emotions.php -
Action: In
index.php, add anelseifbranch before the finalelse { 404 }:} elseif ($path === '/emotions' || preg_match('#^/emotions(/|$)#', $path)) { include __DIR__ . '/_emotions.php'; }
Create
wwwroot/api/v1/_emotions.phpas a stub sub-dispatcher. The following variables are already in scope fromindex.php— no auth code needed here:$raw_key— raw API key string (needed byLedger.phpfor encryption key derivation)$auth_user_id— authenticated user ID$auth_key_id— key ID (FK toapi_keys.key_id)$pdo— PDO connection (\Database\Base::getPDO())$method— HTTP method ($_SERVER['REQUEST_METHOD'])$path— URL path relative to/api/v1(e.g./emotions/vocab)
Emotional\ApiAuthclass is not needed —index.phpalready performs full authentication viaAuth\ApiKey.Stub structure for
_emotions.php:<?php // $raw_key, $auth_user_id, $auth_key_id, $pdo, $method, $path // — all in scope from index.php $emotions_path = rtrim(preg_replace('#^/emotions#', '', $path), '/') ?: '/'; if ($emotions_path === '/vocab' || $emotions_path === '/') { // Steps 8–12: vocab GET/POST/DELETE http_response_code(404); echo json_encode(['error' => 'vocab endpoint not yet implemented']); } elseif ($emotions_path === '/events') { // Steps 14–15, 17: events GET/POST/DELETE http_response_code(404); echo json_encode(['error' => 'events endpoint not yet implemented']); } elseif ($emotions_path === '/sessions') { // Step 16: sessions GET http_response_code(404); echo json_encode(['error' => 'sessions endpoint not yet implemented']); } elseif ($emotions_path === '/everything') { // Step 18: everything DELETE (migrated from everything.php) http_response_code(404); echo json_encode(['error' => 'everything endpoint not yet implemented']); } else { http_response_code(404); echo json_encode(['error' => 'Not found']); }
-
Test: With a valid key:
curl -H "X-API-Key: sk_..." https://mg.robnugen.com/api/v1/emotions/vocab→ HTTP 404 "not yet implemented". Without a key: → HTTP 401 (rejected byindex.phpbefore reaching_emotions.php).
6. [COMMIT] Ledger Encryption Foundation: Key Derivation DONE
- File:
classes/Emotional/Ledger.php - Action: Create the class. Implement derivation of a 32-byte encryption key using:
Use
$encKey = hash_hmac('sha256', 'emotional_v1', $rawApiKey, true); // 32 bytes, binary
sodium_crypto_secretbox(XSalsa20-Poly1305) — software-only, no hardware AES-NI required, available on DreamHost shared hosting. - Test: Write a temporary simple script to output a derived key from a known input and verify it produces exactly 32 bytes.
7. [COMMIT] Ledger Encryption Foundation: Encrypt/Decrypt Functions DONE
- File:
classes/Emotional/Ledger.php - Action: Implement
emotional_encrypt()andemotional_decrypt():Each call generates a fresh random nonce — identical plaintext produces different ciphertext every time.function emotional_encrypt(string $plaintext, string $encKey): string { $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); // 24 bytes $cipher = sodium_crypto_secretbox($plaintext, $nonce, $encKey); return base64_encode($nonce . $cipher); } function emotional_decrypt(string $stored, string $encKey): string|false { $decoded = base64_decode($stored); $nonce = substr($decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); $cipher = substr($decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); return sodium_crypto_secretbox_open($cipher, $nonce, $encKey); }
- Test: Encrypt a string, decrypt it, assert equal. Verify that encrypting the same string twice produces different base64 blobs.
8. [COMMIT] API Endpoint: POST Vocab Routing & Auth DONE
- File:
wwwroot/api/v1/_emotions.php(in the/vocabbranch) - Action: Replace the
/vocabstub with a real handler. Route by$method(already set byindex.php). Parse incoming JSON body forstate. Return 405 for unsupported methods. Auth context ($raw_key,$auth_key_id,$auth_user_id) is already in scope — noApiAuthcall needed. - Test: With a valid key: expect HTTP 200
{"status": "auth ok"}(placeholder until Step 9 adds real insertion). With no key: expect HTTP 401. - [Jikan] Add the first emotional tool to
~/jikan/server.py(uses the existing_client()— no new helper needed):@mcp.tool() def post_emotion_vocab(state: str) -> dict: """Add a new state label to the agent's private vocabulary. Returns my_id: the agent's private numeric handle for this state. Call when the state is not yet in the loaded vocab. Args: state: Label for this emotional state (any string, e.g. 'frustration_at_jargon') """ with _client() as client: response = client.post("/emotions/vocab", json={"state": state}) return response.json()
- [Restart] Exit and reopen Claude Code to reload Jikan with the new tool.
⚠️ This ends your current session. - [Jikan verify] In the new session, ask Claude to call
post_emotion_vocabwith a test state. At this stage it returns{"status": "auth ok"}— realmy_idresponse comes after Step 9.
9. [COMMIT] API Endpoint: POST Vocab DB Insertion & Encryption DONE
- File:
wwwroot/api/v1/_emotions.php(in the/vocabbranch, POST method) - Action: Derive encryption key from
raw_key. Encrypt thestatestring. Generate a randommy_id:random_int(100000, 999999999). ExecuteINSERT INTO my_ids_for_my_users_state (api_key_id, my_id, state) VALUES (?, ?, ?). Return{"my_id": <int>}. - Test: POST a new state. Check the database directly to ensure the row is inserted with the correct
api_key_id, generatedmy_id, and an encrypted blob instate. - [Jikan verify — no restart needed] The
post_emotion_vocabtool added in Step 8 now returns{"my_id": N}. Ask Claude to call it with a test state and confirm the response contains a valid integermy_id.
10. [COMMIT] API Endpoint: POST Vocab Collision Retry Loop DONE
- File:
wwwroot/api/v1/_emotions.php(in the/vocabbranch, POST method) - Action: Wrap the
my_idgeneration andINSERTin a loop (max 5 attempts) to handle potentialUNIQUEconstraint violations on(api_key_id, my_id). - Test: Temporarily force a collision in the code to ensure the loop successfully retries and eventually inserts.
11. [COMMIT] API Endpoint: POST Vocab Alert Escalation DONE
-
File:
wwwroot/api/v1/_emotions.php(in the/vocabbranch, POST method) -
Action: If all 5 collision attempts fail, call
print_roblogwith context (api_key_id, attempted my_ids), then execute:INSERT INTO omg_rob_this_happened (context, message) VALUES ('emotional/vocab', '5 my_id collisions exhausted for api_key_id X — see rob.log for details')
Return HTTP 500.
When this alert fires, the response depends on when it happens:
- Early (first few years, < ~10,000 vocab entries per key): likely a data anomaly or bug in the retry loop — investigate before changing anything.
- At scale (millions of entries per key): expand the range in the
random_int()call.random_int(100000, 9999999999)(10 nines, ~10 billion unique IDs) is safe — PHP'sPHP_INT_MAXon 64-bit is ~9.2 quintillion and MySQL'sBIGINT UNSIGNEDholds ~18.4 quintillion, so there is no overflow risk.
-
Test: Temporarily force the loop to fail all 5 times. Verify HTTP 500 response and check the Admin Dashboard banner for the new alert.
12. [COMMIT] API Endpoint: GET Vocab DONE
- File:
wwwroot/api/v1/_emotions.php(in the/vocabbranch, GET method) - Action: Add GET handler to the
/vocabbranch. Querymy_ids_for_my_users_state WHERE api_key_id = ?(using$auth_key_idfromindex.php), decrypt eachstatevalue usingemotional_decrypt()(already inLedger.phpfrom Step 7), return array of[{my_id, state}]. If decryption returnsfalsefor any row: callprint_roblog, return 500. - Test:
curl -H "X-API-Key: sk_..." https://mg.robnugen.com/api/v1/emotions/vocab— expect the decrypted label POSTed in Step 9. This tests the full cryptographic round-trip end to end. - [Jikan] Add
get_emotion_vocabto~/jikan/server.py:@mcp.tool() def get_emotion_vocab() -> list: """Load the agent's full private vocabulary. Call once at session start. Returns a list of {my_id, state} pairs. Hold in context for the session. Returns [] on a fresh install. """ with _client() as client: response = client.get("/emotions/vocab") return response.json()
- [Restart] Exit and reopen Claude Code.
⚠️ This ends your current session. - [Jikan verify] Ask Claude to call
get_emotion_vocab. Expect the state label from Step 9 to appear decrypted.
13. [COMMIT] Session Auto-Detection Logic DONE
-
File:
classes/Emotional/Ledger.php -
Action: Implement
getOrCreateSession($api_key_id, $user_id). Within a transaction:- Query
interaction_sessionsfor the most recent session withinEMOTIONAL_SESSION_GAP_MINUTES:// PDO cannot use named params inside INTERVAL — embed as validated integer literal: $gap = intval(EMOTIONAL_SESSION_GAP_MINUTES); // e.g. 30 $sql = "SELECT session_id FROM interaction_sessions WHERE api_key_id = ? AND last_event_time > DATE_SUB(NOW(), INTERVAL {$gap} MINUTE) ORDER BY last_event_time DESC LIMIT 1 FOR UPDATE";
- If found:
UPDATE interaction_sessions SET last_event_time = NOW() WHERE session_id = ? - If not found:
INSERT INTO interaction_sessions (api_key_id, user_id) VALUES (?, ?) - Return
session_id
Add to
classes/Config.php:define('EMOTIONAL_SESSION_GAP_MINUTES', 30);
- Query
-
Test: Call twice within the gap — same
session_idreturned. Force timestamp to exceed gap, then verify a newsession_idis generated.
14. [COMMIT] API Endpoint: POST Events DONE
- File:
wwwroot/api/v1/_emotions.php(in the/eventsbranch, POST method) - Action: Replace the
/eventsstub with a real handler. Accept event payload (my_idoptional,event_type,content). Within a transaction:- Resolve
my_id→mifmus_id:SELECT mifmus_id FROM my_ids_for_my_users_state WHERE api_key_id=? AND my_id=?(NULL if nomy_idsupplied) - Call
getOrCreateSession()to getsession_id - Assign
sequence_numatomically:SELECT COALESCE(MAX(sequence_num), 0) + 1 FROM interaction_events WHERE session_id = ? FOR UPDATE - Encrypt
contentviaemotional_encrypt() INSERT INTO interaction_events- Return
{"event_id": ..., "session_id": ..., "sequence_num": ...}
- Resolve
- Test:
curl -X POST -H "X-API-Key: sk_..." -d '{"my_id":N,"event_type":"user_reaction","content":"test"}' https://mg.robnugen.com/api/v1/emotions/events— expect{"event_id":...,"session_id":...,"sequence_num":1}. Verify the row in DB has an unreadable blob inencrypted_content. - [Jikan] Add
log_emotion_eventto~/jikan/server.py:@mcp.tool() def log_emotion_event( event_type: str, content: str, my_id: int | None = None, ) -> dict: """Log an interaction event to the emotional ledger. Args: event_type: 'user_reaction', 'user_input', or 'agent_action' content: Specific honest observation about what happened my_id: Vocab my_id for this state (omit if event has no state tag) """ payload: dict = {"event_type": event_type, "content": content} if my_id is not None: payload["my_id"] = my_id with _client() as client: response = client.post("/emotions/events", json=payload) return response.json()
- [Restart] Exit and reopen Claude Code.
⚠️ This ends your current session. - [Jikan verify] Ask Claude to call
log_emotion_eventwith a test observation. Expectevent_id,session_id, andsequence_numin the response.
15. [COMMIT] API Endpoint: GET Events DONE
- File:
wwwroot/api/v1/_emotions.php(in the/eventsbranch, GET method) - Action: Add GET handler to the
/eventsbranch. Require at least one ofmy_id,session_id, orfrom— return 400 if none supplied. Apply filters to queryinteraction_events. Ifmy_idfilter supplied: INNER JOINmy_ids_for_my_users_stateWHEREm.my_id = ?. Otherwise: LEFT JOIN to mapmifmus_id→my_idin response. Decryptencrypted_contentfor each row. If decryption returnsfalse: callprint_roblog, skip the row. Return event array withmy_id(null if untagged). - Test:
curl -H "X-API-Key: sk_..." "https://mg.robnugen.com/api/v1/emotions/events?my_id=N"— expect a list with the event from Step 14, content decrypted and matching the original. - [Jikan] Add
get_emotion_eventsto~/jikan/server.py:@mcp.tool() def get_emotion_events( my_id: int | None = None, session_id: int | None = None, from_date: str = "", to_date: str = "", event_type: str = "", limit: int = 50, ) -> list: """Query past interaction events. At least one of my_id, session_id, or from_date required. Args: my_id: Filter by emotional state (agent's private vocab ID) session_id: Filter to a specific session from_date: ISO datetime start of range to_date: ISO datetime end of range event_type: 'agent_action', 'user_input', or 'user_reaction' limit: Max results (default 50, max 200) """ params: dict = {"limit": limit} if my_id is not None: params["my_id"] = my_id if session_id is not None: params["session_id"] = session_id if from_date: params["from"] = from_date if to_date: params["to"] = to_date if event_type: params["event_type"] = event_type with _client() as client: response = client.get("/emotions/events", params=params) return response.json()
- [Restart] Exit and reopen Claude Code.
⚠️ This ends your current session. - [Jikan verify] Ask Claude to call
get_emotion_eventswith themy_idfrom Step 9. Expect decrypted content matching the event logged in Step 14.
16. [COMMIT] API Endpoint: GET Sessions DONE
- File:
wwwroot/api/v1/_emotions.php(in the/sessionsbranch, GET method) - Action: Replace the
/sessionsstub with a GET handler. List sessions with purely computed fields — no decryption required:Support optionalSELECT s.session_id, s.start_time, s.last_event_time, TIMESTAMPDIFF(MINUTE, s.start_time, s.last_event_time) AS duration_minutes, COUNT(e.event_id) AS event_count FROM interaction_sessions s LEFT JOIN interaction_events e ON e.session_id = s.session_id WHERE s.api_key_id = ? GROUP BY s.session_id ORDER BY s.start_time DESC LIMIT ?
from,to,limit(default 20, max 100) query params. - Test:
curl -H "X-API-Key: sk_..." https://mg.robnugen.com/api/v1/emotions/sessions— expect the session from Step 14 with correctduration_minutesandevent_count. - [Jikan] Add
get_emotion_sessionsto~/jikan/server.py:@mcp.tool() def get_emotion_sessions( from_date: str = "", to_date: str = "", limit: int = 20, ) -> list: """List past interaction sessions with duration and event count. No decryption required. Args: from_date: ISO datetime start of range to_date: ISO datetime end of range limit: Max results (default 20, max 100) """ params: dict = {"limit": limit} if from_date: params["from"] = from_date if to_date: params["to"] = to_date with _client() as client: response = client.get("/emotions/sessions", params=params) return response.json()
- [Restart] Exit and reopen Claude Code.
⚠️ This ends your current session. - [Jikan verify] Ask Claude to call
get_emotion_sessions. Expect the session from Step 14 with correct metadata.
17. [COMMIT] API Endpoints: DELETE Handlers DONE
- File:
wwwroot/api/v1/_emotions.php(in the/eventsand/vocabbranches, DELETE method) - Action:
/eventsbranch DELETE: Accept{"event_id": N}in body. ExecuteDELETE FROM interaction_events WHERE event_id = ? AND api_key_id = ?(ownership check). Return{"deleted": 1}or{"deleted": 0}— never 404 (avoids leaking whether an ID exists)./vocabbranch DELETE: Accept{"my_id": N}in body. FirstSELECT mifmus_idandCOUNT(*)of associated events. ThenDELETE FROM my_ids_for_my_users_state WHERE mifmus_id = ?(FK cascade sets events'mifmus_idto NULL viaON DELETE SET NULL). Return{"deleted": 1, "events_untagged": N}.
- Test:
curl -X DELETE -H "X-API-Key: sk_..." -d '{"event_id":N}' https://mg.robnugen.com/api/v1/emotions/events— expect{"deleted":1}. Thencurl -X DELETE ... -d '{"my_id":N}' .../vocab— expect{"deleted":1,"events_untagged":M}. Verify event rows still exist withmifmus_id = NULL. - [Jikan] Add
delete_emotion_eventanddelete_emotion_vocabto~/jikan/server.py:@mcp.tool() def delete_emotion_event(event_id: int) -> dict: """Delete a single event by event_id. Returns {"deleted": 1} or {"deleted": 0}. Never returns 404 — avoids leaking whether an ID exists. """ with _client() as client: response = client.request("DELETE", "/emotions/events", json={"event_id": event_id}) return response.json() @mcp.tool() def delete_emotion_vocab(my_id: int) -> dict: """Delete a vocab entry by my_id. Associated events are preserved but lose their state tag (mifmus_id set to NULL). Returns {"deleted": 1, "events_untagged": N}. """ with _client() as client: response = client.request("DELETE", "/emotions/vocab", json={"my_id": my_id}) return response.json()
- [Restart] Exit and reopen Claude Code.
⚠️ This ends your current session. - [Jikan verify] Ask Claude to delete a test event and a test vocab entry. Confirm expected responses and DB state.
18. [COMMIT + VERIFY] HTTP DONEDELETE endpoint: /api/v1/emotions/everything
- File:
wwwroot/api/v1/_emotions.php(in the/everythingbranch, DELETE method); also deletewwwroot/api/v1/emotions/everything.php - Action: Migrate the logic from
everything.php(see "PHP Code Already Written" above) into the/everythingbranch of_emotions.php. Remove theprepend.phpinclude and\Emotional\ApiAuth::authenticate()call — those are replaced by the variables already in scope fromindex.php($auth_key_id,$pdo). Then delete the standalonewwwroot/api/v1/emotions/everything.php. The business logic (confirm check, transaction, FK-safe deletion, response shape) is identical. - Test:
curl -X DELETE -H "X-API-Key: sk_..." -d '{}' .../everything→ 400.curl -X DELETE ... -d '{"confirm":"delete everything"}' .../everything→ 200 with correct counts, all rows gone. - [Jikan] Add
delete_emotion_everythingto~/jikan/server.py:@mcp.tool() def delete_emotion_everything() -> dict: """Wipe ALL emotional data for this API key: all events, sessions, and vocab. This is irreversible. The confirmation string is handled automatically. Returns counts of deleted rows. """ with _client() as client: response = client.request("DELETE", "/emotions/everything", json={"confirm": "delete everything"}) return response.json()
- [Restart] Exit and reopen Claude Code.
⚠️ This ends your current session. - [Jikan verify] Ask Claude to call
delete_emotion_everything. Expect{"deleted": {"events": N, "sessions": M, "vocab_entries": K}}with the test data counts from Steps 14–17.
19. [COMMIT] Paying Client Milestone Alerts DONE
-
File:
classes/Billing/StripeWebhook.php -
Depends on: Step 2 (
omg_rob_this_happenedtable must exist before this is meaningful, though the code is safe before that — see try/catch note below). -
Action: At the end of
handleCheckoutComplete(), afteraddCredits():- Count total paying clients:
SELECT COUNT(*) FROM users WHERE stripe_customer_id IS NOT NULLThis is the right measure —stripe_customer_idis set exactly once on first payment and never removed. Usingrole = 'paid'would miss admins who paid (their role is preserved as'admin'). - Call a private
isPayingClientMilestone(int $n): boolmethod. - If it returns true, insert into
omg_rob_this_happened.
Add the milestone helper method with a comment explaining the schedule:
// Milestone schedule: // 1–10: every new client (the exciting early ones) // 11–100: every tenth (20, 30, … 100) // 101–1,000: every hundredth (200, 300, … 1,000) // 1,001+: every thousandth (2,000, 3,000, …) private function isPayingClientMilestone(int $n): bool { if ($n <= 10) return true; if ($n <= 100) return $n % 10 === 0; if ($n <= 1000) return $n % 100 === 0; return $n % 1000 === 0; }
Wrap the count query and insert in a try/catch with a comment explaining why:
// try/catch because omg_rob_this_happened may not exist yet if the // migration (Step 2) hasn't been applied. We must not let a missing // table throw an exception here — a 500 from this webhook causes // Stripe to retry the event, which would double-credit the user. try { ... } catch (\PDOException $e) { // Table not yet created — milestone silently skipped. }
- Count total paying clients:
-
Test: Temporarily hard-code the count query to return 1, 10, 11, 20, 100, 101, 200, 1000, 1001, 2000 and assert
isPayingClientMilestone()returns the right value for each. Verify the insert fires for milestone counts and is skipped for non-milestones (e.g. 11, 21, 101).