I built LET — a local-first habit and life-events tracker in React Native

I’ve been building side projects for years. most of them live on github, quietly collecting dust. LET is one i actually use daily.

LET (Life Events Tracker) is a React Native + Expo app for tracking habits, mood, and life events — entirely offline, no account required, no data leaves your device.

the problem with most habit trackers

they want your data. subscriptions, cloud sync, accounts. i wanted something simpler: log what happened today, see patterns over time, own the data. so i built it.

the stack

piece why
React Native + Expo iOS, Android, and web from one codebase
NativeWind TailwindCSS for RN — familiar syntax
Zustand minimal state, no boilerplate
expo-sqlite + Drizzle ORM type-safe local SQL, migrations run on device
Expo Router file-based navigation like Next.js
Custom SVG charts hand-rolled paths, no heavy chart lib

what it does

four main modes: daily habit tracking (with custom event colors), mood logging, life milestone recording, and a pattern view that surfaces weekly trends from those custom SVG charts.

data export/import is JSON, dark/light mode is fully supported, and swipe navigation works throughout.

the part worth talking about

this whole app was built through vibe-coding — describing features in natural language, iterating with AI, spending mental energy on product decisions instead of boilerplate.

it is not a toy. the codebase has proper navigation with expo-router, accessible UI atoms via rn-primitives, and a schema-first SQLite setup with Drizzle that runs migrations at startup. just built through a different workflow.

links

LET on GitHub

https://github.com/p32929/let

live web demo: https://p32929.github.io/let/

would love honest takes on what is missing or clunky. stars and forks appreciated if it is useful.

open to building with sharp teams and solo founders — dms and email open.

The “AI Native Builder” Role is Here (But Companies Don’t Know How to Hire You)

If you were building with AI back in 2024, people thought it was a crutch. Today, it’s the most in-demand skill in the startup world.

I’ve been interviewing a lot lately, and there is a massive shift happening. Startups are desperate for developers who can just sit down and ship an MVP in a week using AI.

But there is a huge disconnect in how these companies are hiring.

The Problem: Wrong Titles, Wrong Platforms

Startups know exactly what they want, but they are doing a terrible job finding it.

  1. The titles are wrong: These jobs are impossible to search for because they use generic titles like “Software Developer” instead of anything AI-specific.
  2. The JDs are bad: HR usually just copies and pastes old corporate requirements that have nothing to do with the actual job of “just ship it with Claude.”
  3. They search in the wrong places: Companies post these roles on traditional, corporate job boards like LinkedIn and Indeed. But the scrappy builders they actually want to hire aren’t there—they live on X, Reddit, and dev communities.

What They Actually Want

When you ignore the bad job descriptions and actually talk to the founders, the real requirements are simple:

  • No Corporate Engineers: They don’t want people who need daily standups, multiple PMs, and weeks of planning.
  • Degrees don’t matter: They do not care if you have a CS degree or if you used to work at Google.
  • Show Your Work: The only thing they care about is seeing a real project you built from scratch.
  • High Autonomy, Good Pay: You are given a goal, not a ticket. You work independently, and because you do the work of three people, the salary is very good.

Bridging the Gap

It is exhausting trying to filter through the noise on Indeed just to find a team that gets it. Startups are frustrated they can’t find builders, and builders are frustrated they can’t find these roles.

So I decided to fix it. I built AI Native Builder.

It’s a simple, curated job board just for this. No corporate red tape, no arbitrary degree requirements. Just jobs for people who build and ship with AI, placed where builders actually hang out.

If you just want to build, stop looking at the standard job boards.

Selling Online Courses Without Platform Lockout: The Crypto Fix That Ultimately Fails

The problem we were actually solving,

We’re a small team of software developers in a country where popular online platforms like PayPal, Stripe, Gumroad, and Payhip are blocked. Our online course platform, which we’d built using Ruby on Rails and PostgreSQL, was facing a major hurdle: we couldn’t receive payments from our customers. I was tasked with finding a solution that would allow us to sell our courses without relying on these restricted platforms.

What we tried first (and why it failed),

When we first encountered the restrictions, our instinct was to look for workarounds within the existing platforms. We tried using third-party payment gateways that promised to bypass the restrictions, but these solutions turned out to be flimsy at best. We’d spend hours setting up these gateways only to be locked out again a few days later, with no clear reason as to why. These attempts also introduced a significant amount of technical debt, as we’d have to constantly update our code to accommodate the shifting requirements of these third-party gateways. The technical complexities and maintenance nightmares made us realize that these workarounds weren’t sustainable.

The architecture decision,

After exhausting our exploration of platform-based solutions, we turned to a more unconventional approach: using cryptocurrency to facilitate payments. We researched various cryptocurrency payment processing services like Bitpay and Coinbase Commerce, which allowed us to integrate a payment gateway directly into our course platform. We used a combination of these services and some clever routing logic to create a seamless payment experience for our customers. Our platform would redirect customers to a cryptocurrency payment page, where they could fund their purchases using their preferred cryptocurrency.

What the numbers said after,

At first glance, our new cryptocurrency-based payment system seemed to be a hit. Our course sales began to rise, and we were finally able to reach customers in restricted countries. However, as time went on, we encountered a new set of problems. Our analytics showed that the vast majority of our customers were using cryptocurrency because they had no other choice, not because they preferred it. We saw a significant increase in abandoned checkouts, likely due to the steep learning curve and uncertainties associated with cryptocurrency transactions. Our revenue growth started to slow down, and we realized that our reliance on cryptocurrency was creating more problems than it was solving.

What I would do differently,

