Sana Yavo's blog
HomeAboutWork with meProjectsPostsSearchCV
FR
Home» Tags

Tags

#AI/LLM 2 #Architecture 1 #Backend 3 #CLI 2 #Computer Vision 1 #Data Governance 1 #MLOps 1 #Machine Learning 1 #PHP 1 #PostgreSQL 1 #Python 1 #Rust 2 #Security 1 #System Design 4 #Tooling 2 #TypeScript 1

The AI Illusion: Why feeding an LLM without data governance is a risk you can’t audit

October 30, 2026 · 15 min
A lawyer opens the company’s new AI assistant, uploads an exclusivity contract, and types: “Summarize the key obligations in five lines.” Ten seconds later she has a clean summary. It’s a small, real productivity win.What she doesn’t see is what happened in those ten seconds. The application didn’t just send her question. It sent the whole contract: the counterparty, the prices, the termination clauses, the signatory’s name. All of it landed, as readable text, on a server run by someone else.The same thing happens when an HR manager asks for a summary of a performance review, or a financial analyst digs into a client’s credit file. The question is short. The context that travels with it is enormous.In nearly every executive committee, the directive for 2026 is the same: “We need AI, and we need it now.” Within weeks, API keys are created, language models are wired into internal databases, and whole workflows are handed to third-party algorithms. The demos are impressive and the productivity gains are real.This article is not an argument against that. It’s an argument that the companies that get data governance right will deploy AI faster and in more places, because the foundation underneath is safe. The companies that skip it will eventually face one simple question they cannot answer. We’ll come back to it at the end.1. The Problem: Every Request Carries Your DataEven when your contract with the AI vendor forbids training on your data, and even when the connection is fully encrypted in transit, the raw text still arrives in readable form at a third party. Contracts limit what the vendor is allowed to do with it. They don’t change what is exposed if that vendor suffers a breach, if a log is misconfigured, or if a legal request lands on their desk.The risk grows with multi-agent architectures, where a router agent delegates work to specialized sub-agents that share a common memory. A single name or revenue figure injected into that shared state is now visible to every agent in the chain, and to every log, cache and tracing tool they write to.Figure 1: Injection of raw data into a multi-agent shared memory.Sending raw business data into an LLM with no controls isn’t innovation. It’s outsourcing your company’s strategic memory to a party that will never bear the cost of your leaks.2. Why Naive Anonymization FailsSome teams, aware of the danger, put a basic filter in front of the model: generic Named Entity Recognition (NER) tools, or regular expressions that redact proper nouns. On paper, that sounds reasonable. In production it tends to fail in three ways, and the real danger is that it gives leadership a sense of safety that stops them from asking questions.Failure 1: The filter doesn’t understand your businessGeneric detection tools are trained on everyday text. They confuse company names with common words (“Services”, “Transit”, “Holding”), and they often miss a company whose name contains such a word, like “NET-POLY SERVICES”. Internal identifiers such as tax codes or registration numbers don’t match standard formats, so they slip through.Failure 2: Hiding the name isn’t hiding the companyConsider this example: Raw text: “Company X posted 4.2 billion FCFA in revenue in Abidjan in 2024.” After a naive filter: “COMPANY_1 posted 4.2 billion FCFA in revenue in Abidjan in 2024.”The name is gone, but the exact revenue and the city are enough to identify the company with a single search. A modern model, with its vast training knowledge, can often do the same in a fraction of a second.Failure 3: A prompt is a request, not a controlAn instruction like “Never repeat client names” is a probabilistic nudge. The model will usually comply, until it doesn’t. There is a subtler problem too. Depending on the vendor and the settings, some models return their intermediate reasoning in a channel separate from the final answer, a kind of scratchpad handed back to you. If your protection only inspects the final answer, raw data can travel through the scratchpad unchecked.Believing a system prompt that says “be discreet” protects your data is like locking an armored door and leaving the key in the lock outside.3. The Six Invariants of Robust GovernanceIf instructions and basic filters aren’t enough, what is? Most architects sketch a linear pipeline: data comes in, gets filtered, goes to the model, comes back. A robust system looks different. It is defense in depth, where every boundary carries its own protection, independently of the others.What follows are six invariants. They are contracts that any architect can check against their own system, whatever the stack, the model vendor or the team size.Figure 2: The six governance invariants applied to LLM workflows.Invariant 1: Minimize at the sourceWhat isn’t in the returned data can’t leak. The tools exposed to AI should never be the same ones exposed to your human developers. Pagination is enforced, volumes are capped, and unnecessary fields are removed entirely. The API doesn’t return a full customer record just to compute a ratio. It returns the three numbers actually needed: no contacts, no addresses, no names.This isn’t an AI-specific rule. It’s a data-exposure rule that AI simply makes urgent.Invariant 2: Never let a model resolve what a deterministic path can decideA ranking, a ratio lookup or a simple extraction requires no judgment, so it has no business going through your most expensive and least predictable model. The routing can be a business rule, a choice in the interface or a lightweight classifier. The principle doesn’t prescribe how.Two honest caveats. First, if the router is itself a model, it is still a model: keep it small, tightly constrained, and feed it only protected text. The goal is to shrink exposure, not to claim it disappears. Second, this invariant has a benefit that often convinces a board before security does: it’s cheaper and faster.A fun example comes from Anthropic itself. In March 2026, Claude Code’s source code was accidentally leaked through a source map published on npm. It showed that the tool detects user frustration, meaning whether someone is swearing at it, with a plain regular expression, not a model1. A regex is instant and free. An inference call is neither, for a question that needs no judgment at all.Invariant 3: Treat every text-to-model boundary as a mandatory, reversible protection pointAny data crossing the boundary between your system and a language model, inbound or outbound, should be protected by a reversible mechanism such as substitution, pseudonymization or tokenization. The common mistake is protecting only the obvious response field. Check every channel the vendor exposes, including the reasoning channel described in section 2.One caution: pseudonymized data can still count as personal data under regulations like GDPR. This invariant reduces your exposure. It doesn’t remove your legal obligations.Invariant 4: Rehydrate at the last legitimate momentSubstitution should be reversed only at the very last step, right before the answer is shown to a human who is authorized to see the real values. The model works blind from start to finish. Only the human gets the answer in the clear, and only at the end of the chain.Invariant 5: A deterministic guardrail has the final say on plausibilityThe first four invariants protect confidentiality. This one protects correctness. Any response coming out of a deterministic path must pass through a non-probabilistic check, such as a threshold or a comparison with a known reference, before it goes out. If the result looks aberrant, the system falls back to a slower, supervised path rather than letting an unverified answer through.Invariant 6: Every agent-to-agent hop is its own trust boundaryThis is the invariant most often forgotten, because it doesn’t show up in a static layered diagram. An agent’s output isn’t automatically safe for the agent that called it.Here is the scenario. A sub-agent, believing it is “cleaning up its own output” before replying, de-anonymizes data that was meant to stay protected for the agent that delegated to it. The sub-agent did exactly what it was told. It just didn’t know its answer was headed for another link in the chain, one that was supposed to keep reasoning over pseudonyms.There is an aggravating corollary. When conversation history crosses several agents, turns from different agents end up mixed in one context. Without explicit provenance tagging, you can no longer tell which trust boundary applies to which piece of text.Figure 3: Agent-to-agent trust boundaries and rehydration placement.4. For Engineers: How We Implement the InvariantsThis is one possible implementation among many. The tools are specific to our stack. If your system looks nothing like ours, the invariants still hold; only their embodiment changes. Non-technical readers can skip to section 5. Anonymization gate (invariant 3): Before anything is classified, the user’s free text passes through a strict gate that combines NLP libraries with deterministic substitutions. Names and identifiers become unique tokens (for example CORP_A83B). The correspondence table goes straight into an encrypted vault: HMAC-SHA256 for the token, Fernet encryption for the real value at rest. Router (invariant 2): The pseudonymized question goes to a deliberately small, fast, low-cost LLM whose only job is to classify intent. Ranking, ratio lookup or simple extraction takes the fast path: a tool from a bounded allowlist is called directly, and the heavy model is never invoked. Risk analysis or interpretation takes the slow path and goes to the main agent. Minimized tools (invariant 1): On both paths, internal data is reached through tools exposed via the Model Context Protocol (MCP), cleaned at the source and never the same tools as those given to human developers. Slow path (invariant 3, outbound): Tool results are pseudonymized before they enter the heavy model’s context, so it reasons and writes entirely over pseudonyms. Fast path (invariant 5): A plausibility guardrail, a simple ratio compared against a reference value, blocks any aberrant automatic answer and forces a fallback to the slow path for review. Rehydration (invariant 4): Only once the response is generated and formatted does the system query the vault and swap tokens back for real values, right before display to the authorized user.Figure 4: Fast-path / slow-path routing architecture. Observed results: (Add here your real production metrics — e.g., 72% of queries served via the fast path without heavy model invocation, 4x latency reduction, and 65% API cost reduction).5. Verify, Don’t AssumeA defense that has never been verified under real conditions is a defense you only hope exists.Here is a scenario that is far from theoretical. A control meant to transform every message before it reached the model was written, reviewed and merged. The tests passed. For weeks, the team believed the protection was active. It wasn’t. An assumption about the exact shape of the incoming data was wrong, so the control ran against the wrong structure, silently, with no error and nothing to raise an alarm.A test that passes without actually going through the protection code proves nothing. A code review is not proof of execution, and a clean architecture diagram is not proof that the path it draws is the path taken at runtime. The only proof that counts is a test replayed against the real system: the real model, the real data, the real call chain.Every claim of compliance, such as “this is anonymized” or “this data never leaves in the clear,” should come with replayable proof, available on demand. Otherwise it isn’t a guarantee. It’s a hypothesis that hasn’t been disproved yet.Figure 5: The control illusion — silent failure when data format mismatches expectations.6. The Audit TrailGovernance doesn’t stop at real-time protection. It requires traceability. When a banking regulator, a GDPR auditor or a major client reviews how AI is used in your organization, they won’t accept statements of intent. They will ask for evidence.A responsible enterprise AI system keeps a tamper-evident, append-only audit log, kept separate from ordinary conversation logs and chat history. For every event in an agent’s lifecycle, it records a pair: the raw data as originally received, the data actually transmitted to external models.The rule that matters most is that no configuration flag or single administrator can turn this log off. During load spikes, a team’s first instinct is often to disable logging “for performance”. If that switch exists, then on the day a leak happens you won’t be able to prove its scope or identify what was exposed.Figure 6: Why the audit log must be tamper-proof and impossible to bypass via configuration flags.There is a catch, and it deserves to be stated plainly: this log is itself one of the most sensitive datasets in the company, because it contains the raw data you worked so hard to protect. Treat it accordingly. Encrypt it, restrict and separate access to it, define retention limits, and work out with your legal counsel how it coexists with erasure requests under regulations like GDPR.Finally, add retroactive leak scanning: background processes that keep re-scanning archived audit history for sensitive patterns that slipped past the first filters. When one is found, the scanner never edits the original log. It alerts the security team, who can tighten the rules before a small gap becomes an incident.Figure 7: Immutable audit pipeline and asynchronous leak scanning.7. Conclusion: AI Is a Data Engineering Problem“Garbage in, garbage out” has a modern cousin: secret in, exposure out. AI isn’t a cosmetic layer you bolt onto a corner of your information system to satisfy the board. It changes how information flows, how it is segmented, how it is anonymized and how it is archived.The companies that win with AI won’t be the ones that deployed the most agents the fastest. They will be the ones that built a safe foundation first. A good brake system is what lets a car drive fast. With these controls in place, teams can say yes to more use cases, with less debate and fewer late-stage vetoes.So before you sign the next AI contract, or celebrate the rollout of your latest agent, gather your architects and ask one question: “If an external auditor sent by our biggest client showed up tomorrow at 8am, could you prove to me, line of code by line of code, that not a single name, identifier or confidential figure left our network in the clear over the past month?”If the answer is silence, or “our vendor’s clauses”, or “a prompt that tells the AI to be careful,” you have your next project.The Governance Checklist: 8 boxes to checkBefore you leave the room, eight boxes to check (the six invariants, plus the testing and auditing essentials): Do you minimize at the source? (Invariant 1) Do you keep the expensive model off simple requests? (Invariant 2) Is every text-to-model boundary protected by a reversible mechanism? (Invariant 3) Do you rehydrate only at the last legitimate moment? (Invariant 4) Does a deterministic guardrail have the final say on plausibility? (Invariant 5) Is every agent-to-agent hop its own trust boundary? (Invariant 6) Is every control verified under real conditions, not just assumed? (Section 5) Is the audit log impossible to disable by flag or by a single administrator? (Section 6) Alex Kim, “The Claude Code Source Leak: fake tools, frustration regexes, undercover mode, and more”, March 31, 2026. The leak came from a source map file published by mistake on npm. The file userPromptKeywords.ts contains the regex used for frustration detection. ↩

Laplace Nearby: A WhatsApp Bot That Finds 'The Nearest Maquis' Without Torching the LLM Budget

June 22, 2026 · 8 min
In Abidjan, a huge number of local businesses — the neighborhood maquis (informal restaurant), the pharmacy, the guy who fixes phones — don’t have a website, a Google Business listing, or even a formal street address half the time. What they do have is WhatsApp. Laplace Nearby starts from that observation: instead of building an app people have to download, build the search experience inside the app everyone already has open, and let people describe what they want in plain language — “I need a pharmacy,” “j’ai envie de porcodjo” — rather than filling out a filter form.The assistant itself is internally called Simon. This post is about how Simon is actually built — the parts I think are worth talking about, not a marketing pitch.Repo: github.com/sanayasfp/laplace-nearby.Decision #1: the engine doesn’t know it’s talking to WhatsAppThe core of the system is SimonEngine, a small orchestrator that takes a channel-agnostic InteractionRequest and returns a channel-agnostic InteractionResponse. WhatsApp specifics — webhook payloads, message formatting, buttons and lists — live entirely in an adapter/renderer layer outside the engine. The engine itself just does four things on every message: load the user’s session context, check rate limiting, hand the message to whichever conversational flow is currently active (idle, search, or register), and save the result — always emitting an analytics side effect with the flow name, the state transition, and the latency, whether or not anything interesting happened:async process(req: InteractionRequest): Promise<InteractionResponse> { const context = await this.contextManager.load(req.profileId); const flow = this.flowRegistry.get(context.session.activeFlow || IDLE); const limitResult = this.rateLimitService.check(context.rateLimit); if (!limitResult.allowed) { return { messages: limitResult.notify ? [Responses.tooManyMessages(...)] : [] , ... }; } const response = await flow.handle(req, context, interactionId); await this.contextManager.save(req.profileId, response.contextUpdate); return { ...response, sideEffects: [summaryEffect, ...response.sideEffects] };}This isn’t architecture for its own sake. It means the search logic, the registration flow, the rate limiting, and the metrics don’t need to be rewritten if a second channel (SMS, a web widget, whatever) shows up later — they were never coupled to WhatsApp in the first place.Decision #2: don’t call the LLM if you don’t have toEvery incoming message needs an intent — is this a search, a registration request, small talk, a thank-you, an insult, “stop”? Routing every single message through an LLM call is the easy way to build this, and also the expensive and slow way. So intent classification is two-tiered.FastIntentDetector runs first: a deliberately exhaustive set of regex patterns covering French, English, and local slang and abbreviations you’d actually see in a WhatsApp chat in Abidjan — “wesh,” “cc,” “gab” (cash machine), “essence/gazoil” (fuel), dozens of spelling variants of “merci,” “stop,” “annule.” Its docstring says exactly what it’s for: “Reduce LLM costs and latency for unambiguous user requests.” Only when nothing matches does IntentService fall back to Gemini (gemini-2.5-flash-lite), with temperature 0, JSON response mode, and a tight 150-token cap, asking for structured output: the intent, an extracted search keyword, and a list of semantically related terms to widen the search (“j’ai envie de porcodjo” → keyword “porcodjo restaurant”; “mon habit est sale” → “pressing nettoyage vêtement”).Both paths report where the classification came from — REGEX or LLM — to a Prometheus counter (simonIntentTotal), so the fraction of traffic being deflected from the paid model is something you can actually watch on a dashboard, not something you have to guess at. And the Gemini call itself sits behind a circuit breaker: if it trips, the system doesn’t crash the conversation, it degrades to a neutral “chitchat” response with confidence 0 and moves on.Decision #3: addresses in Abidjan don’t work like addresses elsewhereA huge share of real addresses given in chat aren’t geocodable strings — they’re descriptions: “je suis vers la cité Abdoulaye Diallo.” AddressCodingService takes that kind of input, checks a semantic cache (exact hash first, then embedding similarity above a 0.88 threshold) to avoid re-paying for something already resolved, and if it’s a miss, asks Gemini to turn it into a standardized address, an extracted neighborhood, and — importantly — a flag for whether the description is precise enough to geocode at all or whether the only honest answer is “ask the user to drop a GPS pin.” There’s even a dedicated dictionary of Nouchi (Abidjan street slang) terms feeding into this pipeline, because generic NLP tooling doesn’t know what a “gbaka” stop or a given neighborhood nickname refers to.Decision #4: search is one SQL function, three signals, fusedThis is the part of the codebase I’m most proud of. search_nearby_places is a single Postgres function that blends three independent ranking signals for every candidate business inside a radius: Full-text rank (ts_rank_cd against a French tsquery) — good when the user typed something close to the business’s actual name or category. Vector similarity rank — cosine distance between the query embedding (Gemini text-embedding-004, pgvector with an HNSW index) and each place’s embedding — good when the user described what they want in their own words instead of matching a label. Geographic proximity (ST_Distance over a PostGIS geography column) — because “closest” always matters, and can’t be papered over by relevance alone.These get combined with a weighted Reciprocal Rank Fusion: score = w_fts/(k + fts_rank) + w_vec/(k + vec_rank) + w_prox * proximity_term, with the weights shifting based on whether the user gave a keyword at all — 45/45/10 between text, vector, and proximity when there’s a keyword to match; 85% vector-driven when there isn’t, since full-text search has nothing to grab onto in a purely descriptive query. There’s also a “premium tier” mechanism: a second ranking pass, partitioned by tier, guarantees paying/listed businesses a small quota of slots without letting them drown out relevance for the general result set.I later went back and rewrote this same function for performance after noticing it was doing redundant work: merging two CTEs that were reading the same rows twice, adding a LIMIT inside the vector-ranking CTE specifically so the HNSW index can short-circuit instead of sorting the entire candidate pool, and replacing two separate full materializations (general results, premium results) with a single windowed pass using ROW_NUMBER() OVER (PARTITION BY is_premium ...). That’s a genuinely satisfying kind of fix — same output, measurably less work per query — and it’s the sort of thing that only shows up once real usage patterns put pressure on a first draft.Decision #5: side effects are data, not actionsWhen a place gets registered, or an interaction needs logging for analytics, or a user needs a notification, the engine doesn’t go do that work inline — it returns a plain SideEffect object describing what should happen. A PgmqSideEffectDispatcher collects these, groups them by target queue, and pushes them via pgmq.send_batch (Postgres’s own message-queue extension) inside the same database transaction that marks a newly registered place as “queued.” That detail matters: it means a place can’t end up half-registered — visible in the app but never actually indexed for search — because the status update and the queue write either both commit or neither does. Supabase Edge Functions on the other end (an analytics-worker, a place-embedding-worker, a place-tagging-worker) drain those queues asynchronously.The stack, plainlyFastify + TypeScript on Node.js, Prisma over Supabase Postgres (with pgvector, pgmq, PostGIS, pg_cron, and pg_net doing real work, not just sitting in a dependency list), Redis for caching and rate-limit state, Gemini for both NLU and embeddings, Geoapify for geocoding, and Prometheus metrics wired in from the start rather than bolted on later.Where it standspackage.json says 0.4.0-rc.1 and I mean to leave that context in: this is a real, working system I use to reason about hybrid search and conversational engineering, actively evolving toward a 1.0 — not a finished, scaled product with a client roster behind it. The channel-agnostic design exists specifically so that if this ever needs to be more than a WhatsApp bot, the engine underneath doesn’t need to be rebuilt.If you want to argue about the RRF weights, tell me PostGIS was overkill, or point out a better way to structure the side-effect dispatcher — the code’s public: github.com/sanayasfp/laplace-nearby.

The AI Illusion: Why feeding an LLM without data governance is a risk you can’t audit

