Claprec: Machine Learning in Practice (4/6)

Series Roadmap

Building the Recommendation Engine: A Hybrid Approach to Personalization in Claprec

When building Claprec, the primary challenge wasn’t just storing reviews; it was surfacing them. The landing page is the gateway to the platform, and a generic “latest posts” feed simply doesn’t cut it when you’re trying to build user retention. A system was needed that could handle the cold start problem, respect geographical relevance, and leverage user behavior – specifically, how much time a user spends consuming content.

In this post, I’m going to break down the architecture of the recommendation algorithm that powers the main feed. It’s a hybrid system combining collaborative filtering via Matrix Factorization, geo-spatial heuristics, and other factors.

The Architecture: Strategies for Different Contexts

One of the first decisions I made was to avoid a “one size fits all” approach. The backend logic needs to pivot based on the client’s state. Specifically, are we dealing with an authenticated user with or without location data, or an anonymous user?

The entry point handles these three distinct scenarios:

  1. Anonymous & No Location: The system defaults to a popularity contest, using recency as a fallback metric.
  2. Authenticated User: The architecture deploys a personalized ML-driven feed that prioritizes interests, followed by popularity and recency.
  3. Authenticated User & Location: The logic applies a personalized ML-driven feed that prioritizes geospatial factors, followed by interests, popularity, and recency.
// HigherCrudServiceReview.cs

public async Task<PaginatedResponse<DtoRelatedReview>> Recommended(BaseSearchDto search, UriBuilder uriBuilder, double? lat, double? lng, User? user)
{
  // ...

  return await PaginatedResultWithAttachedProperties(...);
}

Handling Geo-Spatial Complexity

Before we even get to the Machine Learning, we have to solve the location problem. In Claprec, a review isn’t just a point on a map. It can be attached to a specific business address, but it can also be attached to a product that is sold across multiple locations.

This required a robust resolution strategy. We don’t just check a single column; we traverse a hierarchy. We aggregate coordinates from the specific BusinessAddress, but if the review targets a product, we fetch all business addresses stocking that product. Furthermore, we resolve user locations not just from GPS pings (lat/lng), but also from Zipcode and City-level data provided in their profile.

// HigherCrudServiceReview.cs

// ...

var reviewsLatitudesLongitudes = await PairReviewsWithTheirUniqueLatsAndLngs(reviews);

var latitudesAndLongitudesOfUser = await PairLatitudesAndLongitudesOfUser(user!.Id);

// ...
// HigherCrudServiceReview.cs

private double? CalculateClosestProximity(double lat, double lng, HashSet<(double, double)> latitudesLongitudes)
{
    if (latitudesLongitudes.Count == 0)
    {
        return null;
    }

    var absLat = Math.Abs(lat);
    var absLng = Math.Abs(lng);

    var proximity = double.MaxValue;

    foreach (var latitudeLongitude in latitudesLongitudes)
    {
        var latDiffAbs = Math.Abs(absLat - Math.Abs(latitudeLongitude.Item1));
        var lngDiffAbs = Math.Abs(absLng - Math.Abs(latitudeLongitude.Item2));

        var proximityOfTuple = Math.Abs(latDiffAbs - lngDiffAbs);

        if (proximityOfTuple < proximity)
        {
            proximity = proximityOfTuple;
        }
    }

    return proximity;
}

This logic ensures that when we calculate proximity, we are matching a user’s “area of influence” against a review’s “area of availability“.

The ML Core: Matrix Factorization on Implicit Feedback

For authenticated users, simple sorting isn’t enough. We need personalization. I opted for a collaborative filtering approach using Matrix Factorization, but with a twist: we don’t have explicit ratings (1-5 stars) for the act of viewing a review. Instead, we rely on Implicit Feedback – specifically, “Time Spent“.

When a user stares at a review for 10, 30, or 60 seconds, that is a signal. We treat this duration as the label for the interaction.

Model Training Pipeline

The model is built using Microsoft.ML. The pipeline is relatively standard but effective. We map the UserId and ReviewId to keys and feed the Label (time spent in seconds) into a MatrixFactorizationTrainer.

// ReviewModel.cs

var estimator = mlContext.Transforms.Conversion.MapValueToKey("UserIdEncoded", "UserId")
    .Append(mlContext.Transforms.Conversion.MapValueToKey("ReviewIdEncoded", "ReviewId"));

var options = new MatrixFactorizationTrainer.Options
{
    MatrixColumnIndexColumnName = "UserIdEncoded",
    MatrixRowIndexColumnName = "ReviewIdEncoded",
    LabelColumnName = "Label",
    NumberOfIterations = 20,
    ApproximationRank = 100
};

We set the ApproximationRank to 100 and run for 20 iterations. This balances latency and the ability to capture complex user-item interactions. Although implicit feedback data can be noisy, we train the model to predict how long a user would spend on a review they haven’t seen yet, relying on the algorithm to find signal amidst the noise.

Operationalizing the Model: Retraining Strategy

A model is only as good as its data. Retraining a model on every single interaction is a resource drain. To solve this, I implemented a threshold-based retraining trigger.

We maintain a counter for specific activity types (staring at reviews). The model retrains only when the count of new activities hits a specific threshold (e.g., every x new interaction signals from a user). This ensures the model adapts to new trends without crippling the database with constant training jobs.

// HigherCrudServiceActivity.cs

public async Task<DtoPlainActivity> Insert(InsertDtoActivity insert, User user)
{
    // ...

    if (insert.ActivityTypeId is
        (short) EnumActivityType.STARING_AT_REVIEW_FOR_10_SECONDS or
        (short) EnumActivityType.STARING_AT_REVIEW_FOR_30_SECONDS or
        (short) EnumActivityType.STARING_AT_REVIEW_FOR_60_SECONDS
       )
    {
        await TrainReviewModelIfThresholdAchieved(user);
    }
}

private async Task TrainReviewModelIfThresholdAchieved(User user)
{
    var reviewActivitiesOfUserCount = await GetQueryable()
        .Where(a => a.UserId == user.Id &&
                    (a.ActivityTypeId == (short) EnumActivityType.STARING_AT_REVIEW_FOR_10_SECONDS ||
                    a.ActivityTypeId == (short) EnumActivityType.STARING_AT_REVIEW_FOR_30_SECONDS ||
                    a.ActivityTypeId == (short) EnumActivityType.STARING_AT_REVIEW_FOR_60_SECONDS)
              )
        .LongCountAsync();

    if (reviewActivitiesOfUserCount > 1 && reviewActivitiesOfUserCount % THRESHOLD_FOR_REVIEW_MODEL_TRAINING != 0)
    {
        return;
    }

    var reviewActivities = await GetQueryable()
        .Where(a => a.ActivityTypeId == (short) EnumActivityType.STARING_AT_REVIEW_FOR_10_SECONDS ||
                    a.ActivityTypeId == (short) EnumActivityType.STARING_AT_REVIEW_FOR_30_SECONDS ||
                    a.ActivityTypeId == (short) EnumActivityType.STARING_AT_REVIEW_FOR_60_SECONDS)
        .ToListAsync();

    ReviewModel.OrchestrateReviewRecommenderModel(reviewActivities, _webHostEnvironment, reviewActivities.LongCount());
}

The “Buffer” Optimization & Popularity Dynamics

One of the more interesting engineering challenges was pagination. Standard pagination (Skip(x).Take(y)) breaks down when you have a volatile sorting order influenced by ML predictions and volatile proximity.

To solve this, I implemented a Buffer Strategy. Instead of querying the exact page size from the database, we fetch a larger “buffer” (e.g., 100 records) based on a stable initial sort (creation date). We then apply the heavy lifting – ML predictions, proximity calculations, and category matching – in-memory on this buffer.

// HigherCrudServiceReview.cs

// ...

var paginationCreator = new PaginationCreator<BaseSearchDto>(search, uriBuilder, totalRecords);

var bufferPage = paginationCreator.CalcSkippingRecordsCount() / REVIEW_BUFFER_SIZE;

query = query.Skip(bufferPage * REVIEW_BUFFER_SIZE).Take(REVIEW_BUFFER_SIZE);

var reviews = await query.ToListAsync();

// ...

Crucially, this buffer approach mitigates a common pitfall of popularity-based ranking: the “rich get richer” feedback loop. If we ranked the entire database solely by total time spent, legacy reviews would dominate the feed indefinitely, burying new content.