In hindsight, I would explore alternative payment methods that don’t rely on traditional platforms. Our research should have started with services like Mollie, Rapyd, or even PayPal’s own “Instant Transfer” feature, which can help bypass some of the restrictions. These services often have better support and fewer restrictions than the more well-known payment platforms. By exploring these alternatives, we might have avoided the complications associated with cryptocurrency and created a more stable and user-friendly payment experience for our customers. Ultimately, our decision to rely on cryptocurrency highlights the importance of understanding the complexities of platform restrictions and the trade-offs associated with unconventional solutions.

Forward Settlement: how a trading agent locks tomorrow’s price without a clearinghouse

A trading agent agrees a price at 9am. Delivery is tomorrow. For the 24 hours in between, somebody is exposed: either the price moves and one side wants out, or one side simply doesn’t show up to settle.

This is the oldest problem in market structure, and traditional finance solved it a long time ago — with a clearinghouse. A forward contract works because a central counterparty stands between the two parties, holds margin, and guarantees that the trade completes even if one side fails.

That solution doesn’t transfer cleanly to autonomous agents. If you’re building agents that trade — on top of an MCP server, inside a framework like LangChain or CrewAI, wherever — you eventually hit a case where the agent needs to commit to a trade now and settle it later. And the moment you reach for a forward, you’ve reintroduced exactly the trusted intermediary the rest of your stack was designed to avoid.

There’s a way to get the forward without the clearinghouse. It’s worth walking through, because it’s a small idea with a large consequence.

What a forward actually needs

Strip a forward down to its mechanics and it asks for three things:

  1. A price, fixed now. Both parties agree terms today.
  2. A delivery window, in the future. Settlement happens at T+24h, T+48h, or whatever the two sides picked.
  3. A guarantee that both legs complete — or neither does. If one side vanishes, the other must not be left holding a half-finished trade.

The clearinghouse exists almost entirely to provide #3. It is a very expensive, heavily regulated answer to one question: who makes the trade whole if a counterparty fails?

For two agents on two different chains with no shared venue, that question has no good custodial answer. A centralized desk means both agents now trust the desk — and the desk can freeze, fail, or front-run. A bridge means trusting an optimistic settlement model: most bridges assume the far side will be made whole and rely on challenge windows to catch it if not. “Probably settled by tomorrow” is not a forward.

Why the naive on-chain version fails

The first instinct on-chain is to write an escrow contract. Agent A deposits the asset, the contract holds it until the delivery time, then releases it to Agent B against payment.

Look closely at “the contract holds it.” Who can upgrade that contract? Who holds the admin key? Who decides what happens if the delivery oracle disagrees with one party? Every one of those questions is a custodian wearing a smart-contract costume. You haven’t removed the trusted party — you’ve renamed it.

The fix is not a better escrow contract. It’s to remove the holding step entirely.

The HTLC as a forward

A hash time-locked contract (HTLC) binds both legs of a trade to the same cryptographic secret and a timelock. The standard description of an HTLC emphasizes the atomic property: either both sides settle against the secret, or both sides refund after the timeout. No party can take one leg without releasing the other.

The piece that turns an atomic swap into a forward is the timelock itself. The timelock is not just a safety hatch — it is a programmable delivery window. Set it to 24 hours and you have described a T+24h forward in one parameter. Both legs are funded now, at the agreed price. Settlement can occur any time inside the window. If the window closes with the trade incomplete, both sides independently refund. No counterparty can be left exposed, because no counterparty was ever holding the other’s asset — the contract logic was.

That gives you all three things a forward needs:

  • Price fixed now — both legs are committed at agreed terms when the contract is created.
  • Future delivery window — the timelock is the window.
  • All-or-nothing guarantee — the atomic property, enforced by the chain, not by a clearinghouse.

The delivery deadline is the part worth dwelling on. In the clearinghouse model, the guarantee is institutional: you trust that the central counterparty has the capital and the legal standing to make you whole. In the HTLC model, the deadline is enforced by the base layer. On Ethereum mainnet, the timelock is just block timestamps and contract code. Nobody — including the protocol that wrote the contract — can extend it, skip it, or quietly settle around it.

Where this fits for agents

We call this Forward Settlement, and it’s a direct extension of the primitive Hashlock Markets already runs in production: atomic settlement, live on Ethereum mainnet today. The base layer is a sealed-bid RFQ for price discovery fused with HTLC settlement, exposed to agents through a 6-tool MCP server. A forward is not a separate product bolted on — it’s the same HTLC with the timelock used deliberately rather than defensively.

The reason this matters for the agent economy specifically: agents are good at committing to future actions and bad at trusting counterparties they can’t inspect. A forward written as an HTLC plays to both. The agent commits today; the chain enforces the window; neither agent has to evaluate the other’s solvency, because solvency was never load-bearing. The trade either completes atomically or unwinds atomically.

It also composes. Because each leg is independently refundable and bound to the same secret, a forward can be one leg of a multi-leg trade — sell BTC, buy ETH, buy SUI, all or nothing — without inheriting a clearinghouse for the whole bundle.

The honest limits

A few things this does not do, because overclaiming helps nobody:

  • It does not eliminate price risk. A forward fixes a price; if the market moves, one side is worse off. That’s the trade, not a bug.
  • It does not give you margin or leverage. Both legs are fully funded up front. This is a delivery guarantee, not a credit facility.
  • Today the production settlement layer is Ethereum mainnet. Sui contracts are deployed and CLI-tested with gateway wiring in progress; the Bitcoin P2WSH HTLC is validated on signet with mainnet still pending. Forward-dated settlement is live where atomic settlement is live — which today means Ethereum.

The underlying argument is in our working paper on SSRN, for anyone who wants the formal version rather than the blog version.

If an agent can get a forward without a clearinghouse, it’s worth asking how much else in market structure was only ever a workaround for missing settlement guarantees — and how much of that an atomic primitive quietly makes optional.

Protocol: https://hashlock.markets
MCP server (canonical): https://github.com/Hashlock-Tech/hashlock-mcp