October 30, 2026 · 15 min
A lawyer opens the company’s new AI assistant, uploads an exclusivity contract, and types: “Summarize the key obligations in five lines.” Ten seconds later she has a clean summary. It’s a small, real productivity win.What she doesn’t see is what happened in those ten seconds. The application didn’t just send her question. It sent the whole contract: the counterparty, the prices, the termination clauses, the signatory’s name. All of it landed, as readable text, on a server run by someone else.The same thing happens when an HR manager asks for a summary of a performance review, or a financial analyst digs into a client’s credit file. The question is short. The context that travels with it is enormous.In nearly every executive committee, the directive for 2026 is the same: “We need AI, and we need it now.” Within weeks, API keys are created, language models are wired into internal databases, and whole workflows are handed to third-party algorithms. The demos are impressive and the productivity gains are real.This article is not an argument against that. It’s an argument that the companies that get data governance right will deploy AI faster and in more places, because the foundation underneath is safe. The companies that skip it will eventually face one simple question they cannot answer. We’ll come back to it at the end.1. The Problem: Every Request Carries Your DataEven when your contract with the AI vendor forbids training on your data, and even when the connection is fully encrypted in transit, the raw text still arrives in readable form at a third party. Contracts limit what the vendor is allowed to do with it. They don’t change what is exposed if that vendor suffers a breach, if a log is misconfigured, or if a legal request lands on their desk.The risk grows with multi-agent architectures, where a router agent delegates work to specialized sub-agents that share a common memory. A single name or revenue figure injected into that shared state is now visible to every agent in the chain, and to every log, cache and tracing tool they write to.Figure 1: Injection of raw data into a multi-agent shared memory.Sending raw business data into an LLM with no controls isn’t innovation. It’s outsourcing your company’s strategic memory to a party that will never bear the cost of your leaks.2. Why Naive Anonymization FailsSome teams, aware of the danger, put a basic filter in front of the model: generic Named Entity Recognition (NER) tools, or regular expressions that redact proper nouns. On paper, that sounds reasonable. In production it tends to fail in three ways, and the real danger is that it gives leadership a sense of safety that stops them from asking questions.Failure 1: The filter doesn’t understand your businessGeneric detection tools are trained on everyday text. They confuse company names with common words (“Services”, “Transit”, “Holding”), and they often miss a company whose name contains such a word, like “NET-POLY SERVICES”. Internal identifiers such as tax codes or registration numbers don’t match standard formats, so they slip through.Failure 2: Hiding the name isn’t hiding the companyConsider this example: Raw text: “Company X posted 4.2 billion FCFA in revenue in Abidjan in 2024.” After a naive filter: “COMPANY_1 posted 4.2 billion FCFA in revenue in Abidjan in 2024.”The name is gone, but the exact revenue and the city are enough to identify the company with a single search. A modern model, with its vast training knowledge, can often do the same in a fraction of a second.Failure 3: A prompt is a request, not a controlAn instruction like “Never repeat client names” is a probabilistic nudge. The model will usually comply, until it doesn’t. There is a subtler problem too. Depending on the vendor and the settings, some models return their intermediate reasoning in a channel separate from the final answer, a kind of scratchpad handed back to you. If your protection only inspects the final answer, raw data can travel through the scratchpad unchecked.Believing a system prompt that says “be discreet” protects your data is like locking an armored door and leaving the key in the lock outside.3. The Six Invariants of Robust GovernanceIf instructions and basic filters aren’t enough, what is? Most architects sketch a linear pipeline: data comes in, gets filtered, goes to the model, comes back. A robust system looks different. It is defense in depth, where every boundary carries its own protection, independently of the others.What follows are six invariants. They are contracts that any architect can check against their own system, whatever the stack, the model vendor or the team size.Figure 2: The six governance invariants applied to LLM workflows.Invariant 1: Minimize at the sourceWhat isn’t in the returned data can’t leak. The tools exposed to AI should never be the same ones exposed to your human developers. Pagination is enforced, volumes are capped, and unnecessary fields are removed entirely. The API doesn’t return a full customer record just to compute a ratio. It returns the three numbers actually needed: no contacts, no addresses, no names.This isn’t an AI-specific rule. It’s a data-exposure rule that AI simply makes urgent.Invariant 2: Never let a model resolve what a deterministic path can decideA ranking, a ratio lookup or a simple extraction requires no judgment, so it has no business going through your most expensive and least predictable model. The routing can be a business rule, a choice in the interface or a lightweight classifier. The principle doesn’t prescribe how.Two honest caveats. First, if the router is itself a model, it is still a model: keep it small, tightly constrained, and feed it only protected text. The goal is to shrink exposure, not to claim it disappears. Second, this invariant has a benefit that often convinces a board before security does: it’s cheaper and faster.A fun example comes from Anthropic itself. In March 2026, Claude Code’s source code was accidentally leaked through a source map published on npm. It showed that the tool detects user frustration, meaning whether someone is swearing at it, with a plain regular expression, not a model1. A regex is instant and free. An inference call is neither, for a question that needs no judgment at all.Invariant 3: Treat every text-to-model boundary as a mandatory, reversible protection pointAny data crossing the boundary between your system and a language model, inbound or outbound, should be protected by a reversible mechanism such as substitution, pseudonymization or tokenization. The common mistake is protecting only the obvious response field. Check every channel the vendor exposes, including the reasoning channel described in section 2.One caution: pseudonymized data can still count as personal data under regulations like GDPR. This invariant reduces your exposure. It doesn’t remove your legal obligations.Invariant 4: Rehydrate at the last legitimate momentSubstitution should be reversed only at the very last step, right before the answer is shown to a human who is authorized to see the real values. The model works blind from start to finish. Only the human gets the answer in the clear, and only at the end of the chain.Invariant 5: A deterministic guardrail has the final say on plausibilityThe first four invariants protect confidentiality. This one protects correctness. Any response coming out of a deterministic path must pass through a non-probabilistic check, such as a threshold or a comparison with a known reference, before it goes out. If the result looks aberrant, the system falls back to a slower, supervised path rather than letting an unverified answer through.Invariant 6: Every agent-to-agent hop is its own trust boundaryThis is the invariant most often forgotten, because it doesn’t show up in a static layered diagram. An agent’s output isn’t automatically safe for the agent that called it.Here is the scenario. A sub-agent, believing it is “cleaning up its own output” before replying, de-anonymizes data that was meant to stay protected for the agent that delegated to it. The sub-agent did exactly what it was told. It just didn’t know its answer was headed for another link in the chain, one that was supposed to keep reasoning over pseudonyms.There is an aggravating corollary. When conversation history crosses several agents, turns from different agents end up mixed in one context. Without explicit provenance tagging, you can no longer tell which trust boundary applies to which piece of text.Figure 3: Agent-to-agent trust boundaries and rehydration placement.4. For Engineers: How We Implement the InvariantsThis is one possible implementation among many. The tools are specific to our stack. If your system looks nothing like ours, the invariants still hold; only their embodiment changes. Non-technical readers can skip to section 5. Anonymization gate (invariant 3): Before anything is classified, the user’s free text passes through a strict gate that combines NLP libraries with deterministic substitutions. Names and identifiers become unique tokens (for example CORP_A83B). The correspondence table goes straight into an encrypted vault: HMAC-SHA256 for the token, Fernet encryption for the real value at rest. Router (invariant 2): The pseudonymized question goes to a deliberately small, fast, low-cost LLM whose only job is to classify intent. Ranking, ratio lookup or simple extraction takes the fast path: a tool from a bounded allowlist is called directly, and the heavy model is never invoked. Risk analysis or interpretation takes the slow path and goes to the main agent. Minimized tools (invariant 1): On both paths, internal data is reached through tools exposed via the Model Context Protocol (MCP), cleaned at the source and never the same tools as those given to human developers. Slow path (invariant 3, outbound): Tool results are pseudonymized before they enter the heavy model’s context, so it reasons and writes entirely over pseudonyms. Fast path (invariant 5): A plausibility guardrail, a simple ratio compared against a reference value, blocks any aberrant automatic answer and forces a fallback to the slow path for review. Rehydration (invariant 4): Only once the response is generated and formatted does the system query the vault and swap tokens back for real values, right before display to the authorized user.Figure 4: Fast-path / slow-path routing architecture. Observed results: (Add here your real production metrics — e.g., 72% of queries served via the fast path without heavy model invocation, 4x latency reduction, and 65% API cost reduction).5. Verify, Don’t AssumeA defense that has never been verified under real conditions is a defense you only hope exists.Here is a scenario that is far from theoretical. A control meant to transform every message before it reached the model was written, reviewed and merged. The tests passed. For weeks, the team believed the protection was active. It wasn’t. An assumption about the exact shape of the incoming data was wrong, so the control ran against the wrong structure, silently, with no error and nothing to raise an alarm.A test that passes without actually going through the protection code proves nothing. A code review is not proof of execution, and a clean architecture diagram is not proof that the path it draws is the path taken at runtime. The only proof that counts is a test replayed against the real system: the real model, the real data, the real call chain.Every claim of compliance, such as “this is anonymized” or “this data never leaves in the clear,” should come with replayable proof, available on demand. Otherwise it isn’t a guarantee. It’s a hypothesis that hasn’t been disproved yet.Figure 5: The control illusion — silent failure when data format mismatches expectations.6. The Audit TrailGovernance doesn’t stop at real-time protection. It requires traceability. When a banking regulator, a GDPR auditor or a major client reviews how AI is used in your organization, they won’t accept statements of intent. They will ask for evidence.A responsible enterprise AI system keeps a tamper-evident, append-only audit log, kept separate from ordinary conversation logs and chat history. For every event in an agent’s lifecycle, it records a pair: the raw data as originally received, the data actually transmitted to external models.The rule that matters most is that no configuration flag or single administrator can turn this log off. During load spikes, a team’s first instinct is often to disable logging “for performance”. If that switch exists, then on the day a leak happens you won’t be able to prove its scope or identify what was exposed.Figure 6: Why the audit log must be tamper-proof and impossible to bypass via configuration flags.There is a catch, and it deserves to be stated plainly: this log is itself one of the most sensitive datasets in the company, because it contains the raw data you worked so hard to protect. Treat it accordingly. Encrypt it, restrict and separate access to it, define retention limits, and work out with your legal counsel how it coexists with erasure requests under regulations like GDPR.Finally, add retroactive leak scanning: background processes that keep re-scanning archived audit history for sensitive patterns that slipped past the first filters. When one is found, the scanner never edits the original log. It alerts the security team, who can tighten the rules before a small gap becomes an incident.Figure 7: Immutable audit pipeline and asynchronous leak scanning.7. Conclusion: AI Is a Data Engineering Problem“Garbage in, garbage out” has a modern cousin: secret in, exposure out. AI isn’t a cosmetic layer you bolt onto a corner of your information system to satisfy the board. It changes how information flows, how it is segmented, how it is anonymized and how it is archived.The companies that win with AI won’t be the ones that deployed the most agents the fastest. They will be the ones that built a safe foundation first. A good brake system is what lets a car drive fast. With these controls in place, teams can say yes to more use cases, with less debate and fewer late-stage vetoes.So before you sign the next AI contract, or celebrate the rollout of your latest agent, gather your architects and ask one question: “If an external auditor sent by our biggest client showed up tomorrow at 8am, could you prove to me, line of code by line of code, that not a single name, identifier or confidential figure left our network in the clear over the past month?”If the answer is silence, or “our vendor’s clauses”, or “a prompt that tells the AI to be careful,” you have your next project.The Governance Checklist: 8 boxes to checkBefore you leave the room, eight boxes to check (the six invariants, plus the testing and auditing essentials): Do you minimize at the source? (Invariant 1) Do you keep the expensive model off simple requests? (Invariant 2) Is every text-to-model boundary protected by a reversible mechanism? (Invariant 3) Do you rehydrate only at the last legitimate moment? (Invariant 4) Does a deterministic guardrail have the final say on plausibility? (Invariant 5) Is every agent-to-agent hop its own trust boundary? (Invariant 6) Is every control verified under real conditions, not just assumed? (Section 5) Is the audit log impossible to disable by flag or by a single administrator? (Section 6) Alex Kim, “The Claude Code Source Leak: fake tools, frustration regexes, undercover mode, and more”, March 31, 2026. The leak came from a source map file published by mistake on npm. The file userPromptKeywords.ts contains the regex used for frustration detection. ↩

The Ultimate Guide to a Smooth Dev Environment

July 31, 2026 · 9 min
I wrote about Stack a few days ago — covering the short version of why I built it and the whole “no Docker” philosophy. But theory only gets you so far. If you’re looking for the ultimate guide to actually achieving a buttery-smooth dev environment, this is it.No elevator pitch. No marketing fluff. Just the raw, practical reality of getting a project running effortlessly, complete with every weird prompt and console output you’ll encounter along the way.If you just want the dry reference docs, you can find them at sanayavo.com/stack. Otherwise, welcome to the guided tour of a better workflow.What you’re actually gettingA smooth environment means zero bloat. Stack is just one Rust binary. There is no daemon hogging RAM in your system tray, no Docker Desktop spinning up your laptop fans, and absolutely nothing touching your global system PHP install.Instead, it acts as highly intelligent duct tape. It leans on vfox for managing PHP/Node versions, uv for Python, and Caddy for instant local domains. Stack reads a simple stack.toml file in your project root and automatically wires these three tools together.Note: Stack is Windows only right now (PowerShell or cmd). Mac and Linux support are on the roadmap.The Installation: Your one-time headacheA smooth day-to-day workflow requires a tiny bit of upfront setup. Run this to download the installer:irm https://github.com/sanayasfp/stack/releases/latest/download/stackenv-installer.ps1 | iexThen, run this command once per machine:stack setupThis handles the heavy lifting behind the scenes. It adds a hook to your PowerShell profile (so the correct PHP or Node version magically activates when you cd into a directory), installs vfox, uv, and Caddy at heavily-tested versions, and runs caddy trust. That last step is crucial for local HTTPS — more on that in a minute.$ stack setupadded the stack hook for pwshchecking vfox/uv/caddy... vfox: OK (1.0.11) uv: OK (0.11.7) caddy: OK (2.11.4) caddy: local CA trusted (https://*.localhost works with no browser warning)Restart your terminal now. If you skip this, the PowerShell hook won’t be active and you’ll wonder why nothing is working. You will never have to run stack setup again unless you buy a new computer.Starting a project friction-freeLet’s build something real to show off the workflow: a lightweight PHP API backed by MySQL.From scratch$ stack new acme-apidomain: acme-api.localhostlanguages (space to toggle, enter to confirm): [x] phpservices (space to toggle, enter to confirm): [x] mysql php version: 8.3.1 mysql version: 8.0.35created acme-api\stack.tomlnext: cd into it, add a [run] command when you know it, then `stack up`It’s an interactive terminal checklist. Space to toggle, enter to confirm. It feels a bit different the first time, but nothing commits until you press enter. If you accidentally select Node instead of PHP, just hit space again. Zero stress, no do-overs required.If you already have a projectIf your acme-api directory already has a composer.json file specifying "php": "^8.3", Stack is smart enough to skip the manual typing:$ stack initdetected from existing project files: php 8.3 (from composer.json)domain: acme-api.localhoststack init automatically parses composer.json, package.json, or pyproject.toml and pre-fills the wizard for you.The magic artifactStack generates a highly readable manifest:[project]name = "acme-api"domain = "acme-api.localhost"[language]php = "8.3.1"[service.mysql]version = "8.0.35"Commit this stack.toml file to your repo. It is your ultimate “works on my machine” artifact—the lightweight, container-free equivalent of a Dockerfile. When a teammate clones the repo and types stack up, they instantly get the exact same PHP and MySQL versions without installing anything by hand.Teaching it how to runYour stack.toml needs to know how to boot up your dev server. Open the file and add a [run] block:[run]command = "php -S 127.0.0.1:{port} -t public"The {port} placeholder dynamically swaps for an available port at runtime. (Note: If you declare [language.php] but skip the [run] section, Stack falls back to its built-in FastCGI setup using php-cgi.exe and Caddy, which is incredibly solid. But for this guide, we’ll keep it explicit.)stack up and watch it fly$ cd acme-api$ stack upLoaded C:\Users\you\acme-api\stack.toml project: acme-api domain: acme-api.localhost languages: php services: mysqlfirst run for this project — stack.toml will execute: [run] php -S 127.0.0.1:{port} -t publicTrust and run these commands? [y/N] y php: C:\Users\you\.vfox\cache\php\v-8.3.1\...\php.exe -> PHP 8.3.1 (cli) service.mysql: started (pid 41232, port 3306) schema 'acme_api' — automatic creation not yet implemented; create it manually if needed run: php -S 127.0.0.1:52140 -t public (pid 41244, port 52140) log: C:\Users\you\.stack\logs\acme-api.log routed: http://acme-api.localhost -> 127.0.0.1:52140There are two major things happening here that contribute to a seamless experience:1. The security trust promptBecause [run].command is a literal shell invocation, Stack runs it exactly as written. The first time Stack sees a new or modified command (like after a git pull), it asks you to confirm it. Say yes, and it remembers your choice in ~/.stack/trust.json. Every subsequent stack up is blissfully silent. Need to bypass it for CI? Just pass --yes.2. HTTPS works out of the boxThe console says http://, but https://acme-api.localhost works instantly. No red browser warnings. Remember that caddy trust command from the setup phase? It installs a local Certificate Authority (similar to mkcert), meaning your local environment accurately mirrors production SSL from day one.What “no containers” actually means in practiceOpen a second terminal, cd into a totally different project that also requires MySQL 8.0.35, and run stack up: service.mysql: already running, shared with other projects (pid 41232, port 3306)That’s the beauty of it. One MySQL process serving two projects, isolated by schema rather than burning your CPU and RAM on redundant containers.But here is where the “smooth” factor really shines. Open a third terminal, cd into acme-api, and just run php -v. No stack up, no extra commands:$ cd acme-api$ php -vPHP 8.3.1 (cli) (built: ...)$ cd ..$ php -v'php' is not recognized as an internal or external commandThe version switch happens ambiently on every prompt thanks to that PowerShell hook. Your IDE, composer install, and your test runners all automatically get the correctly pinned version the second they enter the folder. Leave the directory, and it vanishes. No source venv/bin/activate, no nvm use, and no polluting your global system.Fresh PHP installs that don’t suckUsually, a fresh PHP installation means spending an afternoon hunting down “PDO driver not found” or “timezone not set” errors. Stack eliminates this entirely.The first time it downloads a PHP version via vfox, it automatically patches php.ini. It turns on OPcache, bumps memory and upload limits, sets a timezone, and enables the extensions you actually need out of the box (pdo_mysql, pdo_pgsql, pdo_sqlite, sockets, sodium, etc.). It happens once, automatically, and you never have to think about it again.stack doctor — The ultimate pre-flight checkBefore you hand off a project or start debugging a strange issue, run this:$ stack doctor --projectchecking C:\Users\you\acme-api\stack.toml... language.php: OK (C:\Users\you\.vfox\cache\php\v-8.3.1\...\php.exe) service.mysql: OK (managed)This command validates your ports, paths, and {PLACEHOLDER} values against your environment (loading .env first) without starting a single service. If something is broken, you get a clean list upfront instead of discovering cryptic errors halfway through startup.Command your setup from anywhereOnce Stack knows about a project, you don’t even need to be in its folder to manage it:stack describe acme-apistack restart acme-apistack down acme-apistack describe dumps everything—resolved binary paths, the exact location of your hashed php.ini, logs, and routed domains. Stack keeps a local registry of your projects, making global management effortless.Shutting it all down cleanlyWhen the day is over:stack down --allEverything stops. Every project, every shared service, and Caddy. You get actual zero CPU usage, not a lingering phantom container you forgot to kill.A quick note on custom domainsUsing .localhost guarantees a smooth experience because browsers automatically resolve it to the loopback address (RFC 6761) without you needing to hack your hosts file. However, if you are migrating from Laragon or Herd and absolutely need .test domains, there is a one-time setup using Acrylic DNS Proxy.Your New WorkflowScaffold, run, trust once, enjoy automatic HTTPS, commit your manifest, and tear it down cleanly when you’re done.I use this exact workflow for real client work every single day. This isn’t a theoretical happy path; it’s a battle-tested blueprint for an ultimate, smooth dev environment.For the complete stack.toml reference, CLI flags, and advanced features (like using [[clone]] to bootstrap from just a manifest), check out the official docs: Why I built this — the longer rant against Docker/XAMPP for local dev Getting Started Manifest Reference CLI Reference Code: github.com/sanayasfp/stack

Stack: A Native, Zero-Container Dev Environment Manager

July 28, 2026 · 3 min
When you are maintaining a legacy application while building its modern replacement, your local development environment quickly becomes a battlefield. At work, we had a legacy app running on an old version of PHP, Vue 2 (requiring an outdated Node version), and Python 3.7. Alongside it, we were bootstrapping the new architecture with PHP 8.4, Vue 3, and Python 3.12.I personally managed this with a bespoke combination of Laravel Herd for PHP, uv for Python, and nvm for Node. It worked for me because I took the time to configure it. But for our junior developers and fresh interns, asking them to juggle these tools just to run both projects simultaneously was an absolute nightmare. They were fighting path conflicts and environment variables instead of writing code. That’s why I built Stack: a native, zero-container multi-project dev environment manager that makes switching contexts as simple as typing stack up.Code’s here: github.com/sanayasfp/stack.The real constraint: Onboarding and Cognitive LoadBefore touching the architecture, the real constraint on a project like this is developer experience. Interns shouldn’t need to learn Docker network bridges or configure five different version managers to fix a bug in the legacy API. The goal was simple: they should be able to clone the repo, run a single command, and get a working local domain routed to the right processes, regardless of whether the project uses PHP 5.6 or 8.4.Why not just use Docker?Containers solve the “works on my machine” problem by shipping an entire OS layer per project. But that comes with virtualization overhead and idle daemons eating RAM.Stack solves the same problem differently. It pins exact versions per project through version managers you’d install anyway (delegating to tools like vfox and uv), shares one downloaded binary across every project needing that version, and runs everything as plain child processes. No hypervisor, no Docker Desktop taking up 4GB of RAM for a simple API. Every project pinning the same service and version shares one running instance, isolated by schema.Under the hood: Simplicity over MagicA stack.toml file in your project defines exactly what is needed:[project]name = "acme-api"[language]php = "8.4.0"[service.mysql]version = "8.0.35"[run]command = "php -S 127.0.0.1:{port} -t public"When you run stack up, the orchestrator reads the manifest, wires up the local domain (e.g. acme-api.localhost), starts MySQL if it’s not already running, boots the PHP dev server, and reverse-proxies the traffic. All native, all blazing fast. It doesn’t try to reinvent package management; it delegates the heavy lifting of language downloads to vfox and uv, and the routing to Caddy.Where it actually stands right nowI want to be precise about this: I started coding Stack in Rust just last week and pushed the first release two days ago. I have completely uninstalled Herd, Laragon, and my previous manager scripts. I am now exclusively dogfooding Stack for my daily work — using it to prove it works, find edge-case bugs, and add new features to improve it.It currently supports Windows (PowerShell & cmd), which was the immediate need, with macOS and Linux on the roadmap. It’s not just a weekend experiment anymore; it’s becoming the foundational tool that keeps our team’s local development sane.You can grab the installer from the repo or read the official documentation: Repo: github.com/sanayasfp/stack Docs: sanayavo.com/stack/

Laplace Nearby: A WhatsApp Bot That Finds 'The Nearest Maquis' Without Torching the LLM Budget

