Digital Debt Tracker

I built a Digital Debt Tracker to stop losing money
on forgotten subscriptions 💳

We all have subscriptions we forgot about — Netflix,
Spotify, random trials that auto-renewed. I built this
app to track them all in one place.

What it does

  • Dashboard to see all subscriptions at a glance
  • Monthly & yearly spending totals
  • Track billing dates & trial expirations
  • Add notes and categories for each subscription

Tech Stack

  • Frontend: React 18
  • Backend: Node.js + Express
  • Database: SQLite
  • Auth: JWT authentication

Why I built it

Most people lose $50-200/year on forgotten
subscriptions. This app solves that.

GitHub

https://github.com/GGWarrior001/Digital-Debt-Tracker

Drop a ⭐ if you find it useful!

Building a Chat Assistant Module for Magento 2: Observers, Message Queues, and 10K Products

Magento stores are large. Not WooCommerce “500 products with a few categories” large. Magento stores run 10,000+ SKUs, configurable products with dozens of variations, multiple store views for different languages, and Multi-Source Inventory across warehouses.

I built Emporiqa, a chat assistant for e-commerce stores. After shipping integrations for Drupal Commerce, WooCommerce, and Sylius, Magento was next. The catalog complexity made it the most interesting one to build.

Here’s how the module works under the hood.

The Problem: Chat That Knows Nothing

Most chat solutions for Magento are JavaScript widgets that sit on the page and know nothing about your catalog. Customer asks “do you have running shoes under 80 euros in size 42?” The widget either sends them to the search page or gives a generic response.

To answer product questions, the chat assistant needs the actual catalog data: product names, descriptions, prices, stock levels, categories, images, and variation attributes. For Magento, it also needs to understand configurable product relationships and handle per-store-view translations.

Architecture: Observers → Queue → Webhooks

The module doesn’t run language models inside Magento. That would tank admin performance. Instead, it sends product and page data to Emporiqa via webhooks, and the chat processing happens on separate infrastructure.

The flow:

  1. Magento observer fires on catalog_product_save_after, catalog_product_delete_before, cms_page_save_after, etc.
  2. The observer formats the product into a consolidated payload (all store view translations in one event)
  3. The event is published to Magento’s DB message queue (emporiqa.webhook.consumer)
  4. The queue consumer sends the webhook to Emporiqa with HMAC-SHA256 signature

The flow is non-blocking: the admin saves a product, the observer runs in milliseconds, and the actual HTTP request happens asynchronously via the queue consumer.

Configurable Products: Parent/Child Consolidation

This was the tricky part. Magento’s configurable products have a parent that holds the name, description, and images, plus child simple products that hold the actual SKU, price, and stock.

The module syncs both:

Parent (configurable):
  identification_number: "product-123"
  sku: "JACKET-BLUE"
  is_parent: true
  variation_attributes: {"base": {"en-us": ["Color", "Size"], "de-de": ["Farbe", "Größe"]}}
  names: {"base": {"en-us": "Trail Jacket", "de-de": "Wanderjacke"}}

Child (simple):
  identification_number: "variation-124"
  sku: "JACKET-BLUE-M"
  is_parent: false
  parent_sku: "JACKET-BLUE"
  prices: {"base": [{"currency": "EUR", "current_price": 79.99}]}
  stock_quantities: {"base": 42}

On the Emporiqa side, the assistant understands that “Trail Jacket” comes in multiple colors and sizes, with specific prices and stock per variation. A customer asking “do you have the trail jacket in medium?” gets an answer based on actual inventory.

Multi-Store-View Consolidation

Magento uses store views for languages. A product might have an English name in the default store view and a German name in the de_DE store view.

Rather than sending one webhook per store view (which would multiply requests by the number of languages), the module consolidates everything into one payload per product:

{
  "names": {
    "base": {
      "en-us": "Summer Jacket",
      "de-de": "Sommerjacke"
    }
  },
  "descriptions": {
    "base": {
      "en-us": "Lightweight jacket for warm days",
      "de-de": "Leichte Jacke für warme Tage"
    }
  }
}

The channel key "base" is the Magento website code. If you have multiple websites (e.g., base, b2b), products nest under each website’s code as the channel key.

The ProductFormatter service handles this. It iterates over all enabled store views, loads the product in each store view’s context, and merges the translations into one structure.

MSI: Salable Quantity vs Physical Stock

Magento’s Multi-Source Inventory (MSI) tracks stock across multiple warehouses. Customers care about the salable quantity (what’s actually available to sell after reservations), not the physical stock at any individual source.

The module uses MSI’s GetProductSalableQtyInterface when available and falls back to StockRegistryInterface for stores not using MSI:

// Simplified: the module checks for MSI availability at runtime
try {
    $salableQty = $this->getProductSalableQty->execute($sku, $stockId);
} catch (Exception $e) {
    // Fallback to legacy stock
    $stockItem = $this->stockRegistry->getStockItemBySku($sku);
    $salableQty = $stockItem->getQty();
}

This handles both Magento Open Source (which may not have MSI) and Adobe Commerce (which always does).

Message Queue: Why Not Direct HTTP

I could have sent webhooks directly from the observer. Drupal’s integration does something similar with queue workers. But Magento’s observer system runs inside the request lifecycle, and making HTTP calls during catalog_product_save_after would slow down admin operations.

Magento’s DB message queue solves this. The observer publishes a message, the consumer processes it later. For large catalog imports (think: importing 5,000 products via CSV), the queue absorbs the load and the consumer works through them at its own pace.

The consumer is either started manually (bin/magento queue:consumers:start emporiqa.webhook.consumer) or runs via Magento’s cron-based consumer processing.

Cart and Order Tracking