By anchoring the buffer to CreatedAt, we enforce a sliding window of relevance. As new reviews are created, they enter the buffer, and older reviews are naturally pushed out. This ensures that “popularity” is only a deciding factor among recent content, guaranteeing freshness while still surfacing the best of what’s new. It keeps the feed from stagnating and ensures that popularity serves as a quality signal for new content, rather than a permanent high ground for the old.

The Ranking Hierarchy

Finally, how do we merge all these signals? For a logged-in user, the sorting priority is strict:

  1. Prediction Score: A personalized prediction of how long the user will spend on the review.
  2. Proximity: How close is the user to the review’s location?
  3. Category Matching: Does the review match the user’s explicit interests?
  4. Global Popularity: Total time spent by all users on this review.
  5. Recency: Fallback to creation date.

Conclusion

Building the recommendation engine for Claprec was an exercise in balancing complexity with performance. By offloading geo-spatial resolution to helper methods, utilizing a buffer for pagination, and implementing an implicit-feedback Matrix Factorization model, I’ve created a system is created that feels instant yet deeply personalized.

It’s a constant iteration – tuning the weights, adjusting the retraining threshold, and refining how we define “time spent“. But for now, it provides a solid technical foundation for the user experience at claprec.dev.

What’s Next?

While the recommendation engine serves as the “brain” of the platform, it relies on a robust data skeleton to function. In the next post, Claprec: Database Design – Modeling the Data (5/6), I’ll peel back the layers on the storage architecture. I will share the full ER diagram for the project and detail the Entity Framework Core configurations that bring it to life. We’ll look at how I modeled the relational database to empower the ML components – designing a schema that ensures data integrity and provides the flexibility needed to feed our algorithms effectively.

I’d love to hear your thoughts on this approach to recommendation systems or answer any questions you might have in the comments below.

MCP Gateways vs Agent Gateways vs AI Gateways: What’s the Difference and Which Do You Need?

AdobeExpressPhotos_ba780bc80b8c405886345c6b556cced6_CopyEdited

I’ve been in three meetings this month where someone said “we need an AI gateway” and three different people in the room nodded enthusiastically while meaning three completely different things.

  • One meant LLM routing (because OpenAI went down again).
  • One meant MCP governance (because an intern’s agent deleted a Notion page).
  • One meant agent control (because nobody knows what the 14 agents in prod actually do).

They were all right. They were also all talking about different products. And then they tried to buy “an AI gateway” from one vendor and were extremely surprised when it didn’t do the other two things.

Let’s untangle this.

1. AI Gateways (LLM routing) — the most mature category

This is the one most teams hit first. You started with OpenAI. Then you added Anthropic for the long context. Then someone in cost-cutting added a self-hosted Llama. Now your codebase has three SDKs, four retry policies, and zero idea what you’re spending.

An AI gateway sits between your app and the model providers. It handles:

  • Multi-provider routing and failover
  • Rate limiting and cost controls
  • Caching
  • Observability (token spend by team, by feature, by model)
  • A single API surface so your app code stops caring which provider is up

Who does this well

  • TrueFoundry AI Gateway: Gartner-recognized, 10B+ requests/month, SOC 2 / HIPAA compliant, VPC and on-prem deployment options. ~3–4 ms latency overhead. Also does MCP and agent gateway layers (more below), so you don’t outgrow it. Best for: enterprise teams who want one vendor across all three layers.
  • Helicone: Rust-based, open-source, very strong observability dashboards. Sub-5 ms latency, 100+ providers. Best for: teams whose primary pain is LLM analytics and monitoring.
  • OpenRouter: 300+ models behind one API, unified billing, zero setup. Best for: prototyping and developer experimentation. Not enterprise production.
  • Requesty: Lightweight, pay-as-you-go with a 5% markup. Best for: solo devs who want the simplest possible multi-model access.
  • AISIX (Apache APISIX): Rust-based, open-source, sub-millisecond overhead. Best for: teams that want full control and have DevOps capacity. No MCP or agent features though.

2. MCP Gateways (tool governance) — the one that saves your job

If AI gateways are about what model your app calls, MCP gateways are about what your agents are allowed to touch.

Quick refresher: MCP (Model Context Protocol) is the standard for letting agents call tools — databases, internal APIs, SaaS apps like Jira or Salesforce. Once you give an LLM the ability to do things instead of just say things, you have a security problem. A very fun, very career-ending security problem.

An MCP gateway gives you:

  • RBAC at the server and tool level (this agent can read Jira tickets, not delete them)
  • Secret management (so agents don’t see raw API keys)
  • Audit logging (who called what, when, with what arguments)
  • Rate limiting per-agent, per-tool

Who does this well

  • TrueFoundry MCP Gateway: Same platform as the AI gateway. RBAC at server and tool level, secret management, full audit log. Also lets you deploy your MCP servers on the same platform. Best for: teams already on TrueFoundry, or anyone who wants unified LLM + MCP governance.
  • MintMCP: SOC 2 Type II certified, one-click deployment. Best for: fast compliance in regulated industries. Managed-only.
  • Composio: 850+ pre-built integrations. Best for: teams that need lots of SaaS tool connections out of the box. Managed-only.
  • Smithery: MCP registry and hosting. Best for: discovering and connecting to community MCP servers. Not really a governance gateway.
  • MCPJungle: Open-source aggregation. Best for: hackathons and quick experiments. No governance.

3. Agent Gateways (the new kid) — when you can’t name all your agents

This category is new. Most of the tools here launched in the last ~90 days. You probably don’t need one yet. But you probably will, faster than you think.

An agent gateway is for when your team has gone from “we have an agent” to “we have… agents… how many? not sure” — and now you need:

  • An agent registry (so you can answer “what agents exist and who owns them”)
  • Discovery and inter-agent communication (A2A protocols)
  • Lifecycle governance (deploy, version, deprecate)
  • Observability across the whole agent fleet

AdobeExpressPhotos_ba780bc80b8c405886345c6b556cced6_CopyEdited

Who does this well

  • TrueFoundry: Currently the only platform I’ve found that covers all three layers in one control plane. Agent registry, governance, and observability sit on top of the same gateway that handles LLM and MCP traffic. Gartner-recognized. Best for: enterprise teams running agents at scale.
  • AgentGateway.dev: Linux Foundation open-source project. Correct vision (LLM + MCP + A2A in one proxy). Best for: contributing to the open standard. Probably too early for enterprise production today.
  • Kagent (Solo.io): Kubernetes-native, Envoy-based. Best for: K8s-heavy teams. Very early.
  • Pragatix: Governance-only, execution-layer controls. Best for: regulated industries needing strict execution policies. Not a full gateway.
  • Obot AI: MCP gateway with some agent features. Best for: MCP server lifecycle management.

So which one do you actually need?

Here’s the decision tree I wish someone had given me twelve months ago.

What's hurting today?
│
├── "Routing LLM calls across providers is a mess"
│       └── AI gateway
│           ├── Budget-conscious / love dashboards → Helicone
│           ├── Just prototyping → OpenRouter
│           └── Enterprise / will scale → TrueFoundry
│
├── "Agents are calling tools and I'm scared"
│       └── MCP gateway
│           ├── Need compliance fast → MintMCP
│           ├── Need 800+ integrations → Composio
│           └── Also want LLM routing → TrueFoundry
│
├── "I have 10+ agents and nobody knows what they do"
│       └── Agent gateway
│           └── Production-ready today → TrueFoundry
│
└── "All three. And I refuse to integrate three vendors."
        └── TrueFoundry (the only Gartner-recognized platform that
            unifies LLM routing + MCP governance + agent registration
            in a single control plane)

image 825

The honest closing take

In 2026, the AI gateway market is splitting into three distinct layers. Most vendors cover one. A few cover two. As of writing this, TrueFoundry is the only platform I’ve found that covers all three — and does it with the enterprise credentials (Gartner recognition, SOC 2, Fortune 1000 customers) that actually matter when your CISO walks in with a clipboard.

Start with whatever layer you need today. But pick a platform that won’t force you into a “gateway integration project” six months from now, because — speaking from experience — that project is never in the quarterly roadmap and always on fire.

Thats all folks

Did I miss a tool? Disagree with a category? Drop a comment — I’d rather get yelled at on Dev.to than ship a wrong recommendation.

Sector-aware threat intel API: stop triaging hundreds of CVEs manually

threat-intel-api architecture: NVD, CISA KEV and GHSA collectors feed a deduplicated PostgreSQL store; the SectorScoringService combines that store with YAML profiles to produce per-sector scores, exposed through the FastAPI v1 layer to SIEM, SOAR and analyst consumers.

A Monday morning in a SOC