June 22, 2026 · 8 min
In Abidjan, a huge number of local businesses — the neighborhood maquis (informal restaurant), the pharmacy, the guy who fixes phones — don’t have a website, a Google Business listing, or even a formal street address half the time. What they do have is WhatsApp. Laplace Nearby starts from that observation: instead of building an app people have to download, build the search experience inside the app everyone already has open, and let people describe what they want in plain language — “I need a pharmacy,” “j’ai envie de porcodjo” — rather than filling out a filter form.The assistant itself is internally called Simon. This post is about how Simon is actually built — the parts I think are worth talking about, not a marketing pitch.Repo: github.com/sanayasfp/laplace-nearby.Decision #1: the engine doesn’t know it’s talking to WhatsAppThe core of the system is SimonEngine, a small orchestrator that takes a channel-agnostic InteractionRequest and returns a channel-agnostic InteractionResponse. WhatsApp specifics — webhook payloads, message formatting, buttons and lists — live entirely in an adapter/renderer layer outside the engine. The engine itself just does four things on every message: load the user’s session context, check rate limiting, hand the message to whichever conversational flow is currently active (idle, search, or register), and save the result — always emitting an analytics side effect with the flow name, the state transition, and the latency, whether or not anything interesting happened:async process(req: InteractionRequest): Promise<InteractionResponse> { const context = await this.contextManager.load(req.profileId); const flow = this.flowRegistry.get(context.session.activeFlow || IDLE); const limitResult = this.rateLimitService.check(context.rateLimit); if (!limitResult.allowed) { return { messages: limitResult.notify ? [Responses.tooManyMessages(...)] : [] , ... }; } const response = await flow.handle(req, context, interactionId); await this.contextManager.save(req.profileId, response.contextUpdate); return { ...response, sideEffects: [summaryEffect, ...response.sideEffects] };}This isn’t architecture for its own sake. It means the search logic, the registration flow, the rate limiting, and the metrics don’t need to be rewritten if a second channel (SMS, a web widget, whatever) shows up later — they were never coupled to WhatsApp in the first place.Decision #2: don’t call the LLM if you don’t have toEvery incoming message needs an intent — is this a search, a registration request, small talk, a thank-you, an insult, “stop”? Routing every single message through an LLM call is the easy way to build this, and also the expensive and slow way. So intent classification is two-tiered.FastIntentDetector runs first: a deliberately exhaustive set of regex patterns covering French, English, and local slang and abbreviations you’d actually see in a WhatsApp chat in Abidjan — “wesh,” “cc,” “gab” (cash machine), “essence/gazoil” (fuel), dozens of spelling variants of “merci,” “stop,” “annule.” Its docstring says exactly what it’s for: “Reduce LLM costs and latency for unambiguous user requests.” Only when nothing matches does IntentService fall back to Gemini (gemini-2.5-flash-lite), with temperature 0, JSON response mode, and a tight 150-token cap, asking for structured output: the intent, an extracted search keyword, and a list of semantically related terms to widen the search (“j’ai envie de porcodjo” → keyword “porcodjo restaurant”; “mon habit est sale” → “pressing nettoyage vêtement”).Both paths report where the classification came from — REGEX or LLM — to a Prometheus counter (simonIntentTotal), so the fraction of traffic being deflected from the paid model is something you can actually watch on a dashboard, not something you have to guess at. And the Gemini call itself sits behind a circuit breaker: if it trips, the system doesn’t crash the conversation, it degrades to a neutral “chitchat” response with confidence 0 and moves on.Decision #3: addresses in Abidjan don’t work like addresses elsewhereA huge share of real addresses given in chat aren’t geocodable strings — they’re descriptions: “je suis vers la cité Abdoulaye Diallo.” AddressCodingService takes that kind of input, checks a semantic cache (exact hash first, then embedding similarity above a 0.88 threshold) to avoid re-paying for something already resolved, and if it’s a miss, asks Gemini to turn it into a standardized address, an extracted neighborhood, and — importantly — a flag for whether the description is precise enough to geocode at all or whether the only honest answer is “ask the user to drop a GPS pin.” There’s even a dedicated dictionary of Nouchi (Abidjan street slang) terms feeding into this pipeline, because generic NLP tooling doesn’t know what a “gbaka” stop or a given neighborhood nickname refers to.Decision #4: search is one SQL function, three signals, fusedThis is the part of the codebase I’m most proud of. search_nearby_places is a single Postgres function that blends three independent ranking signals for every candidate business inside a radius: Full-text rank (ts_rank_cd against a French tsquery) — good when the user typed something close to the business’s actual name or category. Vector similarity rank — cosine distance between the query embedding (Gemini text-embedding-004, pgvector with an HNSW index) and each place’s embedding — good when the user described what they want in their own words instead of matching a label. Geographic proximity (ST_Distance over a PostGIS geography column) — because “closest” always matters, and can’t be papered over by relevance alone.These get combined with a weighted Reciprocal Rank Fusion: score = w_fts/(k + fts_rank) + w_vec/(k + vec_rank) + w_prox * proximity_term, with the weights shifting based on whether the user gave a keyword at all — 45/45/10 between text, vector, and proximity when there’s a keyword to match; 85% vector-driven when there isn’t, since full-text search has nothing to grab onto in a purely descriptive query. There’s also a “premium tier” mechanism: a second ranking pass, partitioned by tier, guarantees paying/listed businesses a small quota of slots without letting them drown out relevance for the general result set.I later went back and rewrote this same function for performance after noticing it was doing redundant work: merging two CTEs that were reading the same rows twice, adding a LIMIT inside the vector-ranking CTE specifically so the HNSW index can short-circuit instead of sorting the entire candidate pool, and replacing two separate full materializations (general results, premium results) with a single windowed pass using ROW_NUMBER() OVER (PARTITION BY is_premium ...). That’s a genuinely satisfying kind of fix — same output, measurably less work per query — and it’s the sort of thing that only shows up once real usage patterns put pressure on a first draft.Decision #5: side effects are data, not actionsWhen a place gets registered, or an interaction needs logging for analytics, or a user needs a notification, the engine doesn’t go do that work inline — it returns a plain SideEffect object describing what should happen. A PgmqSideEffectDispatcher collects these, groups them by target queue, and pushes them via pgmq.send_batch (Postgres’s own message-queue extension) inside the same database transaction that marks a newly registered place as “queued.” That detail matters: it means a place can’t end up half-registered — visible in the app but never actually indexed for search — because the status update and the queue write either both commit or neither does. Supabase Edge Functions on the other end (an analytics-worker, a place-embedding-worker, a place-tagging-worker) drain those queues asynchronously.The stack, plainlyFastify + TypeScript on Node.js, Prisma over Supabase Postgres (with pgvector, pgmq, PostGIS, pg_cron, and pg_net doing real work, not just sitting in a dependency list), Redis for caching and rate-limit state, Gemini for both NLU and embeddings, Geoapify for geocoding, and Prometheus metrics wired in from the start rather than bolted on later.Where it standspackage.json says 0.4.0-rc.1 and I mean to leave that context in: this is a real, working system I use to reason about hybrid search and conversational engineering, actively evolving toward a 1.0 — not a finished, scaled product with a client roster behind it. The channel-agnostic design exists specifically so that if this ever needs to be more than a WhatsApp bot, the engine underneath doesn’t need to be rebuilt.If you want to argue about the RRF weights, tell me PostGIS was overkill, or point out a better way to structure the side-effect dispatcher — the code’s public: github.com/sanayasfp/laplace-nearby.

The Ultimate Guide to a Smooth Dev Environment

July 31, 2026 · 9 min
I wrote about Stack a few days ago — covering the short version of why I built it and the whole “no Docker” philosophy. But theory only gets you so far. If you’re looking for the ultimate guide to actually achieving a buttery-smooth dev environment, this is it.No elevator pitch. No marketing fluff. Just the raw, practical reality of getting a project running effortlessly, complete with every weird prompt and console output you’ll encounter along the way.If you just want the dry reference docs, you can find them at sanayavo.com/stack. Otherwise, welcome to the guided tour of a better workflow.What you’re actually gettingA smooth environment means zero bloat. Stack is just one Rust binary. There is no daemon hogging RAM in your system tray, no Docker Desktop spinning up your laptop fans, and absolutely nothing touching your global system PHP install.Instead, it acts as highly intelligent duct tape. It leans on vfox for managing PHP/Node versions, uv for Python, and Caddy for instant local domains. Stack reads a simple stack.toml file in your project root and automatically wires these three tools together.Note: Stack is Windows only right now (PowerShell or cmd). Mac and Linux support are on the roadmap.The Installation: Your one-time headacheA smooth day-to-day workflow requires a tiny bit of upfront setup. Run this to download the installer:irm https://github.com/sanayasfp/stack/releases/latest/download/stackenv-installer.ps1 | iexThen, run this command once per machine:stack setupThis handles the heavy lifting behind the scenes. It adds a hook to your PowerShell profile (so the correct PHP or Node version magically activates when you cd into a directory), installs vfox, uv, and Caddy at heavily-tested versions, and runs caddy trust. That last step is crucial for local HTTPS — more on that in a minute.$ stack setupadded the stack hook for pwshchecking vfox/uv/caddy... vfox: OK (1.0.11) uv: OK (0.11.7) caddy: OK (2.11.4) caddy: local CA trusted (https://*.localhost works with no browser warning)Restart your terminal now. If you skip this, the PowerShell hook won’t be active and you’ll wonder why nothing is working. You will never have to run stack setup again unless you buy a new computer.Starting a project friction-freeLet’s build something real to show off the workflow: a lightweight PHP API backed by MySQL.From scratch$ stack new acme-apidomain: acme-api.localhostlanguages (space to toggle, enter to confirm): [x] phpservices (space to toggle, enter to confirm): [x] mysql php version: 8.3.1 mysql version: 8.0.35created acme-api\stack.tomlnext: cd into it, add a [run] command when you know it, then `stack up`It’s an interactive terminal checklist. Space to toggle, enter to confirm. It feels a bit different the first time, but nothing commits until you press enter. If you accidentally select Node instead of PHP, just hit space again. Zero stress, no do-overs required.If you already have a projectIf your acme-api directory already has a composer.json file specifying "php": "^8.3", Stack is smart enough to skip the manual typing:$ stack initdetected from existing project files: php 8.3 (from composer.json)domain: acme-api.localhoststack init automatically parses composer.json, package.json, or pyproject.toml and pre-fills the wizard for you.The magic artifactStack generates a highly readable manifest:[project]name = "acme-api"domain = "acme-api.localhost"[language]php = "8.3.1"[service.mysql]version = "8.0.35"Commit this stack.toml file to your repo. It is your ultimate “works on my machine” artifact—the lightweight, container-free equivalent of a Dockerfile. When a teammate clones the repo and types stack up, they instantly get the exact same PHP and MySQL versions without installing anything by hand.Teaching it how to runYour stack.toml needs to know how to boot up your dev server. Open the file and add a [run] block:[run]command = "php -S 127.0.0.1:{port} -t public"The {port} placeholder dynamically swaps for an available port at runtime. (Note: If you declare [language.php] but skip the [run] section, Stack falls back to its built-in FastCGI setup using php-cgi.exe and Caddy, which is incredibly solid. But for this guide, we’ll keep it explicit.)stack up and watch it fly$ cd acme-api$ stack upLoaded C:\Users\you\acme-api\stack.toml project: acme-api domain: acme-api.localhost languages: php services: mysqlfirst run for this project — stack.toml will execute: [run] php -S 127.0.0.1:{port} -t publicTrust and run these commands? [y/N] y php: C:\Users\you\.vfox\cache\php\v-8.3.1\...\php.exe -> PHP 8.3.1 (cli) service.mysql: started (pid 41232, port 3306) schema 'acme_api' — automatic creation not yet implemented; create it manually if needed run: php -S 127.0.0.1:52140 -t public (pid 41244, port 52140) log: C:\Users\you\.stack\logs\acme-api.log routed: http://acme-api.localhost -> 127.0.0.1:52140There are two major things happening here that contribute to a seamless experience:1. The security trust promptBecause [run].command is a literal shell invocation, Stack runs it exactly as written. The first time Stack sees a new or modified command (like after a git pull), it asks you to confirm it. Say yes, and it remembers your choice in ~/.stack/trust.json. Every subsequent stack up is blissfully silent. Need to bypass it for CI? Just pass --yes.2. HTTPS works out of the boxThe console says http://, but https://acme-api.localhost works instantly. No red browser warnings. Remember that caddy trust command from the setup phase? It installs a local Certificate Authority (similar to mkcert), meaning your local environment accurately mirrors production SSL from day one.What “no containers” actually means in practiceOpen a second terminal, cd into a totally different project that also requires MySQL 8.0.35, and run stack up: service.mysql: already running, shared with other projects (pid 41232, port 3306)That’s the beauty of it. One MySQL process serving two projects, isolated by schema rather than burning your CPU and RAM on redundant containers.But here is where the “smooth” factor really shines. Open a third terminal, cd into acme-api, and just run php -v. No stack up, no extra commands:$ cd acme-api$ php -vPHP 8.3.1 (cli) (built: ...)$ cd ..$ php -v'php' is not recognized as an internal or external commandThe version switch happens ambiently on every prompt thanks to that PowerShell hook. Your IDE, composer install, and your test runners all automatically get the correctly pinned version the second they enter the folder. Leave the directory, and it vanishes. No source venv/bin/activate, no nvm use, and no polluting your global system.Fresh PHP installs that don’t suckUsually, a fresh PHP installation means spending an afternoon hunting down “PDO driver not found” or “timezone not set” errors. Stack eliminates this entirely.The first time it downloads a PHP version via vfox, it automatically patches php.ini. It turns on OPcache, bumps memory and upload limits, sets a timezone, and enables the extensions you actually need out of the box (pdo_mysql, pdo_pgsql, pdo_sqlite, sockets, sodium, etc.). It happens once, automatically, and you never have to think about it again.stack doctor — The ultimate pre-flight checkBefore you hand off a project or start debugging a strange issue, run this:$ stack doctor --projectchecking C:\Users\you\acme-api\stack.toml... language.php: OK (C:\Users\you\.vfox\cache\php\v-8.3.1\...\php.exe) service.mysql: OK (managed)This command validates your ports, paths, and {PLACEHOLDER} values against your environment (loading .env first) without starting a single service. If something is broken, you get a clean list upfront instead of discovering cryptic errors halfway through startup.Command your setup from anywhereOnce Stack knows about a project, you don’t even need to be in its folder to manage it:stack describe acme-apistack restart acme-apistack down acme-apistack describe dumps everything—resolved binary paths, the exact location of your hashed php.ini, logs, and routed domains. Stack keeps a local registry of your projects, making global management effortless.Shutting it all down cleanlyWhen the day is over:stack down --allEverything stops. Every project, every shared service, and Caddy. You get actual zero CPU usage, not a lingering phantom container you forgot to kill.A quick note on custom domainsUsing .localhost guarantees a smooth experience because browsers automatically resolve it to the loopback address (RFC 6761) without you needing to hack your hosts file. However, if you are migrating from Laragon or Herd and absolutely need .test domains, there is a one-time setup using Acrylic DNS Proxy.Your New WorkflowScaffold, run, trust once, enjoy automatic HTTPS, commit your manifest, and tear it down cleanly when you’re done.I use this exact workflow for real client work every single day. This isn’t a theoretical happy path; it’s a battle-tested blueprint for an ultimate, smooth dev environment.For the complete stack.toml reference, CLI flags, and advanced features (like using [[clone]] to bootstrap from just a manifest), check out the official docs: Why I built this — the longer rant against Docker/XAMPP for local dev Getting Started Manifest Reference CLI Reference Code: github.com/sanayasfp/stack

Stack: A Native, Zero-Container Dev Environment Manager

July 28, 2026 · 3 min
When you are maintaining a legacy application while building its modern replacement, your local development environment quickly becomes a battlefield. At work, we had a legacy app running on an old version of PHP, Vue 2 (requiring an outdated Node version), and Python 3.7. Alongside it, we were bootstrapping the new architecture with PHP 8.4, Vue 3, and Python 3.12.I personally managed this with a bespoke combination of Laravel Herd for PHP, uv for Python, and nvm for Node. It worked for me because I took the time to configure it. But for our junior developers and fresh interns, asking them to juggle these tools just to run both projects simultaneously was an absolute nightmare. They were fighting path conflicts and environment variables instead of writing code. That’s why I built Stack: a native, zero-container multi-project dev environment manager that makes switching contexts as simple as typing stack up.Code’s here: github.com/sanayasfp/stack.The real constraint: Onboarding and Cognitive LoadBefore touching the architecture, the real constraint on a project like this is developer experience. Interns shouldn’t need to learn Docker network bridges or configure five different version managers to fix a bug in the legacy API. The goal was simple: they should be able to clone the repo, run a single command, and get a working local domain routed to the right processes, regardless of whether the project uses PHP 5.6 or 8.4.Why not just use Docker?Containers solve the “works on my machine” problem by shipping an entire OS layer per project. But that comes with virtualization overhead and idle daemons eating RAM.Stack solves the same problem differently. It pins exact versions per project through version managers you’d install anyway (delegating to tools like vfox and uv), shares one downloaded binary across every project needing that version, and runs everything as plain child processes. No hypervisor, no Docker Desktop taking up 4GB of RAM for a simple API. Every project pinning the same service and version shares one running instance, isolated by schema.Under the hood: Simplicity over MagicA stack.toml file in your project defines exactly what is needed:[project]name = "acme-api"[language]php = "8.4.0"[service.mysql]version = "8.0.35"[run]command = "php -S 127.0.0.1:{port} -t public"When you run stack up, the orchestrator reads the manifest, wires up the local domain (e.g. acme-api.localhost), starts MySQL if it’s not already running, boots the PHP dev server, and reverse-proxies the traffic. All native, all blazing fast. It doesn’t try to reinvent package management; it delegates the heavy lifting of language downloads to vfox and uv, and the routing to Caddy.Where it actually stands right nowI want to be precise about this: I started coding Stack in Rust just last week and pushed the first release two days ago. I have completely uninstalled Herd, Laragon, and my previous manager scripts. I am now exclusively dogfooding Stack for my daily work — using it to prove it works, find edge-case bugs, and add new features to improve it.It currently supports Windows (PowerShell & cmd), which was the immediate need, with macOS and Linux on the roadmap. It’s not just a weekend experiment anymore; it’s becoming the foundational tool that keeps our team’s local development sane.You can grab the installer from the repo or read the official documentation: Repo: github.com/sanayasfp/stack Docs: sanayavo.com/stack/

Car Inspect AI: Teaching YOLO to Read a Car the Way an Inspector Does

June 22, 2026 · 6 min
Anyone who’s rented a car or filed an insurance claim knows the ritual: someone walks around the vehicle, snaps a few photos, and a human decides later whether that mark on the bumper was already there. It’s slow, it’s subjective, and disputes between renters, owners, and insurers over “pre-existing damage” are extremely common precisely because the whole process runs on someone’s word against a handful of photos. Car Inspect AI is my attempt to chip away at that problem: a computer-vision system that automatically detects and labels the individual parts of a vehicle from a photo, as a first building block toward more objective, automated inspection.Code’s here: github.com/sanayasfp/car-inspect-ai.Starting with the actual constraint: dataBefore touching a model, the real constraint on a project like this is data. I trained on the public Car Parts Segmentation dataset (Kitsuchart Pasupa et al.), 500 annotated images of sedans, pickups, and SUVs in COCO format, covering 18 distinct vehicle parts — bumpers, doors, lights, mirrors, hood, trunk, wheels, and so on, shot from front, back, and angled views, with plates and faces blurred for privacy. 500 images is not a lot by deep-learning standards, and that constraint shaped almost every other decision in the project.Why YOLO11n specificallyI picked YOLO11n — the nano variant — deliberately, not just because “YOLO is what people use for object detection.” Two things mattered: It’s small enough to run on modest hardware. A tool meant for garages, small rental agencies, or independent inspectors is useless if it needs a beefy GPU to run inference. YOLO11n trades some raw accuracy for a footprint that runs comfortably on a CPU or an entry-level GPU. With only 500 images, model capacity is a liability, not an asset. A larger model has more room to overfit a small dataset. A lightweight architecture, combined with aggressive data augmentation, was the more honest choice given what I actually had to train on.To stretch that small dataset further, preprocessing included resizing to YOLO’s expected input dimensions and augmenting with rotation (simulating different shooting angles), horizontal flips, and Gaussian noise (to make the model less sensitive to lighting variation — a real problem when photos come from random phone cameras in a parking lot, not a studio).Training ran for 50 epochs with an 80/20 train/validation split, batch size 16, and early stopping if validation performance stalled for 10 consecutive epochs. The result: 87% mAP on the validation set, with the strongest performance on well-defined, geometrically simple parts like wheels and doors — exactly where I’d expect a detector to do best, and exactly the kind of result that tells you where to focus next (smaller/ambiguous parts like mirrors are the harder cases).Rolling my own tiny ORM instead of reaching for SQLAlchemyThis is the part of the project I’d guess most people skip past, but it’s the part I learned the most from. The app needs to track training runs — which model, how many epochs, whether it completed, where the checkpoint lives, and whether it resumes from a previous run. Instead of pulling in SQLAlchemy for what’s fundamentally a handful of tables, I wrote a small dataclass-based model layer myself:@dataclasses.dataclassclass TrainLogsModel(BaseModel): _table_name = "train_logs" name: str epochs: int model: str path: str completed: bool = Field(type=bool, default=False).set() id: Optional[int] = Field(type=int, primary_key=True, autoincrement=True).set() created_at: Optional[float] = Field(type=int, default=lambda: dt.now().timestamp()).set() resumed_from: Optional[int] = Field(type=int, foreign_key="id", foreign_table=_table_name).set()BaseModel reads the dataclass’s type annotations and field metadata and turns them into SQL column definitions (INTEGER, TEXT, REAL, with primary keys, autoincrement, and foreign keys handled explicitly), and derives table names from class names in snake_case, camelCase, or PascalCase depending on what’s asked for. It’s a fraction of what a real ORM does — no query builder, no migrations system — but building even that fraction by hand forced me to actually understand what an ORM is automating away, rather than just importing one and trusting the magic. That’s the trade I’d make again: for a project this size, writing 100 lines to understand the mechanism beat 10 lines that hide it.That model backs a genuinely useful feature: the training page lets me pick either a fresh YOLO11n base or any previous checkpoint, kick off training, and log it — including a resumed_from foreign key back to the run it continued, so I have an actual lineage of experiments instead of a folder full of best_v2_final_FINAL.pt files.The interface: Streamlit, on purposeThe whole thing is wrapped in a small multi-page Streamlit app — a home page, a “register a car” page (upload front/back/left/right photos plus color and plate number), the training page described above, and a scratch page for in-progress experiments. Streamlit was the right call here specifically because it isn’t the point of the project: I wanted to spend my time on the detection model and the training/versioning story, not on hand-rolling a frontend, and Streamlit gets out of the way for that.Where it actually stands right nowI want to be precise about this rather than round it up: the part-detection model and the training/versioning pipeline are working and measured (that 87% mAP figure is real, from an actual run, not an estimate). The full inspection pipeline — fusing all four angles of a vehicle into a single confident report, and moving from “these are the detected parts” to an actual damage or fraud verdict — is still active, in-progress work, not a finished product. The README lists automated damage severity scoring and vehicle-history integration as future directions, and that’s accurate: those are the roadmap, not something I’m claiming already works end-to-end.If I were pitching what’s genuinely done today: a lightweight, honestly-benchmarked vehicle part detector, trained reproducibly, with its own minimal experiment-tracking layer built from first principles. That’s a smaller claim than “automated fraud detection system,” but it’s the true one — and it’s a better foundation to build the rest on top of.Repo’s public if you want to see the training code, the mini-ORM, or argue that I should’ve just used SQLAlchemy: github.com/sanayasfp/car-inspect-ai.

The AI Illusion: Why feeding an LLM without data governance is a risk you can’t audit