Stop Building Space Shuttles When All You Need Is a Bicycle

We’ve all been there. You join a new project, excited to look at the codebase. It’s a simple application—maybe a Todo app, a simple blog, or a feedback collector.

You open the repository, expecting a clean, straightforward structure. Instead, you find:

  • 12 different microservices.
  • A Kubernetes configuration that looks like a NASA launch manual.
  • 5 layers of abstractions (Interfaces, Repositories, DTOs, Presenters, Adapters) just to fetch a single row from a database.
  • Docker containers eating 90% of your laptop’s RAM just to run npm run dev.

All of this… for an application that gets 50 users a day.

Welcome to the era of Overengineering.

How Did We Get Here?

As developers, we suffer from what I call “Resume-Driven Development” (RDD). We don’t choose tools based on what the project needs today; we choose tools based on what looks cool on our LinkedIn profile or what Netflix uses to handle billions of requests.

We became so obsessed with scalability that we forgot about maintainability and velocity.

If it takes a junior developer three days just to set up the local environment and understand how a single HTTP request routes through twenty layers of architecture, your system isn’t “advanced.” It’s broken.

The Power of the “Boring” Monolith

There is a silent counter-movement happening right now. Senior developers who have tasted the pain of managing distributed transactions and network latency in tiny microservices are screaming: Go back to the monolith!

Building a “boring”, well-structured monolithic application using simple tools (like a clean Fastify/Node.js setup, standard relational databases, and straightforward code) gives you something a distributed system can’t:

  1. Unmatched Speed: You can ship features in hours, not weeks.
  2. Mental Clarity: You can open one folder and understand the entire data flow.
  3. Cheap Hosting: A single tiny instance on Render, Fly.io, or DigitalOcean can easily handle thousands of concurrent users if your code is efficient.

Pragmatism > Hype

Don’t get me wrong. If you are building the next Uber, Netflix, or Amazon, yes—bring on the microservices, Kafka, and complex caching layers. You earned that complexity.

But if you are starting an MVP, testing a product, or building a standard internal tool, keep it stupidly simple (KISS). Use a reliable SQLite or Postgres database, write clean functions, validate your data with Zod, protect your endpoints with a simple Rate Limiter, and get things done.

Architecture shouldn’t be a monument to your ego. It should be a tool to deliver value to the user.

Let’s Be Honest…

What is the worst case of overengineering you have ever witnessed (or accidentally built) in your career? Are we, as a community, making coding harder than it needs to be?

Drop your horror stories in the comments below! 🛰️👇

My first collaboration post on DEV! Was so much fun! Check it out to see verdicts on Gemma 4 from multiple writers here!

The DEVengers logo

A Guide to Parameterized Batch Apex

Every Salesforce team hits the same wall eventually. A business stakeholder walks over and says: “We need to update the Region__c field on 2.3 million Account records based on a new territory model.” Someone opens Data Loader, exports a CSV, runs a VLOOKUP in Excel, and re-imports. It takes a full afternoon. Two weeks later, the territory model changes again.
There’s a better way. Batch Apex lets you encode the logic of that update - not just the data - so it’s repeatable, testable, auditable, and parameterized for the next time it happens. This article covers how to design batch classes that are genuinely reusable across backfill scenarios, the trade-offs you need to understand before choosing this approach, and how to monitor jobs once they’re running.

The Anatomy of a Batch Class (Quick Refresher)
If you’ve written Batch Apex before, skim this section. If not, here’s the contract you’re implementing:
public class AccountRegionBackfill implements Database.Batchable {
public class AccountRegionBackfill implements Database.Batchable {
// 1. QUERY – what records are we operating on?
public Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator(
‘SELECT Id, BillingState FROM Account WHERE Region_c = null’
);
}
// 2. PROCESS – what do we do to each chunk?
public void execute(Database.BatchableContext bc, List scope) {
for (Account acc : scope) {
acc.Region
_c = TerritoryUtil.deriveRegion(acc.BillingState);
}
update scope;
}
// 3. CLEANUP – what happens when we’re done?
public void finish(Database.BatchableContext bc) {
AsyncApexJob job = [
SELECT TotalJobItems, JobItemsProcessed, NumberOfErrors
FROM AsyncApexJob WHERE Id = :bc.getJobId()
];
System.debug(‘Completed: ‘ + job.JobItemsProcessed + ‘/’ +
job.TotalJobItems + ‘ batches, ‘ +
job.NumberOfErrors + ‘ errors’);
}
}
You invoke it from Anonymous Apex, a trigger, or a scheduled class:
Database.executeBatch(new AccountRegionBackfill(), 200);
The 200 is the batch size - the number of records passed to each execute call. Salesforce then breaks your query result into chunks, runs execute for each chunk in its own transaction with fresh governor limits, and calls finish once everything’s done.
That’s the textbook version. Let’s talk about what the textbook leaves out.

Why Batch Apex Beats Data Loader for Backfills
Data Loader is a fine tool for one-off imports. But for data updates that involve logic, it introduces problems that compound over time.
Data Loader requires you to externalize your logic. If the update rule is “set Region__c based on BillingState, but only for Accounts created after 2020 that aren’t owned by the integration user,” you have to express that in an Excel formula, a Python script, or your own head. None of those live in your Salesforce org. None of them are version-controlled by default. None of them fire when requirements change.
Batch Apex keeps the logic where the data is. The derivation logic, the filter criteria, and the error handling all live in the same class, deployed through the same CI pipeline as the rest of your code. When the territory model changes, you update one class - you don’t re-export 2.3 million records and pray the VLOOKUP columns still line up.
Here’s a practical comparison for the scenarios that matter most:
Runtime business logic. Data Loader can’t call an Apex utility method, execute a formula at write-time, or make an API callout to an external mapping service mid-update. Batch Apex can. If your backfill rule is “call the Geocoding API for every Account missing coordinates,” Data Loader simply can’t do it.
Trigger and flow execution. Data Loader updates fire triggers and flows, which is sometimes what you want and sometimes catastrophic. With Batch Apex, you can structure your DML to use Database.update(scope, false) for partial success, and you can add conditional logic to skip trigger paths when appropriate using a static bypass flag.
Repeatability. When a PM says “run it again but only for EMEA,” you change one parameter. With Data Loader, you re-export, re-filter, re-import.
Auditability. Every Batch Apex execution creates an AsyncApexJob record with start time, end time, error count, and the submitting user. Data Loader logs live on whoever’s laptop ran it.
There are scenarios where Data Loader is the right call: simple one-time imports of static CSVs, sandbox data seeding, or situations where a non-developer needs to do a quick update. But if you’re doing the same type of update more than twice, Batch Apex pays for itself.