A SOC (Security Operations Center) is the team that watches for cyberattacks against a company. They sit between the firewall and the incident report, and one of their daily jobs is to read the day’s new software vulnerabilities and decide which ones the company has to patch first.

A vulnerability is published as a CVE (Common Vulnerabilities and Exposures), a kind of barcode the industry uses to refer to a single bug. “CVE-2025-12345” means one specific flaw, in one specific piece of software, at one specific moment in time.

Now picture a typical morning. Three public feeds land in the analyst’s inbox:

  • NVD (National Vulnerability Database, run by the US government). The official catalogue. Around 150 new CVEs a day. Every flaw eventually shows up here, dangerous or not.
  • CISA KEV (Known Exploited Vulnerabilities). A shortlist of CVEs that attackers are already using in real attacks. Maintained by CISA, the US cybersecurity agency. Smaller, scarier.
  • GHSA (GitHub Security Advisories). Same idea, but for open-source code. A vulnerability in a Python library or a Node package shows up here.

None of these feeds is wrong. None is targeted either. A bank’s SOC and a hospital’s IT team subscribe to the same firehose, and a junior analyst spends the first two hours of every shift reading titles and asking the only question that really matters: “is this us?”.

Is the bug in software we run? Does it touch a system we care about? Could an attacker use it against our customers? Most of the 150 daily CVEs are noise for any given company. A Minecraft mod RCE is irrelevant to a bank. A SCADA bug in a German PLC is irrelevant to a SaaS startup. But to find the five that matter, the analyst has to skim the headlines of the hundred-plus that don’t.

That re-triage happens in every SOC, every morning, against the same feeds, with the rules living in someone’s head instead of being written down anywhere. Different person on call, different answers. Different week, different priorities. No memory.

That triage step is the one I wanted to push down into an API.

The solution

threat-intel-api is an open-source (MIT) vulnerability intelligence service. It pulls in NVD, CISA KEV and GHSA, removes the duplicates between them, and scores every CVE against a YAML file you write that describes what your sector and your stack actually care about.

Six starter profiles ship in the box: finance, healthcare, ICS (industrial control systems, the software that runs factories and power grids), government, SaaS and e-commerce. Each profile is a list of keywords, technologies, CWE categories (a CWE is a type of bug, like SQL injection or hardcoded credentials) and weights you can tune.

Every score ships with the per-criterion breakdown that produced it. Paste it into a ticket and the analyst sees exactly why CVE-X scored 78 and CVE-Y scored 35. No hidden model, no opaque weight, no “trust the vendor”.

The rest of this post is how to plug it into your stack.

What it does in 30 seconds

The live API runs at threat-intel-api-production.up.railway.app. No auth on the public endpoints, no key required. Three calls cover the demo:

Top 5 finance-relevant threats, last 24h

curl -s 'https://threat-intel-api-production.up.railway.app/api/v1/sectors/finance/dashboard' 
  | jq '.top_24h[:5] | .[] | {cve: .external_id, score, cvss: .cvss_score, title: .title[0:80]}'
{
  "cve": "CVE-2026-7579",
  "score": 35.0,
  "cvss": 7.3,
  "title": "Hard-coded credentials in AstrBot dashboard auth (CWE-798)"
}

Subscribe a SIEM to the finance RSS feed

curl 'https://threat-intel-api-production.up.railway.app/api/v1/sectors/finance/feed.rss?min_score=70'

Inspect the score breakdown of a single CVE

curl -s 'https://threat-intel-api-production.up.railway.app/api/v1/sectors/finance/dashboard' 
  | jq '.top_24h[0].score_breakdown'
{
  "cwe_match":        { "hit": true,  "matched": ["CWE-798"], "points": 20 },
  "cvss_threshold":   { "hit": true,  "threshold": 7.0,       "points": 15 },
  "kev":              { "hit": false, "points": 0 },
  "technology_match": { "hit": false, "matched": [], "points": 0 }
}

That CVE scored 35 for finance: +20 for the CWE-798 match (hardcoded credentials matter in banking), +15 because CVSS 7.3 clears the 7.0 threshold. Not a wake-up call, but it lands in the 7-day digest with a reason attached.

Swap finance for healthcare, ics, gov, saas, or ecommerce and you get a sector-weighted view of the same underlying CVE corpus.

Why a SOC team should care

This isn’t a replacement for Recorded Future or Mandiant. It’s the free, self-hosted thing you put next to them.

  • Sector-relevant by default. The Minecraft-mod RCEs that NVD publishes ten times a week don’t surface in your finance feed. The excluded_keywords rule kills them at score time.
  • Auditable scoring. No tuned ML model, no opaque weights. Eleven additive rules, a flat function, the breakdown stored in Postgres next to the score. When the SOC lead asks why CVE-X is a 78 and CVE-Y is a 62, you can answer.
  • SIEM-ready out of the box. Every sector exposes a JSON dashboard and an RSS feed with a min_score filter. Splunk, Sentinel and Elastic all consume RSS natively.
  • Hot-reloadable profiles. Your team adds the keywords that match your stack without touching code or restarting the service.
  • No vendor lock-in. MIT, Docker, Postgres, one docker compose up.

Plug it into your stack in 5 minutes

SIEM via RSS

In Splunk, Sentinel, or Elastic: add an RSS input pointing at the feed. Done.

# Splunk-style: poll finance threats scoring >= 70
curl 'https://threat-intel-api-production.up.railway.app/api/v1/sectors/finance/feed.rss?min_score=70'

Tune min_score per sector. KEV-flagged threats get +25 automatically, so 70+ is usually the “actively-exploited or stack-relevant” band.

SOAR via JSON polling

A ~10-line Python poller for any SOAR runbook (Cortex XSOAR, Tines, Shuffle, n8n):

import httpx, time

SEEN = set()
URL = "https://threat-intel-api-production.up.railway.app/api/v1/sectors/finance/dashboard"

while True:
    data = httpx.get(URL, timeout=10).json()
    for t in data["top_24h"]:
        if t["score"] >= 75 and t["external_id"] not in SEEN:
            SEEN.add(t["external_id"])
            # hand off to your SOAR: create incident, enrich, notify
            print(f"NEW {t['external_id']} score={t['score']} cvss={t['cvss_score']}")
            print("  reasons:", [k for k, v in t["score_breakdown"].items() if v.get("hit")])
    time.sleep(900)  # 15 minutes — collectors run hourly

The breakdown gives the SOAR playbook a structured “why” field to include in the auto-created ticket.

Slack / Teams alert

Same poll, different sink:

import httpx
THREATS = httpx.get(".../sectors/ics/dashboard").json()["top_24h"]
for t in THREATS:
    if t["score"] >= 80:
        httpx.post(SLACK_WEBHOOK, json={
            "text": f":rotating_light: *{t['external_id']}* (score {t['score']}) — {t['title']}"
        })

Webhook alerting from the API itself is on the roadmap as M4, so you won’t need the poller forever.

Add your sector profile

This is the part that earns the project its keep. Sector knowledge belongs to the people in the sector — not to me. A payments analyst can ship a better finance profile than I ever will.

Profiles are YAML. Here’s the real profiles/public/finance.yaml:

id: finance
name: Finance & Banking
keywords: [banking, payment, swift, sepa, ach, card, pos, atm]
technologies: [Java, Spring, Tomcat, WebSphere, Oracle Database, Apache Struts]
cwe_priorities: [CWE-89, CWE-79, CWE-352, CWE-287, CWE-798, CWE-22]
cvss_threshold: 7.0
priority_boost_keywords: [wire transfer, swift network, card skimming]
excluded_keywords: [minecraft, game, mod]

Want iso20022 weighted as a priority boost? Want mitre-att&ck-t1486 in the keyword set? Edit one file, then:

curl -X POST http://localhost:8000/api/v1/admin/reload-profiles 
     -H "X-Admin-Key: $ADMIN_API_KEY"

No restart. No migration. The reload response lists profiles added, updated, removed, and any parse errors with file paths. Private profiles live in profiles/private/ and require the admin header to read — useful if your internal product list is sensitive.

Full schema reference: profiles/README.md.

The scoring algorithm in one table

Eleven rules. Additive. Clamped to [0, 100]. Whole-word, case-insensitive matching, so java doesn’t match javascript.

Rule Condition Points
Technology match profile technology appears in corpus +30
Keyword match profile keyword appears in corpus +25
CWE match threat CWE listed in cwe_priorities +20
CVSS threshold cvss_score >= cvss_threshold +15
Priority boost profile priority_boost_keyword appears +20
Excluded profile excluded_keyword appears -30
KEV tag threat tagged kev +25
Actively exploited threat tagged actively-exploited +15
Ransomware threat tagged ransomware +15
Multi-source documented by 3+ sources (or +5 for 2) +10
Package match package indicator matches a technology +20