October 30, 2026 · 15 min
A lawyer opens the company’s new AI assistant, uploads an exclusivity contract, and types: “Summarize the key obligations in five lines.” Ten seconds later she has a clean summary. It’s a small, real productivity win.What she doesn’t see is what happened in those ten seconds. The application didn’t just send her question. It sent the whole contract: the counterparty, the prices, the termination clauses, the signatory’s name. All of it landed, as readable text, on a server run by someone else.The same thing happens when an HR manager asks for a summary of a performance review, or a financial analyst digs into a client’s credit file. The question is short. The context that travels with it is enormous.In nearly every executive committee, the directive for 2026 is the same: “We need AI, and we need it now.” Within weeks, API keys are created, language models are wired into internal databases, and whole workflows are handed to third-party algorithms. The demos are impressive and the productivity gains are real.This article is not an argument against that. It’s an argument that the companies that get data governance right will deploy AI faster and in more places, because the foundation underneath is safe. The companies that skip it will eventually face one simple question they cannot answer. We’ll come back to it at the end.1. The Problem: Every Request Carries Your DataEven when your contract with the AI vendor forbids training on your data, and even when the connection is fully encrypted in transit, the raw text still arrives in readable form at a third party. Contracts limit what the vendor is allowed to do with it. They don’t change what is exposed if that vendor suffers a breach, if a log is misconfigured, or if a legal request lands on their desk.The risk grows with multi-agent architectures, where a router agent delegates work to specialized sub-agents that share a common memory. A single name or revenue figure injected into that shared state is now visible to every agent in the chain, and to every log, cache and tracing tool they write to.Figure 1: Injection of raw data into a multi-agent shared memory.Sending raw business data into an LLM with no controls isn’t innovation. It’s outsourcing your company’s strategic memory to a party that will never bear the cost of your leaks.2. Why Naive Anonymization FailsSome teams, aware of the danger, put a basic filter in front of the model: generic Named Entity Recognition (NER) tools, or regular expressions that redact proper nouns. On paper, that sounds reasonable. In production it tends to fail in three ways, and the real danger is that it gives leadership a sense of safety that stops them from asking questions.Failure 1: The filter doesn’t understand your businessGeneric detection tools are trained on everyday text. They confuse company names with common words (“Services”, “Transit”, “Holding”), and they often miss a company whose name contains such a word, like “NET-POLY SERVICES”. Internal identifiers such as tax codes or registration numbers don’t match standard formats, so they slip through.Failure 2: Hiding the name isn’t hiding the companyConsider this example: Raw text: “Company X posted 4.2 billion FCFA in revenue in Abidjan in 2024.” After a naive filter: “COMPANY_1 posted 4.2 billion FCFA in revenue in Abidjan in 2024.”The name is gone, but the exact revenue and the city are enough to identify the company with a single search. A modern model, with its vast training knowledge, can often do the same in a fraction of a second.Failure 3: A prompt is a request, not a controlAn instruction like “Never repeat client names” is a probabilistic nudge. The model will usually comply, until it doesn’t. There is a subtler problem too. Depending on the vendor and the settings, some models return their intermediate reasoning in a channel separate from the final answer, a kind of scratchpad handed back to you. If your protection only inspects the final answer, raw data can travel through the scratchpad unchecked.Believing a system prompt that says “be discreet” protects your data is like locking an armored door and leaving the key in the lock outside.3. The Six Invariants of Robust GovernanceIf instructions and basic filters aren’t enough, what is? Most architects sketch a linear pipeline: data comes in, gets filtered, goes to the model, comes back. A robust system looks different. It is defense in depth, where every boundary carries its own protection, independently of the others.What follows are six invariants. They are contracts that any architect can check against their own system, whatever the stack, the model vendor or the team size.Figure 2: The six governance invariants applied to LLM workflows.Invariant 1: Minimize at the sourceWhat isn’t in the returned data can’t leak. The tools exposed to AI should never be the same ones exposed to your human developers. Pagination is enforced, volumes are capped, and unnecessary fields are removed entirely. The API doesn’t return a full customer record just to compute a ratio. It returns the three numbers actually needed: no contacts, no addresses, no names.This isn’t an AI-specific rule. It’s a data-exposure rule that AI simply makes urgent.Invariant 2: Never let a model resolve what a deterministic path can decideA ranking, a ratio lookup or a simple extraction requires no judgment, so it has no business going through your most expensive and least predictable model. The routing can be a business rule, a choice in the interface or a lightweight classifier. The principle doesn’t prescribe how.Two honest caveats. First, if the router is itself a model, it is still a model: keep it small, tightly constrained, and feed it only protected text. The goal is to shrink exposure, not to claim it disappears. Second, this invariant has a benefit that often convinces a board before security does: it’s cheaper and faster.A fun example comes from Anthropic itself. In March 2026, Claude Code’s source code was accidentally leaked through a source map published on npm. It showed that the tool detects user frustration, meaning whether someone is swearing at it, with a plain regular expression, not a model1. A regex is instant and free. An inference call is neither, for a question that needs no judgment at all.Invariant 3: Treat every text-to-model boundary as a mandatory, reversible protection pointAny data crossing the boundary between your system and a language model, inbound or outbound, should be protected by a reversible mechanism such as substitution, pseudonymization or tokenization. The common mistake is protecting only the obvious response field. Check every channel the vendor exposes, including the reasoning channel described in section 2.One caution: pseudonymized data can still count as personal data under regulations like GDPR. This invariant reduces your exposure. It doesn’t remove your legal obligations.Invariant 4: Rehydrate at the last legitimate momentSubstitution should be reversed only at the very last step, right before the answer is shown to a human who is authorized to see the real values. The model works blind from start to finish. Only the human gets the answer in the clear, and only at the end of the chain.Invariant 5: A deterministic guardrail has the final say on plausibilityThe first four invariants protect confidentiality. This one protects correctness. Any response coming out of a deterministic path must pass through a non-probabilistic check, such as a threshold or a comparison with a known reference, before it goes out. If the result looks aberrant, the system falls back to a slower, supervised path rather than letting an unverified answer through.Invariant 6: Every agent-to-agent hop is its own trust boundaryThis is the invariant most often forgotten, because it doesn’t show up in a static layered diagram. An agent’s output isn’t automatically safe for the agent that called it.Here is the scenario. A sub-agent, believing it is “cleaning up its own output” before replying, de-anonymizes data that was meant to stay protected for the agent that delegated to it. The sub-agent did exactly what it was told. It just didn’t know its answer was headed for another link in the chain, one that was supposed to keep reasoning over pseudonyms.There is an aggravating corollary. When conversation history crosses several agents, turns from different agents end up mixed in one context. Without explicit provenance tagging, you can no longer tell which trust boundary applies to which piece of text.Figure 3: Agent-to-agent trust boundaries and rehydration placement.4. For Engineers: How We Implement the InvariantsThis is one possible implementation among many. The tools are specific to our stack. If your system looks nothing like ours, the invariants still hold; only their embodiment changes. Non-technical readers can skip to section 5. Anonymization gate (invariant 3): Before anything is classified, the user’s free text passes through a strict gate that combines NLP libraries with deterministic substitutions. Names and identifiers become unique tokens (for example CORP_A83B). The correspondence table goes straight into an encrypted vault: HMAC-SHA256 for the token, Fernet encryption for the real value at rest. Router (invariant 2): The pseudonymized question goes to a deliberately small, fast, low-cost LLM whose only job is to classify intent. Ranking, ratio lookup or simple extraction takes the fast path: a tool from a bounded allowlist is called directly, and the heavy model is never invoked. Risk analysis or interpretation takes the slow path and goes to the main agent. Minimized tools (invariant 1): On both paths, internal data is reached through tools exposed via the Model Context Protocol (MCP), cleaned at the source and never the same tools as those given to human developers. Slow path (invariant 3, outbound): Tool results are pseudonymized before they enter the heavy model’s context, so it reasons and writes entirely over pseudonyms. Fast path (invariant 5): A plausibility guardrail, a simple ratio compared against a reference value, blocks any aberrant automatic answer and forces a fallback to the slow path for review. Rehydration (invariant 4): Only once the response is generated and formatted does the system query the vault and swap tokens back for real values, right before display to the authorized user.Figure 4: Fast-path / slow-path routing architecture. Observed results: (Add here your real production metrics — e.g., 72% of queries served via the fast path without heavy model invocation, 4x latency reduction, and 65% API cost reduction).5. Verify, Don’t AssumeA defense that has never been verified under real conditions is a defense you only hope exists.Here is a scenario that is far from theoretical. A control meant to transform every message before it reached the model was written, reviewed and merged. The tests passed. For weeks, the team believed the protection was active. It wasn’t. An assumption about the exact shape of the incoming data was wrong, so the control ran against the wrong structure, silently, with no error and nothing to raise an alarm.A test that passes without actually going through the protection code proves nothing. A code review is not proof of execution, and a clean architecture diagram is not proof that the path it draws is the path taken at runtime. The only proof that counts is a test replayed against the real system: the real model, the real data, the real call chain.Every claim of compliance, such as “this is anonymized” or “this data never leaves in the clear,” should come with replayable proof, available on demand. Otherwise it isn’t a guarantee. It’s a hypothesis that hasn’t been disproved yet.Figure 5: The control illusion — silent failure when data format mismatches expectations.6. The Audit TrailGovernance doesn’t stop at real-time protection. It requires traceability. When a banking regulator, a GDPR auditor or a major client reviews how AI is used in your organization, they won’t accept statements of intent. They will ask for evidence.A responsible enterprise AI system keeps a tamper-evident, append-only audit log, kept separate from ordinary conversation logs and chat history. For every event in an agent’s lifecycle, it records a pair: the raw data as originally received, the data actually transmitted to external models.The rule that matters most is that no configuration flag or single administrator can turn this log off. During load spikes, a team’s first instinct is often to disable logging “for performance”. If that switch exists, then on the day a leak happens you won’t be able to prove its scope or identify what was exposed.Figure 6: Why the audit log must be tamper-proof and impossible to bypass via configuration flags.There is a catch, and it deserves to be stated plainly: this log is itself one of the most sensitive datasets in the company, because it contains the raw data you worked so hard to protect. Treat it accordingly. Encrypt it, restrict and separate access to it, define retention limits, and work out with your legal counsel how it coexists with erasure requests under regulations like GDPR.Finally, add retroactive leak scanning: background processes that keep re-scanning archived audit history for sensitive patterns that slipped past the first filters. When one is found, the scanner never edits the original log. It alerts the security team, who can tighten the rules before a small gap becomes an incident.Figure 7: Immutable audit pipeline and asynchronous leak scanning.7. Conclusion: AI Is a Data Engineering Problem“Garbage in, garbage out” has a modern cousin: secret in, exposure out. AI isn’t a cosmetic layer you bolt onto a corner of your information system to satisfy the board. It changes how information flows, how it is segmented, how it is anonymized and how it is archived.The companies that win with AI won’t be the ones that deployed the most agents the fastest. They will be the ones that built a safe foundation first. A good brake system is what lets a car drive fast. With these controls in place, teams can say yes to more use cases, with less debate and fewer late-stage vetoes.So before you sign the next AI contract, or celebrate the rollout of your latest agent, gather your architects and ask one question: “If an external auditor sent by our biggest client showed up tomorrow at 8am, could you prove to me, line of code by line of code, that not a single name, identifier or confidential figure left our network in the clear over the past month?”If the answer is silence, or “our vendor’s clauses”, or “a prompt that tells the AI to be careful,” you have your next project.The Governance Checklist: 8 boxes to checkBefore you leave the room, eight boxes to check (the six invariants, plus the testing and auditing essentials): Do you minimize at the source? (Invariant 1) Do you keep the expensive model off simple requests? (Invariant 2) Is every text-to-model boundary protected by a reversible mechanism? (Invariant 3) Do you rehydrate only at the last legitimate moment? (Invariant 4) Does a deterministic guardrail have the final say on plausibility? (Invariant 5) Is every agent-to-agent hop its own trust boundary? (Invariant 6) Is every control verified under real conditions, not just assumed? (Section 5) Is the audit log impossible to disable by flag or by a single administrator? (Section 6) Alex Kim, “The Claude Code Source Leak: fake tools, frustration regexes, undercover mode, and more”, March 31, 2026. The leak came from a source map file published by mistake on npm. The file userPromptKeywords.ts contains the regex used for frustration detection. ↩

Car Inspect AI: Teaching YOLO to Read a Car the Way an Inspector Does

June 22, 2026 · 6 min
Anyone who’s rented a car or filed an insurance claim knows the ritual: someone walks around the vehicle, snaps a few photos, and a human decides later whether that mark on the bumper was already there. It’s slow, it’s subjective, and disputes between renters, owners, and insurers over “pre-existing damage” are extremely common precisely because the whole process runs on someone’s word against a handful of photos. Car Inspect AI is my attempt to chip away at that problem: a computer-vision system that automatically detects and labels the individual parts of a vehicle from a photo, as a first building block toward more objective, automated inspection.Code’s here: github.com/sanayasfp/car-inspect-ai.Starting with the actual constraint: dataBefore touching a model, the real constraint on a project like this is data. I trained on the public Car Parts Segmentation dataset (Kitsuchart Pasupa et al.), 500 annotated images of sedans, pickups, and SUVs in COCO format, covering 18 distinct vehicle parts — bumpers, doors, lights, mirrors, hood, trunk, wheels, and so on, shot from front, back, and angled views, with plates and faces blurred for privacy. 500 images is not a lot by deep-learning standards, and that constraint shaped almost every other decision in the project.Why YOLO11n specificallyI picked YOLO11n — the nano variant — deliberately, not just because “YOLO is what people use for object detection.” Two things mattered: It’s small enough to run on modest hardware. A tool meant for garages, small rental agencies, or independent inspectors is useless if it needs a beefy GPU to run inference. YOLO11n trades some raw accuracy for a footprint that runs comfortably on a CPU or an entry-level GPU. With only 500 images, model capacity is a liability, not an asset. A larger model has more room to overfit a small dataset. A lightweight architecture, combined with aggressive data augmentation, was the more honest choice given what I actually had to train on.To stretch that small dataset further, preprocessing included resizing to YOLO’s expected input dimensions and augmenting with rotation (simulating different shooting angles), horizontal flips, and Gaussian noise (to make the model less sensitive to lighting variation — a real problem when photos come from random phone cameras in a parking lot, not a studio).Training ran for 50 epochs with an 80/20 train/validation split, batch size 16, and early stopping if validation performance stalled for 10 consecutive epochs. The result: 87% mAP on the validation set, with the strongest performance on well-defined, geometrically simple parts like wheels and doors — exactly where I’d expect a detector to do best, and exactly the kind of result that tells you where to focus next (smaller/ambiguous parts like mirrors are the harder cases).Rolling my own tiny ORM instead of reaching for SQLAlchemyThis is the part of the project I’d guess most people skip past, but it’s the part I learned the most from. The app needs to track training runs — which model, how many epochs, whether it completed, where the checkpoint lives, and whether it resumes from a previous run. Instead of pulling in SQLAlchemy for what’s fundamentally a handful of tables, I wrote a small dataclass-based model layer myself:@dataclasses.dataclassclass TrainLogsModel(BaseModel): _table_name = "train_logs" name: str epochs: int model: str path: str completed: bool = Field(type=bool, default=False).set() id: Optional[int] = Field(type=int, primary_key=True, autoincrement=True).set() created_at: Optional[float] = Field(type=int, default=lambda: dt.now().timestamp()).set() resumed_from: Optional[int] = Field(type=int, foreign_key="id", foreign_table=_table_name).set()BaseModel reads the dataclass’s type annotations and field metadata and turns them into SQL column definitions (INTEGER, TEXT, REAL, with primary keys, autoincrement, and foreign keys handled explicitly), and derives table names from class names in snake_case, camelCase, or PascalCase depending on what’s asked for. It’s a fraction of what a real ORM does — no query builder, no migrations system — but building even that fraction by hand forced me to actually understand what an ORM is automating away, rather than just importing one and trusting the magic. That’s the trade I’d make again: for a project this size, writing 100 lines to understand the mechanism beat 10 lines that hide it.That model backs a genuinely useful feature: the training page lets me pick either a fresh YOLO11n base or any previous checkpoint, kick off training, and log it — including a resumed_from foreign key back to the run it continued, so I have an actual lineage of experiments instead of a folder full of best_v2_final_FINAL.pt files.The interface: Streamlit, on purposeThe whole thing is wrapped in a small multi-page Streamlit app — a home page, a “register a car” page (upload front/back/left/right photos plus color and plate number), the training page described above, and a scratch page for in-progress experiments. Streamlit was the right call here specifically because it isn’t the point of the project: I wanted to spend my time on the detection model and the training/versioning story, not on hand-rolling a frontend, and Streamlit gets out of the way for that.Where it actually stands right nowI want to be precise about this rather than round it up: the part-detection model and the training/versioning pipeline are working and measured (that 87% mAP figure is real, from an actual run, not an estimate). The full inspection pipeline — fusing all four angles of a vehicle into a single confident report, and moving from “these are the detected parts” to an actual damage or fraud verdict — is still active, in-progress work, not a finished product. The README lists automated damage severity scoring and vehicle-history integration as future directions, and that’s accurate: those are the roadmap, not something I’m claiming already works end-to-end.If I were pitching what’s genuinely done today: a lightweight, honestly-benchmarked vehicle part detector, trained reproducibly, with its own minimal experiment-tracking layer built from first principles. That’s a smaller claim than “automated fraud detection system,” but it’s the true one — and it’s a better foundation to build the rest on top of.Repo’s public if you want to see the training code, the mini-ORM, or argue that I should’ve just used SQLAlchemy: github.com/sanayasfp/car-inspect-ai.

Car Inspect AI: Teaching YOLO to Read a Car the Way an Inspector Does

June 22, 2026 · 6 min
Anyone who’s rented a car or filed an insurance claim knows the ritual: someone walks around the vehicle, snaps a few photos, and a human decides later whether that mark on the bumper was already there. It’s slow, it’s subjective, and disputes between renters, owners, and insurers over “pre-existing damage” are extremely common precisely because the whole process runs on someone’s word against a handful of photos. Car Inspect AI is my attempt to chip away at that problem: a computer-vision system that automatically detects and labels the individual parts of a vehicle from a photo, as a first building block toward more objective, automated inspection.Code’s here: github.com/sanayasfp/car-inspect-ai.Starting with the actual constraint: dataBefore touching a model, the real constraint on a project like this is data. I trained on the public Car Parts Segmentation dataset (Kitsuchart Pasupa et al.), 500 annotated images of sedans, pickups, and SUVs in COCO format, covering 18 distinct vehicle parts — bumpers, doors, lights, mirrors, hood, trunk, wheels, and so on, shot from front, back, and angled views, with plates and faces blurred for privacy. 500 images is not a lot by deep-learning standards, and that constraint shaped almost every other decision in the project.Why YOLO11n specificallyI picked YOLO11n — the nano variant — deliberately, not just because “YOLO is what people use for object detection.” Two things mattered: It’s small enough to run on modest hardware. A tool meant for garages, small rental agencies, or independent inspectors is useless if it needs a beefy GPU to run inference. YOLO11n trades some raw accuracy for a footprint that runs comfortably on a CPU or an entry-level GPU. With only 500 images, model capacity is a liability, not an asset. A larger model has more room to overfit a small dataset. A lightweight architecture, combined with aggressive data augmentation, was the more honest choice given what I actually had to train on.To stretch that small dataset further, preprocessing included resizing to YOLO’s expected input dimensions and augmenting with rotation (simulating different shooting angles), horizontal flips, and Gaussian noise (to make the model less sensitive to lighting variation — a real problem when photos come from random phone cameras in a parking lot, not a studio).Training ran for 50 epochs with an 80/20 train/validation split, batch size 16, and early stopping if validation performance stalled for 10 consecutive epochs. The result: 87% mAP on the validation set, with the strongest performance on well-defined, geometrically simple parts like wheels and doors — exactly where I’d expect a detector to do best, and exactly the kind of result that tells you where to focus next (smaller/ambiguous parts like mirrors are the harder cases).Rolling my own tiny ORM instead of reaching for SQLAlchemyThis is the part of the project I’d guess most people skip past, but it’s the part I learned the most from. The app needs to track training runs — which model, how many epochs, whether it completed, where the checkpoint lives, and whether it resumes from a previous run. Instead of pulling in SQLAlchemy for what’s fundamentally a handful of tables, I wrote a small dataclass-based model layer myself:@dataclasses.dataclassclass TrainLogsModel(BaseModel): _table_name = "train_logs" name: str epochs: int model: str path: str completed: bool = Field(type=bool, default=False).set() id: Optional[int] = Field(type=int, primary_key=True, autoincrement=True).set() created_at: Optional[float] = Field(type=int, default=lambda: dt.now().timestamp()).set() resumed_from: Optional[int] = Field(type=int, foreign_key="id", foreign_table=_table_name).set()BaseModel reads the dataclass’s type annotations and field metadata and turns them into SQL column definitions (INTEGER, TEXT, REAL, with primary keys, autoincrement, and foreign keys handled explicitly), and derives table names from class names in snake_case, camelCase, or PascalCase depending on what’s asked for. It’s a fraction of what a real ORM does — no query builder, no migrations system — but building even that fraction by hand forced me to actually understand what an ORM is automating away, rather than just importing one and trusting the magic. That’s the trade I’d make again: for a project this size, writing 100 lines to understand the mechanism beat 10 lines that hide it.That model backs a genuinely useful feature: the training page lets me pick either a fresh YOLO11n base or any previous checkpoint, kick off training, and log it — including a resumed_from foreign key back to the run it continued, so I have an actual lineage of experiments instead of a folder full of best_v2_final_FINAL.pt files.The interface: Streamlit, on purposeThe whole thing is wrapped in a small multi-page Streamlit app — a home page, a “register a car” page (upload front/back/left/right photos plus color and plate number), the training page described above, and a scratch page for in-progress experiments. Streamlit was the right call here specifically because it isn’t the point of the project: I wanted to spend my time on the detection model and the training/versioning story, not on hand-rolling a frontend, and Streamlit gets out of the way for that.Where it actually stands right nowI want to be precise about this rather than round it up: the part-detection model and the training/versioning pipeline are working and measured (that 87% mAP figure is real, from an actual run, not an estimate). The full inspection pipeline — fusing all four angles of a vehicle into a single confident report, and moving from “these are the detected parts” to an actual damage or fraud verdict — is still active, in-progress work, not a finished product. The README lists automated damage severity scoring and vehicle-history integration as future directions, and that’s accurate: those are the roadmap, not something I’m claiming already works end-to-end.If I were pitching what’s genuinely done today: a lightweight, honestly-benchmarked vehicle part detector, trained reproducibly, with its own minimal experiment-tracking layer built from first principles. That’s a smaller claim than “automated fraud detection system,” but it’s the true one — and it’s a better foundation to build the rest on top of.Repo’s public if you want to see the training code, the mini-ORM, or argue that I should’ve just used SQLAlchemy: github.com/sanayasfp/car-inspect-ai.

The Ultimate Guide to a Smooth Dev Environment