The module exposes REST endpoints at /rest/V1/emporiqa/cart/* for in-chat cart operations (add, remove, update, checkout URL). All use same-origin session cookies, no API tokens needed. The chat widget’s EmporiqaCartHandler JavaScript calls these automatically.

For order tracking, a separate endpoint at /rest/V1/emporiqa/order/tracking accepts HMAC-signed requests from Emporiqa. The customer must verify their billing email before any order data is returned. Requests older than 5 minutes are rejected.

CLI Commands for Automation

Sync commands for deployment pipelines or scheduled jobs:

bin/magento emporiqa:sync:products          # Full product sync
bin/magento emporiqa:sync:pages             # Full CMS page sync
bin/magento emporiqa:sync:all               # Both
bin/magento emporiqa:test-connection         # Verify webhook connectivity
bin/magento emporiqa:sync:products --dry-run # Test without sending
bin/magento emporiqa:sync:products --batch-size=25

Each sync creates a session (sync.start → batched events → sync.complete). Items not seen during the session are marked as deleted on the Emporiqa side, so you don’t need to manually delete stale products.

What Doesn’t Work Well

Grouped and bundle products sync as standalone items without parent/child relationships. The module handles simple and configurable products with full parent/child consolidation. Grouped and bundle product types sync their component products individually, without the grouping structure.

The queue consumer needs babysitting. If emporiqa.webhook.consumer dies (OOM, timeout, server restart), webhook events pile up silently. Magento’s cron-based consumer processing is the safer option for production, but it adds latency. There’s no built-in alerting when the queue backs up.

CMS page sync is basic. Magento’s CMS pages are flat content blocks. The module syncs title, content, and URL, but can’t handle Page Builder layouts or widget directives. Stores using complex Page Builder pages get the raw content, which isn’t always useful.

What I Learned

Three things stood out building this compared to the WooCommerce and Sylius integrations:

  1. MSI detection is messy. You can’t assume MSI is present. Magento Open Source installs may have it removed. The module needs graceful fallback at runtime, not compile time.

  2. Configurable product observers fire for children too. When saving a configurable product, Magento also saves each child. Without deduplication, you’d send the same parent product multiple times. The queue handles this with a simple check before publishing.

  3. Store view context switching is expensive. Loading a product in store view A, then store view B, then back to A involves Magento’s store emulation. The formatter batches all store views together to minimize context switches.

Try It

The module is on the Adobe Commerce Marketplace and supports Magento 2.4.4+ (both Open Source and Adobe Commerce).

composer require emporiqa/module-chat-assistant
bin/magento module:enable Emporiqa_ChatAssistant
bin/magento setup:upgrade
bin/magento cache:flush

Start with a free sandbox store (100 products and 20 pages, no credit card). Sync some of your catalog, test with questions your customers ask, and see if the results make sense.

Full setup guide: emporiqa.com/docs/magento/. I also wrote about the full Magento integration on the Emporiqa blog.

Cross-Chain Bridging for AI Agents: LI.FI and Across

Your trading bot needs to move funds from Ethereum to Solana, then swap for USDC, then stake on Jito — all programmatically. Instead of juggling multiple APIs, wallet connections, and bridge interfaces, what if you could execute cross-chain strategies with simple REST calls?

The Multi-Chain DeFi Problem

DeFi is fragmented across chains. Your yield strategy might require Aave lending on Ethereum, Jupiter swaps on Solana, and Drift perpetuals — but each protocol has different APIs, signing patterns, and wallet requirements. Building a cross-chain trading system means:

  • Managing wallets on multiple chains
  • Learning each protocol’s SDK (Jupiter, Across, LI.FI all have different interfaces)
  • Handling bridge timing and gas optimization
  • Coordinating transaction sequences across chains

The complexity compounds when you’re building AI agents that need to execute these strategies autonomously. Your agent shouldn’t need to understand Ethereum gas mechanics AND Solana transaction versions AND bridge settlement times.

Cross-Chain Bridges in WAIaaS

WAIaaS provides unified REST APIs for cross-chain bridging through LI.FI and Across protocols. Your AI agent gets one interface to move funds between any supported chains, with automatic route optimization and transaction monitoring.

LI.FI aggregates bridge liquidity across protocols like Hop, Connext, and Stargate. Across specializes in fast, capital-efficient bridging between major EVM chains. WAIaaS handles the complexity — your agent just specifies source chain, destination chain, and amount.

Bridge Integration Example

Here’s how to bridge USDC from Ethereum to Polygon using LI.FI:

curl -X POST http://127.0.0.1:3100/v1/actions/lifi/bridge 
  -H "Content-Type: application/json" 
  -H "Authorization: Bearer wai_sess_<token>" 
  -d '{
    "fromChain": "ethereum-mainnet",
    "toChain": "polygon-mainnet",
    "fromToken": "0xA0b86a33E6441146C3039A5CB6EfF1f1De03aAef",
    "toToken": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",
    "amount": "100000000",
    "recipient": "0x742d35Cc6B99D100c3b3AA5F9A05d6d4b2f5B8A4"
  }'

WAIaaS returns the transaction ID immediately. The bridge operation happens asynchronously — you can monitor progress through the transaction status API:

curl http://127.0.0.1:3100/v1/transactions/<tx-id> 
  -H "Authorization: Bearer wai_sess_<token>"

The response includes bridge-specific status like settlement time estimates and destination transaction hashes once available.

Cross-Chain Strategy Automation

With 14 DeFi protocol providers integrated (Aave, Jupiter, Lido, Drift, Hyperliquid, and more), you can build sophisticated cross-chain strategies. Here’s a yield optimization sequence:

  1. Bridge USDC from Ethereum to Solana via LI.FI
  2. Swap half to SOL using Jupiter
  3. Stake SOL with Jito liquid staking
  4. Use remaining USDC for Drift perpetual position

Each step is a single API call:

# Step 1: Bridge USDC ETH → SOL
curl -X POST http://127.0.0.1:3100/v1/actions/lifi/bridge 
  -H "Authorization: Bearer wai_sess_<token>" 
  -d '{"fromChain": "ethereum-mainnet", "toChain": "solana-mainnet", ...}'

# Step 2: Swap USDC → SOL on Jupiter  
curl -X POST http://127.0.0.1:3100/v1/actions/jupiter-swap/swap 
  -H "Authorization: Bearer wai_sess_<token>" 
  -d '{"inputMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "outputMint": "So11111111111111111111111111111111111111112", ...}'

# Step 3: Stake SOL with Jito
curl -X POST http://127.0.0.1:3100/v1/actions/jito-staking/stake 
  -H "Authorization: Bearer wai_sess_<token>" 
  -d '{"amount": "5000000000"}'

# Step 4: Open Drift perp position
curl -X POST http://127.0.0.1:3100/v1/actions/drift/place-order 
  -H "Authorization: Bearer wai_sess_<token>" 
  -d '{"marketIndex": 0, "direction": "long", "baseAssetAmount": "1000000000"}'

Policy-Controlled Bridge Operations

Cross-chain bridges involve significant funds movement. WAIaaS policy engine provides granular controls for bridge operations:

# Create bridge-specific policy
curl -X POST http://127.0.0.1:3100/v1/policies 
  -H "X-Master-Password: <password>" 
  -d '{
    "type": "ALLOWED_NETWORKS",
    "rules": {
      "networks": [
        {"network": "ethereum-mainnet"},
        {"network": "polygon-mainnet"},
        {"network": "solana-mainnet"}
      ]
    }
  }'

This policy blocks bridge operations to unauthorized chains. Combined with SPENDING_LIMIT policies, you can require human approval for large bridge amounts while allowing smaller automated operations.

The policy engine supports 21 policy types with 4 security tiers: INSTANT, NOTIFY, DELAY, APPROVAL. Bridge operations exceeding your delay threshold get queued for the specified delay period, giving you time to cancel if needed.

Multi-Chain Portfolio Monitoring

WAIaaS aggregates DeFi positions across all integrated protocols. One API call shows your complete cross-chain exposure:

curl http://127.0.0.1:3100/v1/defi/positions 
  -H "Authorization: Bearer wai_sess_<token>"

Response includes positions from Aave (Ethereum), Jupiter LP pools (Solana), Jito staking (Solana), Drift perpetuals (Solana), and other protocols — with USD values, health factors, and yield rates unified in one response.

Transaction Pipeline and Gas Optimization

WAIaaS runs a 7-stage transaction pipeline that handles bridge complexities automatically:

  • Stage 1-3: Validation, authentication, policy checks
  • Stage 4: Gas condition waiting — bridges execute only when gas meets your threshold
  • Stage 5: Execution with automatic retry logic
  • Stage 6-7: Confirmation monitoring across both chains

Gas conditional execution is crucial for bridges since they involve two transaction fees. Set your gas threshold to avoid executing during fee spikes:

# Transaction waits until Ethereum gas < 30 gwei
curl -X POST http://127.0.0.1:3100/v1/actions/across/bridge 
  -H "Authorization: Bearer wai_sess_<token>" 
  -d '{
    "fromChain": "ethereum-mainnet",
    "gasCondition": {"maxGasPrice": "30000000000"},
    ...
  }'

Getting Started with Cross-Chain DeFi

Here’s how to set up cross-chain bridging in 5 minutes:

  1. Start WAIaaS with Docker:
git clone https://github.com/minhoyoo-iotrust/WAIaaS.git
cd WAIaaS
docker compose up -d
  1. Create wallets on multiple chains:
npm install -g @waiaas/cli
waiaas quickset --mode mainnet  # Creates Ethereum + Solana wallets
  1. Fund your wallets — deposit tokens you want to bridge

  2. Create bridge policies:

waiaas quickstart  # Sets up reasonable default policies
  1. Test a bridge operation using the REST API examples above

Your wallets are now ready for cross-chain DeFi automation. The 39 REST API route modules handle everything from bridge routing to transaction monitoring.

Advanced Bridge Features

For production systems, WAIaaS offers additional bridge capabilities:

  • Dry-run simulation: Test bridge operations without execution using "dryRun": true
  • Batch operations: Bridge multiple tokens in one transaction
  • Custom recipients: Bridge directly to different addresses on destination chain
  • Route optimization: LI.FI automatically finds cheapest/fastest bridge routes

The MCP integration provides 45 tools that AI agents like Claude can use directly — including bridge operations, balance checks, and position monitoring across all chains.

What’s Next

Cross-chain DeFi strategies are just getting started. WAIaaS gives you the infrastructure to build sophisticated AI agents that can arbitrage yield opportunities, rebalance portfolios, and execute complex strategies across the entire DeFi ecosystem.

Ready to build cross-chain DeFi automation? Check out the full documentation and examples at https://github.com/minhoyoo-iotrust/WAIaaS, or explore the live API reference at https://waiaas.ai.

Velocity Is Up. Ask Why Your Team Is So Quiet.

The engineer has been at the company three years. Senior. Knows the system well. Knew it, anyway. The codebase has been moving fast since the AI tools arrived. New services, new patterns, new surface area every sprint. The features are shipping. The metrics look clean. Someone in a leadership meeting said the word “10x” and nobody pushed back because the PRs are right there on the board, merged and closed. I was in those rooms. I said the right things about momentum.

Then something breaks. And the engineer sits down to debug it.

Six months ago, they would have known exactly where to look. Not because they’re exceptional. Because they wrote the relevant code. They had the context in their hands. The muscle memory was there. You build that over years of writing, breaking, fixing, and writing again inside the same system. It lives in you before you think to use it.

Now the code was generated. The patterns aren’t theirs. The structure is mostly right. And the engineer is staring at something they technically own that they don’t understand at the line level. So they do what you do in that situation. They reach for AI to help them debug it.

AI writes. Human reviews. Human can’t debug. Human asks AI. AI writes more.

That loop is what nobody named when the mandate came down.

The Shift Nobody Announced

The throughput is real. I’m not disputing the board.

What the board doesn’t show is what the work actually became.

Before AI, a senior engineer’s edge wasn’t just technical skill. It was orientation. Knowing where to go. When something broke and drug them out of bed, they didn’t reason from first principles every time. They had a map built from years of writing the same system. That map was the actual asset. The thing that made them genuinely senior.

AI adoption at speed is quietly erasing it.

AI is generating new surface area faster than anyone can internalize it. The senior engineer reviewing a PR today may have never touched that module. Because AI wrote it. Because the sprint was moving and there was no reason to slow down to build context when the model could generate the function in forty seconds.

The result is a codebase that is nominally owned by the team and practically understood by no one.

That’s not on the engineers. That’s what happens when you optimize for output faster than the team can absorb it.

The Cycle

An engineer receives an AI-generated function to review. They evaluate it. Structure, logic, edge cases. They approve it. Time was saved. Velocity moved.

Three weeks later something breaks in that function. The engineer who approved it opens the file. The code isn’t wrong, exactly. But it’s not theirs. They don’t have the mental model for why it’s structured the way it is. The instinct they would have had if they’d written it … the one that says “this will fail under this condition” … isn’t there. Because instinct comes from authorship. You can’t review your way into it.

So they reach for AI. Ask it to help trace the bug. AI generates a hypothesis. The engineer evaluates it. Maybe it’s right. Maybe it isn’t. Either way, another layer of generated content sits in the stack that nobody fully owns. The loop deepens.

I’m not just describing what I’ve watched happen on teams. This is something I’ve felt. When I go into a codebase to debug something I reviewed but didn’t write, the instinct I used to carry isn’t there. I reach for AI. The cycle continues. The more I’ve thought about it, the more I understand it’s not a skill gap. It’s a context gap. And context doesn’t transfer through review.

For many senior engineers, the contrast is sharp and getting sharper. Junior engineers are building their baseline in this environment. The new way of working is just the way working works. For the people who built their careers on knowing exactly where to go, that loss is unmistakable. They feel it every time they open a file they technically approved but don’t recognize at depth. They feel it in every debugging session where the first instinct is no longer instinct at all.

They just don’t have permission to name it.

Instinct comes from authorship. You can’t review your way into it.

What Leadership Sees

Leadership sees the board. PRs merged. Features shipped. Velocity at a number that would have seemed ambitious eighteen months ago. Someone says “AI is working.” Everyone nods.

The team is quiet.

Not the quiet of focus. Not the quiet of flow state. The quiet of people navigating something unfamiliar, on the fly, without a compass, and without permission to say it’s hard.

Engineers are producing output. They’re also making up the methodology as they go, session by session, because nobody told them how to review AI-generated code at the pace they’re being asked to review it. There’s no playbook for the verification load. There’s no training for the judgment work that replaced the writing work. There’s a mandate and a dashboard and a stand-up.

The engineers are building the plane while flying it. Leadership is measuring how fast the plane is going.

I’ve heard the same story from enough people in enough orgs to know this is not an edge case. It’s the pattern.

Why Mandates Make It Worse

An AI mandate doesn’t just add a tool. It adds the pressure of compulsion to the fog of unfamiliarity.

Engineers who are already figuring out how to do this are now figuring it out under an org-level instruction to move faster. The pressure is real. The permission to name what it costs is gone. You don’t raise your hand in an all-hands and say “I used to know this codebase and now I don’t” when the company just announced AI is a strategic priority.

The senior engineers feel it most. The ones who built their careers on knowing the codebase. On being the person people came to when something broke. On having that orientation so internalized it felt like instinct. They’re the quietest ones in the room. Not because things are fine. Because what they’re experiencing doesn’t fit the narrative, and they’re not going to be the person who slows down the momentum.

That silence is not adaptation. It’s people absorbing a loss they were never given language for, while being told the change is unambiguously good.

When the engineers who used to push back in planning stop asking why … when the people who questioned the framing of a problem start accepting it and jumping straight to solution … that’s not focus. That’s depletion showing up as compliance. The same signal I described in the cognitive load piece. The pattern doesn’t stop at the individual day. It moves into the team’s relationship with its own codebase.

That silence is not adaptation. It’s people absorbing a loss they were never given language for, while being told the change is unambiguously good.

What This Requires From You

I’m not arguing against the tooling. I use it. The gains are real.

What I’m arguing for is understanding what your team is actually inside of.

Your senior engineers are not struggling because they can’t adapt. They’re struggling because something was taken from them that nobody acknowledged. The muscle memory. The orientation. The knowledge of exactly where to go when something breaks at an hour when you’d rather be anywhere else. That was years of accumulated context. AI adoption at speed is outrunning the ability to rebuild it. And mandates don’t leave room to say so.

Before you read another velocity dashboard, ask your team one question. Not about output. About navigation.

When something breaks, do you know where to look? Are you going there because you know it, or because you’re using AI to find your way back in?

If the answer is the second one, you are not watching a team that’s adapting smoothly. You are watching a team in the middle of a transition they haven’t been given room to name.

Meet them there. Not with a policy update. Not with a new process. With a conversation. What has actually changed for you? What do you own that you didn’t write? What do you not know anymore that you used to? What does debugging feel like now compared to a year ago?

Those answers won’t show up in the sprint summary. They will show up in who’s still on your team in twelve months, and what shape they’re in when they are.

Those answers won’t show up in the sprint summary. They will show up in who’s still on your team in twelve months.

The Quiet Is the Data

The throughput is real. The sustainability is not something you can see on the board.

The work didn’t shrink. It shifted. From creation to judgment. From authorship to evaluation. From knowing exactly where to go to reaching for a tool that didn’t write the code you’re trying to understand. It is a different category of work, running at a different pace, on a team whose muscle memory was built for something else.

Your team went quiet after AI arrived. That quiet is not a sign things are going well. It’s a sign your team is carrying something they don’t have permission to put down.

Stop celebrating velocity. Start asking your team what they actually know anymore.

That question is harder than reading the dashboard. It’s the only one worth asking before you find out the hard way.

One email a week from The Builder’s Leader. The frameworks, the blind spots, and the conversations most leaders avoid. Subscribe for free.

PWC 366, Task 2, Valid Times

PWC 366 Task 2, Valid Times

Here we are at another Weekly Challenge, ticking away the moments that make up a dull day. Fritter and waste the hours in an off-hand way. Kicking around on a piece of ground in your home town, waiting for someone or something to show you the way. Here’s a way.

The Requirements Phase

You are given a time in the form ‘HH:MM’. The earliest possible time is ‘00:00’ and the latest possible time is ‘23:59’. In the string time, the digits represented by the ‘?’ symbol are unknown, and must be replaced with a digit from 0 to 9. Write a script to return the count of different ways we can make it a valid time.

  • Example 1: Input: $time = "?2:34", Output: 3

    • 02:34, 12:34, 22:34
  • Example 2: Input: $time = "?4:?0", Output: 12

    • Combinations of hours 04 and 14, with minutes 00, 10, 20, 30, 40, 50
  • Example 3: Input: $time = "??:??", Output: 1440

  • Example 4: Input: $time = "?3:45", Output: 3

    • 03:45, 13:45, 23:45
  • Example 5: Input: $time = "2?:15", Output: 4

    • 20:15, 21:15, 22:15, 23:15

The Design Phase

My first thought is that it would be fun to make some kind of iterator or generator that replaces each ? with its valid possibilities. My second thought is, “Nah, that seems like a lot of work.” And since the cardinal virtue of Perl programming is laziness, I moved on to something simpler.

There are 24 valid hours and 60 valid minutes. The total valid combinations is the cross-product of the two. Let’s go with that.

The Implementation Phase

sub validTime($time)
{
    state @hour   = '00' .. '23';
    state @minute = '00' .. '59';

    my ($h, $m) = map { s/?/./gr } split(/:/, $time);

    return (grep /$h/, @hour) * (grep /$m/, @minute);
}

Notes:

  • state variables — These reference lists only need to be set up once, and only in the scope of this function. That’s what state does.

  • '00' .. '23' — The .. sequence operator has the nice feature that if you want leading zeroes, you get them. Looking at you, bash.

  • split(/:/, $time) — Divide the input into its components. As in so many weekly challenge tasks, I’m assuming that the input has already been sanitized and is showing up here in a valid form.

  • map { ... } — Do something to both the hours and the minutes.

  • s/?/./gr — The something is to replace ? with . so that it becomes a regular expression. The ? is a meta-character, so it needs to be quoted. Adding the r flag yields the modified string; otherwise s///g would yield the number of substitutions, which is not useful in this context.

  • my ($h, $m) = ... — Declaring and initializing two variables from a list of two things.

  • (grep /$h/, @hour) — Select a list of the valid hours that can match the $h pattern. This is in a scalar context (numerical multiplication), so the scalar value will result — the number of matches. Similarly for minutes.

  • We only need the count, so multiply the two. We could have generated the list of valid times by using the hours and minutes returned from grep in array context, but (I believe I already mentioned this), lazy.

The Delivery Phase

And there you have it. You run and you run to catch up with the sun, but it’s sinking, and racing around to come up behind you again. The sun is the same in a relative way, but you’re older, shorter of breath, and one day closer to death. But at least you solved weekly challenge 366.

Micronaut 4 application on AWS Lambda- Part 8 Measuring Lambda cold and warm starts with REST API application

In part 6, we learned how to develop a pure Micronaut REST application and deploy it on AWS Lambda.

For the preparation of my talk about developing Serverless Java REST applications on AWS using frameworks such as Micronaut, I found time to measure the performance of the Lambda function with the different approaches (see below). I refer to my articles where we didn’t use the Micronaut REST controllers directly, but the pure AWS Lambda functions, to see how we implemented the SnapStart and priming approaches. They look completely the same when using Micronaut REST controllers.

  1. No SnapStart. Please read the article Introduction to the sample application and first Lambda performance measurements.
  2. SnapStart enabled, but no priming applied. Please read the article Reducing Lambda cold starts with Lambda SnapStart.
  3. SnapStart enabled, and DynamoDB request priming applied. Please read the article Reducing Lambda cold starts with SnapStart and DynamoDB request priming.
  4. SnapStart enabled, and API Gateway request event priming applied. Please read the article Reducing Lambda cold starts with SnapStart and API Gateway request event priming.

Cold (c) and warm (w) start time with -XX:+TieredCompilation -XX:TieredStopAtLevel=1 compilation in ms:

Scenario Number c p50 c p75 c p90 c p99 c p99.9 c max w p50 w p75 w p90 w p99 w p99.9 w max
No SnapStart enabled 5496 5646 5894 6239 6378 6383 8.00 9.68 13.51 29.40 68.19 1817
SnapStart enabled but no priming applied, all 2064 2116 2185 2260 4007 4009 8.00 9.23 11.34 23.54 49.64 3201
SnapStart enabled but no priming applied, last 70 2042 2100 2166 2262 2262 2262 8.13 9.53 12.09 24.30 48.86 1742
SnapStart enabled and DynamoDB request priming applied, all 849 911 1856 1892 11951 1951 7.75 8.88 10.41 22.19 52.47 1115
SnapStart enabled and DynamoDB request priming applied, last 70 827 862 923 1151 1151 1151 7.87 9.09 10.66 22.73 48.06 449
SnapStart enabled and API Gateway request event priming applied, all 693 741 1443 1457 1460 1461 8.07 9.54 11.73 22.37 61.04 685
SnapStart enabled and API Gateway request event priming applied, last 70 685 728 773 893 893 893 8.33 10.00 12.30 23.28 52.47 240

What we observe is that both cold and warm start times of the sample application using Micronaut REST controllers are generally higher when using the pure AWS Lambda functions. I suspect the reason for it lies in an extra adapter layer used to convert the Lambda request to the REST controller. This adapter uses several extra dependencies, which leads to a much bigger artifact size, which is also one of the factors of the increased cold start times. The advantage of using the REST controller is the ability to more easily migrate the business logic from AWS Lambda to EC2 or Containers.

Manage Secrets and Environment Variables using dotenvar

Every developer has done it. You’re onboarding a new teammate, the project needs a DATABASE_URL, and instead of setting up a proper system, you paste the connection string in a DM. Fast. Done. Forgotten.

Until it isn’t.

The Real Problem Isn’t Laziness. It’s the Gap.

Raw .env files work — until they don’t. Every developer ends up with their own version. Onboarding takes hours of “hey, what’s the value for JWT_SECRET?” Config drift is invisible. Nobody knows whose copy is canonical. And if one accidentally gets committed, you’re rotating credentials at the worst possible time.

Enterprise secrets managers exist, but they assume a dedicated platform team, a budget, and months of integration work. A startup of five engineers doesn’t need an infrastructure overhaul just to share database credentials safely.

There’s a massive gap between the chaos of ad hoc credential sharing and the complexity of enterprise tooling. That gap is where most teams live. And that’s exactly where dotenvar was built to live.

What Is dotenvar?

dotenvar is the first free, cloud-native secrets and environment variables platform built specifically for developers and engineering teams.

Not a password manager repurposed for .env files. Not a paid enterprise product with a secrets feature bolted on. A platform designed from the ground up for how developers actually work — from local development all the way to production pipelines.

“We built it so we literally can’t see your secrets.”

Every secret you store is encrypted on your device before it ever reaches our servers.

What You Actually Get

Your secrets — organized the way your projects work. Folders, environments, services — everything in one place, clean and structured, accessible to the right people and no one else.

Invite a teammate and they get exactly the access you intended — nothing more. Set it to expire. Revoke it instantly. No credential rotation, no engineering overhead, no coordination required.

Every change to a secret is versioned automatically. Accidentally overwrote something critical? Roll it back in one click. The full history is always there, always encrypted, always yours.

When you’re ready to go beyond the web app, the CLI pulls your secrets into any local project with a single command, or injects them directly into any process without ever writing a file to disk. GitHub Actions, GitLab, Vercel, Docker, Kubernetes — it works everywhere, with any language, any stack.

Why This Matters

Think about how developers managed code before version control. Ad hoc, fragmented, one mistake away from losing everything. Then a platform arrived that made it seamless, collaborative, and reliable — and the entire industry moved forward.

Environment variables and secrets are at that exact same inflection point right now. Every team has the problem. Almost no one has a proper solution — because until now, the proper solution was either too manual or too expensive.

dotenvar is the platform that solves this completely, for every team, at no cost. Versioning. Sharing. Zero-knowledge security. CI/CD integration. All in one place. Free.

Get Started in Two Minutes

No infrastructure. No configuration. No credit card.

dotenvar.com — start now, free.

Documentation · CLI & CI/CD Guide

Tags: Developer Tools DevOps Security Secrets Management CI/CD

Why Most QA Engineers Can’t Practice Their Core Skill — and How Mutation Testing Changes That

There is a strange problem in QA engineering.

If you want to improve as a software developer, you have LeetCode, HackerRank, Codewars. Thousands of problems. Clear scoring. A growing streak to obsess over. You write code, it either passes or it does not, and you learn.

But if you want to improve as a QA engineer — at the actual skill of finding bugs — what do you do?

You can read blog posts about test design techniques. You can study ISTQB syllabuses. You can write tests on personal projects and hope you are getting better. But there is no clear feedback loop. No equivalent of “your solution passed 47 of 50 test cases.” No way to know if you are actually improving at the thing that matters: writing tests that catch real bugs.

That gap is what mutation testing was designed to fill.

The Problem With Practicing on LeetCode

LeetCode is excellent at what it does. It trains algorithmic thinking, data structure fluency, and the ability to write correct implementations under pressure.

But that is not what QA work is.

When a QA engineer sits down with a function like calculate_discount(price, customer_tier), the job is not to implement it. The job is to think: what could go wrong here? What edge cases exist? What assumptions is the implementation making that might not hold? And then — crucially — to write tests that would catch those failures.

LeetCode gives you a specification and asks you to pass it. QA work gives you an implementation and asks you to break it.

These are fundamentally different cognitive skills. One is synthesis. The other is analysis.

Practicing synthesis does not make you better at analysis. And yet, for years, “practice on LeetCode” has been the default advice given to QA engineers who want to sharpen their technical skills.

What Mutation Testing Actually Is

Mutation testing is a technique where small, deliberate changes — called mutants — are injected into working code. Your test suite then runs against each mutant. If your tests catch the bug, the mutant is killed. If your tests all pass anyway, the mutant survives, which means your test suite missed a real defect.

Your score is your kill ratio: the percentage of mutants your tests killed.

Kill Ratio = Killed Mutants / Total Mutants

A kill ratio of 100% means your tests caught every injected bug. A kill ratio of 40% means most of your bugs would slip through undetected.

This gives QA engineers something they have never had before: an objective, repeatable measurement of test effectiveness.

A mutant is not a random or catastrophic change. It is a subtle, plausible defect — the kind a developer might actually introduce. Typical mutations include:

  • Changing > to >= (off-by-one)
  • Replacing and with or in a condition
  • Removing a boundary check
  • Flipping a return True to return False
  • Changing + to - in a calculation

Each one of those is a bug that has appeared in real production systems. Mutation testing forces you to write tests that would catch them.

A Quick Example

Let us make this concrete. Here is a simple discount function:

def calculate_discount(price: float, customer_tier: str) -> float:
    """
    Apply discount based on customer tier.
    - 'gold': 20% discount
    - 'silver': 10% discount
    - All others: no discount
    Returns the final price after discount.
    """
    if customer_tier == 'gold':
        return price * 0.80
    elif customer_tier == 'silver':
        return price * 0.90
    else:
        return price

This is the original implementation. It is correct. Now, a mutation testing system injects a mutant:

# MUTANT: Changed 0.80 to 0.90 (gold tier gets silver discount)
def calculate_discount(price: float, customer_tier: str) -> float:
    if customer_tier == 'gold':
        return price * 0.90  # <-- mutation here
    elif customer_tier == 'silver':
        return price * 0.90
    else:
        return price

This mutant is subtle. The function still runs. It still returns a number. It is the exact kind of bug a tired developer might introduce — and the kind that could cost a business money without triggering an obvious error.

A weak test misses it:

def test_gold_discount():
    result = calculate_discount(100, 'gold')
    assert result < 100  # Too vague — just checks that some discount happened

This test passes against the mutant. The mutant survives.

A strong test kills it:

def test_gold_discount():
    result = calculate_discount(100, 'gold')
    assert result == 80.0  # Exact expected value — catches the wrong discount

This test fails against the mutant. The mutant is killed.

That is mutation testing. You are not testing whether the code runs. You are testing whether your tests can distinguish correct behavior from incorrect behavior.

Why This Matters for Your Career

It Trains the Exact Skill QA Interviews Test

Most QA interviews at some point ask a question like: “How would you test this function?” or “What test cases would you write for a login form?”

What they are really asking is: can you think adversarially? Can you identify the ways this could fail?

Mutation testing practice trains exactly this. When you repeatedly write tests against mutated code and watch your kill ratio go up or down, you start building intuition for which test cases actually matter and which ones are just noise.

After a few dozen problems, you start thinking differently about specifications. You see the boundaries. You see the operator assumptions. You see the edge cases that are easy to miss.

That is what interviewers are looking for — and it is hard to demonstrate if you have never deliberately practiced it.

It Gives You an Objective Metric

One of the perennial challenges in QA is that skill is hard to quantify. Line coverage is widely understood to be a poor proxy. Test count means nothing on its own. “I found 47 bugs last quarter” is not portable across teams or companies.

Kill ratio is different. It is directly connected to the thing that matters: whether your tests catch defects.

A QA engineer who can consistently achieve 90%+ kill ratios on mutation testing challenges has demonstrated something real. That number is not a measure of how fast you type or how well you memorize API syntax. It is a measure of how well you think about failure.

It Builds a Verifiable Portfolio

Most QA portfolio advice is vague. “Contribute to open source.” “Write a personal project with tests.” These are fine suggestions, but they do not produce evidence that is easy for a hiring manager to evaluate.

Mutation testing scores are different. They are objective, reproducible, and specific. A solved challenge at 95% kill ratio with a short explanation of your test design approach is concrete evidence of skill.

It is the difference between saying “I am good at writing effective tests” and being able to show what that looks like in practice.

Try It Yourself

If you want to start practicing, SDET Code is a platform built specifically for this. You can try 3 challenges without signing up — just open the site and start writing pytest. It has 339 challenges across difficulty levels, all focused on mutation testing.

Everything runs in your browser using WebAssembly (no setup, no install), and an AI coach gives feedback on your test design when you want it. It is free to start.

The goal is the same as LeetCode for developers — a deliberate practice environment with clear feedback — but built around the skill QA engineers actually need.

The Bigger Picture

The QA field has a skills measurement problem. We talk about testing principles, but we struggle to create environments where people can actually practice them and get clear feedback.

Mutation testing does not solve every problem in QA. It is one tool, focused on one dimension of test effectiveness. But it fills a gap that has been open for a long time: a way to practice the core adversarial thinking skill of QA work, with an objective score, in a repeatable environment.

If you spend an hour a week on mutation testing problems, you will think differently about test design within a month. The patterns become internalized. The edge cases become automatic.

That is what deliberate practice does. And QA engineers have deserved a proper practice environment for a long time.

This is Part 1 of the “Mutation Testing for QA Engineers” series. Part 2 will cover boundary value mutations and how to develop systematic coverage strategies.

PII-aware routing: how to use cloud AI and keep your sensitive data local

Here’s the tension at the heart of every personal AI system: cloud models are better at reasoning, but your data is private. A self-hosted system can run everything locally — but a 2B parameter model on a mini-PC isn’t going to draft a nuanced email response or analyze a complex financial situation the way a frontier model can.

The naive solutions are both bad. “Send everything to the cloud” means your diary entries, medical notes, and financial records pass through someone else’s servers. “Run everything locally” means accepting worse reasoning on tasks where model quality actually matters.

We built a third option: a PII-aware routing layer that classifies every piece of data by sensitivity, routes it to the right model, and pseudonymizes anything sensitive that needs cloud reasoning power.

The classification: four levels, zero LLM calls

Every record in the system gets a sensitivity level. The classification is entirely deterministic — regex patterns and domain rules. No LLM in the classification loop, because sending data to an LLM to decide if the data is too sensitive to send to an LLM is a circular problem.

Level What it means Example domains
low Public or low-risk data Events, bookmarks
normal Common personal data Notes, contacts, files, diary
high Sensitive personal data Emails, financial transactions
critical Never leaves the device Medical/health data

Each domain has a default sensitivity level. Events are low — knowing you have a meeting at 3pm isn’t particularly sensitive. Emails are high — they contain names, addresses, business context, and sometimes confidential information. Health entries are critical — always local, no exceptions.

But domains are just the baseline. The classifier also scans content for PII patterns that override the default:

Email addresses     → elevate to high minimum
Phone numbers       → elevate to high minimum
Credit card numbers → elevate to high minimum
IBAN codes          → elevate to high minimum
SSN / DNI / NIE     → elevate to high minimum
Medical terminology → elevate to critical

A note titled “Grocery list” stays at normal. A note containing “Dr. García prescribed 20mg omeprazole” gets elevated to critical because the regex matched medical terminology. The content drives the classification, not just the domain.

This is deliberately conservative. The regex patterns over-match — “Dr.” triggers medical detection even if it’s “Dr. Pepper.” False positives mean data gets routed locally when it could have gone to the cloud. False negatives mean sensitive data leaks. Over-matching is the correct failure mode.

The routing decision

Once classified, the router decides where each piece of data goes:

low / normal  → Cloud LLM — best reasoning
high          → Cloud LLM WITH pseudonymization — good reasoning, protected data
critical      → Local model only (Ollama) — or skip if Ollama unavailable

The decision isn’t binary “local vs cloud.” There’s a middle path: pseudonymize the sensitive parts, send to the cloud for reasoning, and de-pseudonymize the response before the user sees it.

This matters because most tasks involving sensitive data don’t need the sensitive parts for reasoning. “Summarize this email thread” needs the content structure and topic — not the actual names and email addresses. “What’s the sentiment of this diary entry?” needs the emotional content — not the specific people mentioned.

The pseudonymizer

When a high sensitivity record needs cloud processing, the pseudonymizer replaces PII with consistent tokens:

Entity type Pseudonym format Example
Person Person_XXXX “Ana García” → Person_A3F2
Email [email protected] “[email protected]” → [email protected]
Phone +00-XXXX-0000 “+34 612 345 678” → +00-E5D9-0000
Organization Org_XXXX “TechCorp” → Org_4C8A
Location Location_XXXX “Calle Sagasta 15” → Location_B2E1

Three properties make this work:

Consistency. The same value always produces the same pseudonym (SHA-256 of the original value, truncated). “Ana García” is always Person_A3F2, in every record, in every session. This means the cloud model can reason about relationships: “Person_A3F2 sent 3 emails to Person_B7D1 about Org_4C8A” preserves the structure even though the names are hidden.

Reversibility. The pseudonym_map table stores every mapping. When the cloud model’s response comes back, the system replaces all pseudonyms with real values before storing or displaying the result. The user never sees Person_A3F2 — they see “Ana García.”

Persistence. Mappings survive across sessions. If “Ana García” was pseudonymized yesterday and appears again today, she gets the same pseudonym. This means the cloud model can build consistent context across multiple interactions without ever learning the real name.

The detection itself uses regex — no LLM call. It’s the same NER-lite approach as the sensitivity classifier: pattern matching for emails, phones, card numbers, and named entity patterns for persons and organizations. Not perfect, but fast and deterministic.

What this looks like in practice

Scenario 1: Calendar event (low sensitivity)

User asks: “What’s on my calendar tomorrow?”

The system fetches tomorrow’s events. Events are low sensitivity. The full data — titles, locations, attendees — goes straight to the cloud model. No pseudonymization needed. The model reasons about the schedule and responds with a natural summary.

Cost: one cloud API call. Privacy: no sensitive data exposed.

Scenario 2: Email analysis (high sensitivity)

User asks: “Summarize the email thread about the partnership.”

The email thread is high sensitivity (default for emails). Before sending to the cloud model:

Original: "Ana García <[email protected]> wrote: Hi Paco, 
regarding the TechCorp partnership with NexaTech..."

Pseudonymized: "Person_A3F2 <[email protected]> wrote: 
Hi Person_0D4E, regarding the Org_4C8A partnership with Org_9F3B..."

The cloud model receives the pseudonymized version. It can still analyze the thread structure, identify that Person_A3F2 is negotiating with Person_0D4E, and summarize the key points. The reasoning quality is nearly identical — the model doesn’t need to know the real names to understand the negotiation dynamics.

The response comes back with pseudonyms:

"Person_A3F2 proposed a revenue-sharing model with Org_9F3B. 
Person_0D4E agreed in principle but requested..."

The system de-pseudonymizes:

"Ana García proposed a revenue-sharing model with NexaTech. 
Paco agreed in principle but requested..."

Cost: one cloud API call + ~2ms pseudonymization. Privacy: no real names or emails left the device.

Scenario 3: Health data (critical sensitivity)

User asks: “What medications am I taking?”

Health entries are critical. They never leave the device, period. The system routes to the local Ollama model. If Ollama is unavailable, the query fails gracefully — it does NOT fall back to the cloud.

The local model’s response might be less polished, but for medical data retrieval, the task is usually simple: find the records and list them. A 2B model handles that fine.

Cost: one local model call. Privacy: absolute — zero data exposure.

Scenario 4: Note with accidental PII (elevated sensitivity)

User creates a note: “Meeting with Dr. López about the lab results. Blood pressure 140/90.”

The note’s domain is normal, but the content contains medical terminology (“Dr.”, “lab results”, “blood pressure”). The classifier elevates it to critical. From this point on, this note is treated like health data — local only.

The user didn’t tag it as medical. They didn’t configure anything. The system caught it automatically. Conservative false positives are the design choice: if a note mentions “Dr. Pepper,” it gets elevated too. That’s a minor inconvenience (one note processed locally instead of on the cloud) with zero privacy risk.

The audit trail

Every routing decision is logged:

Field What it records
domain Which data domain (notes, emails, etc.)
record_id Which specific record
sensitivity Classified sensitivity level
action What happened: sent_pseudonymized, sent_plain, blocked
destination Where it went: embeddings, contextual_retrieval, sleep_time

The pii_routing_log table creates a complete audit of what data was exposed to which processing pipeline. If you ever need to answer “did my medical data ever touch a cloud service?”, the answer is in the log.

This is also how we verify the system works correctly. The log shows every routing decision. If a critical record ever appears with action sent_plain and a cloud destination, that’s a bug — and the log caught it.

Where routing applies

PII-aware routing isn’t just for chat interactions. It applies everywhere the system sends data to an LLM:

Embeddings. When generating semantic embeddings, the text is classified before being sent to the embedding model. If you’re using a cloud embedding API (future option), high and critical records get embedded locally via Ollama instead.

Contextual retrieval. The HyDE pipeline (generating hypothetical answers for better search) uses LLM calls. If the search touches sensitive domains, those calls route through the pseudonymizer.

Sleep-time compute. The background intelligence jobs process records during idle periods. The enrichment job (re-extracting entities from hot records) respects the same routing rules — a critical record only gets re-extracted if Ollama is available.

Entity extraction. When the CRUD hooks pipeline sends text to the LLM for entity extraction, the same classification applies. A health-related note gets extracted locally.

The routing layer sits between every LLM consumer in the system and the actual model call. It’s middleware — invisible to the features that use it, enforced consistently everywhere.

The multi-agent dimension

With a multi-agent topology (7 agents in our system), PII routing gets another layer: agent scoping.

Each agent has a scoped token that defines what domains it can access:

Francis (main):     notes:*, events:*, emails:*, contacts:*, diary:*
Sentinel (infra):   storage:*, hal:*, network:*
Dalí (creative):    photos:read, files:read
Ledger (finance):   finance:*, crm:*
Darwin (analytics): graph:*, insights:*

Sentinel can’t access emails. Dalí can’t read the diary. This is enforced at the API level — even if a prompt injection tricks Dalí into requesting diary entries, the scoped token blocks it.

Combined with PII routing, this creates defense in depth:

  1. Agent scoping prevents access to domains the agent shouldn’t touch
  2. Sensitivity classification catches PII regardless of domain
  3. Pseudonymization protects data that needs cloud processing
  4. Audit logging records everything for verification

A prompt injection attack would need to bypass all four layers to exfiltrate sensitive data. The scoping blocks the API call. The classification catches the content. The pseudonymizer strips the PII. The audit log records the attempt.

What we explicitly don’t do

We don’t use ML for classification. A fine-tuned classifier could be more accurate than regex patterns. But it would need to see the data to classify it — which means sending potentially sensitive data to a model before deciding if it’s safe to send to a model. Regex is dumber but has zero data exposure during classification.

We don’t redact — we pseudonymize. Redaction ([REDACTED]) destroys information the cloud model needs for reasoning. Pseudonymization preserves structure (“Person_A sent an email to Person_B”) while hiding identity. The cloud model can still reason about relationships, quantities, and patterns.

We don’t let the user override critical. You can change a record’s sensitivity from normal to high manually. You cannot downgrade critical to anything else. Health data stays local regardless of user preferences. This is a deliberate paternalistic choice — the privacy risk of accidentally exposing medical data outweighs the convenience of sending it to a better model.

We don’t route based on the LLM provider’s privacy policy. Whether provider A’s privacy policy is better than provider B’s is irrelevant. The system treats all cloud LLMs identically: external services that should never see critical data and should only see high data in pseudonymized form. Trust the math, not the terms of service.

What I’d do differently

I’d add per-field sensitivity, not just per-record. Currently, a contact record is normal even though the phones field is arguably more sensitive than the company field. Per-field classification would let us pseudonymize just the phone number while sending the company name to the cloud. More precise, but also more complex — the pseudonymizer would need to understand JSON field structure.

I’d build a sensitivity dashboard earlier. The pii_routing_log has all the data, but there’s no UI for it yet. A dashboard showing “this week: 450 records processed, 380 sent plain, 65 pseudonymized, 5 blocked” would build user trust and make the privacy system tangible.

I’d make the regex patterns configurable. Different users have different sensitivity needs. A doctor might want “aspirin” to be flagged as medical. A pharmacist might want it treated as normal. The current patterns are one-size-fits-all, which means they’re too aggressive for some users and not aggressive enough for others.

The takeaway

The privacy problem in personal AI isn’t “local vs cloud.” It’s “which data goes where.” Most of your data is fine to send to a cloud model — your calendar events and bookmark titles aren’t secrets. Some data needs protection but can still benefit from cloud reasoning — pseudonymize it and send the structure without the identity. A small fraction of data should never leave your device — route it locally and accept the quality trade-off.

Three components: a regex classifier (zero LLM calls, deterministic), a SHA-256 pseudonymizer (consistent, reversible, persistent), and a routing table (domain defaults + content elevation). No ML, no fine-tuning, no privacy policy trust assumptions.

The system processes your medical notes with a 2B local model and your calendar queries with a cloud model. It knows the difference because a regex matched “blood pressure” — not because it asked an AI what’s sensitive.

Next up: designing an AI approval system — when should your agent ask for permission, and how do you build a confirmation workflow that doesn’t slow everything down?

I built an expense tracker because every other one wanted my bank login

I’ve been tracking my spending since 2008. Back then, the app ecosystem wasn’t really a thing yet. I was living abroad and I just wanted to know where my money was going.

I’ve been in and out of different countries ever since. Through all of it, I needed to track what I was spending. Not budget it, not forecast it, not connect it to my bank. Just write down what I spent so I wouldn’t get a nasty surprise when I checked my balance.

And every expense tracker I looked at over the years wanted something from me before I could start. Create an account. Link your bank. Connect your credit card. Set up a budget. Choose a plan.

I have a credit card and more than one bank account, some of them foreign. Connecting all of that to an app is not “easy setup.” It’s a project. And I didn’t want auto-import anyway. I just wanted to type a number.

The Google Form era

I ended up doing what any reasonable person would do: I made a Google Form.

One form per purchase. It dumped into a spreadsheet. The spreadsheet pulled everything together into months, categories, totals. For seeing the big picture, it was actually great.

For input? Not so much. Every entry meant opening Chrome, filling in every field, even when it was the same location or category as the last one. Currency conversion was a manual job I’d do later, if I got around to it. Too much friction. Easy to skip one entry, then two, then you stop entirely. And once the habit dies, the whole system falls apart.

I used that setup for years. It always kind of worked and kind of didn’t.

What I actually wanted

It came down to one rule: logging an expense should be faster than buying the thing.

Three taps. Open the app, type the amount, hit save. If it takes longer than that, I won’t do it. And if I won’t do it, it doesn’t matter how many features it has.

That constraint shaped almost every design decision. The amount field is focused the second you open the app. You don’t pick a category or location unless you want to. Those fields are hidden by default so they don’t slow you down. After you save, the screen resets and you’re ready for the next one.

Get in and get out. Under 5 seconds.

The currency thing

This is the part that’s personal to me. I live in Thailand. I spend in baht every day. But my brain still thinks in Canadian dollars. A couple times a year I travel and add a third currency to the mix.

Most expense trackers either don’t handle multiple currencies at all, or they handle it badly. You pick one currency and that’s it.

I wanted to log lunch in baht, a hotel in US dollars, and a coffee in euros, and have it all convert back to CAD automatically. Each transaction stores the original amount, the original currency, and the exact exchange rate used. You can always see what you actually paid.

I also built Travel Mode. When you’re abroad and every purchase is in the same foreign currency, you lock that currency so the app defaults to it. Flip it on when you land, flip it off when you’re home. 162 currencies total.

How I built it

Flutter, because I wanted to ship on Android first but keep the door open for iOS. Cross-platform from the start.

For storage, I used Hive, a fast local NoSQL database. Everything is encrypted on-device. There’s no server, no cloud, no backend. I actually built a backend in an earlier version of this project, but it was overkill. So I started over. Keeping it on-device was partly a privacy decision and partly a practical one. Expense data is personal, and I didn’t want to maintain a server.

Offline-first from day one. I travel. I’m not always on Wi-Fi. The app ships with bundled exchange rates so conversions work without internet. When you do have a connection, it fetches fresh rates. But it never depends on one.

I used AI throughout the build. Started with ChatGPT, moved to Claude for most of the heavy lifting. Architecture, debugging, code generation. It’s 2026. If you’re building solo and not using AI, you’re making your life harder than it needs to be.

Six months of solid effort from idea to Play Store.

What’s not there yet

The app doesn’t do everything. No export yet. No backup or restore. No iOS. No receipt scanning. All on the roadmap, but not built.

If you need bank sync, this will never be your app, and that’s fine. There are great options for that. This is for people who want to manually log what they spend, fast, and move on.

Try it

It’s called Enough Spent. Free on Google Play. Right now there aren’t even any ads in it.

If you try it, I’d love to hear what you think. What’s missing? What’s annoying? What would make you keep using it or stop using it? I’m one person building this and real feedback is the most useful thing I can get.

Enough Spent on Google Play