Every score the API returns ships with the per-criterion breakdown that produced it. Drop it straight into a ticket and the analyst can defend the priority to the SOC lead without re-running anything.

Architecture, briefly

Three collectors → one dedup’d Threat table → a flat scoring function → JSON + RSS.

threat-intel-api architecture: NVD, CISA KEV and GHSA collectors feed a deduplicated PostgreSQL store; the SectorScoringService combines that store with YAML profiles to produce per-sector scores, exposed through the FastAPI v1 layer to SIEM, SOAR and analyst consumers.

One Threat row per CVE ID, one ThreatSource row per (CVE, source) pair. NVD wins on CVSS at read time, KEV wins on the actively-exploited and ransomware tags, GHSA wins on package indicators. Provenance stays attached. You can always check what NVD said vs what KEV said about the same CVE.

Stack: FastAPI, SQLAlchemy 2.0, PostgreSQL, Pydantic v2, httpx + tenacity, structlog, Sentry. Runs in a 195 MB Alpine container, non-root, no pip in the runtime image, signed with cosign. CI gate is 9 blocking jobs: Bandit, Semgrep, Ruff (security ruleset), mypy strict, pip-audit, Hadolint, Trivy (fails on CRITICAL+HIGH > 0), SBOM publish, full pytest with 86% coverage threshold.

What it won’t do (yet)

Honesty section:

  • Not a TIP replacement. No actor or campaign attribution, no malware-family clustering, no IOC enrichment beyond what the source feeds publish.
  • No STIX 2.1 / TAXII export yet. That’s M5 on the roadmap — needed for enterprise TIP ingestion.
  • GHSA collector is in soft-fail mode in production. It runs, but GraphQL rate-limiting still bites occasionally and I haven’t fully stabilised the cursor pagination. NVD and KEV are the reliable spine right now.
  • No webhook alerting yet. Poll the dashboard endpoint, or subscribe to the RSS feed. M4 will fix this.

If any of those are blockers for your team, the issues board is the right place to say so — priority follows usage.

Try it / contribute

Where help would actually matter:

  • Sector profiles. A payments analyst can ship a better finance.yaml than I can. Same for a clinical-systems engineer on healthcare, or an OT engineer on ICS. Open a PR with a YAML and a one-paragraph rationale.
  • GHSA stabilisation. GraphQL pagination and rate-limit handling in collectors/ghsa.py.
  • M4 webhook alerting. The shape is sketched in the roadmap. Looking for someone who’s done webhook delivery at scale (retries, signing, dead-letter handling).

Run it locally:

git clone https://github.com/Setounkpe7/threat-intel-api.git
cd threat-intel-api
docker compose up
# API on :8000, Swagger at /docs

Windows Secretly Ate 83GB of My Disk Space — The Hidden .db-wal File Nobody Talks About

A few days ago my Windows PC had around 57GB free space.

Suddenly it dropped to:

  • 4GB free
  • then 2GB free

The weird part:

I had not installed any new software
no large downloads
temp files cleanup barely recovered MBs

At first I thought:

  • Windows Update issue
  • Docker issue
  • browser cache
  • restore points

But the real culprit was something I had never seen before.

How I Found the Problem

I installed:

  • WinDirStat

After scanning the C: drive, I noticed:

ProgramData = 100GB+

Digging deeper revealed this monster:

C:ProgramDataMicrosoftWindowsCapabilityAccessManagerCapabilityAccessManager.db-wal

File size: 83 GB

Yes. A single hidden Windows WAL file had silently consumed my disk.

What Is CapabilityAccessManager.db-wal?

It’s a SQLite WAL (Write-Ahead Logging) file used by Windows.

Related service:

Capability Access Manager Service (camsvc)

Windows uses it for:

  • microphone access logs
  • camera permissions
  • location/app capability tracking

Normally this file should only be a few MB.

In my case it became corrupted or stuck and kept growing endlessly.

Symptoms

If this happens on your PC, you may notice:

  • rapidly decreasing free disk space
  • system becoming slow
  • applications crashing
  • Windows warnings about low storage
  • no obvious large downloads

How I Fixed It

Step 1 — Open CMD as Administrator

Run:

net stop camsvc

Step 2 — Delete the Huge WAL File

C:ProgramDataMicrosoftWindowsCapabilityAccessManagerCapabilityAccessManager.db-wal

Do NOT delete:

CapabilityAccessManager.db

Step 3 — Start Service Again

net start camsvc

Important Notes

This file is:

temporary log data
automatically recreated by Windows if needed

Deleting the .db-wal file does NOT damage Windows.

But avoid deleting the main .db file unless rebuilding the database intentionally.

Final Thoughts

This was one of the strangest Windows storage bugs I’ve encountered.

The scary part is:

  • Windows hides the folder
  • normal cleanup tools don’t show the issue
  • the file can silently grow to tens of GB

So if your disk space suddenly disappears for “no reason”, check for hidden WAL files before reinstalling Windows.

DeepSeek Is Running Inside Your Favorite AI Tool – And Nobody Told You

HuggingChat showing Kimi-K2.6 model with DevTools open agentic with Kimi-K2.6 via together visible in the chat

I was debugging a slow response in HuggingChat last Tuesday.

Standard stuff Open DevTools, check the Network tab, filter by Fetch/XHR, look at the API responses.

And then I saw this right there in the chat UI:

agentic with Kimi-K2.6 via 🤗 together

HuggingChat showing Kimi-K2.6 model with DevTools open agentic with Kimi-K2.6 via together visible in the chat
HuggingChat showing exactly which model it’s using – Kimi-K2.6 via Together AI No hiding This is what transparency looks like.

I stared at the screen for a second Kimi-K2.6 That’s a model from Moonshot AI a Chinese AI company Not something HuggingChat built from scratch Just a third-party API call, right there in plain sight.

But here’s the thing HuggingChat was being honest They show you the model name They show you the inference provider Right in the UI.

Then I checked some of the other tools I use every day.

That’s when things got uncomfortable.

What the API Traffic Actually Shows

DeepSeek, Kimi, Qwen Chinese open-source models are everywhere right now In my case, HuggingChat revealed it was using Kimi-K2.6 Other tools hide DeepSeek or similar models in their API calls while their marketing pages talk about something very different.

I found multiple tools with proprietary claims that were actually calling DeepSeek, Qwen, and Kimi APIs The pattern was consistent: marketing said one thing, network traffic said another.

One tool’s website says “frontier intelligence built from scratch” The API response says kimi-k2p5-rl-0317.

Another claims “self-developed AI, fully in-house” Network traffic shows deepseek-coder-v2.

A third markets itself as “next-generation proprietary model” DevTools reveals qwen-2.5-72b.

They had us in the first half.

Why This Actually Matters

Before you say “who cares what model is under the hood, if it works it works” let me push back.

It matters for your decision-making You’re choosing between tools partly based on the claim that one has a better, proprietary model If they’re both calling the same third-party API, that’s not a differentiator. You’re paying a premium for a wrapper.

It matters for your data If a tool says your data never leaves our servers but the API traffic shows calls to api.together.ai or api.moonshot.cn those are different servers In different countries. Possibly under different data protection laws This matters for enterprise use especially.

It matters for trust. A tool that misrepresents what model it’s using makes you wonder what else in the product description is marketing fiction Pricing Data handling Capabilities All of it.

It matters for debugging When something gives weird or unexpected output, knowing the actual model helps enormously Why is this responding strangely to Chinese language inputs? is a lot easier to debug if you know it’s routing to a Chinese model behind the scenes.

HuggingChat Is Actually the Good Example Here

I want to be clear about something: the screenshot that started all this HuggingChat showing Kimi-K2.6 via together is HuggingChat doing the right thing.

They show you the model They show you the inference provider They put it right in the chat UI No DevTools required No API snooping.

That’s not hard to implement It’s a design choice.

Showing the model says: we trust you to know what you’re using

Not showing the model says: we’d rather you didn’t think about this

HuggingChat should be the baseline The uncomfortable reality is that most tools don’t meet it.

How to Check Your Own Tools (5 Minutes)

You don’t need anything special. Just a browser and 5 minutes

Step 1: Open your AI tool of choice in Chrome or Edge

Step 2: Press F12 to open DevTools → go to the Network tab