July 31, 2026 · 9 min
I wrote about Stack a few days ago — covering the short version of why I built it and the whole “no Docker” philosophy. But theory only gets you so far. If you’re looking for the ultimate guide to actually achieving a buttery-smooth dev environment, this is it.No elevator pitch. No marketing fluff. Just the raw, practical reality of getting a project running effortlessly, complete with every weird prompt and console output you’ll encounter along the way.If you just want the dry reference docs, you can find them at sanayavo.com/stack. Otherwise, welcome to the guided tour of a better workflow.What you’re actually gettingA smooth environment means zero bloat. Stack is just one Rust binary. There is no daemon hogging RAM in your system tray, no Docker Desktop spinning up your laptop fans, and absolutely nothing touching your global system PHP install.Instead, it acts as highly intelligent duct tape. It leans on vfox for managing PHP/Node versions, uv for Python, and Caddy for instant local domains. Stack reads a simple stack.toml file in your project root and automatically wires these three tools together.Note: Stack is Windows only right now (PowerShell or cmd). Mac and Linux support are on the roadmap.The Installation: Your one-time headacheA smooth day-to-day workflow requires a tiny bit of upfront setup. Run this to download the installer:irm https://github.com/sanayasfp/stack/releases/latest/download/stackenv-installer.ps1 | iexThen, run this command once per machine:stack setupThis handles the heavy lifting behind the scenes. It adds a hook to your PowerShell profile (so the correct PHP or Node version magically activates when you cd into a directory), installs vfox, uv, and Caddy at heavily-tested versions, and runs caddy trust. That last step is crucial for local HTTPS — more on that in a minute.$ stack setupadded the stack hook for pwshchecking vfox/uv/caddy... vfox: OK (1.0.11) uv: OK (0.11.7) caddy: OK (2.11.4) caddy: local CA trusted (https://*.localhost works with no browser warning)Restart your terminal now. If you skip this, the PowerShell hook won’t be active and you’ll wonder why nothing is working. You will never have to run stack setup again unless you buy a new computer.Starting a project friction-freeLet’s build something real to show off the workflow: a lightweight PHP API backed by MySQL.From scratch$ stack new acme-apidomain: acme-api.localhostlanguages (space to toggle, enter to confirm): [x] phpservices (space to toggle, enter to confirm): [x] mysql php version: 8.3.1 mysql version: 8.0.35created acme-api\stack.tomlnext: cd into it, add a [run] command when you know it, then `stack up`It’s an interactive terminal checklist. Space to toggle, enter to confirm. It feels a bit different the first time, but nothing commits until you press enter. If you accidentally select Node instead of PHP, just hit space again. Zero stress, no do-overs required.If you already have a projectIf your acme-api directory already has a composer.json file specifying "php": "^8.3", Stack is smart enough to skip the manual typing:$ stack initdetected from existing project files: php 8.3 (from composer.json)domain: acme-api.localhoststack init automatically parses composer.json, package.json, or pyproject.toml and pre-fills the wizard for you.The magic artifactStack generates a highly readable manifest:[project]name = "acme-api"domain = "acme-api.localhost"[language]php = "8.3.1"[service.mysql]version = "8.0.35"Commit this stack.toml file to your repo. It is your ultimate “works on my machine” artifact—the lightweight, container-free equivalent of a Dockerfile. When a teammate clones the repo and types stack up, they instantly get the exact same PHP and MySQL versions without installing anything by hand.Teaching it how to runYour stack.toml needs to know how to boot up your dev server. Open the file and add a [run] block:[run]command = "php -S 127.0.0.1:{port} -t public"The {port} placeholder dynamically swaps for an available port at runtime. (Note: If you declare [language.php] but skip the [run] section, Stack falls back to its built-in FastCGI setup using php-cgi.exe and Caddy, which is incredibly solid. But for this guide, we’ll keep it explicit.)stack up and watch it fly$ cd acme-api$ stack upLoaded C:\Users\you\acme-api\stack.toml project: acme-api domain: acme-api.localhost languages: php services: mysqlfirst run for this project — stack.toml will execute: [run] php -S 127.0.0.1:{port} -t publicTrust and run these commands? [y/N] y php: C:\Users\you\.vfox\cache\php\v-8.3.1\...\php.exe -> PHP 8.3.1 (cli) service.mysql: started (pid 41232, port 3306) schema 'acme_api' — automatic creation not yet implemented; create it manually if needed run: php -S 127.0.0.1:52140 -t public (pid 41244, port 52140) log: C:\Users\you\.stack\logs\acme-api.log routed: http://acme-api.localhost -> 127.0.0.1:52140There are two major things happening here that contribute to a seamless experience:1. The security trust promptBecause [run].command is a literal shell invocation, Stack runs it exactly as written. The first time Stack sees a new or modified command (like after a git pull), it asks you to confirm it. Say yes, and it remembers your choice in ~/.stack/trust.json. Every subsequent stack up is blissfully silent. Need to bypass it for CI? Just pass --yes.2. HTTPS works out of the boxThe console says http://, but https://acme-api.localhost works instantly. No red browser warnings. Remember that caddy trust command from the setup phase? It installs a local Certificate Authority (similar to mkcert), meaning your local environment accurately mirrors production SSL from day one.What “no containers” actually means in practiceOpen a second terminal, cd into a totally different project that also requires MySQL 8.0.35, and run stack up: service.mysql: already running, shared with other projects (pid 41232, port 3306)That’s the beauty of it. One MySQL process serving two projects, isolated by schema rather than burning your CPU and RAM on redundant containers.But here is where the “smooth” factor really shines. Open a third terminal, cd into acme-api, and just run php -v. No stack up, no extra commands:$ cd acme-api$ php -vPHP 8.3.1 (cli) (built: ...)$ cd ..$ php -v'php' is not recognized as an internal or external commandThe version switch happens ambiently on every prompt thanks to that PowerShell hook. Your IDE, composer install, and your test runners all automatically get the correctly pinned version the second they enter the folder. Leave the directory, and it vanishes. No source venv/bin/activate, no nvm use, and no polluting your global system.Fresh PHP installs that don’t suckUsually, a fresh PHP installation means spending an afternoon hunting down “PDO driver not found” or “timezone not set” errors. Stack eliminates this entirely.The first time it downloads a PHP version via vfox, it automatically patches php.ini. It turns on OPcache, bumps memory and upload limits, sets a timezone, and enables the extensions you actually need out of the box (pdo_mysql, pdo_pgsql, pdo_sqlite, sockets, sodium, etc.). It happens once, automatically, and you never have to think about it again.stack doctor — The ultimate pre-flight checkBefore you hand off a project or start debugging a strange issue, run this:$ stack doctor --projectchecking C:\Users\you\acme-api\stack.toml... language.php: OK (C:\Users\you\.vfox\cache\php\v-8.3.1\...\php.exe) service.mysql: OK (managed)This command validates your ports, paths, and {PLACEHOLDER} values against your environment (loading .env first) without starting a single service. If something is broken, you get a clean list upfront instead of discovering cryptic errors halfway through startup.Command your setup from anywhereOnce Stack knows about a project, you don’t even need to be in its folder to manage it:stack describe acme-apistack restart acme-apistack down acme-apistack describe dumps everything—resolved binary paths, the exact location of your hashed php.ini, logs, and routed domains. Stack keeps a local registry of your projects, making global management effortless.Shutting it all down cleanlyWhen the day is over:stack down --allEverything stops. Every project, every shared service, and Caddy. You get actual zero CPU usage, not a lingering phantom container you forgot to kill.A quick note on custom domainsUsing .localhost guarantees a smooth experience because browsers automatically resolve it to the loopback address (RFC 6761) without you needing to hack your hosts file. However, if you are migrating from Laragon or Herd and absolutely need .test domains, there is a one-time setup using Acrylic DNS Proxy.Your New WorkflowScaffold, run, trust once, enjoy automatic HTTPS, commit your manifest, and tear it down cleanly when you’re done.I use this exact workflow for real client work every single day. This isn’t a theoretical happy path; it’s a battle-tested blueprint for an ultimate, smooth dev environment.For the complete stack.toml reference, CLI flags, and advanced features (like using [[clone]] to bootstrap from just a manifest), check out the official docs: Why I built this — the longer rant against Docker/XAMPP for local dev Getting Started Manifest Reference CLI Reference Code: github.com/sanayasfp/stack

Laplace Nearby: A WhatsApp Bot That Finds 'The Nearest Maquis' Without Torching the LLM Budget

June 22, 2026 · 8 min
In Abidjan, a huge number of local businesses — the neighborhood maquis (informal restaurant), the pharmacy, the guy who fixes phones — don’t have a website, a Google Business listing, or even a formal street address half the time. What they do have is WhatsApp. Laplace Nearby starts from that observation: instead of building an app people have to download, build the search experience inside the app everyone already has open, and let people describe what they want in plain language — “I need a pharmacy,” “j’ai envie de porcodjo” — rather than filling out a filter form.The assistant itself is internally called Simon. This post is about how Simon is actually built — the parts I think are worth talking about, not a marketing pitch.Repo: github.com/sanayasfp/laplace-nearby.Decision #1: the engine doesn’t know it’s talking to WhatsAppThe core of the system is SimonEngine, a small orchestrator that takes a channel-agnostic InteractionRequest and returns a channel-agnostic InteractionResponse. WhatsApp specifics — webhook payloads, message formatting, buttons and lists — live entirely in an adapter/renderer layer outside the engine. The engine itself just does four things on every message: load the user’s session context, check rate limiting, hand the message to whichever conversational flow is currently active (idle, search, or register), and save the result — always emitting an analytics side effect with the flow name, the state transition, and the latency, whether or not anything interesting happened:async process(req: InteractionRequest): Promise<InteractionResponse> { const context = await this.contextManager.load(req.profileId); const flow = this.flowRegistry.get(context.session.activeFlow || IDLE); const limitResult = this.rateLimitService.check(context.rateLimit); if (!limitResult.allowed) { return { messages: limitResult.notify ? [Responses.tooManyMessages(...)] : [] , ... }; } const response = await flow.handle(req, context, interactionId); await this.contextManager.save(req.profileId, response.contextUpdate); return { ...response, sideEffects: [summaryEffect, ...response.sideEffects] };}This isn’t architecture for its own sake. It means the search logic, the registration flow, the rate limiting, and the metrics don’t need to be rewritten if a second channel (SMS, a web widget, whatever) shows up later — they were never coupled to WhatsApp in the first place.Decision #2: don’t call the LLM if you don’t have toEvery incoming message needs an intent — is this a search, a registration request, small talk, a thank-you, an insult, “stop”? Routing every single message through an LLM call is the easy way to build this, and also the expensive and slow way. So intent classification is two-tiered.FastIntentDetector runs first: a deliberately exhaustive set of regex patterns covering French, English, and local slang and abbreviations you’d actually see in a WhatsApp chat in Abidjan — “wesh,” “cc,” “gab” (cash machine), “essence/gazoil” (fuel), dozens of spelling variants of “merci,” “stop,” “annule.” Its docstring says exactly what it’s for: “Reduce LLM costs and latency for unambiguous user requests.” Only when nothing matches does IntentService fall back to Gemini (gemini-2.5-flash-lite), with temperature 0, JSON response mode, and a tight 150-token cap, asking for structured output: the intent, an extracted search keyword, and a list of semantically related terms to widen the search (“j’ai envie de porcodjo” → keyword “porcodjo restaurant”; “mon habit est sale” → “pressing nettoyage vêtement”).Both paths report where the classification came from — REGEX or LLM — to a Prometheus counter (simonIntentTotal), so the fraction of traffic being deflected from the paid model is something you can actually watch on a dashboard, not something you have to guess at. And the Gemini call itself sits behind a circuit breaker: if it trips, the system doesn’t crash the conversation, it degrades to a neutral “chitchat” response with confidence 0 and moves on.Decision #3: addresses in Abidjan don’t work like addresses elsewhereA huge share of real addresses given in chat aren’t geocodable strings — they’re descriptions: “je suis vers la cité Abdoulaye Diallo.” AddressCodingService takes that kind of input, checks a semantic cache (exact hash first, then embedding similarity above a 0.88 threshold) to avoid re-paying for something already resolved, and if it’s a miss, asks Gemini to turn it into a standardized address, an extracted neighborhood, and — importantly — a flag for whether the description is precise enough to geocode at all or whether the only honest answer is “ask the user to drop a GPS pin.” There’s even a dedicated dictionary of Nouchi (Abidjan street slang) terms feeding into this pipeline, because generic NLP tooling doesn’t know what a “gbaka” stop or a given neighborhood nickname refers to.Decision #4: search is one SQL function, three signals, fusedThis is the part of the codebase I’m most proud of. search_nearby_places is a single Postgres function that blends three independent ranking signals for every candidate business inside a radius: Full-text rank (ts_rank_cd against a French tsquery) — good when the user typed something close to the business’s actual name or category. Vector similarity rank — cosine distance between the query embedding (Gemini text-embedding-004, pgvector with an HNSW index) and each place’s embedding — good when the user described what they want in their own words instead of matching a label. Geographic proximity (ST_Distance over a PostGIS geography column) — because “closest” always matters, and can’t be papered over by relevance alone.These get combined with a weighted Reciprocal Rank Fusion: score = w_fts/(k + fts_rank) + w_vec/(k + vec_rank) + w_prox * proximity_term, with the weights shifting based on whether the user gave a keyword at all — 45/45/10 between text, vector, and proximity when there’s a keyword to match; 85% vector-driven when there isn’t, since full-text search has nothing to grab onto in a purely descriptive query. There’s also a “premium tier” mechanism: a second ranking pass, partitioned by tier, guarantees paying/listed businesses a small quota of slots without letting them drown out relevance for the general result set.I later went back and rewrote this same function for performance after noticing it was doing redundant work: merging two CTEs that were reading the same rows twice, adding a LIMIT inside the vector-ranking CTE specifically so the HNSW index can short-circuit instead of sorting the entire candidate pool, and replacing two separate full materializations (general results, premium results) with a single windowed pass using ROW_NUMBER() OVER (PARTITION BY is_premium ...). That’s a genuinely satisfying kind of fix — same output, measurably less work per query — and it’s the sort of thing that only shows up once real usage patterns put pressure on a first draft.Decision #5: side effects are data, not actionsWhen a place gets registered, or an interaction needs logging for analytics, or a user needs a notification, the engine doesn’t go do that work inline — it returns a plain SideEffect object describing what should happen. A PgmqSideEffectDispatcher collects these, groups them by target queue, and pushes them via pgmq.send_batch (Postgres’s own message-queue extension) inside the same database transaction that marks a newly registered place as “queued.” That detail matters: it means a place can’t end up half-registered — visible in the app but never actually indexed for search — because the status update and the queue write either both commit or neither does. Supabase Edge Functions on the other end (an analytics-worker, a place-embedding-worker, a place-tagging-worker) drain those queues asynchronously.The stack, plainlyFastify + TypeScript on Node.js, Prisma over Supabase Postgres (with pgvector, pgmq, PostGIS, pg_cron, and pg_net doing real work, not just sitting in a dependency list), Redis for caching and rate-limit state, Gemini for both NLU and embeddings, Geoapify for geocoding, and Prometheus metrics wired in from the start rather than bolted on later.Where it standspackage.json says 0.4.0-rc.1 and I mean to leave that context in: this is a real, working system I use to reason about hybrid search and conversational engineering, actively evolving toward a 1.0 — not a finished, scaled product with a client roster behind it. The channel-agnostic design exists specifically so that if this ever needs to be more than a WhatsApp bot, the engine underneath doesn’t need to be rebuilt.If you want to argue about the RRF weights, tell me PostGIS was overkill, or point out a better way to structure the side-effect dispatcher — the code’s public: github.com/sanayasfp/laplace-nearby.

Car Inspect AI: Teaching YOLO to Read a Car the Way an Inspector Does

June 22, 2026 · 6 min
Anyone who’s rented a car or filed an insurance claim knows the ritual: someone walks around the vehicle, snaps a few photos, and a human decides later whether that mark on the bumper was already there. It’s slow, it’s subjective, and disputes between renters, owners, and insurers over “pre-existing damage” are extremely common precisely because the whole process runs on someone’s word against a handful of photos. Car Inspect AI is my attempt to chip away at that problem: a computer-vision system that automatically detects and labels the individual parts of a vehicle from a photo, as a first building block toward more objective, automated inspection.Code’s here: github.com/sanayasfp/car-inspect-ai.Starting with the actual constraint: dataBefore touching a model, the real constraint on a project like this is data. I trained on the public Car Parts Segmentation dataset (Kitsuchart Pasupa et al.), 500 annotated images of sedans, pickups, and SUVs in COCO format, covering 18 distinct vehicle parts — bumpers, doors, lights, mirrors, hood, trunk, wheels, and so on, shot from front, back, and angled views, with plates and faces blurred for privacy. 500 images is not a lot by deep-learning standards, and that constraint shaped almost every other decision in the project.Why YOLO11n specificallyI picked YOLO11n — the nano variant — deliberately, not just because “YOLO is what people use for object detection.” Two things mattered: It’s small enough to run on modest hardware. A tool meant for garages, small rental agencies, or independent inspectors is useless if it needs a beefy GPU to run inference. YOLO11n trades some raw accuracy for a footprint that runs comfortably on a CPU or an entry-level GPU. With only 500 images, model capacity is a liability, not an asset. A larger model has more room to overfit a small dataset. A lightweight architecture, combined with aggressive data augmentation, was the more honest choice given what I actually had to train on.To stretch that small dataset further, preprocessing included resizing to YOLO’s expected input dimensions and augmenting with rotation (simulating different shooting angles), horizontal flips, and Gaussian noise (to make the model less sensitive to lighting variation — a real problem when photos come from random phone cameras in a parking lot, not a studio).Training ran for 50 epochs with an 80/20 train/validation split, batch size 16, and early stopping if validation performance stalled for 10 consecutive epochs. The result: 87% mAP on the validation set, with the strongest performance on well-defined, geometrically simple parts like wheels and doors — exactly where I’d expect a detector to do best, and exactly the kind of result that tells you where to focus next (smaller/ambiguous parts like mirrors are the harder cases).Rolling my own tiny ORM instead of reaching for SQLAlchemyThis is the part of the project I’d guess most people skip past, but it’s the part I learned the most from. The app needs to track training runs — which model, how many epochs, whether it completed, where the checkpoint lives, and whether it resumes from a previous run. Instead of pulling in SQLAlchemy for what’s fundamentally a handful of tables, I wrote a small dataclass-based model layer myself:@dataclasses.dataclassclass TrainLogsModel(BaseModel): _table_name = "train_logs" name: str epochs: int model: str path: str completed: bool = Field(type=bool, default=False).set() id: Optional[int] = Field(type=int, primary_key=True, autoincrement=True).set() created_at: Optional[float] = Field(type=int, default=lambda: dt.now().timestamp()).set() resumed_from: Optional[int] = Field(type=int, foreign_key="id", foreign_table=_table_name).set()BaseModel reads the dataclass’s type annotations and field metadata and turns them into SQL column definitions (INTEGER, TEXT, REAL, with primary keys, autoincrement, and foreign keys handled explicitly), and derives table names from class names in snake_case, camelCase, or PascalCase depending on what’s asked for. It’s a fraction of what a real ORM does — no query builder, no migrations system — but building even that fraction by hand forced me to actually understand what an ORM is automating away, rather than just importing one and trusting the magic. That’s the trade I’d make again: for a project this size, writing 100 lines to understand the mechanism beat 10 lines that hide it.That model backs a genuinely useful feature: the training page lets me pick either a fresh YOLO11n base or any previous checkpoint, kick off training, and log it — including a resumed_from foreign key back to the run it continued, so I have an actual lineage of experiments instead of a folder full of best_v2_final_FINAL.pt files.The interface: Streamlit, on purposeThe whole thing is wrapped in a small multi-page Streamlit app — a home page, a “register a car” page (upload front/back/left/right photos plus color and plate number), the training page described above, and a scratch page for in-progress experiments. Streamlit was the right call here specifically because it isn’t the point of the project: I wanted to spend my time on the detection model and the training/versioning story, not on hand-rolling a frontend, and Streamlit gets out of the way for that.Where it actually stands right nowI want to be precise about this rather than round it up: the part-detection model and the training/versioning pipeline are working and measured (that 87% mAP figure is real, from an actual run, not an estimate). The full inspection pipeline — fusing all four angles of a vehicle into a single confident report, and moving from “these are the detected parts” to an actual damage or fraud verdict — is still active, in-progress work, not a finished product. The README lists automated damage severity scoring and vehicle-history integration as future directions, and that’s accurate: those are the roadmap, not something I’m claiming already works end-to-end.If I were pitching what’s genuinely done today: a lightweight, honestly-benchmarked vehicle part detector, trained reproducibly, with its own minimal experiment-tracking layer built from first principles. That’s a smaller claim than “automated fraud detection system,” but it’s the true one — and it’s a better foundation to build the rest on top of.Repo’s public if you want to see the training code, the mini-ORM, or argue that I should’ve just used SQLAlchemy: github.com/sanayasfp/car-inspect-ai.

The Ultimate Guide to a Smooth Dev Environment