Parameterizing for Reuse
The mistake most teams make is building one batch class per backfill task. You end up with AccountRegionBackfill, AccountIndustryBackfill, ContactEmailNormalizeBatch, and thirty other classes that all follow the same pattern: query records matching a filter, apply a transformation, update.
Instead, build one parameterized class that accepts its configuration at construction time.
Pattern 1: Constructor-Injected Parameters
The simplest approach. Pass the query and the field-value map into the constructor:
public class GenericFieldUpdateBatch implements Database.Batchable {
private String query;
private Map fieldValues;
private Boolean allOrNone;
public GenericFieldUpdateBatch(String query, Map fieldValues) {
this(query, fieldValues, false);
}
public GenericFieldUpdateBatch(
String query,
Map fieldValues,
Boolean allOrNone
) {
this.query = query;
this.fieldValues = fieldValues;
this.allOrNone = allOrNone;
}
public Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator(query);
}
public void execute(Database.BatchableContext bc, List scope) {
for (SObject record : scope) {
for (String field : fieldValues.keySet()) {
record.put(field, fieldValues.get(field));
}
}
Database.update(scope, allOrNone);
}
public void finish(Database.BatchableContext bc) {
// Send notification, chain next batch, etc.
}
}
Invocation becomes declarative:
// Backfill Region for all null-region Accounts in California
Database.executeBatch(
new GenericFieldUpdateBatch(
‘SELECT Id FROM Account WHERE Region_c = null AND BillingState = ‘CA”,
new Map{ ‘Region
c’ => ‘West’ }
),
200
);
// Normalize a status field on Opportunities
Database.executeBatch(
new GenericFieldUpdateBatch(
‘SELECT Id FROM Opportunity WHERE StageName = ‘Closed/Won”,
new Map{ ‘StageName’ => ‘Closed Won’ }
),
200
);
This single class replaces dozens of one-off batch classes for simple field-value updates.
Pattern 2: Strategy Interface for Complex Logic
When the transformation isn’t a static value but requires computation, inject a strategy:
public interface IBatchTransform {
void apply(List records);
}
public class TransformableBatch implements Database.Batchable {
private String query;
private IBatchTransform transformer;
public TransformableBatch(String query, IBatchTransform transformer) {
this.query = query;
this.transformer = transformer;
}
public Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator(query);
}
public void execute(Database.BatchableContext bc, List scope) {
transformer.apply(scope);
Database.update(scope, false);
}
public void finish(Database.BatchableContext bc) { }
}
Now a territory-mapping backfill looks like this:
public class TerritoryTransform implements IBatchTransform {
public void apply(List records) {
for (SObject rec : records) {
Account acc = (Account) rec;
acc.Region
c = TerritoryUtil.deriveRegion(acc.BillingState);
}
}
}
// Run it
Database.executeBatch(
new TransformableBatch(
‘SELECT Id, BillingState FROM Account WHERE Region
c = null’,
new TerritoryTransform()
),
200
);
The batch infrastructure and the business logic are fully separated. You can unit-test TerritoryTransform without ever invoking the batch framework.
Pattern 3: Custom Metadata–Driven Configuration
For teams that want admins (not just developers) to control batch behavior, store your parameters in Custom Metadata:
Batch_Job_Config
mdt
├── DeveloperName: Account_Region_Backfill
├── SOQL_Query
c: SELECT Id, BillingState FROM Account WHERE Regionc = null
├── Batch_Size
c: 200
├── Active
c: true
└── Notification_Email
_c: [email protected]
Your batch class reads from this at runtime. Combined with a Scheduled Apex wrapper, you can toggle jobs on and off without a deployment.

Managing State and Error Handling
Tracking Errors Across Batches
By default, Batch Apex is stateless - instance variables reset between execute calls. If you need to accumulate errors or counts across the entire job, implement Database.Stateful:
public class AccountBackfillWithTracking
implements Database.Batchable, Database.Stateful {
private Integer successCount = 0;
private Integer errorCount = 0;
private List errorMessages = new List();
public Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator(
‘SELECT Id, BillingState FROM Account WHERE Region_c = null’
);
}
public void execute(Database.BatchableContext bc, List scope) {
for (Account acc : scope) {
acc.Region
_c = TerritoryUtil.deriveRegion(acc.BillingState);
}
List results = Database.update(scope, false);
for (Integer i = 0; i < results.size(); i++) {
if (results[i].isSuccess()) {
successCount++;
} else {
errorCount++;
for (Database.Error err : results[i].getErrors()) {
errorMessages.add(
scope[i].Id + ‘: ‘ + err.getMessage()
);
}
}
}
}
public void finish(Database.BatchableContext bc) {
Messaging.SingleEmailMessage mail = new Messaging.SingleEmailMessage();
mail.setToAddresses(new List{ ‘[email protected]‘ });
mail.setSubject(‘Backfill Complete: ‘ + successCount + ‘ updated, ‘
+ errorCount + ‘ errors’);
mail.setPlainTextBody(
errorMessages.isEmpty()
? ‘All records updated successfully.’
: ‘Errors:n’ + String.join(errorMessages, ‘n’)
);
Messaging.sendEmail(new List{ mail });
}
}
The trade-off with Database.Stateful: Salesforce serializes and deserializes your instance variables between every batch execution. If you’re accumulating a list of 50,000 error messages, you’ll hit heap limits. Keep your state lean - store counts and a capped list of errors, not the full record set.
Partial Success with Database.update(records, false)
This is critical for backfills. The false parameter means “don’t roll back the whole batch if one record fails.” Without it, a single validation rule failure on record #47 kills the entire chunk of 200 records. With it, 199 succeed and you log the one failure.
Always use allOrNone = false for backfills unless you have a specific reason to require atomicity within a chunk.