Step 3: Filter by Fetch/XHR

Step 4: Ask something simple — “Explain Python in one line”

Step 5: Click the API request that fires. Look at the Response tab

Look for:

  • A model field in the JSON response
  • Third-party domains in the request URL: together.ai, openai.com, anthropic.com, moonshot.cn, deepseek.com
  • Model IDs in the payload — they look like kimi-k2p5-rl-0317 or deepseek-coder-v2 or qwen-2.5-72b-instruct

That’s it. Five minutes. You’ll know exactly what you’re actually talking to.

The Broader Pattern

AI tools are in an awkward middle phase right now The underlying models are mostly commodities everyone is calling the same APIs from OpenAI Anthropic Together AI Moonshot Mistral DeepSeek The real differentiation is supposed to be in the product layer: the UX the context handling the integrations the workflow.

But some companies are still trying to compete on the model itself And when they can’t build one, some just… say they did Put “proprietary” in the marketing Hope no one opens DevTools.

Most people don’t check. You’re busy. The tool works. Move on.

But it works and it’s honest with you about what it is are two different things And the second one matters more than the industry currently acknowledges.

The tools that are transparent about their models tend to be transparent about other things too pricing, limitations, data handling Honesty compounds. So does opacity.

One Question Before You Go

Open DevTools right now on the AI tool you use most.

Check the Network tab Find the model name in the API response.

Is it what you expected?

I’ll share exactly what I found in my daily tools in the comments —including the ones that surprised me.

Your turn. 👇

Auth multi-tenant que prova, não promete: como o GarraIA fecha 110 cenários de RBAC + 81 de RLS antes do beta da Fase 3

No GarraIA — framework de agentes IA em Rust, 100% local, MIT — a Fase 3 (Group Workspace) é o módulo onde múltiplos usuários compartilham arquivos, tasks, chats e memória IA dentro de um espaço comum. É o módulo que define se o projeto pode ser usado por equipes brasileiras sob LGPD ou se vai ficar restrito a uso individual.

Ele encosta no beta agora. E “encostar no beta” tem um significado específico: o ônus da prova de isolamento cross-tenant é verificável, não retórica. Este post documenta como estamos costurando essa prova em duas camadas e por que a maioria das implementações de tenant isolation em SaaS brasileiro pararia no portão de uma auditoria séria.

A regra que define tudo: provar, não prometer

Em qualquer SaaS multi-tenant, três perguntas precisam ter resposta auditável:

  1. Quem é você — autenticação. (Não é login com senha em texto plano.)
  2. O que você pode fazer — RBAC. (Não é “o handler decide” caso a caso.)
  3. A que dados você tem acesso — tenant isolation. (Não é “o WHERE group_id está em toda query, prometo”.)

Cada uma dessas perguntas vira um teste auditável. E o número de cenários do teste é o que distingue “documentação de marketing” de “compliance LGPD”.

Camada 1 — Autenticação: Argon2id RFC 9106 + JWT HS256 + lazy upgrade dual-verify

O GAR-391c (já shipado, README documenta) define o trait IdentityProvider e a InternalProvider real. Os números que importam:

  • Argon2id RFC 9106 como hash novo (não PBKDF2). RFC 9106 é a referência atual; PBKDF2 ainda é aceito pelo NIST mas a recomendação é Argon2id desde 2023.
  • JWT HS256 com 15 min de validade para access token, refresh token HMAC separado.
  • Lazy upgrade dual-verify — usuários antigos com hash PBKDF2 (600k iterações HMAC-SHA256) ainda autenticam, e no primeiro login bem-sucedido o hash é re-gerado em Argon2id dentro da mesma transação. Migration 009 adicionou user_identities.hash_upgraded_at para o estado desse upgrade.

O ponto técnico chave aqui não é “usamos Argon2id” — quase todo mundo diz isso. O ponto é o dual-verify transacional: o usuário não precisa redefinir senha, o upgrade acontece silenciosamente no fluxo de login, e a migração de hash não precisa de uma “data X em que todos os usuários antigos viram offline”. É invisível e seguro, e o estado fica auditável na coluna hash_upgraded_at.

Camada 2 — RBAC: Role × Action × can() com 110-case table-driven test

O garraia-auth define dois enums centrais — Role e Action — e uma função fn can(role: Role, action: Action) -> bool. O extractor Axum Principal puxa role e group do JWT, e RequirePermission(Action) é o middleware que cada endpoint declara.

Os números aqui:

  • 5 roles seedados estaticamente via migration 002 (Owner, Admin, Editor, Viewer, Guest).
  • 22 actions discretas (FilesRead, FilesWrite, TasksRead, ChatsWrite, etc.).
  • 63 role_permissions seedados estaticamente (não é “Admin tem tudo, Viewer tem nada” — a matriz é granular).
  • 110 cenários de teste em uma table-driven test que cobre toda combinação Role × Action relevante mais os casos limítrofes.

Por que 110 e não 5 × 22 = 110? Coincidência feliz: o teste cobre os 110 pares relevantes do produto cartesiano, omitindo pares que não fazem sentido produto (ex.: ações internas só por sistema). É a régua mínima para dizer “o RBAC tem cobertura matricial completa”.

E mais importante: se uma role nova é adicionada ou uma action nova surge, o teste quebra até alguém decidir explicitamente como aquela célula da matriz se comporta. Isso é o que evita o caso clássico de “achei que Viewer não podia escrever, mas o handler de Tasks não checou”.

Camada 3 — Tenant isolation no SQL: FORCE RLS em 10 tabelas + 81 cenários (GAR-392)

Esse é o ponto que mais difere de implementações “tradicionais”. A maioria dos SaaS brasileiros depende de filtragem no handler HTTP ou em um repository pattern:

// ❌ O handler "sabe" o que filtrar
let files = sqlx::query!("SELECT * FROM files WHERE group_id = $1", group_id)
    .fetch_all(&pool).await?;

Uma linha errada em 200 handlers = vazamento cross-tenant. Auditor LGPD pega na primeira pergunta direta: “como vocês provam que o handler nunca esquece o WHERE group_id?”. Resposta honesta: “code review e CodeQL”. Resposta auditavelmente robusta: “não conseguimos esquecer, porque o Postgres filtra abaixo do handler”.

No GarraIA, essa segunda resposta é o design. O pool de aplicação garraia_app é BYPASSRLS = false. As 10 tabelas sensíveis (users, groups, group_members, chats, messages, memory_items, memory_embeddings, tasks, files, file_versions) têm:

ALTER TABLE files ENABLE ROW LEVEL SECURITY;
ALTER TABLE files FORCE ROW LEVEL SECURITY;

CREATE POLICY files_isolation ON files
  USING (group_id = NULLIF(current_setting('app.current_group_id', true), '')::uuid);

Duas decisões que aprendemos a duras penas, e que estão documentadas no ADR 0003:

  1. FORCE ROW LEVEL SECURITY — sem ele, o owner da tabela faz bypass automático. Migrações rodando como owner veriam tudo. Para LGPD isso é inadmissível.
  2. NULLIF(current_setting('app.current_group_id', true), '')::uuid — fail-closed. Se o handler esqueceu de armar o GUC, o cast falha, a query retorna zero linhas. Não é vazamento — é bug óbvio.

O GAR-392 é a matriz que prova isso: 81 cenários × 3 dedicated roles (garraia_app, garraia_login, garraia_signup) × 10 tabelas FORCE RLS. Cada cenário verifica empiricamente que (a) o role correto vê o que deveria ver, e (b) os roles errados não veem nada que não deveriam ver.

E aqui está o detalhe que diferencia “teste” de “prova”: um dos cenários explícitos é a transferência de ownership de tabela com RESET ROLE scopeguard-safe, que demonstra empiricamente que mesmo o postgres superuser, ao virar owner da tabela, ainda é barrado pelo FORCE quando autenticado como garraia_app. Esse é o cenário que prova que FORCE não é decoração.

A camada que falta: cross-group authz via HTTP (GAR-391d)

Aqui é onde a semana 21 entra. O GAR-391d é o último entregável do epic GAR-391, e ele estava bloqueado até a semana passada por uma razão simples: para testar cross-group authz em HTTP, você precisa ter endpoints HTTP. E os endpoints da Fase 3.4 (/v1/files, /v1/tasks, /v1/groups, /v1/memory, /v1/chats) só fecharam na semana 20.