July 31, 2026 · 9 min
I wrote about Stack a few days ago — covering the short version of why I built it and the whole “no Docker” philosophy. But theory only gets you so far. If you’re looking for the ultimate guide to actually achieving a buttery-smooth dev environment, this is it.No elevator pitch. No marketing fluff. Just the raw, practical reality of getting a project running effortlessly, complete with every weird prompt and console output you’ll encounter along the way.If you just want the dry reference docs, you can find them at sanayavo.com/stack. Otherwise, welcome to the guided tour of a better workflow.What you’re actually gettingA smooth environment means zero bloat. Stack is just one Rust binary. There is no daemon hogging RAM in your system tray, no Docker Desktop spinning up your laptop fans, and absolutely nothing touching your global system PHP install.Instead, it acts as highly intelligent duct tape. It leans on vfox for managing PHP/Node versions, uv for Python, and Caddy for instant local domains. Stack reads a simple stack.toml file in your project root and automatically wires these three tools together.Note: Stack is Windows only right now (PowerShell or cmd). Mac and Linux support are on the roadmap.The Installation: Your one-time headacheA smooth day-to-day workflow requires a tiny bit of upfront setup. Run this to download the installer:irm https://github.com/sanayasfp/stack/releases/latest/download/stackenv-installer.ps1 | iexThen, run this command once per machine:stack setupThis handles the heavy lifting behind the scenes. It adds a hook to your PowerShell profile (so the correct PHP or Node version magically activates when you cd into a directory), installs vfox, uv, and Caddy at heavily-tested versions, and runs caddy trust. That last step is crucial for local HTTPS — more on that in a minute.$ stack setupadded the stack hook for pwshchecking vfox/uv/caddy... vfox: OK (1.0.11) uv: OK (0.11.7) caddy: OK (2.11.4) caddy: local CA trusted (https://*.localhost works with no browser warning)Restart your terminal now. If you skip this, the PowerShell hook won’t be active and you’ll wonder why nothing is working. You will never have to run stack setup again unless you buy a new computer.Starting a project friction-freeLet’s build something real to show off the workflow: a lightweight PHP API backed by MySQL.From scratch$ stack new acme-apidomain: acme-api.localhostlanguages (space to toggle, enter to confirm): [x] phpservices (space to toggle, enter to confirm): [x] mysql php version: 8.3.1 mysql version: 8.0.35created acme-api\stack.tomlnext: cd into it, add a [run] command when you know it, then `stack up`It’s an interactive terminal checklist. Space to toggle, enter to confirm. It feels a bit different the first time, but nothing commits until you press enter. If you accidentally select Node instead of PHP, just hit space again. Zero stress, no do-overs required.If you already have a projectIf your acme-api directory already has a composer.json file specifying "php": "^8.3", Stack is smart enough to skip the manual typing:$ stack initdetected from existing project files: php 8.3 (from composer.json)domain: acme-api.localhoststack init automatically parses composer.json, package.json, or pyproject.toml and pre-fills the wizard for you.The magic artifactStack generates a highly readable manifest:[project]name = "acme-api"domain = "acme-api.localhost"[language]php = "8.3.1"[service.mysql]version = "8.0.35"Commit this stack.toml file to your repo. It is your ultimate “works on my machine” artifact—the lightweight, container-free equivalent of a Dockerfile. When a teammate clones the repo and types stack up, they instantly get the exact same PHP and MySQL versions without installing anything by hand.Teaching it how to runYour stack.toml needs to know how to boot up your dev server. Open the file and add a [run] block:[run]command = "php -S 127.0.0.1:{port} -t public"The {port} placeholder dynamically swaps for an available port at runtime. (Note: If you declare [language.php] but skip the [run] section, Stack falls back to its built-in FastCGI setup using php-cgi.exe and Caddy, which is incredibly solid. But for this guide, we’ll keep it explicit.)stack up and watch it fly$ cd acme-api$ stack upLoaded C:\Users\you\acme-api\stack.toml project: acme-api domain: acme-api.localhost languages: php services: mysqlfirst run for this project — stack.toml will execute: [run] php -S 127.0.0.1:{port} -t publicTrust and run these commands? [y/N] y php: C:\Users\you\.vfox\cache\php\v-8.3.1\...\php.exe -> PHP 8.3.1 (cli) service.mysql: started (pid 41232, port 3306) schema 'acme_api' — automatic creation not yet implemented; create it manually if needed run: php -S 127.0.0.1:52140 -t public (pid 41244, port 52140) log: C:\Users\you\.stack\logs\acme-api.log routed: http://acme-api.localhost -> 127.0.0.1:52140There are two major things happening here that contribute to a seamless experience:1. The security trust promptBecause [run].command is a literal shell invocation, Stack runs it exactly as written. The first time Stack sees a new or modified command (like after a git pull), it asks you to confirm it. Say yes, and it remembers your choice in ~/.stack/trust.json. Every subsequent stack up is blissfully silent. Need to bypass it for CI? Just pass --yes.2. HTTPS works out of the boxThe console says http://, but https://acme-api.localhost works instantly. No red browser warnings. Remember that caddy trust command from the setup phase? It installs a local Certificate Authority (similar to mkcert), meaning your local environment accurately mirrors production SSL from day one.What “no containers” actually means in practiceOpen a second terminal, cd into a totally different project that also requires MySQL 8.0.35, and run stack up: service.mysql: already running, shared with other projects (pid 41232, port 3306)That’s the beauty of it. One MySQL process serving two projects, isolated by schema rather than burning your CPU and RAM on redundant containers.But here is where the “smooth” factor really shines. Open a third terminal, cd into acme-api, and just run php -v. No stack up, no extra commands:$ cd acme-api$ php -vPHP 8.3.1 (cli) (built: ...)$ cd ..$ php -v'php' is not recognized as an internal or external commandThe version switch happens ambiently on every prompt thanks to that PowerShell hook. Your IDE, composer install, and your test runners all automatically get the correctly pinned version the second they enter the folder. Leave the directory, and it vanishes. No source venv/bin/activate, no nvm use, and no polluting your global system.Fresh PHP installs that don’t suckUsually, a fresh PHP installation means spending an afternoon hunting down “PDO driver not found” or “timezone not set” errors. Stack eliminates this entirely.The first time it downloads a PHP version via vfox, it automatically patches php.ini. It turns on OPcache, bumps memory and upload limits, sets a timezone, and enables the extensions you actually need out of the box (pdo_mysql, pdo_pgsql, pdo_sqlite, sockets, sodium, etc.). It happens once, automatically, and you never have to think about it again.stack doctor — The ultimate pre-flight checkBefore you hand off a project or start debugging a strange issue, run this:$ stack doctor --projectchecking C:\Users\you\acme-api\stack.toml... language.php: OK (C:\Users\you\.vfox\cache\php\v-8.3.1\...\php.exe) service.mysql: OK (managed)This command validates your ports, paths, and {PLACEHOLDER} values against your environment (loading .env first) without starting a single service. If something is broken, you get a clean list upfront instead of discovering cryptic errors halfway through startup.Command your setup from anywhereOnce Stack knows about a project, you don’t even need to be in its folder to manage it:stack describe acme-apistack restart acme-apistack down acme-apistack describe dumps everything—resolved binary paths, the exact location of your hashed php.ini, logs, and routed domains. Stack keeps a local registry of your projects, making global management effortless.Shutting it all down cleanlyWhen the day is over:stack down --allEverything stops. Every project, every shared service, and Caddy. You get actual zero CPU usage, not a lingering phantom container you forgot to kill.A quick note on custom domainsUsing .localhost guarantees a smooth experience because browsers automatically resolve it to the loopback address (RFC 6761) without you needing to hack your hosts file. However, if you are migrating from Laragon or Herd and absolutely need .test domains, there is a one-time setup using Acrylic DNS Proxy.Your New WorkflowScaffold, run, trust once, enjoy automatic HTTPS, commit your manifest, and tear it down cleanly when you’re done.I use this exact workflow for real client work every single day. This isn’t a theoretical happy path; it’s a battle-tested blueprint for an ultimate, smooth dev environment.For the complete stack.toml reference, CLI flags, and advanced features (like using [[clone]] to bootstrap from just a manifest), check out the official docs: Why I built this — the longer rant against Docker/XAMPP for local dev Getting Started Manifest Reference CLI Reference Code: github.com/sanayasfp/stack

Stack: A Native, Zero-Container Dev Environment Manager

July 28, 2026 · 3 min
When you are maintaining a legacy application while building its modern replacement, your local development environment quickly becomes a battlefield. At work, we had a legacy app running on an old version of PHP, Vue 2 (requiring an outdated Node version), and Python 3.7. Alongside it, we were bootstrapping the new architecture with PHP 8.4, Vue 3, and Python 3.12.I personally managed this with a bespoke combination of Laravel Herd for PHP, uv for Python, and nvm for Node. It worked for me because I took the time to configure it. But for our junior developers and fresh interns, asking them to juggle these tools just to run both projects simultaneously was an absolute nightmare. They were fighting path conflicts and environment variables instead of writing code. That’s why I built Stack: a native, zero-container multi-project dev environment manager that makes switching contexts as simple as typing stack up.Code’s here: github.com/sanayasfp/stack.The real constraint: Onboarding and Cognitive LoadBefore touching the architecture, the real constraint on a project like this is developer experience. Interns shouldn’t need to learn Docker network bridges or configure five different version managers to fix a bug in the legacy API. The goal was simple: they should be able to clone the repo, run a single command, and get a working local domain routed to the right processes, regardless of whether the project uses PHP 5.6 or 8.4.Why not just use Docker?Containers solve the “works on my machine” problem by shipping an entire OS layer per project. But that comes with virtualization overhead and idle daemons eating RAM.Stack solves the same problem differently. It pins exact versions per project through version managers you’d install anyway (delegating to tools like vfox and uv), shares one downloaded binary across every project needing that version, and runs everything as plain child processes. No hypervisor, no Docker Desktop taking up 4GB of RAM for a simple API. Every project pinning the same service and version shares one running instance, isolated by schema.Under the hood: Simplicity over MagicA stack.toml file in your project defines exactly what is needed:[project]name = "acme-api"[language]php = "8.4.0"[service.mysql]version = "8.0.35"[run]command = "php -S 127.0.0.1:{port} -t public"When you run stack up, the orchestrator reads the manifest, wires up the local domain (e.g. acme-api.localhost), starts MySQL if it’s not already running, boots the PHP dev server, and reverse-proxies the traffic. All native, all blazing fast. It doesn’t try to reinvent package management; it delegates the heavy lifting of language downloads to vfox and uv, and the routing to Caddy.Where it actually stands right nowI want to be precise about this: I started coding Stack in Rust just last week and pushed the first release two days ago. I have completely uninstalled Herd, Laragon, and my previous manager scripts. I am now exclusively dogfooding Stack for my daily work — using it to prove it works, find edge-case bugs, and add new features to improve it.It currently supports Windows (PowerShell & cmd), which was the immediate need, with macOS and Linux on the roadmap. It’s not just a weekend experiment anymore; it’s becoming the foundational tool that keeps our team’s local development sane.You can grab the installer from the repo or read the official documentation: Repo: github.com/sanayasfp/stack Docs: sanayavo.com/stack/

The AI Illusion: Why feeding an LLM without data governance is a risk you can’t audit

October 30, 2026 · 15 min
A lawyer opens the company’s new AI assistant, uploads an exclusivity contract, and types: “Summarize the key obligations in five lines.” Ten seconds later she has a clean summary. It’s a small, real productivity win.What she doesn’t see is what happened in those ten seconds. The application didn’t just send her question. It sent the whole contract: the counterparty, the prices, the termination clauses, the signatory’s name. All of it landed, as readable text, on a server run by someone else.The same thing happens when an HR manager asks for a summary of a performance review, or a financial analyst digs into a client’s credit file. The question is short. The context that travels with it is enormous.In nearly every executive committee, the directive for 2026 is the same: “We need AI, and we need it now.” Within weeks, API keys are created, language models are wired into internal databases, and whole workflows are handed to third-party algorithms. The demos are impressive and the productivity gains are real.This article is not an argument against that. It’s an argument that the companies that get data governance right will deploy AI faster and in more places, because the foundation underneath is safe. The companies that skip it will eventually face one simple question they cannot answer. We’ll come back to it at the end.1. The Problem: Every Request Carries Your DataEven when your contract with the AI vendor forbids training on your data, and even when the connection is fully encrypted in transit, the raw text still arrives in readable form at a third party. Contracts limit what the vendor is allowed to do with it. They don’t change what is exposed if that vendor suffers a breach, if a log is misconfigured, or if a legal request lands on their desk.The risk grows with multi-agent architectures, where a router agent delegates work to specialized sub-agents that share a common memory. A single name or revenue figure injected into that shared state is now visible to every agent in the chain, and to every log, cache and tracing tool they write to.Figure 1: Injection of raw data into a multi-agent shared memory.Sending raw business data into an LLM with no controls isn’t innovation. It’s outsourcing your company’s strategic memory to a party that will never bear the cost of your leaks.2. Why Naive Anonymization FailsSome teams, aware of the danger, put a basic filter in front of the model: generic Named Entity Recognition (NER) tools, or regular expressions that redact proper nouns. On paper, that sounds reasonable. In production it tends to fail in three ways, and the real danger is that it gives leadership a sense of safety that stops them from asking questions.Failure 1: The filter doesn’t understand your businessGeneric detection tools are trained on everyday text. They confuse company names with common words (“Services”, “Transit”, “Holding”), and they often miss a company whose name contains such a word, like “NET-POLY SERVICES”. Internal identifiers such as tax codes or registration numbers don’t match standard formats, so they slip through.Failure 2: Hiding the name isn’t hiding the companyConsider this example: Raw text: “Company X posted 4.2 billion FCFA in revenue in Abidjan in 2024.” After a naive filter: “COMPANY_1 posted 4.2 billion FCFA in revenue in Abidjan in 2024.”The name is gone, but the exact revenue and the city are enough to identify the company with a single search. A modern model, with its vast training knowledge, can often do the same in a fraction of a second.Failure 3: A prompt is a request, not a controlAn instruction like “Never repeat client names” is a probabilistic nudge. The model will usually comply, until it doesn’t. There is a subtler problem too. Depending on the vendor and the settings, some models return their intermediate reasoning in a channel separate from the final answer, a kind of scratchpad handed back to you. If your protection only inspects the final answer, raw data can travel through the scratchpad unchecked.Believing a system prompt that says “be discreet” protects your data is like locking an armored door and leaving the key in the lock outside.3. The Six Invariants of Robust GovernanceIf instructions and basic filters aren’t enough, what is? Most architects sketch a linear pipeline: data comes in, gets filtered, goes to the model, comes back. A robust system looks different. It is defense in depth, where every boundary carries its own protection, independently of the others.What follows are six invariants. They are contracts that any architect can check against their own system, whatever the stack, the model vendor or the team size.Figure 2: The six governance invariants applied to LLM workflows.Invariant 1: Minimize at the sourceWhat isn’t in the returned data can’t leak. The tools exposed to AI should never be the same ones exposed to your human developers. Pagination is enforced, volumes are capped, and unnecessary fields are removed entirely. The API doesn’t return a full customer record just to compute a ratio. It returns the three numbers actually needed: no contacts, no addresses, no names.This isn’t an AI-specific rule. It’s a data-exposure rule that AI simply makes urgent.Invariant 2: Never let a model resolve what a deterministic path can decideA ranking, a ratio lookup or a simple extraction requires no judgment, so it has no business going through your most expensive and least predictable model. The routing can be a business rule, a choice in the interface or a lightweight classifier. The principle doesn’t prescribe how.Two honest caveats. First, if the router is itself a model, it is still a model: keep it small, tightly constrained, and feed it only protected text. The goal is to shrink exposure, not to claim it disappears. Second, this invariant has a benefit that often convinces a board before security does: it’s cheaper and faster.A fun example comes from Anthropic itself. In March 2026, Claude Code’s source code was accidentally leaked through a source map published on npm. It showed that the tool detects user frustration, meaning whether someone is swearing at it, with a plain regular expression, not a model1. A regex is instant and free. An inference call is neither, for a question that needs no judgment at all.Invariant 3: Treat every text-to-model boundary as a mandatory, reversible protection pointAny data crossing the boundary between your system and a language model, inbound or outbound, should be protected by a reversible mechanism such as substitution, pseudonymization or tokenization. The common mistake is protecting only the obvious response field. Check every channel the vendor exposes, including the reasoning channel described in section 2.One caution: pseudonymized data can still count as personal data under regulations like GDPR. This invariant reduces your exposure. It doesn’t remove your legal obligations.Invariant 4: Rehydrate at the last legitimate momentSubstitution should be reversed only at the very last step, right before the answer is shown to a human who is authorized to see the real values. The model works blind from start to finish. Only the human gets the answer in the clear, and only at the end of the chain.Invariant 5: A deterministic guardrail has the final say on plausibilityThe first four invariants protect confidentiality. This one protects correctness. Any response coming out of a deterministic path must pass through a non-probabilistic check, such as a threshold or a comparison with a known reference, before it goes out. If the result looks aberrant, the system falls back to a slower, supervised path rather than letting an unverified answer through.Invariant 6: Every agent-to-agent hop is its own trust boundaryThis is the invariant most often forgotten, because it doesn’t show up in a static layered diagram. An agent’s output isn’t automatically safe for the agent that called it.Here is the scenario. A sub-agent, believing it is “cleaning up its own output” before replying, de-anonymizes data that was meant to stay protected for the agent that delegated to it. The sub-agent did exactly what it was told. It just didn’t know its answer was headed for another link in the chain, one that was supposed to keep reasoning over pseudonyms.There is an aggravating corollary. When conversation history crosses several agents, turns from different agents end up mixed in one context. Without explicit provenance tagging, you can no longer tell which trust boundary applies to which piece of text.Figure 3: Agent-to-agent trust boundaries and rehydration placement.4. For Engineers: How We Implement the InvariantsThis is one possible implementation among many. The tools are specific to our stack. If your system looks nothing like ours, the invariants still hold; only their embodiment changes. Non-technical readers can skip to section 5. Anonymization gate (invariant 3): Before anything is classified, the user’s free text passes through a strict gate that combines NLP libraries with deterministic substitutions. Names and identifiers become unique tokens (for example CORP_A83B). The correspondence table goes straight into an encrypted vault: HMAC-SHA256 for the token, Fernet encryption for the real value at rest. Router (invariant 2): The pseudonymized question goes to a deliberately small, fast, low-cost LLM whose only job is to classify intent. Ranking, ratio lookup or simple extraction takes the fast path: a tool from a bounded allowlist is called directly, and the heavy model is never invoked. Risk analysis or interpretation takes the slow path and goes to the main agent. Minimized tools (invariant 1): On both paths, internal data is reached through tools exposed via the Model Context Protocol (MCP), cleaned at the source and never the same tools as those given to human developers. Slow path (invariant 3, outbound): Tool results are pseudonymized before they enter the heavy model’s context, so it reasons and writes entirely over pseudonyms. Fast path (invariant 5): A plausibility guardrail, a simple ratio compared against a reference value, blocks any aberrant automatic answer and forces a fallback to the slow path for review. Rehydration (invariant 4): Only once the response is generated and formatted does the system query the vault and swap tokens back for real values, right before display to the authorized user.Figure 4: Fast-path / slow-path routing architecture. Observed results: (Add here your real production metrics — e.g., 72% of queries served via the fast path without heavy model invocation, 4x latency reduction, and 65% API cost reduction).5. Verify, Don’t AssumeA defense that has never been verified under real conditions is a defense you only hope exists.Here is a scenario that is far from theoretical. A control meant to transform every message before it reached the model was written, reviewed and merged. The tests passed. For weeks, the team believed the protection was active. It wasn’t. An assumption about the exact shape of the incoming data was wrong, so the control ran against the wrong structure, silently, with no error and nothing to raise an alarm.A test that passes without actually going through the protection code proves nothing. A code review is not proof of execution, and a clean architecture diagram is not proof that the path it draws is the path taken at runtime. The only proof that counts is a test replayed against the real system: the real model, the real data, the real call chain.Every claim of compliance, such as “this is anonymized” or “this data never leaves in the clear,” should come with replayable proof, available on demand. Otherwise it isn’t a guarantee. It’s a hypothesis that hasn’t been disproved yet.Figure 5: The control illusion — silent failure when data format mismatches expectations.6. The Audit TrailGovernance doesn’t stop at real-time protection. It requires traceability. When a banking regulator, a GDPR auditor or a major client reviews how AI is used in your organization, they won’t accept statements of intent. They will ask for evidence.A responsible enterprise AI system keeps a tamper-evident, append-only audit log, kept separate from ordinary conversation logs and chat history. For every event in an agent’s lifecycle, it records a pair: the raw data as originally received, the data actually transmitted to external models.The rule that matters most is that no configuration flag or single administrator can turn this log off. During load spikes, a team’s first instinct is often to disable logging “for performance”. If that switch exists, then on the day a leak happens you won’t be able to prove its scope or identify what was exposed.Figure 6: Why the audit log must be tamper-proof and impossible to bypass via configuration flags.There is a catch, and it deserves to be stated plainly: this log is itself one of the most sensitive datasets in the company, because it contains the raw data you worked so hard to protect. Treat it accordingly. Encrypt it, restrict and separate access to it, define retention limits, and work out with your legal counsel how it coexists with erasure requests under regulations like GDPR.Finally, add retroactive leak scanning: background processes that keep re-scanning archived audit history for sensitive patterns that slipped past the first filters. When one is found, the scanner never edits the original log. It alerts the security team, who can tighten the rules before a small gap becomes an incident.Figure 7: Immutable audit pipeline and asynchronous leak scanning.7. Conclusion: AI Is a Data Engineering Problem“Garbage in, garbage out” has a modern cousin: secret in, exposure out. AI isn’t a cosmetic layer you bolt onto a corner of your information system to satisfy the board. It changes how information flows, how it is segmented, how it is anonymized and how it is archived.The companies that win with AI won’t be the ones that deployed the most agents the fastest. They will be the ones that built a safe foundation first. A good brake system is what lets a car drive fast. With these controls in place, teams can say yes to more use cases, with less debate and fewer late-stage vetoes.So before you sign the next AI contract, or celebrate the rollout of your latest agent, gather your architects and ask one question: “If an external auditor sent by our biggest client showed up tomorrow at 8am, could you prove to me, line of code by line of code, that not a single name, identifier or confidential figure left our network in the clear over the past month?”If the answer is silence, or “our vendor’s clauses”, or “a prompt that tells the AI to be careful,” you have your next project.The Governance Checklist: 8 boxes to checkBefore you leave the room, eight boxes to check (the six invariants, plus the testing and auditing essentials): Do you minimize at the source? (Invariant 1) Do you keep the expensive model off simple requests? (Invariant 2) Is every text-to-model boundary protected by a reversible mechanism? (Invariant 3) Do you rehydrate only at the last legitimate moment? (Invariant 4) Does a deterministic guardrail have the final say on plausibility? (Invariant 5) Is every agent-to-agent hop its own trust boundary? (Invariant 6) Is every control verified under real conditions, not just assumed? (Section 5) Is the audit log impossible to disable by flag or by a single administrator? (Section 6) Alex Kim, “The Claude Code Source Leak: fake tools, frustration regexes, undercover mode, and more”, March 31, 2026. The leak came from a source map file published by mistake on npm. The file userPromptKeywords.ts contains the regex used for frustration detection. ↩

The AI Illusion: Why feeding an LLM without data governance is a risk you can’t audit