Batch Size: The Trade-Off Nobody Explains Well
The batch size parameter (the second argument to Database.executeBatch) controls how many records land in each execute call. The default is 200. The maximum is 2,000.
Here’s the decision framework:
Smaller batches (50–100) when your execute method does heavy work per record - callouts, complex cross-object queries, CPU-intensive calculations. Each execute gets its own governor limits (100 SOQL queries, 150 DML statements, 10-second CPU time). Fewer records per batch means you’re less likely to hit those limits.
Larger batches (200–2,000) when your execute method is simple - setting a field value, light computation. More records per batch means fewer total transactions, which means the job finishes faster and you consume fewer of your org’s daily async execution quota (250,000 batch executions per 24 hours).
If you’re making callouts (Database.AllowsCallouts), the max batch size is effectively limited by the 100-callout-per-transaction limit. If each record needs one callout, your batch size can’t exceed 100.
A common mistake is setting the batch size to 2,000 without testing it first. Start at 200, monitor for governor limit errors, and adjust up or down based on what the AsyncApexJob record tells you.

Monitoring Your Jobs
In the UI
Navigate to Setup → Apex Jobs to see all running, queued, and completed batch jobs. Each row shows the class name, status (Queued, Processing, Completed, Failed, Aborted), and the number of batches processed versus total.
The Apex Flex Queue (Setup → Apex Flex Queue) shows jobs waiting to start. Salesforce allows 5 concurrent batch jobs; the rest queue here (up to 100). You can drag to reorder priority.
Programmatically via SOQL
The AsyncApexJob object is your best friend for building dashboards, sending alerts, or chaining jobs:
AsyncApexJob job = [
SELECT Id,
Status,
JobItemsProcessed,
TotalJobItems,
NumberOfErrors,
CreatedDate,
CompletedDate,
ExtendedStatus,
ApexClass.Name
FROM AsyncApexJob
WHERE Id = :batchJobId
];
// Calculate progress
Decimal progress = (job.TotalJobItems == 0) ? 0 :
(Decimal.valueOf(job.JobItemsProcessed) /
Decimal.valueOf(job.TotalJobItems) * 100).setScale(1);
System.debug(job.ApexClass.Name + ‘: ‘ + progress + ‘% complete, ‘
+ job.NumberOfErrors + ‘ errors’);
Building a Lightweight Monitoring Dashboard
Create a simple LWC or Flow that queries AsyncApexJob and displays active jobs:
// Controller method for a monitoring component
@AuraEnabled(cacheable=true)
public static List getActiveBatchJobs() {
return [
SELECT Id, ApexClass.Name, Status, JobItemsProcessed,
TotalJobItems, NumberOfErrors, CreatedDate
FROM AsyncApexJob
WHERE JobType = ‘BatchApex’
AND Status IN (‘Queued’, ‘Preparing’, ‘Processing’, ‘Holding’)
ORDER BY CreatedDate DESC
LIMIT 20
];
}
Alerting on Failures
In your finish method, always check NumberOfErrors. If it’s non-zero, send an email, post to a Slack webhook (via a Platform Event or a quick callout), or create a custom Batch_Job_Log__c record that an admin dashboard monitors.

Chaining Batches for Multi-Step Backfills
Sometimes a backfill isn’t one query - it’s a sequence. First update Accounts, then recalculate Opportunities, then refresh a rollup on a custom object. You chain batches from the finish method:
public void finish(Database.BatchableContext bc) {
// After Account backfill completes, start Opportunity recalc
Database.executeBatch(new OpportunityRecalcBatch(), 200);
}
For more complex chains, use a dispatcher pattern:
public class BatchChain {
private List> steps;
private Integer batchSize;
public BatchChain(List> steps, Integer batchSize) {
this.steps = steps;
this.batchSize = batchSize;
}
public void start() {
if (!steps.isEmpty()) {
Database.executeBatch(steps.remove(0), batchSize);
}
}
}
Be careful here. Each batch in the chain consumes one of your 5 concurrent batch slots. If you chain 8 batches and other processes are also submitting batch jobs, you’ll start seeing jobs stuck in the Flex Queue. For chains longer than 3–4 steps, consider using Queueable Apex instead, which has a higher concurrency ceiling.

The Trade-Offs You Should Know About
Batch Apex isn’t always the right tool. Here’s when it’s not:
Small record counts (under 10,000). The overhead of the batch framework - job queuing, context serialization, async scheduling - can make a simple anonymous Apex script or even a Flow faster for small updates.
Real-time requirements. Batch jobs can sit in the queue for minutes (or longer during peak hours). If you need an update to happen within seconds, use Queueable Apex or a trigger.
Complex transaction boundaries. Each execute call is its own transaction. If your backfill requires that all 50,000 records either succeed together or fail together, Batch Apex can’t give you that. (Though in practice, all-or-nothing at that scale is rarely the real requirement.)
Callout-heavy workloads. The 100-callout-per-transaction limit applies per execute call. If every record needs a callout, your effective batch size is ≤100, which means a million-record job takes 10,000+ transactions. At that point, evaluate whether a Mulesoft or Heroku integration might be more appropriate.