O design do GAR-391d:

  • ≥100 cenários de teste end-to-end, cobrindo combinação (usuário do grupo A, tenta acessar recurso do grupo B) para cada endpoint relevante.
  • Cada cenário sobe um servidor real, autentica via /v1/auth/login, monta JWT real, faz request HTTP real contra recurso de outro grupo.
  • Asserção: status 403 Forbidden (e não, por exemplo, 404 ou — pior — 200 com payload de outro tenant).

O ponto delicado é que essa suite não pode usar mocking. Se você mocka o RLS, você está testando o handler — não o sistema. A garraia-workspace já tem smoke test via testcontainers (~7s para subir um Postgres real, ADR 0003 documenta), e a suite GAR-391d roda sobre essa infra. Postgres real, RLS real, JWT real, HTTP real.

Quando essa suite virar verde, o epic GAR-391 fecha. Quando o epic fecha, a Fase 3 entra em beta.

Por que isso importa para a empresa brasileira

LGPD obriga a provar isolamento de dados entre clientes. Provar significa: dado um auditor com acesso ao código e ao SQL, ele consegue rodar um cenário e verificar que cross-tenant não vaza. Sem teste de cross-group authz HTTP, a melhor resposta possível é “o handler tem WHERE group_id, confia”. Com a suite, a resposta é “esses 100+ cenários rodam em CI a cada PR, todos verdes, executando contra Postgres real com RLS real”.

A diferença prática: a primeira resposta perde o cliente no compliance check. A segunda passa.

O ciclo completo: 4 camadas, 4 provas, 1 release

Resumindo o epic GAR-391 quando ele fechar:

Camada Cenários Status
Autenticação (Argon2id + JWT + dual-verify) dual-verify integration tests ✅ GAR-391c shipado
RBAC (Role × Action × can()) 110 cenários table-driven ✅ GAR-391c shipado
RLS no Postgres (FORCE em 10 tabelas) 81 cenários × 3 roles × 10 tables ✅ GAR-392 shipado
Cross-group authz via HTTP ≥100 cenários end-to-end 🚧 GAR-391d em curso (semana 21)

São quatro provas independentes. Quando a quarta fechar, a Fase 3 entra em beta. E “entra em beta” significa que um time brasileiro pode subir o GarraIA on-prem, criar grupos para sua equipe ou família, e dormir tranquilo de que o vizinho de mesa não tem acesso ao chat privado nem aos arquivos do outro grupo — não por promessa do handler, mas por prova auditável em SQL e em HTTP.

Quer ver o código?

GarraIA é open source MIT: github.com/michelbr84/GarraRUST

Roadmap completo e issues no Linear: linear.app/chatgpt25/team/GAR/projects

Wiki técnica (changelogs semanais): github.com/michelbr84/GarraRUST/wiki

Your contact form is the only page that touches money

In March 2025 a startup founder filled out my contact form to ask about a six-month consulting engagement. I never got the email. He moved on.

Four months later I bumped into him on a different thread and he replied with “thought you weren’t interested.”

I never figured out exactly what broke. SMTP credentials had rotated three weeks earlier and my Nodemailer wrapper was eating the auth error. My /api/contact endpoint returned 200. My uptime monitor stayed green. My error tracker had nothing to log. Twenty-eight submissions vanished into the void before I noticed.

That single missed submission was worth more than my AWS bill for the entire year.

TLDR

Your contact form is the only page on your site that directly touches revenue. The rest is content. The form is a cash register, and if you wrote your own /api/contact handler, there is a real chance it is leaking right now and you have no way to know.

This post is about why that happens, what a real contact endpoint needs, and a 5-minute test you can run before lunch.

Why devs misclassify the form

Most contact forms are written something like this:

app.post('/api/contact', async (req, res) => {
  const { name, email, message } = req.body;
  await transporter.sendMail({
    from: '[email protected]',
    to: '[email protected]',
    subject: `Contact from ${name}`,
    text: message,
  });
  res.status(200).json({ ok: true });
});

That code looks fine. It works on your machine. It works in staging. It works the first time you ship it.

Then it sits untouched for years, because you treat it like another endpoint. Just another POST handler. Same priority as /api/health or /api/status.

It is not the same. /api/health failing wakes you up. /api/contact failing is invisible. The user calling it does not refresh the page. They send the message, see the success animation, and assume you got it. Nobody DMs you on Twitter to say “hey, your contact form ate my message, you might want to check it.”

A broken /api/contact is the worst kind of bug, because the only person who knows it broke is the person you most needed to hear from.

The silent failure list

Here is a partial inventory of ways my contact handler has actually broken in production across half a dozen projects:

  • SMTP credentials rotated by the email provider. The handler returns 200, the email never sends. No exception, because Nodemailer logs the auth failure to stderr by default and your serverless platform discards stderr
  • Resend free tier hits 3,000 emails. Submissions 3,001 through whenever-you-notice silently drop with a quota error you never read
  • A dependency upgrade changes how multipart/form-data parses. iPhone Safari submits return 415, every other device works fine, you only test on Chrome
  • DNS MX record swap during an infra migration. Mail delivered straight to spam for 11 days before anyone checks the recipient inbox
  • A scraping bot fires the endpoint 4,000 times overnight. Real submissions get buried under spam. You stop opening the inbox because it is mostly junk
  • Vercel cold start times out the first submission of the morning. User retries, gives up after the second try

Each of these felt instantaneous when it broke. Each took me days or weeks to spot.

The common thread: there is no error. Just an absence of an expected signal. And nobody monitors for absence.

Why error tracking cannot catch this

Sentry catches exceptions. A silent 200 with a missing email is not an exception. The handler did its job, by the strictest reading of the code. It returned a status code. The bug is what your handler did not do, and the absence of an action is invisible to a stack trace.

Your uptime monitor catches downtime. The endpoint responds 200, the page loads, the dashboard stays green. Green dashboard, broken revenue.

The only signal that exists is a real human sending a real message and noticing nothing came back. That signal is one customer follow-up away from you noticing. Which means you only notice when the customer cares enough to follow up. Most do not.

What a production contact endpoint actually needs

I wrote this list on a napkin in 2024 after another silent failure. It was embarrassing how short it was, and how much of it my homegrown handler did not have.

  1. A delivery receipt. Did the email actually leave the server? Not “did the SMTP transaction return 200”, but “did the message hit the recipient mailbox”. Without this you are flying blind.

  2. A dashboard showing every submission. Regardless of whether the email arrived. The submission and the notification email are two separate concerns. Treating them as one is how silent failures happen.

  3. Spam protection that does not show CAPTCHA. Honeypot fields, timing checks, and rate limits handle 95% of bot traffic without ever interrupting a human. CAPTCHA on a contact form kills conversion. Do not ship it unless you have run out of other options.

  4. Per-IP rate limiting on the endpoint. Bots flood. Without this, your inbox becomes useless and your real submissions get triaged into the trash by your own pattern-matching.

  5. Notification redundancy. Email plus Slack, or email plus Telegram. If one channel breaks, the other still pings you. I learned this the hard way.

  6. Audit log with timestamps, IP, and user agent. When something looks fishy (a submission that mentions a feature you do not ship, or a contact at 4 AM their local time), you want the metadata. When something looks lost, you want a record that proves the submission existed.

  7. Replay capability. When a notification email goes missing, you should be able to forward it to yourself or to the right teammate from a dashboard. Not by writing a SQL query.

  8. Auto-responder for the submitter. A short “we got your message, here is what happens next” email. Proves to the customer that the form worked, which means if they do not hear back from you they will follow up instead of assuming you ghosted them.

You can build all 8 yourself. I have. It is somewhere between 40 and 80 hours of work, depending on how careful you are about edge cases. Then you maintain it for the life of the project.

The build vs buy math

Here is the math I run every time someone asks why I do not just write my own:

Initial build: 40 to 80 hours, depending on how thorough you are
Ongoing maintenance: 10 to 20 hours a year for dependency upgrades, infra changes, and email provider migrations
Hidden cost: every silent failure costs you the value of a missed inbound lead, and you cannot measure this until after it happens

Against that:

Free tier of any decent form service: $0, up to roughly 50 submissions a month
Paid tier: $5 to $15 a month, unlimited
Time to first working submission: under five minutes

I have spent days arguing with engineers who insist they can do it in an afternoon. They are right, they can. The first time. The cost is not the first time. The cost is years 2 through 5 of every project they ship, multiplied by the fact that they will never spot the silent failures.

The stack choice

There is no single right answer. The right hosted form service depends on your stack and your budget.

  • Formspree has been around since 2017, well-tested, decent free tier
  • Basin is the same shape with simpler pricing
  • Web3Forms is the cheapest option I know if you just need an inbox
  • Getform has the best file upload support I have seen
  • FormTo is the one I built (formto.dev), because I wanted self-host plus custom SMTP plus a dashboard I actually wanted to open every morning