October 30, 2026 · 15 min
A lawyer opens the company’s new AI assistant, uploads an exclusivity contract, and types: “Summarize the key obligations in five lines.” Ten seconds later she has a clean summary. It’s a small, real productivity win.What she doesn’t see is what happened in those ten seconds. The application didn’t just send her question. It sent the whole contract: the counterparty, the prices, the termination clauses, the signatory’s name. All of it landed, as readable text, on a server run by someone else.The same thing happens when an HR manager asks for a summary of a performance review, or a financial analyst digs into a client’s credit file. The question is short. The context that travels with it is enormous.In nearly every executive committee, the directive for 2026 is the same: “We need AI, and we need it now.” Within weeks, API keys are created, language models are wired into internal databases, and whole workflows are handed to third-party algorithms. The demos are impressive and the productivity gains are real.This article is not an argument against that. It’s an argument that the companies that get data governance right will deploy AI faster and in more places, because the foundation underneath is safe. The companies that skip it will eventually face one simple question they cannot answer. We’ll come back to it at the end.1. The Problem: Every Request Carries Your DataEven when your contract with the AI vendor forbids training on your data, and even when the connection is fully encrypted in transit, the raw text still arrives in readable form at a third party. Contracts limit what the vendor is allowed to do with it. They don’t change what is exposed if that vendor suffers a breach, if a log is misconfigured, or if a legal request lands on their desk.The risk grows with multi-agent architectures, where a router agent delegates work to specialized sub-agents that share a common memory. A single name or revenue figure injected into that shared state is now visible to every agent in the chain, and to every log, cache and tracing tool they write to.Figure 1: Injection of raw data into a multi-agent shared memory.Sending raw business data into an LLM with no controls isn’t innovation. It’s outsourcing your company’s strategic memory to a party that will never bear the cost of your leaks.2. Why Naive Anonymization FailsSome teams, aware of the danger, put a basic filter in front of the model: generic Named Entity Recognition (NER) tools, or regular expressions that redact proper nouns. On paper, that sounds reasonable. In production it tends to fail in three ways, and the real danger is that it gives leadership a sense of safety that stops them from asking questions.Failure 1: The filter doesn’t understand your businessGeneric detection tools are trained on everyday text. They confuse company names with common words (“Services”, “Transit”, “Holding”), and they often miss a company whose name contains such a word, like “NET-POLY SERVICES”. Internal identifiers such as tax codes or registration numbers don’t match standard formats, so they slip through.Failure 2: Hiding the name isn’t hiding the companyConsider this example: Raw text: “Company X posted 4.2 billion FCFA in revenue in Abidjan in 2024.” After a naive filter: “COMPANY_1 posted 4.2 billion FCFA in revenue in Abidjan in 2024.”The name is gone, but the exact revenue and the city are enough to identify the company with a single search. A modern model, with its vast training knowledge, can often do the same in a fraction of a second.Failure 3: A prompt is a request, not a controlAn instruction like “Never repeat client names” is a probabilistic nudge. The model will usually comply, until it doesn’t. There is a subtler problem too. Depending on the vendor and the settings, some models return their intermediate reasoning in a channel separate from the final answer, a kind of scratchpad handed back to you. If your protection only inspects the final answer, raw data can travel through the scratchpad unchecked.Believing a system prompt that says “be discreet” protects your data is like locking an armored door and leaving the key in the lock outside.3. The Six Invariants of Robust GovernanceIf instructions and basic filters aren’t enough, what is? Most architects sketch a linear pipeline: data comes in, gets filtered, goes to the model, comes back. A robust system looks different. It is defense in depth, where every boundary carries its own protection, independently of the others.What follows are six invariants. They are contracts that any architect can check against their own system, whatever the stack, the model vendor or the team size.Figure 2: The six governance invariants applied to LLM workflows.Invariant 1: Minimize at the sourceWhat isn’t in the returned data can’t leak. The tools exposed to AI should never be the same ones exposed to your human developers. Pagination is enforced, volumes are capped, and unnecessary fields are removed entirely. The API doesn’t return a full customer record just to compute a ratio. It returns the three numbers actually needed: no contacts, no addresses, no names.This isn’t an AI-specific rule. It’s a data-exposure rule that AI simply makes urgent.Invariant 2: Never let a model resolve what a deterministic path can decideA ranking, a ratio lookup or a simple extraction requires no judgment, so it has no business going through your most expensive and least predictable model. The routing can be a business rule, a choice in the interface or a lightweight classifier. The principle doesn’t prescribe how.Two honest caveats. First, if the router is itself a model, it is still a model: keep it small, tightly constrained, and feed it only protected text. The goal is to shrink exposure, not to claim it disappears. Second, this invariant has a benefit that often convinces a board before security does: it’s cheaper and faster.A fun example comes from Anthropic itself. In March 2026, Claude Code’s source code was accidentally leaked through a source map published on npm. It showed that the tool detects user frustration, meaning whether someone is swearing at it, with a plain regular expression, not a model1. A regex is instant and free. An inference call is neither, for a question that needs no judgment at all.Invariant 3: Treat every text-to-model boundary as a mandatory, reversible protection pointAny data crossing the boundary between your system and a language model, inbound or outbound, should be protected by a reversible mechanism such as substitution, pseudonymization or tokenization. The common mistake is protecting only the obvious response field. Check every channel the vendor exposes, including the reasoning channel described in section 2.One caution: pseudonymized data can still count as personal data under regulations like GDPR. This invariant reduces your exposure. It doesn’t remove your legal obligations.Invariant 4: Rehydrate at the last legitimate momentSubstitution should be reversed only at the very last step, right before the answer is shown to a human who is authorized to see the real values. The model works blind from start to finish. Only the human gets the answer in the clear, and only at the end of the chain.Invariant 5: A deterministic guardrail has the final say on plausibilityThe first four invariants protect confidentiality. This one protects correctness. Any response coming out of a deterministic path must pass through a non-probabilistic check, such as a threshold or a comparison with a known reference, before it goes out. If the result looks aberrant, the system falls back to a slower, supervised path rather than letting an unverified answer through.Invariant 6: Every agent-to-agent hop is its own trust boundaryThis is the invariant most often forgotten, because it doesn’t show up in a static layered diagram. An agent’s output isn’t automatically safe for the agent that called it.Here is the scenario. A sub-agent, believing it is “cleaning up its own output” before replying, de-anonymizes data that was meant to stay protected for the agent that delegated to it. The sub-agent did exactly what it was told. It just didn’t know its answer was headed for another link in the chain, one that was supposed to keep reasoning over pseudonyms.There is an aggravating corollary. When conversation history crosses several agents, turns from different agents end up mixed in one context. Without explicit provenance tagging, you can no longer tell which trust boundary applies to which piece of text.Figure 3: Agent-to-agent trust boundaries and rehydration placement.4. For Engineers: How We Implement the InvariantsThis is one possible implementation among many. The tools are specific to our stack. If your system looks nothing like ours, the invariants still hold; only their embodiment changes. Non-technical readers can skip to section 5. Anonymization gate (invariant 3): Before anything is classified, the user’s free text passes through a strict gate that combines NLP libraries with deterministic substitutions. Names and identifiers become unique tokens (for example CORP_A83B). The correspondence table goes straight into an encrypted vault: HMAC-SHA256 for the token, Fernet encryption for the real value at rest. Router (invariant 2): The pseudonymized question goes to a deliberately small, fast, low-cost LLM whose only job is to classify intent. Ranking, ratio lookup or simple extraction takes the fast path: a tool from a bounded allowlist is called directly, and the heavy model is never invoked. Risk analysis or interpretation takes the slow path and goes to the main agent. Minimized tools (invariant 1): On both paths, internal data is reached through tools exposed via the Model Context Protocol (MCP), cleaned at the source and never the same tools as those given to human developers. Slow path (invariant 3, outbound): Tool results are pseudonymized before they enter the heavy model’s context, so it reasons and writes entirely over pseudonyms. Fast path (invariant 5): A plausibility guardrail, a simple ratio compared against a reference value, blocks any aberrant automatic answer and forces a fallback to the slow path for review. Rehydration (invariant 4): Only once the response is generated and formatted does the system query the vault and swap tokens back for real values, right before display to the authorized user.Figure 4: Fast-path / slow-path routing architecture. Observed results: (Add here your real production metrics — e.g., 72% of queries served via the fast path without heavy model invocation, 4x latency reduction, and 65% API cost reduction).5. Verify, Don’t AssumeA defense that has never been verified under real conditions is a defense you only hope exists.Here is a scenario that is far from theoretical. A control meant to transform every message before it reached the model was written, reviewed and merged. The tests passed. For weeks, the team believed the protection was active. It wasn’t. An assumption about the exact shape of the incoming data was wrong, so the control ran against the wrong structure, silently, with no error and nothing to raise an alarm.A test that passes without actually going through the protection code proves nothing. A code review is not proof of execution, and a clean architecture diagram is not proof that the path it draws is the path taken at runtime. The only proof that counts is a test replayed against the real system: the real model, the real data, the real call chain.Every claim of compliance, such as “this is anonymized” or “this data never leaves in the clear,” should come with replayable proof, available on demand. Otherwise it isn’t a guarantee. It’s a hypothesis that hasn’t been disproved yet.Figure 5: The control illusion — silent failure when data format mismatches expectations.6. The Audit TrailGovernance doesn’t stop at real-time protection. It requires traceability. When a banking regulator, a GDPR auditor or a major client reviews how AI is used in your organization, they won’t accept statements of intent. They will ask for evidence.A responsible enterprise AI system keeps a tamper-evident, append-only audit log, kept separate from ordinary conversation logs and chat history. For every event in an agent’s lifecycle, it records a pair: the raw data as originally received, the data actually transmitted to external models.The rule that matters most is that no configuration flag or single administrator can turn this log off. During load spikes, a team’s first instinct is often to disable logging “for performance”. If that switch exists, then on the day a leak happens you won’t be able to prove its scope or identify what was exposed.Figure 6: Why the audit log must be tamper-proof and impossible to bypass via configuration flags.There is a catch, and it deserves to be stated plainly: this log is itself one of the most sensitive datasets in the company, because it contains the raw data you worked so hard to protect. Treat it accordingly. Encrypt it, restrict and separate access to it, define retention limits, and work out with your legal counsel how it coexists with erasure requests under regulations like GDPR.Finally, add retroactive leak scanning: background processes that keep re-scanning archived audit history for sensitive patterns that slipped past the first filters. When one is found, the scanner never edits the original log. It alerts the security team, who can tighten the rules before a small gap becomes an incident.Figure 7: Immutable audit pipeline and asynchronous leak scanning.7. Conclusion: AI Is a Data Engineering Problem“Garbage in, garbage out” has a modern cousin: secret in, exposure out. AI isn’t a cosmetic layer you bolt onto a corner of your information system to satisfy the board. It changes how information flows, how it is segmented, how it is anonymized and how it is archived.The companies that win with AI won’t be the ones that deployed the most agents the fastest. They will be the ones that built a safe foundation first. A good brake system is what lets a car drive fast. With these controls in place, teams can say yes to more use cases, with less debate and fewer late-stage vetoes.So before you sign the next AI contract, or celebrate the rollout of your latest agent, gather your architects and ask one question: “If an external auditor sent by our biggest client showed up tomorrow at 8am, could you prove to me, line of code by line of code, that not a single name, identifier or confidential figure left our network in the clear over the past month?”If the answer is silence, or “our vendor’s clauses”, or “a prompt that tells the AI to be careful,” you have your next project.The Governance Checklist: 8 boxes to checkBefore you leave the room, eight boxes to check (the six invariants, plus the testing and auditing essentials): Do you minimize at the source? (Invariant 1) Do you keep the expensive model off simple requests? (Invariant 2) Is every text-to-model boundary protected by a reversible mechanism? (Invariant 3) Do you rehydrate only at the last legitimate moment? (Invariant 4) Does a deterministic guardrail have the final say on plausibility? (Invariant 5) Is every agent-to-agent hop its own trust boundary? (Invariant 6) Is every control verified under real conditions, not just assumed? (Section 5) Is the audit log impossible to disable by flag or by a single administrator? (Section 6) Alex Kim, “The Claude Code Source Leak: fake tools, frustration regexes, undercover mode, and more”, March 31, 2026. The leak came from a source map file published by mistake on npm. The file userPromptKeywords.ts contains the regex used for frustration detection. ↩

The Ultimate Guide to a Smooth Dev Environment

July 31, 2026 · 9 min
I wrote about Stack a few days ago — covering the short version of why I built it and the whole “no Docker” philosophy. But theory only gets you so far. If you’re looking for the ultimate guide to actually achieving a buttery-smooth dev environment, this is it.No elevator pitch. No marketing fluff. Just the raw, practical reality of getting a project running effortlessly, complete with every weird prompt and console output you’ll encounter along the way.If you just want the dry reference docs, you can find them at sanayavo.com/stack. Otherwise, welcome to the guided tour of a better workflow.What you’re actually gettingA smooth environment means zero bloat. Stack is just one Rust binary. There is no daemon hogging RAM in your system tray, no Docker Desktop spinning up your laptop fans, and absolutely nothing touching your global system PHP install.Instead, it acts as highly intelligent duct tape. It leans on vfox for managing PHP/Node versions, uv for Python, and Caddy for instant local domains. Stack reads a simple stack.toml file in your project root and automatically wires these three tools together.Note: Stack is Windows only right now (PowerShell or cmd). Mac and Linux support are on the roadmap.The Installation: Your one-time headacheA smooth day-to-day workflow requires a tiny bit of upfront setup. Run this to download the installer:irm https://github.com/sanayasfp/stack/releases/latest/download/stackenv-installer.ps1 | iexThen, run this command once per machine:stack setupThis handles the heavy lifting behind the scenes. It adds a hook to your PowerShell profile (so the correct PHP or Node version magically activates when you cd into a directory), installs vfox, uv, and Caddy at heavily-tested versions, and runs caddy trust. That last step is crucial for local HTTPS — more on that in a minute.$ stack setupadded the stack hook for pwshchecking vfox/uv/caddy... vfox: OK (1.0.11) uv: OK (0.11.7) caddy: OK (2.11.4) caddy: local CA trusted (https://*.localhost works with no browser warning)Restart your terminal now. If you skip this, the PowerShell hook won’t be active and you’ll wonder why nothing is working. You will never have to run stack setup again unless you buy a new computer.Starting a project friction-freeLet’s build something real to show off the workflow: a lightweight PHP API backed by MySQL.From scratch$ stack new acme-apidomain: acme-api.localhostlanguages (space to toggle, enter to confirm): [x] phpservices (space to toggle, enter to confirm): [x] mysql php version: 8.3.1 mysql version: 8.0.35created acme-api\stack.tomlnext: cd into it, add a [run] command when you know it, then `stack up`It’s an interactive terminal checklist. Space to toggle, enter to confirm. It feels a bit different the first time, but nothing commits until you press enter. If you accidentally select Node instead of PHP, just hit space again. Zero stress, no do-overs required.If you already have a projectIf your acme-api directory already has a composer.json file specifying "php": "^8.3", Stack is smart enough to skip the manual typing:$ stack initdetected from existing project files: php 8.3 (from composer.json)domain: acme-api.localhoststack init automatically parses composer.json, package.json, or pyproject.toml and pre-fills the wizard for you.The magic artifactStack generates a highly readable manifest:[project]name = "acme-api"domain = "acme-api.localhost"[language]php = "8.3.1"[service.mysql]version = "8.0.35"Commit this stack.toml file to your repo. It is your ultimate “works on my machine” artifact—the lightweight, container-free equivalent of a Dockerfile. When a teammate clones the repo and types stack up, they instantly get the exact same PHP and MySQL versions without installing anything by hand.Teaching it how to runYour stack.toml needs to know how to boot up your dev server. Open the file and add a [run] block:[run]command = "php -S 127.0.0.1:{port} -t public"The {port} placeholder dynamically swaps for an available port at runtime. (Note: If you declare [language.php] but skip the [run] section, Stack falls back to its built-in FastCGI setup using php-cgi.exe and Caddy, which is incredibly solid. But for this guide, we’ll keep it explicit.)stack up and watch it fly$ cd acme-api$ stack upLoaded C:\Users\you\acme-api\stack.toml project: acme-api domain: acme-api.localhost languages: php services: mysqlfirst run for this project — stack.toml will execute: [run] php -S 127.0.0.1:{port} -t publicTrust and run these commands? [y/N] y php: C:\Users\you\.vfox\cache\php\v-8.3.1\...\php.exe -> PHP 8.3.1 (cli) service.mysql: started (pid 41232, port 3306) schema 'acme_api' — automatic creation not yet implemented; create it manually if needed run: php -S 127.0.0.1:52140 -t public (pid 41244, port 52140) log: C:\Users\you\.stack\logs\acme-api.log routed: http://acme-api.localhost -> 127.0.0.1:52140There are two major things happening here that contribute to a seamless experience:1. The security trust promptBecause [run].command is a literal shell invocation, Stack runs it exactly as written. The first time Stack sees a new or modified command (like after a git pull), it asks you to confirm it. Say yes, and it remembers your choice in ~/.stack/trust.json. Every subsequent stack up is blissfully silent. Need to bypass it for CI? Just pass --yes.2. HTTPS works out of the boxThe console says http://, but https://acme-api.localhost works instantly. No red browser warnings. Remember that caddy trust command from the setup phase? It installs a local Certificate Authority (similar to mkcert), meaning your local environment accurately mirrors production SSL from day one.What “no containers” actually means in practiceOpen a second terminal, cd into a totally different project that also requires MySQL 8.0.35, and run stack up: service.mysql: already running, shared with other projects (pid 41232, port 3306)That’s the beauty of it. One MySQL process serving two projects, isolated by schema rather than burning your CPU and RAM on redundant containers.But here is where the “smooth” factor really shines. Open a third terminal, cd into acme-api, and just run php -v. No stack up, no extra commands:$ cd acme-api$ php -vPHP 8.3.1 (cli) (built: ...)$ cd ..$ php -v'php' is not recognized as an internal or external commandThe version switch happens ambiently on every prompt thanks to that PowerShell hook. Your IDE, composer install, and your test runners all automatically get the correctly pinned version the second they enter the folder. Leave the directory, and it vanishes. No source venv/bin/activate, no nvm use, and no polluting your global system.Fresh PHP installs that don’t suckUsually, a fresh PHP installation means spending an afternoon hunting down “PDO driver not found” or “timezone not set” errors. Stack eliminates this entirely.The first time it downloads a PHP version via vfox, it automatically patches php.ini. It turns on OPcache, bumps memory and upload limits, sets a timezone, and enables the extensions you actually need out of the box (pdo_mysql, pdo_pgsql, pdo_sqlite, sockets, sodium, etc.). It happens once, automatically, and you never have to think about it again.stack doctor — The ultimate pre-flight checkBefore you hand off a project or start debugging a strange issue, run this:$ stack doctor --projectchecking C:\Users\you\acme-api\stack.toml... language.php: OK (C:\Users\you\.vfox\cache\php\v-8.3.1\...\php.exe) service.mysql: OK (managed)This command validates your ports, paths, and {PLACEHOLDER} values against your environment (loading .env first) without starting a single service. If something is broken, you get a clean list upfront instead of discovering cryptic errors halfway through startup.Command your setup from anywhereOnce Stack knows about a project, you don’t even need to be in its folder to manage it:stack describe acme-apistack restart acme-apistack down acme-apistack describe dumps everything—resolved binary paths, the exact location of your hashed php.ini, logs, and routed domains. Stack keeps a local registry of your projects, making global management effortless.Shutting it all down cleanlyWhen the day is over:stack down --allEverything stops. Every project, every shared service, and Caddy. You get actual zero CPU usage, not a lingering phantom container you forgot to kill.A quick note on custom domainsUsing .localhost guarantees a smooth experience because browsers automatically resolve it to the loopback address (RFC 6761) without you needing to hack your hosts file. However, if you are migrating from Laragon or Herd and absolutely need .test domains, there is a one-time setup using Acrylic DNS Proxy.Your New WorkflowScaffold, run, trust once, enjoy automatic HTTPS, commit your manifest, and tear it down cleanly when you’re done.I use this exact workflow for real client work every single day. This isn’t a theoretical happy path; it’s a battle-tested blueprint for an ultimate, smooth dev environment.For the complete stack.toml reference, CLI flags, and advanced features (like using [[clone]] to bootstrap from just a manifest), check out the official docs: Why I built this — the longer rant against Docker/XAMPP for local dev Getting Started Manifest Reference CLI Reference Code: github.com/sanayasfp/stack

Stack: A Native, Zero-Container Dev Environment Manager

July 28, 2026 · 3 min
When you are maintaining a legacy application while building its modern replacement, your local development environment quickly becomes a battlefield. At work, we had a legacy app running on an old version of PHP, Vue 2 (requiring an outdated Node version), and Python 3.7. Alongside it, we were bootstrapping the new architecture with PHP 8.4, Vue 3, and Python 3.12.I personally managed this with a bespoke combination of Laravel Herd for PHP, uv for Python, and nvm for Node. It worked for me because I took the time to configure it. But for our junior developers and fresh interns, asking them to juggle these tools just to run both projects simultaneously was an absolute nightmare. They were fighting path conflicts and environment variables instead of writing code. That’s why I built Stack: a native, zero-container multi-project dev environment manager that makes switching contexts as simple as typing stack up.Code’s here: github.com/sanayasfp/stack.The real constraint: Onboarding and Cognitive LoadBefore touching the architecture, the real constraint on a project like this is developer experience. Interns shouldn’t need to learn Docker network bridges or configure five different version managers to fix a bug in the legacy API. The goal was simple: they should be able to clone the repo, run a single command, and get a working local domain routed to the right processes, regardless of whether the project uses PHP 5.6 or 8.4.Why not just use Docker?Containers solve the “works on my machine” problem by shipping an entire OS layer per project. But that comes with virtualization overhead and idle daemons eating RAM.Stack solves the same problem differently. It pins exact versions per project through version managers you’d install anyway (delegating to tools like vfox and uv), shares one downloaded binary across every project needing that version, and runs everything as plain child processes. No hypervisor, no Docker Desktop taking up 4GB of RAM for a simple API. Every project pinning the same service and version shares one running instance, isolated by schema.Under the hood: Simplicity over MagicA stack.toml file in your project defines exactly what is needed:[project]name = "acme-api"[language]php = "8.4.0"[service.mysql]version = "8.0.35"[run]command = "php -S 127.0.0.1:{port} -t public"When you run stack up, the orchestrator reads the manifest, wires up the local domain (e.g. acme-api.localhost), starts MySQL if it’s not already running, boots the PHP dev server, and reverse-proxies the traffic. All native, all blazing fast. It doesn’t try to reinvent package management; it delegates the heavy lifting of language downloads to vfox and uv, and the routing to Caddy.Where it actually stands right nowI want to be precise about this: I started coding Stack in Rust just last week and pushed the first release two days ago. I have completely uninstalled Herd, Laragon, and my previous manager scripts. I am now exclusively dogfooding Stack for my daily work — using it to prove it works, find edge-case bugs, and add new features to improve it.It currently supports Windows (PowerShell & cmd), which was the immediate need, with macOS and Linux on the roadmap. It’s not just a weekend experiment anymore; it’s becoming the foundational tool that keeps our team’s local development sane.You can grab the installer from the repo or read the official documentation: Repo: github.com/sanayasfp/stack Docs: sanayavo.com/stack/

Laplace Nearby: A WhatsApp Bot That Finds 'The Nearest Maquis' Without Torching the LLM Budget