A Production-Ready Template
Here’s the pattern I use on every project. It combines parameterization, error tracking, optional chaining, and notification:
public class ConfigurableBackfillBatch
implements Database.Batchable, Database.Stateful {
private String query;
private IBatchTransform transformer;
private String notificationEmail;
private Database.Batchable nextBatch;
private Integer nextBatchSize;
// Error tracking
private Integer totalProcessed = 0;
private Integer totalErrors = 0;
private List sampleErrors = new List();
private static final Integer MAX_SAMPLE_ERRORS = 50;
public ConfigurableBackfillBatch(
String query,
IBatchTransform transformer,
String notificationEmail
) {
this.query = query;
this.transformer = transformer;
this.notificationEmail = notificationEmail;
}
public ConfigurableBackfillBatch chainNext(
Database.Batchable nextBatch,
Integer batchSize
) {
this.nextBatch = nextBatch;
this.nextBatchSize = batchSize;
return this; // fluent API
}
public Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator(query);
}
public void execute(Database.BatchableContext bc, List scope) {
transformer.apply(scope);
List results = Database.update(scope, false);
for (Integer i = 0; i < results.size(); i++) {
if (results[i].isSuccess()) {
totalProcessed++;
} else {
totalErrors++;
if (sampleErrors.size() < MAX_SAMPLE_ERRORS) {
sampleErrors.add(
scope[i].Id + ‘: ‘
+ results[i].getErrors()[0].getMessage()
);
}
}
}
}
public void finish(Database.BatchableContext bc) {
// Send summary email
if (String.isNotBlank(notificationEmail)) {
String body = ‘Batch complete.n’
+ ‘Records updated: ‘ + totalProcessed + ‘n’
+ ‘Errors: ‘ + totalErrors + ‘n’;
if (!sampleErrors.isEmpty()) {
body += ‘nSample errors:n’
+ String.join(sampleErrors, ‘n’);
}
Messaging.SingleEmailMessage mail =
new Messaging.SingleEmailMessage();
mail.setToAddresses(new List{ notificationEmail });
mail.setSubject(‘Backfill Job: ‘ + totalProcessed
+ ‘ updated, ‘ + totalErrors + ‘ errors’);
mail.setPlainTextBody(body);
Messaging.sendEmail(
new List{ mail }
);
}
// Chain next batch if configured
if (nextBatch != null) {
Database.executeBatch(nextBatch, nextBatchSize);
}
}
}
Invoke it:
Database.executeBatch(
new ConfigurableBackfillBatch(
‘SELECT Id, BillingState FROM Account WHERE Region__c = null’,
new TerritoryTransform(),
‘[email protected]‘
).chainNext(new OpportunityRecalcBatch(), 200),
200
);
One class. Reusable across any backfill. Error tracking built in. Chaining built in. Notifications built in.

Final Checklist Before You Run a Backfill
Before kicking off a batch job in production, walk through this list:
Test in a sandbox first. Use a representative data volume - not 50 records, but 50,000.
Check the Flex Queue. If there are already 5 active batch jobs, yours will queue. Timing matters.
Use Database.update(scope, false). Partial success is almost always what you want for backfills.
Cap your error logging. If you’re using Database.Stateful, don’t accumulate unbounded lists. Set a maximum.
Add a notification in finish. You will forget to check manually. Your finish method should tell you the job is done.
Document the job. A comment in the class header that says “Run this when: the territory model changes. Last run: 2026–03–15 by Author Name” is worth more than you think.
Consider bypass flags. If your records have triggers or flows that shouldn’t fire during a backfill, use a static variable or Custom Permission to bypass them.

Why sameSite: “lax” doesn’t save your Next.js admin routes from CSRF

Admin interface

The admin order update endpoint authenticates via cookie and validates nothing else, allowing any same-session page to flip an order’s status on the admin’s behalf.

OopsSec Store exposes PATCH /api/orders/:id to update an order’s status. The handler trusts the authToken cookie alone: there is no CSRF token, no Origin check, no Referer check. The cookie is set with sameSite: "lax", so any page the admin loads in the same browser can issue the request and the server will execute it.

Lab setup

From an empty directory:

npx create-oss-store oss-store
cd oss-store
npm start

Or with Docker (no Node.js required):

docker run -p 3000:3000 leogra/oss-oopssec-store

The application runs at http://localhost:3000.

Vulnerability overview

The admin dashboard at /admin lists every order in the system and offers a status selector that issues a PATCH /api/orders/:id request with a JSON body of the form { "status": "DELIVERED" }.

Admin interface

The handler performs three operations:

  1. Reads the authToken cookie and resolves the current user.
  2. Verifies the user has the ADMIN role.
  3. Updates the order with the supplied status.

Nothing sits between steps 1 and 3 to prove the request actually came from the admin’s UI. The endpoint treats authentication as authorization. With sameSite: "lax" on the auth cookie, the browser also attaches it on top-level cross-site navigations and on every same-origin request, which is all an attacker page needs.

Exploitation

The lab serves the attacker page from the same origin (/exploits/csrf-attack.html) so the exploit works without setting up DNS or hosting. The same exploit works from a third-party origin too, either by carrying the request through a top-level navigation or against any deployment that loosens sameSite.

Step 1: Authenticate as an administrator

Sign in with an account that has the ADMIN role. If no such account is available, escalate using one of the other vulnerabilities in the lab (mass assignment on registration, JWT weak secret, etc.). After login, the browser holds an HTTP-only authToken cookie scoped to the application origin.

Step 2: Identify a target order

Open /admin and pick an order to manipulate. The walkthrough uses ORD-003 in PENDING status as the target. Any order will work; the flag is not tied to a specific identifier.