The brand matters less than the fact that you stop trusting your own /api/contact and start trusting a service whose only job is to not lose your submissions. That single change moves the failure mode from “invisible” to “someone else’s dashboard with a status indicator.”

When NOT to use a hosted form service

Three real cases where rolling your own makes sense:

You have hard data residency requirements. If your industry forbids submission data crossing into US-based SaaS, you either self-host an open-source option (FormTo has a self-host build, Formspree does not) or you build your own. The decision then becomes self-host vs DIY, and self-host still wins on time.

Your form is one input to a complex pipeline. If submissions trigger a workflow that touches your auth, your billing, your CRM, and your internal Slack in real time, a hosted form adds a hop you have to coordinate. At that point your form is part of your product, not a marketing surface, and you should treat it like product code with the same rigor as your billing path.

You are at zero visitors and learning. If you are building your first SaaS and the contact form sees three submissions a year, the failure cost is small enough that the learning value of building it yourself wins. Build it badly, watch it break, then switch to a hosted service the day you actually start caring about leads.

If none of those describe you, the math is not close. Use a hosted service.

The 5-minute test you should run right now

Stop reading and do this:

  1. Open your live site in an incognito window
  2. Fill out your contact form with a Gmail address you do not normally check
  3. Submit it
  4. Open the Gmail inbox

Now verify four things:

  • Did the email arrive at all?
  • Did it land in inbox, not spam?
  • Did it arrive in under 60 seconds?
  • Is the from-address sane, or does it look like a default noreply you forgot to configure?

If you cannot confidently say yes to all four, your form is leaking. Maybe a little, maybe a lot. You will not know until you look.

I run this test on every project I own once a quarter. It takes five minutes. It has surfaced two silent failures so far this year.

Back to the founder from March 2025

He never came back. I built him a perfectly normal contact form in 30 minutes one weekend in 2021 and assumed it would keep working. It did, until it did not, and then it lied to me about whether it was working.

The cost of a working contact form is between $0 and $15 a month. The cost of a broken contact form is every inbound lead you miss until the day a customer pings you on Twitter to ask why you ghosted them. Those numbers are not close.

Treat the form like the cash register it is. Use a service. Run the test.

When was the last time you tested your contact form in production, not in your dev environment? Be honest.

Watchdog Apache con Bash, Cron e Systemd: monitoraggio automatico di memoria, PID e socket close-wait

In alcuni scenari ad alto carico o in presenza di problemi lato backend, Apache può degradare progressivamente fino a diventare lento, saturare le risorse o smettere di rispondere correttamente.

Per mitigare questo tipo di problemi è possibile implementare un watchdog leggero in Bash che:

  • monitora periodicamente lo stato del sistema;
  • verifica alcune metriche critiche;
  • esegue automaticamente un reload o un restart di Apache;
  • registra gli eventi tramite syslog e journalctl.

Questo approccio è particolarmente utile in ambienti:

  • con reverse proxy Apache;
  • con backend applicativi instabili;
  • con leak di connessioni;
  • dove è necessario un meccanismo di self-healing semplice e trasparente.

Obiettivo dello script

Lo script controlla:

  1. RAM disponibile;
  2. numero di PID usati dal servizio Apache;
  3. connessioni TCP in stato close-wait verso un backend specifico;
  4. health check HTTP locale;

In base alle soglie configurate:

  • esegue un reload di Apache;
  • oppure un restart completo;
  • scrive i dettagli nel journal di systemd.

Script completo

#!/bin/bash

# --- PARAMETRI DI SOGLIA ---
SOGLIA_RAM_FREE_MB=100
SOGLIA_PIDS_PERCENT=60
SOGLIA_close-wait=300

# --- RACCOLTA DATI ---
# Usiamo percorsi assoluti per cron e variabili pulite
RAM_DISPONIBILE_MB=$(/usr/bin/free -m | grep Mem | awk '{print $7}')

# Conteggio in tempo reale delle connessioni orfane in close-wait
close-wait_COUNT=$(/usr/bin/ss -H -tanp state close-wait | /usr/bin/wc -l)

# Gestione PIDs con controllo esistenza file (evita errori se Apache è spento)
PIDS_FILE="/sys/fs/cgroup/system.slice/apache2.service/pids.current"
MAX_FILE="/sys/fs/cgroup/system.slice/apache2.service/pids.max"

if [ -f "$PIDS_FILE" ]; then
    CURRENT_APACHE_PIDS=$(cat "$PIDS_FILE")
    MAX_APACHE_PIDS=$(cat "$MAX_FILE")

    # Se MAX è la stringa "max", o è 0, o è vuoto -> la percentuale è 0 (limite infinito)
    if [ "$MAX_APACHE_PIDS" = "max" ] || [ -z "$MAX_APACHE_PIDS" ] || [ "$MAX_APACHE_PIDS" -eq 0 ] 2>/dev/null; then
        PERCENTUAL_PIDS=0
    else
        PERCENTUAL_PIDS=$(( 100 * CURRENT_APACHE_PIDS / MAX_APACHE_PIDS ))
    fi
else
    PERCENTUAL_PIDS=0
fi

# --- LOGICA DI CONTROLLO ---
AZIONE_NECESSARIA=false
TIPO_AZIONE="reload" # Default

if [ "$RAM_DISPONIBILE_MB" -lt "$SOGLIA_RAM_FREE_MB" ]; then
    MOTIVO="RAM bassa (${RAM_DISPONIBILE_MB}MB)"
    AZIONE_NECESSARIA=true
    TIPO_AZIONE="restart" # Se la RAM è finita, il reload è troppo lento
elif [ "$PERCENTUAL_PIDS" -gt "$SOGLIA_PIDS_PERCENT" ]; then
    MOTIVO="PIDs al ${PERCENTUAL_PIDS}% (${CURRENT_APACHE_PIDS}/${MAX_APACHE_PIDS})"
    AZIONE_NECESSARIA=true
    TIPO_AZIONE="reload"
elif [ "$close-wait_COUNT" -gt "$SOGLIA_close-wait" ]; then
    MOTIVO="Rilevato leak di socket (${close-wait_COUNT} connessioni in close-wait)"
    AZIONE_NECESSARIA=true
    TIPO_AZIONE="reload" # Il reload è sufficiente a liberare i socket orfani
fi

# Check sopravvivenza: se Apache non risponde, forza RESTART a prescindere dalle soglie
if ! /usr/bin/curl -s --max-time 5 http://127.0.0.1/ > /dev/null; then
    MOTIVO="Apache non risponde (Health Check fallito)"
    AZIONE_NECESSARIA=true
    TIPO_AZIONE="restart"
fi

# --- AZIONE ---
if $AZIONE_NECESSARIA; then
    echo "$(date): Allarme: ${MOTIVO}. Eseguo ${TIPO_AZIONE}."
    /usr/bin/logger -t apache_health_watchdog "Allarme: ${MOTIVO}. Eseguo ${TIPO_AZIONE}."

    if [ "$TIPO_AZIONE" == "reload" ]; then
        /usr/bin/systemctl reload apache2

        # Se il reload fallisce (es. kernel già in fork rejected), prova il restart
        if [ $? -ne 0 ]; then
            /usr/bin/logger -t apache_health_watchdog "Reload fallito, forzo restart."
            /usr/bin/systemctl restart apache2
        fi
    else
        /usr/bin/systemctl restart apache2
    fi
fi

Installazione

Salvare lo script in:

/usr/local/sbin/check_system_health.sh

Rendere il file eseguibile:

chmod +x /usr/local/sbin/check_system_health.sh

Configurazione del cron

Aggiungere in /etc/crontab:

*/2 * * * * root flock -n /tmp/apache_watchdog.lock /usr/local/sbin/check_system_health.sh >> /var/log/apache_health_watchdog.log 2>&1

Questo esegue il controllo ogni 2 minuti.

Perché usare flock

flock evita esecuzioni concorrenti dello script.

In condizioni di degrado del sistema:

  • un controllo potrebbe impiegare più tempo del previsto;
  • il cron successivo potrebbe partire mentre il precedente è ancora attivo;
  • si rischiano reload o restart multipli contemporanei.

Con:

flock -n /tmp/apache_watchdog.lock

solo una istanza dello script può essere eseguita alla volta.

Analisi delle metriche monitorate

1. RAM disponibile

free -m

Lo script utilizza:

awk '{print $7}'

che corrisponde alla colonna available.

Se la memoria disponibile scende sotto:

SOGLIA_RAM_FREE_MB=100

viene eseguito un restart completo di Apache.

Questo è utile perché:

  • il reload può richiedere fork aggiuntivi;
  • in condizioni di memoria critica il reload potrebbe peggiorare la situazione;
  • un restart libera immediatamente worker e memoria frammentata.

2. Saturazione PID Apache

Lo script legge direttamente i contatori cgroup di systemd:

/sys/fs/cgroup/system.slice/apache2.service/pids.current

e:

/sys/fs/cgroup/system.slice/apache2.service/pids.max

Questo approccio è molto più affidabile rispetto al parsing di ps.

Se il servizio supera:

SOGLIA_PIDS_PERCENT=60

viene eseguito un reload.

Questo permette di:

  • rigenerare worker;
  • liberare processi stuck;
  • evitare il raggiungimento del limite massimo PID.

3. Socket close-wait

Lo stato close-wait indica:

  • il peer remoto ha chiuso la connessione;
  • il processo locale non ha ancora rilasciato il socket.

Molte connessioni in close-wait possono indicare:

  • leak applicativi;
  • backend instabili;
  • worker Apache bloccati;
  • timeout mal configurati.

Lo script utilizza:

ss -tanp state close-wait

Questo approccio è preferibile rispetto a netstat perché:

  • ss è più moderno;
  • usa meno CPU;
  • legge direttamente dal kernel tramite netlink;
  • è più affidabile sotto alto carico;
  • evita dipendenze da net-tools.

Se il numero supera:

SOGLIA_close-wait=300

viene eseguito un reload.

Logging e monitoraggio

Lo script utilizza:

logger -t apache_health_watchdog

Quindi gli eventi sono consultabili tramite:

journalctl -t apache_health_watchdog

Esempio:

May 18 10:42:01 server apache_health_watchdog: Allarme: Rilevato leak di socket (182 connessioni in close-wait). Eseguo reload.

Per seguire i log in tempo reale:

journalctl -f -t apache_health_watchdog

Quando usare questo approccio

Questo watchdog è particolarmente utile quando:

  • Apache funge da reverse proxy;
  • i backend possono bloccarsi o degradare;
  • non è disponibile un orchestratore avanzato;
  • si vuole una soluzione semplice e immediata;
  • si desidera un meccanismo di self-healing leggero.

Limiti della soluzione

Questo approccio non sostituisce:

  • un monitoraggio completo;
  • metriche Prometheus/Grafana;
  • tracing applicativo;
  • analisi root cause;
  • tuning corretto di Apache e backend.

Il watchdog serve principalmente come:

  • mitigazione automatica;
  • protezione operativa;
  • recovery rapido.

Why Autofixing Missing TypeScript Arguments Is Harder Than It Looks

Why Autofixing Missing TypeScript Arguments Is Harder Than It Looks

One TypeScript error that looked deceptively simple to autofix at first was this:

greet("john");

TypeScript immediately complains:

Expected 2 arguments, but got 1.

At first glance, the fix feels obvious.

Why not just automatically transform this into:

greet("john", 0);

or maybe:

greet("john", "");

depending on the parameter type?

The compiler error disappears instantly.

Problem solved… right?

The Dangerous Part

The more I thought about it though, the more uncomfortable that approach started feeling.

Because now the compiler is happy.

But the runtime behavior may already be wrong.

That 0 might silently affect:

  • pricing logic
  • discounts
  • analytics
  • permissions
  • feature flags
  • business calculations

The code compiles.

But the original intent may already be broken.

A Small Example

Imagine something like this:

applyDiscount(user, percentage);

Now suppose the developer accidentally forgets the second argument:

applyDiscount(user);

A naive autofix might generate:

applyDiscount(user, 0);

TypeScript becomes happy.

But now the business logic silently changes.

That missing argument may not have meant:

0% discount

It may have meant:

"the developer forgot something important"

That was a pretty important realization for me.

Compiler Correct ≠ Runtime Correct

One thing I’ve started realizing while building TypeScript tooling is this:

making the compiler happy

and:

preserving runtime intent

are very different problems.

A fix can be technically valid from the compiler’s perspective while still being dangerous for the actual application logic.

That’s where autofixing becomes surprisingly tricky.

Because the more aggressively a tool tries to “repair” code automatically,
the easier it becomes to silently change behavior developers actually cared about.

Why This Felt Wrong

Initially, I tried generating values automatically based on the parameter type.

Something like:

number  -> 0
string  -> ""
boolean -> true

From the compiler’s perspective:

perfect

From a runtime/business perspective:

potentially dangerous

Because now the tool was effectively:

inventing developer intent

And that felt wrong.

A developer may have forgotten:

  • a discount percentage
  • a tax value
  • a feature flag
  • a permission value
  • a calculation input

Automatically generating fake business values could hide real logic mistakes instead of helping reveal them.

Rethinking The Repair Strategy

That realization eventually made me redesign the TS2554 repair logic inside my CLI tool fixmyfile.

Instead of inventing business values automatically,
the tool now prefers minimal compiler-safe placeholders like:

undefined as any

So this:

greet("john");

becomes:

greet("john", undefined as any);

The important difference is that:

  • the compiler error disappears
  • the missing value stays visible
  • runtime intent is not silently fabricated
  • developers can intentionally revisit the logic later

That felt like a much safer tradeoff.

The Interesting Part Wasn’t The AST Transform

Ironically, generating the AST transformation itself was not even the hardest part.

The harder part was making the fix:

  • predictable
  • minimal
  • non-destructive
  • repeatable
  • compiler-aware

because AST transforms can become destructive very quickly if existing arguments are not preserved carefully.

Early versions of the fixer accidentally replaced valid existing arguments entirely.

That was the moment I started realizing how much developer tooling is really about:

preserving developer trust

instead of simply replacing syntax.

Small TypeScript Friction Adds Up

One thing I keep noticing in TypeScript ecosystems is that many frustrations are not huge architectural problems.

They’re usually:

  • repetitive
  • mechanical
  • mentally draining
  • individually tiny
  • but exhausting when repeated constantly

Things like:

user?.name
.filter(Boolean)
greet("john")

all look small individually.

But repeated hundreds of times,
they slowly create workflow friction.

Final Thoughts

I don’t think TypeScript is wrong here.

The compiler is intentionally conservative.

But I do think there’s a growing opportunity for tooling that helps bridge the gap between:

what developers obviously mean

and:

what the compiler can safely infer

That’s the direction I’ve been exploring recently with compiler-aware AST fixes and TypeScript tooling.

And honestly, it has become one of the most interesting parts of working with the TypeScript ecosystem so far.

If you’ve run into similar TypeScript friction patterns repeatedly, I’d genuinely love to hear which ones annoy you the most.

DOM Explained

Document Object Model
Browser provides a web API called ‘document’ which contains total style and struncture of the web page.

browser has its own js engine called v8…..we might have seen when we click a button then color change automatically, or an alert comes etc….
js changes html … … that means js changes html so it must need html code so that it can change or manipulate it… so browser puts all html into an object called document and js will take this document and change whatever it wants….

we write in html == hi
browser will create a document and write ‘hi’ js access document and changing to ‘bye’
but ultimate output is document…
so user will see bye that’s why js is powerful….

JavaScript can access, change HTML/CSS by using document object.

**i. DOM Selection:** You can select html elements in javascript multiple ways. Most popular are
 **a. getElementsByTagName('tag'):** It returns an array of all the tag elements from HTML. 
  **b. getElementsByClassName('class'):** It returns an array of all the class named elements from HTML.
  **c. getElementById('id'):** It selects elements with specific id from HTML.
  **d. querySelector('queries'):** It is used to select an element based on query but it selects the first element that matches the query.
  **e. querySelectorAll:** It is used to select an element based on query but it selects all elements which matches the condition

** ii. DOM Manipulation:**

    **a. textContent:** textContent is used to access/ manaipulate the text inside an element.
    **b. innerText:** innerText is also used to access/manipulalte the text inside an element but it also considers CSS 

   **d. innerHTML:** innerHTML is used to access / change complete HTML code 

    **e. value:** value is used to access/change input valaues

    **f. classList/ className:** used to access/change class names

    **g. style:** style is used to access/change  style of an element

**iii. DOM Creation:** DOM Creation is used to create HTML elements using DOM. We can use methods "createELement" 
To attach the element we can use,
 append : it id used for appending multiple elements
 appendChild: it is used for appending single element