June 22, 2026 · 8 min
In Abidjan, a huge number of local businesses — the neighborhood maquis (informal restaurant), the pharmacy, the guy who fixes phones — don’t have a website, a Google Business listing, or even a formal street address half the time. What they do have is WhatsApp. Laplace Nearby starts from that observation: instead of building an app people have to download, build the search experience inside the app everyone already has open, and let people describe what they want in plain language — “I need a pharmacy,” “j’ai envie de porcodjo” — rather than filling out a filter form.The assistant itself is internally called Simon. This post is about how Simon is actually built — the parts I think are worth talking about, not a marketing pitch.Repo: github.com/sanayasfp/laplace-nearby.Decision #1: the engine doesn’t know it’s talking to WhatsAppThe core of the system is SimonEngine, a small orchestrator that takes a channel-agnostic InteractionRequest and returns a channel-agnostic InteractionResponse. WhatsApp specifics — webhook payloads, message formatting, buttons and lists — live entirely in an adapter/renderer layer outside the engine. The engine itself just does four things on every message: load the user’s session context, check rate limiting, hand the message to whichever conversational flow is currently active (idle, search, or register), and save the result — always emitting an analytics side effect with the flow name, the state transition, and the latency, whether or not anything interesting happened:async process(req: InteractionRequest): Promise<InteractionResponse> { const context = await this.contextManager.load(req.profileId); const flow = this.flowRegistry.get(context.session.activeFlow || IDLE); const limitResult = this.rateLimitService.check(context.rateLimit); if (!limitResult.allowed) { return { messages: limitResult.notify ? [Responses.tooManyMessages(...)] : [] , ... }; } const response = await flow.handle(req, context, interactionId); await this.contextManager.save(req.profileId, response.contextUpdate); return { ...response, sideEffects: [summaryEffect, ...response.sideEffects] };}This isn’t architecture for its own sake. It means the search logic, the registration flow, the rate limiting, and the metrics don’t need to be rewritten if a second channel (SMS, a web widget, whatever) shows up later — they were never coupled to WhatsApp in the first place.Decision #2: don’t call the LLM if you don’t have toEvery incoming message needs an intent — is this a search, a registration request, small talk, a thank-you, an insult, “stop”? Routing every single message through an LLM call is the easy way to build this, and also the expensive and slow way. So intent classification is two-tiered.FastIntentDetector runs first: a deliberately exhaustive set of regex patterns covering French, English, and local slang and abbreviations you’d actually see in a WhatsApp chat in Abidjan — “wesh,” “cc,” “gab” (cash machine), “essence/gazoil” (fuel), dozens of spelling variants of “merci,” “stop,” “annule.” Its docstring says exactly what it’s for: “Reduce LLM costs and latency for unambiguous user requests.” Only when nothing matches does IntentService fall back to Gemini (gemini-2.5-flash-lite), with temperature 0, JSON response mode, and a tight 150-token cap, asking for structured output: the intent, an extracted search keyword, and a list of semantically related terms to widen the search (“j’ai envie de porcodjo” → keyword “porcodjo restaurant”; “mon habit est sale” → “pressing nettoyage vêtement”).Both paths report where the classification came from — REGEX or LLM — to a Prometheus counter (simonIntentTotal), so the fraction of traffic being deflected from the paid model is something you can actually watch on a dashboard, not something you have to guess at. And the Gemini call itself sits behind a circuit breaker: if it trips, the system doesn’t crash the conversation, it degrades to a neutral “chitchat” response with confidence 0 and moves on.Decision #3: addresses in Abidjan don’t work like addresses elsewhereA huge share of real addresses given in chat aren’t geocodable strings — they’re descriptions: “je suis vers la cité Abdoulaye Diallo.” AddressCodingService takes that kind of input, checks a semantic cache (exact hash first, then embedding similarity above a 0.88 threshold) to avoid re-paying for something already resolved, and if it’s a miss, asks Gemini to turn it into a standardized address, an extracted neighborhood, and — importantly — a flag for whether the description is precise enough to geocode at all or whether the only honest answer is “ask the user to drop a GPS pin.” There’s even a dedicated dictionary of Nouchi (Abidjan street slang) terms feeding into this pipeline, because generic NLP tooling doesn’t know what a “gbaka” stop or a given neighborhood nickname refers to.Decision #4: search is one SQL function, three signals, fusedThis is the part of the codebase I’m most proud of. search_nearby_places is a single Postgres function that blends three independent ranking signals for every candidate business inside a radius: Full-text rank (ts_rank_cd against a French tsquery) — good when the user typed something close to the business’s actual name or category. Vector similarity rank — cosine distance between the query embedding (Gemini text-embedding-004, pgvector with an HNSW index) and each place’s embedding — good when the user described what they want in their own words instead of matching a label. Geographic proximity (ST_Distance over a PostGIS geography column) — because “closest” always matters, and can’t be papered over by relevance alone.These get combined with a weighted Reciprocal Rank Fusion: score = w_fts/(k + fts_rank) + w_vec/(k + vec_rank) + w_prox * proximity_term, with the weights shifting based on whether the user gave a keyword at all — 45/45/10 between text, vector, and proximity when there’s a keyword to match; 85% vector-driven when there isn’t, since full-text search has nothing to grab onto in a purely descriptive query. There’s also a “premium tier” mechanism: a second ranking pass, partitioned by tier, guarantees paying/listed businesses a small quota of slots without letting them drown out relevance for the general result set.I later went back and rewrote this same function for performance after noticing it was doing redundant work: merging two CTEs that were reading the same rows twice, adding a LIMIT inside the vector-ranking CTE specifically so the HNSW index can short-circuit instead of sorting the entire candidate pool, and replacing two separate full materializations (general results, premium results) with a single windowed pass using ROW_NUMBER() OVER (PARTITION BY is_premium ...). That’s a genuinely satisfying kind of fix — same output, measurably less work per query — and it’s the sort of thing that only shows up once real usage patterns put pressure on a first draft.Decision #5: side effects are data, not actionsWhen a place gets registered, or an interaction needs logging for analytics, or a user needs a notification, the engine doesn’t go do that work inline — it returns a plain SideEffect object describing what should happen. A PgmqSideEffectDispatcher collects these, groups them by target queue, and pushes them via pgmq.send_batch (Postgres’s own message-queue extension) inside the same database transaction that marks a newly registered place as “queued.” That detail matters: it means a place can’t end up half-registered — visible in the app but never actually indexed for search — because the status update and the queue write either both commit or neither does. Supabase Edge Functions on the other end (an analytics-worker, a place-embedding-worker, a place-tagging-worker) drain those queues asynchronously.The stack, plainlyFastify + TypeScript on Node.js, Prisma over Supabase Postgres (with pgvector, pgmq, PostGIS, pg_cron, and pg_net doing real work, not just sitting in a dependency list), Redis for caching and rate-limit state, Gemini for both NLU and embeddings, Geoapify for geocoding, and Prometheus metrics wired in from the start rather than bolted on later.Where it standspackage.json says 0.4.0-rc.1 and I mean to leave that context in: this is a real, working system I use to reason about hybrid search and conversational engineering, actively evolving toward a 1.0 — not a finished, scaled product with a client roster behind it. The channel-agnostic design exists specifically so that if this ever needs to be more than a WhatsApp bot, the engine underneath doesn’t need to be rebuilt.If you want to argue about the RRF weights, tell me PostGIS was overkill, or point out a better way to structure the side-effect dispatcher — the code’s public: github.com/sanayasfp/laplace-nearby.

The Ultimate Guide to a Smooth Dev Environment

July 31, 2026 · 9 min
I wrote about Stack a few days ago — covering the short version of why I built it and the whole “no Docker” philosophy. But theory only gets you so far. If you’re looking for the ultimate guide to actually achieving a buttery-smooth dev environment, this is it.No elevator pitch. No marketing fluff. Just the raw, practical reality of getting a project running effortlessly, complete with every weird prompt and console output you’ll encounter along the way.If you just want the dry reference docs, you can find them at sanayavo.com/stack. Otherwise, welcome to the guided tour of a better workflow.What you’re actually gettingA smooth environment means zero bloat. Stack is just one Rust binary. There is no daemon hogging RAM in your system tray, no Docker Desktop spinning up your laptop fans, and absolutely nothing touching your global system PHP install.Instead, it acts as highly intelligent duct tape. It leans on vfox for managing PHP/Node versions, uv for Python, and Caddy for instant local domains. Stack reads a simple stack.toml file in your project root and automatically wires these three tools together.Note: Stack is Windows only right now (PowerShell or cmd). Mac and Linux support are on the roadmap.The Installation: Your one-time headacheA smooth day-to-day workflow requires a tiny bit of upfront setup. Run this to download the installer:irm https://github.com/sanayasfp/stack/releases/latest/download/stackenv-installer.ps1 | iexThen, run this command once per machine:stack setupThis handles the heavy lifting behind the scenes. It adds a hook to your PowerShell profile (so the correct PHP or Node version magically activates when you cd into a directory), installs vfox, uv, and Caddy at heavily-tested versions, and runs caddy trust. That last step is crucial for local HTTPS — more on that in a minute.$ stack setupadded the stack hook for pwshchecking vfox/uv/caddy... vfox: OK (1.0.11) uv: OK (0.11.7) caddy: OK (2.11.4) caddy: local CA trusted (https://*.localhost works with no browser warning)Restart your terminal now. If you skip this, the PowerShell hook won’t be active and you’ll wonder why nothing is working. You will never have to run stack setup again unless you buy a new computer.Starting a project friction-freeLet’s build something real to show off the workflow: a lightweight PHP API backed by MySQL.From scratch$ stack new acme-apidomain: acme-api.localhostlanguages (space to toggle, enter to confirm): [x] phpservices (space to toggle, enter to confirm): [x] mysql php version: 8.3.1 mysql version: 8.0.35created acme-api\stack.tomlnext: cd into it, add a [run] command when you know it, then `stack up`It’s an interactive terminal checklist. Space to toggle, enter to confirm. It feels a bit different the first time, but nothing commits until you press enter. If you accidentally select Node instead of PHP, just hit space again. Zero stress, no do-overs required.If you already have a projectIf your acme-api directory already has a composer.json file specifying "php": "^8.3", Stack is smart enough to skip the manual typing:$ stack initdetected from existing project files: php 8.3 (from composer.json)domain: acme-api.localhoststack init automatically parses composer.json, package.json, or pyproject.toml and pre-fills the wizard for you.The magic artifactStack generates a highly readable manifest:[project]name = "acme-api"domain = "acme-api.localhost"[language]php = "8.3.1"[service.mysql]version = "8.0.35"Commit this stack.toml file to your repo. It is your ultimate “works on my machine” artifact—the lightweight, container-free equivalent of a Dockerfile. When a teammate clones the repo and types stack up, they instantly get the exact same PHP and MySQL versions without installing anything by hand.Teaching it how to runYour stack.toml needs to know how to boot up your dev server. Open the file and add a [run] block:[run]command = "php -S 127.0.0.1:{port} -t public"The {port} placeholder dynamically swaps for an available port at runtime. (Note: If you declare [language.php] but skip the [run] section, Stack falls back to its built-in FastCGI setup using php-cgi.exe and Caddy, which is incredibly solid. But for this guide, we’ll keep it explicit.)stack up and watch it fly$ cd acme-api$ stack upLoaded C:\Users\you\acme-api\stack.toml project: acme-api domain: acme-api.localhost languages: php services: mysqlfirst run for this project — stack.toml will execute: [run] php -S 127.0.0.1:{port} -t publicTrust and run these commands? [y/N] y php: C:\Users\you\.vfox\cache\php\v-8.3.1\...\php.exe -> PHP 8.3.1 (cli) service.mysql: started (pid 41232, port 3306) schema 'acme_api' — automatic creation not yet implemented; create it manually if needed run: php -S 127.0.0.1:52140 -t public (pid 41244, port 52140) log: C:\Users\you\.stack\logs\acme-api.log routed: http://acme-api.localhost -> 127.0.0.1:52140There are two major things happening here that contribute to a seamless experience:1. The security trust promptBecause [run].command is a literal shell invocation, Stack runs it exactly as written. The first time Stack sees a new or modified command (like after a git pull), it asks you to confirm it. Say yes, and it remembers your choice in ~/.stack/trust.json. Every subsequent stack up is blissfully silent. Need to bypass it for CI? Just pass --yes.2. HTTPS works out of the boxThe console says http://, but https://acme-api.localhost works instantly. No red browser warnings. Remember that caddy trust command from the setup phase? It installs a local Certificate Authority (similar to mkcert), meaning your local environment accurately mirrors production SSL from day one.What “no containers” actually means in practiceOpen a second terminal, cd into a totally different project that also requires MySQL 8.0.35, and run stack up: service.mysql: already running, shared with other projects (pid 41232, port 3306)That’s the beauty of it. One MySQL process serving two projects, isolated by schema rather than burning your CPU and RAM on redundant containers.But here is where the “smooth” factor really shines. Open a third terminal, cd into acme-api, and just run php -v. No stack up, no extra commands:$ cd acme-api$ php -vPHP 8.3.1 (cli) (built: ...)$ cd ..$ php -v'php' is not recognized as an internal or external commandThe version switch happens ambiently on every prompt thanks to that PowerShell hook. Your IDE, composer install, and your test runners all automatically get the correctly pinned version the second they enter the folder. Leave the directory, and it vanishes. No source venv/bin/activate, no nvm use, and no polluting your global system.Fresh PHP installs that don’t suckUsually, a fresh PHP installation means spending an afternoon hunting down “PDO driver not found” or “timezone not set” errors. Stack eliminates this entirely.The first time it downloads a PHP version via vfox, it automatically patches php.ini. It turns on OPcache, bumps memory and upload limits, sets a timezone, and enables the extensions you actually need out of the box (pdo_mysql, pdo_pgsql, pdo_sqlite, sockets, sodium, etc.). It happens once, automatically, and you never have to think about it again.stack doctor — The ultimate pre-flight checkBefore you hand off a project or start debugging a strange issue, run this:$ stack doctor --projectchecking C:\Users\you\acme-api\stack.toml... language.php: OK (C:\Users\you\.vfox\cache\php\v-8.3.1\...\php.exe) service.mysql: OK (managed)This command validates your ports, paths, and {PLACEHOLDER} values against your environment (loading .env first) without starting a single service. If something is broken, you get a clean list upfront instead of discovering cryptic errors halfway through startup.Command your setup from anywhereOnce Stack knows about a project, you don’t even need to be in its folder to manage it:stack describe acme-apistack restart acme-apistack down acme-apistack describe dumps everything—resolved binary paths, the exact location of your hashed php.ini, logs, and routed domains. Stack keeps a local registry of your projects, making global management effortless.Shutting it all down cleanlyWhen the day is over:stack down --allEverything stops. Every project, every shared service, and Caddy. You get actual zero CPU usage, not a lingering phantom container you forgot to kill.A quick note on custom domainsUsing .localhost guarantees a smooth experience because browsers automatically resolve it to the loopback address (RFC 6761) without you needing to hack your hosts file. However, if you are migrating from Laragon or Herd and absolutely need .test domains, there is a one-time setup using Acrylic DNS Proxy.Your New WorkflowScaffold, run, trust once, enjoy automatic HTTPS, commit your manifest, and tear it down cleanly when you’re done.I use this exact workflow for real client work every single day. This isn’t a theoretical happy path; it’s a battle-tested blueprint for an ultimate, smooth dev environment.For the complete stack.toml reference, CLI flags, and advanced features (like using [[clone]] to bootstrap from just a manifest), check out the official docs: Why I built this — the longer rant against Docker/XAMPP for local dev Getting Started Manifest Reference CLI Reference Code: github.com/sanayasfp/stack

Stack: A Native, Zero-Container Dev Environment Manager

July 28, 2026 · 3 min
When you are maintaining a legacy application while building its modern replacement, your local development environment quickly becomes a battlefield. At work, we had a legacy app running on an old version of PHP, Vue 2 (requiring an outdated Node version), and Python 3.7. Alongside it, we were bootstrapping the new architecture with PHP 8.4, Vue 3, and Python 3.12.I personally managed this with a bespoke combination of Laravel Herd for PHP, uv for Python, and nvm for Node. It worked for me because I took the time to configure it. But for our junior developers and fresh interns, asking them to juggle these tools just to run both projects simultaneously was an absolute nightmare. They were fighting path conflicts and environment variables instead of writing code. That’s why I built Stack: a native, zero-container multi-project dev environment manager that makes switching contexts as simple as typing stack up.Code’s here: github.com/sanayasfp/stack.The real constraint: Onboarding and Cognitive LoadBefore touching the architecture, the real constraint on a project like this is developer experience. Interns shouldn’t need to learn Docker network bridges or configure five different version managers to fix a bug in the legacy API. The goal was simple: they should be able to clone the repo, run a single command, and get a working local domain routed to the right processes, regardless of whether the project uses PHP 5.6 or 8.4.Why not just use Docker?Containers solve the “works on my machine” problem by shipping an entire OS layer per project. But that comes with virtualization overhead and idle daemons eating RAM.Stack solves the same problem differently. It pins exact versions per project through version managers you’d install anyway (delegating to tools like vfox and uv), shares one downloaded binary across every project needing that version, and runs everything as plain child processes. No hypervisor, no Docker Desktop taking up 4GB of RAM for a simple API. Every project pinning the same service and version shares one running instance, isolated by schema.Under the hood: Simplicity over MagicA stack.toml file in your project defines exactly what is needed:[project]name = "acme-api"[language]php = "8.4.0"[service.mysql]version = "8.0.35"[run]command = "php -S 127.0.0.1:{port} -t public"When you run stack up, the orchestrator reads the manifest, wires up the local domain (e.g. acme-api.localhost), starts MySQL if it’s not already running, boots the PHP dev server, and reverse-proxies the traffic. All native, all blazing fast. It doesn’t try to reinvent package management; it delegates the heavy lifting of language downloads to vfox and uv, and the routing to Caddy.Where it actually stands right nowI want to be precise about this: I started coding Stack in Rust just last week and pushed the first release two days ago. I have completely uninstalled Herd, Laragon, and my previous manager scripts. I am now exclusively dogfooding Stack for my daily work — using it to prove it works, find edge-case bugs, and add new features to improve it.It currently supports Windows (PowerShell & cmd), which was the immediate need, with macOS and Linux on the roadmap. It’s not just a weekend experiment anymore; it’s becoming the foundational tool that keeps our team’s local development sane.You can grab the installer from the repo or read the official documentation: Repo: github.com/sanayasfp/stack Docs: sanayavo.com/stack/

Laplace Nearby: A WhatsApp Bot That Finds 'The Nearest Maquis' Without Torching the LLM Budget

June 22, 2026 · 8 min
In Abidjan, a huge number of local businesses — the neighborhood maquis (informal restaurant), the pharmacy, the guy who fixes phones — don’t have a website, a Google Business listing, or even a formal street address half the time. What they do have is WhatsApp. Laplace Nearby starts from that observation: instead of building an app people have to download, build the search experience inside the app everyone already has open, and let people describe what they want in plain language — “I need a pharmacy,” “j’ai envie de porcodjo” — rather than filling out a filter form.The assistant itself is internally called Simon. This post is about how Simon is actually built — the parts I think are worth talking about, not a marketing pitch.Repo: github.com/sanayasfp/laplace-nearby.Decision #1: the engine doesn’t know it’s talking to WhatsAppThe core of the system is SimonEngine, a small orchestrator that takes a channel-agnostic InteractionRequest and returns a channel-agnostic InteractionResponse. WhatsApp specifics — webhook payloads, message formatting, buttons and lists — live entirely in an adapter/renderer layer outside the engine. The engine itself just does four things on every message: load the user’s session context, check rate limiting, hand the message to whichever conversational flow is currently active (idle, search, or register), and save the result — always emitting an analytics side effect with the flow name, the state transition, and the latency, whether or not anything interesting happened:async process(req: InteractionRequest): Promise<InteractionResponse> { const context = await this.contextManager.load(req.profileId); const flow = this.flowRegistry.get(context.session.activeFlow || IDLE); const limitResult = this.rateLimitService.check(context.rateLimit); if (!limitResult.allowed) { return { messages: limitResult.notify ? [Responses.tooManyMessages(...)] : [] , ... }; } const response = await flow.handle(req, context, interactionId); await this.contextManager.save(req.profileId, response.contextUpdate); return { ...response, sideEffects: [summaryEffect, ...response.sideEffects] };}This isn’t architecture for its own sake. It means the search logic, the registration flow, the rate limiting, and the metrics don’t need to be rewritten if a second channel (SMS, a web widget, whatever) shows up later — they were never coupled to WhatsApp in the first place.Decision #2: don’t call the LLM if you don’t have toEvery incoming message needs an intent — is this a search, a registration request, small talk, a thank-you, an insult, “stop”? Routing every single message through an LLM call is the easy way to build this, and also the expensive and slow way. So intent classification is two-tiered.FastIntentDetector runs first: a deliberately exhaustive set of regex patterns covering French, English, and local slang and abbreviations you’d actually see in a WhatsApp chat in Abidjan — “wesh,” “cc,” “gab” (cash machine), “essence/gazoil” (fuel), dozens of spelling variants of “merci,” “stop,” “annule.” Its docstring says exactly what it’s for: “Reduce LLM costs and latency for unambiguous user requests.” Only when nothing matches does IntentService fall back to Gemini (gemini-2.5-flash-lite), with temperature 0, JSON response mode, and a tight 150-token cap, asking for structured output: the intent, an extracted search keyword, and a list of semantically related terms to widen the search (“j’ai envie de porcodjo” → keyword “porcodjo restaurant”; “mon habit est sale” → “pressing nettoyage vêtement”).Both paths report where the classification came from — REGEX or LLM — to a Prometheus counter (simonIntentTotal), so the fraction of traffic being deflected from the paid model is something you can actually watch on a dashboard, not something you have to guess at. And the Gemini call itself sits behind a circuit breaker: if it trips, the system doesn’t crash the conversation, it degrades to a neutral “chitchat” response with confidence 0 and moves on.Decision #3: addresses in Abidjan don’t work like addresses elsewhereA huge share of real addresses given in chat aren’t geocodable strings — they’re descriptions: “je suis vers la cité Abdoulaye Diallo.” AddressCodingService takes that kind of input, checks a semantic cache (exact hash first, then embedding similarity above a 0.88 threshold) to avoid re-paying for something already resolved, and if it’s a miss, asks Gemini to turn it into a standardized address, an extracted neighborhood, and — importantly — a flag for whether the description is precise enough to geocode at all or whether the only honest answer is “ask the user to drop a GPS pin.” There’s even a dedicated dictionary of Nouchi (Abidjan street slang) terms feeding into this pipeline, because generic NLP tooling doesn’t know what a “gbaka” stop or a given neighborhood nickname refers to.Decision #4: search is one SQL function, three signals, fusedThis is the part of the codebase I’m most proud of. search_nearby_places is a single Postgres function that blends three independent ranking signals for every candidate business inside a radius: Full-text rank (ts_rank_cd against a French tsquery) — good when the user typed something close to the business’s actual name or category. Vector similarity rank — cosine distance between the query embedding (Gemini text-embedding-004, pgvector with an HNSW index) and each place’s embedding — good when the user described what they want in their own words instead of matching a label. Geographic proximity (ST_Distance over a PostGIS geography column) — because “closest” always matters, and can’t be papered over by relevance alone.These get combined with a weighted Reciprocal Rank Fusion: score = w_fts/(k + fts_rank) + w_vec/(k + vec_rank) + w_prox * proximity_term, with the weights shifting based on whether the user gave a keyword at all — 45/45/10 between text, vector, and proximity when there’s a keyword to match; 85% vector-driven when there isn’t, since full-text search has nothing to grab onto in a purely descriptive query. There’s also a “premium tier” mechanism: a second ranking pass, partitioned by tier, guarantees paying/listed businesses a small quota of slots without letting them drown out relevance for the general result set.I later went back and rewrote this same function for performance after noticing it was doing redundant work: merging two CTEs that were reading the same rows twice, adding a LIMIT inside the vector-ranking CTE specifically so the HNSW index can short-circuit instead of sorting the entire candidate pool, and replacing two separate full materializations (general results, premium results) with a single windowed pass using ROW_NUMBER() OVER (PARTITION BY is_premium ...). That’s a genuinely satisfying kind of fix — same output, measurably less work per query — and it’s the sort of thing that only shows up once real usage patterns put pressure on a first draft.Decision #5: side effects are data, not actionsWhen a place gets registered, or an interaction needs logging for analytics, or a user needs a notification, the engine doesn’t go do that work inline — it returns a plain SideEffect object describing what should happen. A PgmqSideEffectDispatcher collects these, groups them by target queue, and pushes them via pgmq.send_batch (Postgres’s own message-queue extension) inside the same database transaction that marks a newly registered place as “queued.” That detail matters: it means a place can’t end up half-registered — visible in the app but never actually indexed for search — because the status update and the queue write either both commit or neither does. Supabase Edge Functions on the other end (an analytics-worker, a place-embedding-worker, a place-tagging-worker) drain those queues asynchronously.The stack, plainlyFastify + TypeScript on Node.js, Prisma over Supabase Postgres (with pgvector, pgmq, PostGIS, pg_cron, and pg_net doing real work, not just sitting in a dependency list), Redis for caching and rate-limit state, Gemini for both NLU and embeddings, Geoapify for geocoding, and Prometheus metrics wired in from the start rather than bolted on later.Where it standspackage.json says 0.4.0-rc.1 and I mean to leave that context in: this is a real, working system I use to reason about hybrid search and conversational engineering, actively evolving toward a 1.0 — not a finished, scaled product with a client roster behind it. The channel-agnostic design exists specifically so that if this ever needs to be more than a WhatsApp bot, the engine underneath doesn’t need to be rebuilt.If you want to argue about the RRF weights, tell me PostGIS was overkill, or point out a better way to structure the side-effect dispatcher — the code’s public: github.com/sanayasfp/laplace-nearby.

© 2026 Sana Yavo's blog · Powered by Jekyll & Minima · Inspired on Alex Kim's blog
French Flag Français