Step 3: Locate the exploit page

View the page source of /admin. A hidden link points to:

/exploits/csrf-attack.html

This file ships with the lab and simulates a phishing page that an attacker would normally host on a third-party domain.

Phishing page

Step 4: Trigger the request

While still authenticated, open http://localhost:3000/exploits/csrf-attack.html. The page is styled as a PayPal account-security notification with a single call-to-action button. Clicking it executes the following request:

fetch("/api/orders/ORD-003", {
  method: "POST",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ status: "DELIVERED" }),
});

The route handler is registered for both POST and PATCH, so either verb hits the same code path. The credentials: "include" flag instructs the browser to attach cookies. Because the request originates from the same origin, sameSite: "lax" does not block it. The server receives a fully authenticated admin request and updates the order.

Flag retrieval

The vulnerable endpoint returns the flag in its JSON response when the status update succeeds:

{
  "success": true,
  "order": { "id": "ORD-003", "status": "DELIVERED" },
  "flag": "OSS{cr0ss_s1t3_r3qu3st_f0rg3ry}"
}

Reloading /admin confirms the persisted change: the targeted order now shows the new status. The admin never touched the dashboard.

New order status

Vulnerable code analysis

The handler authenticates the user and checks the role, then writes:

export async function PATCH(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const user = await getAuthenticatedUser(request);
  // No CSRF token validation
  // No Origin or Referer header check
  const { status } = await request.json();
  await prisma.order.update({ where: { id }, data: { status } });
}

The cookie configuration makes things worse:

response.cookies.set("authToken", token, {
  httpOnly: true,
  secure: process.env.NODE_ENV === "production",
  sameSite: "lax",
  maxAge: 60 * 60 * 24 * 7,
  path: "/",
});

httpOnly: true blocks JavaScript reads, which helps against token theft via XSS. It does nothing against CSRF, because CSRF does not need to read the cookie — it just needs the browser to send it. The sameSite semantics:

Value Cross-site cookie behavior
strict Cookie never sent on cross-site requests, including top-level navigation.
lax Cookie sent on top-level navigations (GET) but not on cross-site fetch.
none Cookie always sent; requires secure: true.

In this lab the exploit page is same-origin, so lax does not apply at all. Even on a real cross-site deployment, lax still permits cookies on top-level GET navigations and on any same-site context, so it is not on its own enough to stop CSRF.

Remediation

No single one of the controls below is enough; apply them together.

Tighten the authentication cookie

Set sameSite: "strict" on the cookie that authorizes state-changing operations. Strict keeps the cookie out of every cross-site context, top-level navigation included:

response.cookies.set("authToken", token, {
  httpOnly: true,
  secure: true,
  sameSite: "strict",
  maxAge: 60 * 60 * 24 * 7,
  path: "/",
});

If strict breaks legitimate flows like email links landing on an authenticated page, split the session: a long-lived lax cookie for navigation and a separate strict cookie required for sensitive endpoints.

Require a CSRF token on state-changing routes

Issue a per-session token at login, hand it to the client via a non-httpOnly cookie or a bootstrap endpoint, and require the client to echo it back in a custom header:

const tokenFromCookie = request.cookies.get("csrfToken")?.value;
const tokenFromHeader = request.headers.get("X-CSRF-Token");

if (!tokenFromCookie || tokenFromCookie !== tokenFromHeader) {
  return NextResponse.json({ error: "Invalid CSRF token" }, { status: 403 });
}

A cross-origin attacker page cannot read cookies for the target origin, and it cannot set custom headers on a cross-origin request without a passing CORS preflight. It can supply at most one half of the pair.

Validate the Origin header

For non-GET requests, reject anything whose Origin (or, failing that, Referer) is not on an explicit allowlist:

const origin = request.headers.get("origin");
const allowedOrigins = ["https://yourdomain.com"];

if (!origin || !allowedOrigins.includes(origin)) {
  return NextResponse.json({ error: "Invalid origin" }, { status: 403 });
}

The check is cheap and runs before any business logic. It catches most cross-origin CSRF attempts even when the token-based defense is misconfigured or partially deployed.

Lab

GitHub logo

kOaDT
/
oss-oopssec-store

Security training for the apps you actually ship. Open your browser and start hacking.

OSS – OopsSec Store

An intentionally vulnerable e-commerce app for learning web security.
Master real-world attack vectors through a realistic CTF platform.
Hunt for flags, exploit vulnerabilities, and level up your security skills

Docker Hub ·
npm ·
Walkthroughs ·
Contributing ·
Good first issues

GitHub license
PRs Welcome
Good first issues
Intentionally Vulnerable

GitHub stars
GitHub forks

   ____  ____ ____     ____                  ____            ____  _
  / __ / __// __/    / __  ___   ___  ___ / __/ ___  ____ / __/ / /_ ___   ____ ___
 / /_/ /  _      / /_/ // _  / _ (_-<_   / -_)/ __/_   / __// _  / __// -_)
 ____/___//___/     ____/ ___// .__/___/___/  __/ __//___/  __/ ___//_/   __/
                                /_/

# Node.js
npx create-oss-store my-ctf-lab && cd my-ctf-lab && npm start

# Docker
docker run -p 3000:3000 leogra/oss-oopssec-store

# Then open http://localhost:3000 and start hacking

…

References

Disclaimers

Do not deploy OopsSec Store on a production server. This application is intentionally vulnerable and should only be used in isolated, local environments for educational purposes.

Do not exploit vulnerabilities on systems you don’t have explicit authorization to test. Unauthorized access to computer systems is illegal. Always obtain proper permission before performing security testing.

Feedback & Support

Having trouble following this writeup? Found a typo or have suggestions for improvement?

Feel free to open an issue or start a discussion on GitHub.

The Edge AI Revolution: Why Gemma 4 E4B is a Game-Changer for Offline Multimodality

This is a submission for the Gemma 4 Challenge: Write About Gemma 4

The Cloud is Great, But the Edge is Essential

When we talk about the future of AI, the conversation almost always drifts toward massive data centers, hundreds of gigabytes of VRAM, and cloud APIs. But what happens when the cloud isn’t there?

In real-world crises—like the catastrophic floods that frequently hit South Asia—power grids fail and internet connectivity vanishes. In these critical moments, an API key is useless. This is exactly where the true potential of open-source, edge-optimized models comes into play.

With the release of Gemma 4, Google didn’t just give us a capable open model; they gave us the Gemma 4 E4B (4B parameter) variant. After spending time building offline systems with it, I believe this specific model is a massive paradigm shift for edge computing. Here is a technical breakdown of why Gemma 4 E4B is quietly revolutionizing local AI.

1. Native Multimodality vs. The “Frankenstein” Pipeline

Before Gemma 4, building a multimodal offline system meant chaining together multiple different models. If you wanted to process a victim’s voice note and a photo from a disaster zone on a local laptop, your pipeline looked like this:

  1. Audio to Text: Run OpenAI’s Whisper (requires its own memory footprint).
  2. Vision to Text: Run LLaVA or Moondream to generate image descriptions.
  3. Text to Action: Feed all those text strings into an LLM for reasoning.

This “Frankenstein” approach is a nightmare for edge devices. Context switching between models destroys VRAM efficiency, spikes latency, and drains laptop batteries.

The Gemma 4 E4B Solution:
Gemma 4 E4B introduces native multimodality at the edge. It doesn’t rely on external transcription or OCR hacks. Through Ollama, you can pass an audio file, an image, and a text prompt in a single /api/chat request.

The model’s native audio and vision encoders process the raw data directly into its context window. This single-forward-pass architecture drops latency from over 15 seconds (in chained pipelines) to sub-5 seconds on a modest 4GB VRAM GPU.

2. Agentic Tool Calling… Offline!

One of the most impressive features of the Gemma 4 family is its advanced reasoning and tool-calling capabilities. While we expect this from 100B+ parameter models, seeing it in a 4B model running on a local machine is staggering.

In my experience integrating Gemma 4 into an offline command center, the model isn’t just generating text—it’s taking actions. You can define Python tools (e.g., dispatch_rescue_team(location, priority)) and Gemma 4 will reliably format JSON arguments to execute those functions.

Because it operates within a 128K context window, you can inject local RAG (Retrieval-Augmented Generation) data—like NDMA or WHO protocols—directly into the prompt. Gemma 4 will read the offline documents, analyze a photo of a flooded area, and accurately call a backend function to dispatch a rescue boat. No internet required.

3. The Power of “Small” Dense Models

We often get caught up in the parameter wars, but the Gemma 4 E4B dense model proves that architecture and training data quality trump raw size.

By packaging advanced reasoning, multimodality, and tool-calling into a 4B effective parameter footprint, developers can deploy sophisticated AI on:

  • Consumer-grade laptops in remote disaster zones.
  • Raspberry Pi 5s for localized IoT networks.
  • Mobile devices operating entirely off-grid.

Conclusion: Building for Global Resilience

The release of Gemma 4 forces developers to ask a new question: “Does this app actually need the internet?” For years, we’ve built AI applications that assume perfect connectivity. But the most impactful use cases for AI—disaster response, remote healthcare, and off-grid education—exist in places where connectivity is a luxury.

Gemma 4 E4B proves that we don’t need to sacrifice intelligence to achieve true offline capability. The future of AI isn’t just in the cloud; it’s decentralized, local, and running right at the edge where it’s needed most.

Beyond Text Rewrites: The Shift to AST-Aware Code Refactoring for AI Agents

The rapid adoption of AI coding assistants has fundamentally changed how software engineers write, debug, and explore codebases. Tools like Claude Code, Cursor, and Windsurf have made generating snippets and explaining logic near-instantaneous. However, as developers push these systems to handle larger engineering tasks—such as codebase-wide refactoring or porting legacy applications—they inevitably hit a wall: the token limits and structural inaccuracy of file-based operations.

Traditionally, when an AI agent modifies code, it relies on full-file rewrites. This approach is not only incredibly token-intensive, but it also carries severe operational risks. Whole-file edits frequently strip out developer comments, alter formatting styles, and introduce silent syntax bugs. To cross the threshold into true autonomy, AI agents must stop treating source files as plain text blocks and start interacting with them seminally via Abstract Syntax Trees (AST).

The Hidden Cost of File-Based AI Coding

Forcing an AI agent to read and rewrite entire source files to modify a single function is an engineering anti-pattern. On enterprise monorepos, this architectural limitation introduces heavy friction:

  • Context Window Depletion: Reading an entire module just to check external cross-references rapidly consumes context windows, causing the agent to forget critical project parameters mid-session.
  • Syntax Breakdown: Lacking strict syntax awareness means agents can inadvertently drop closing brackets, break indentation rules, or introduce scoping errors during multi-file updates.
  • Destructive Updates: Without an explicit checkpoint framework, an AI’s erroneous modification can corrupt active repositories, requiring manual git intervention to undo.

To bypass these limitations, the developer tooling landscape is shifting toward specialized Model Context Protocol (MCP) engines. Systems like act101 operate as local binaries that expose typed, AST-aware operations directly to coding agents, shifting the paradigm from textual chat to semantic manipulation.

The Efficiency of Slice-Based Queries

The primary benefit of integrating an AST-aware engine into an AI workflow is radical token optimization. Instead of feeding thousands of lines of raw text into a model, an agent can request the exact syntax slice it needs to solve a specific problem.

According to community benchmarks, utilizing specialized syntax slices results in an unweighted average of 85% fewer tokens consumed compared to file-based operations.