My GSoC 2026 Journey: Spectra.jl across the Electromagnetic Spectrum

I’m applying for Google Summer of Code 2026 with JuliaAstro
to work on Spectra.jl — a Julia package for loading and
manipulating astrophysical spectra across the electromagnetic spectrum.

My goal is to migrate the OGIP parser from SpectralFitting.jl
into Spectra.jl, implement loaders for optical, infrared, and
radio spectral formats, and add common manipulation routines
like rebinning and unit conversion.

I’ve been exploring the codebase, reading the OGIP standard
documentation, and engaging with the mentors on GitHub issues #41 and #47.

Follow my journey here as I document my progress!

How a Branded Cents Type Eliminated an Entire Class of Bugs Across 97 Files

TypeScript branded types turned compile-time into our most reliable financial auditor here is the pattern, the code, and the lessons from deploying it across a real production PWA.

Estimated reading time: 9 minutes

The Penny That Should Not Exist

Open your browser console right now and type 0.1 + 0.2. Go ahead, I will wait.

You got 0.30000000000000004. Not 0.3. Every developer discovers this at some point and shrugs it off. But when you are building software that handles someone’s rent payment, their emergency fund, their debt payoff plan that invisible four-quadrillionth of a cent compounds. It hides in running totals. It lurks in tax calculations. It turns a perfectly balanced budget into one that is off by a penny, and the user stares at the screen wondering if they can trust anything your app is telling them.

I hit this wall while building Talliofi, a local-first financial planning PWA built with React 19 and TypeScript. The app handles income, expenses, budgets, goals, net worth tracking, and multi-currency conversion. Money touches every feature. And every feature was a potential floating-point landmine.

The fix was not a library. It was not a dependency. It was twelve lines of TypeScript that now protect 928 occurrences of monetary values across 97 files in the codebase.

Why Floating-Point Arithmetic Hates Your Money

This is not a new problem. IEEE 754 double-precision floating-point numbers cannot represent most decimal fractions exactly. The number 0.1 in binary is a repeating fraction, like trying to write one-third in decimal. You get close, never exact.

For most applications, “close” is fine. For money, “close” is a lawsuit.

The classic fix is to work in the smallest unit of currency. Instead of storing $19.99 as 19.99, you store 1999, an integer representing cents. Integer arithmetic in JavaScript is exact up to Number.MAX_SAFE_INTEGER (about 9 quadrillion cents, or $90 trillion). That is plenty of headroom for a personal finance app.

But here is the thing nobody tells you: converting to integer cents solves the math problem, not the human problem.

A developer on your team writes a function that accepts a number. Is that number dollars or cents? Is it pre-tax or post-tax income? The type system says number, which tells you nothing. Someone passes in 19.99 where 1999 was expected, and you have a bug that is off by a factor of 100 — far worse than a floating-point rounding error.

I needed the compiler itself to refuse bad money.

The Naive Fix (And Why It Still Fails)

The first attempt is always the same. You create a convention: “All monetary values end in Cents.” You name your variables amountCents, incomeCents, balanceCents. You write it in the README. You mention it in code review.

Then someone writes:

const taxRate = 0.22;
const grossIncome: number = 500000; // cents
const tax: number = grossIncome * taxRate; // 110000 -- correct, but is it Cents?
const netIncome: number = grossIncome - tax; // works fine... this time

Everything compiles. Everything runs. The bug arrives three weeks later when a different developer passes netIncome into a function that expects dollars instead of cents. Or when tax gets rounded to a float and passed into another calculation that assumes integer cents.

Naming conventions are documentation. Documentation lies. Types do not.

Enter TypeScript Branded Types

A branded type is a TypeScript pattern that creates a distinct type from a primitive without any runtime overhead. The entire implementation in Talliofi looks like this:

// The branded type: a number that has been validated as integer cents
export type Cents = number & { readonly __brand: 'Cents' };

// The only way to create a Cents value -- our validation gate
export function cents(value: number): Cents {
  if (!Number.isSafeInteger(value)) {
    throw new Error(`Invalid cents value: ${value}`);
  }
  return value as Cents;
}

That & { readonly __brand: 'Cents' } intersection is the key. At runtime, it does not exist. There is no extra property on the number. JavaScript has no idea it is there. But TypeScript treats Cents as a distinct type from number. You cannot pass a raw number where Cents is expected without an explicit cast or going through the cents() constructor.

The constructor is your validation gate. Every monetary value in the system must pass through it. If you try to create Cents from 19.99 (a float), NaN, Infinity, or a number beyond safe integer range it throws at runtime. Two layers of protection: compile-time type checking and runtime validation.

For non-technical readers, think of it like a currency exchange booth at an airport. You cannot just hand someone random paper and call it money. You go through the booth, they validate it, stamp it, and now it is recognized currency. The cents() function is that booth.

Why Even Addition Gets Its Own Function

Once you have a branded type, you cannot just add two Cents values with +. The result of a + b in TypeScript is number, not Cents. Your carefully validated types would leak back to raw numbers after a single operation.

So every arithmetic operation gets a wrapper:

export const addMoney = (a: Cents, b: Cents): Cents => cents(a + b);
export const subtractMoney = (a: Cents, b: Cents): Cents => cents(a - b);

// Multiplication and percentage introduce fractions -- Math.round keeps us in integers
export const multiplyMoney = (amount: Cents, factor: number): Cents =>
  cents(Math.round(amount * factor));
export const percentOf = (amount: Cents, percent: number): Cents =>
  cents(Math.round(amount * (percent / 100)));

// Summing an array -- starts from validated zero, accumulates through addMoney
export const sumMoney = (amounts: readonly Cents[]): Cents =>
  amounts.reduce((sum, amt) => addMoney(sum, amt), cents(0));

Notice something subtle: multiplyMoney and percentOf accept a plain number as the second argument, not Cents. That is intentional. A tax rate is not money. A multiplication factor is not money. The type signatures enforce this distinction. You cannot accidentally pass a Cents value where a percentage belongs.

Here is the gotcha that surprises most developers: addMoney re-validates through cents() on every operation. That means overflow protection is automatic. If two enormous Cents values added together exceed Number.MAX_SAFE_INTEGER, the cents() constructor throws immediately rather than silently producing a corrupt value. Our test suite verifies this explicitly:

it('addMoney throws when result exceeds MAX_SAFE_INTEGER', () => {
  const large = cents(Number.MAX_SAFE_INTEGER - 1);
  expect(() => addMoney(large, cents(2))).toThrow('Invalid cents value');
});

Division by zero? Also caught. Math.round(100 / 0) produces Infinity, and cents(Infinity) throws. No special handling needed — the validation gate catches everything.

The Performance Play: Caching Intl.NumberFormat

Formatting money for display happens constantly in a financial app. Every list item, every chart tooltip, every summary card. Creating a new Intl.NumberFormat object on every render is expensive — these constructors parse locale data and build internal state.

Talliofi caches formatters by locale and currency:

const formatterCache = new Map<string, Intl.NumberFormat>();

export function formatMoney(
  amount: Cents,
  options: { locale?: string; currency: CurrencyCode },
): string {
  const { locale = 'en-US', currency } = options;
  const key = `${locale}:${currency}`;   // e.g., "en-US:USD" or "de-DE:EUR"
  let formatter = formatterCache.get(key);
  if (!formatter) {
    formatter = new Intl.NumberFormat(locale, { style: 'currency', currency });
    formatterCache.set(key, formatter);
  }
  return formatter.format(centsToDollars(amount));
}

The function signature enforces correctness: it accepts Cents, not number. You cannot accidentally format raw dollars or an unconverted value. The conversion to display-friendly dollars happens once, at the very last moment, inside this function. Everywhere else in the system, money stays in its branded integer form.

With six supported currencies and their associated locales, the cache holds at most a handful of entries. The memory cost is negligible. The performance gain on rendering a list of 200 expenses is noticeable.

Being Honest About Uncertainty: The ConversionResult Pattern

Multi-currency support introduced a design question I had not anticipated. When you convert USD to EUR and the exchange rate is unavailable (the user is offline, the rate API is down, the currency pair is not configured), what do you return?

Returning the original amount silently is dangerous the user would see “$500” labeled as “EUR 500” and never know the conversion failed. Throwing an error is disruptive for a display-only calculation. Returning null forces every consumer to handle the missing case.

Talliofi uses a tagged result:

export interface ConversionResult {
  readonly amount: Cents;
  /** Whether the amount was actually converted using a real exchange rate */
  readonly converted: boolean;
}

Callers get their Cents value regardless, but they also get a boolean that says “I actually converted this” versus “I just gave you back the original because I did not have a rate.” The UI can then decide how to present this showing a warning icon, displaying “approximate” text, or fetching fresh rates.

This pattern is worth stealing for any domain where a function might produce a plausible-looking result from incomplete data. Do not hide uncertainty. Type it.

What 928 Occurrences Across 97 Files Looks Like

The Cents type is not a novelty confined to a utility file. It flows through the entire domain:

// Plans track income in Cents
export interface Plan {
  readonly grossIncomeCents: Cents;
  // ...
}

// Every expense uses Cents -- no raw numbers allowed
export interface ExpenseItem {
  readonly amountCents: Cents;
  // ...
}

// Split transactions carry Cents per split
export interface ExpenseSplit {
  readonly amountCents: Cents;
  // ...
}

// Goals, assets, liabilities, snapshots -- all branded
export interface Goal {
  readonly targetAmountCents: Cents;
  readonly currentAmountCents: Cents;
}

From income calculations to tax estimates to budget summaries to multi-month trend charts, every monetary value in Talliofi is Cents. The branded type creates a closed system. Money enters through cents() or dollarsToCents(), stays branded through all calculations, and only converts back to a display-friendly number at the very edge — inside formatMoney(). A new developer joining the project cannot accidentally create monetary bugs without the compiler flagging it.

What Almost Broke Me

The hardest part was not the type system. It was the migration.

When I introduced the branded type, every function that touched money needed to change. Not just the types, the call sites. Every place that did income - expenses had to become subtractMoney(income, expenses). Every array.reduce((sum, x) => sum + x.amount, 0) had to become sumMoney(items.map(i => i.amountCents)).

The compiler caught every one. That was the point. But “the compiler tells you where to fix things” is different from “the compiler fixes things for you.” It was a methodical, file-by-file grind.

The lesson: introduce branded types early. The cost of adopting them grows linearly with your codebase. In a fresh project, it is nearly free. In a project with 97 files touching money, it is a weekend.

What I Learned (If I Built This Again)

1. Brand early, brand aggressively. I would introduce Cents in the very first commit, before any business logic exists. The migration cost is real and grows with time.

2. The constructor is the entire security model. The cents() function is one of the most important functions in the codebase. It is small, it is simple, and it is the single chokepoint for monetary validation. Guard it. Test it. Never bypass it.

3. This pattern generalizes far beyond money. Percentages (0–100 vs 0–1), timestamps (milliseconds vs seconds), distances (meters vs feet), IDs (user IDs vs order IDs), any domain where raw numbers carry implicit meaning is a candidate for branding.

4. Readonly interfaces compound the safety. Every domain type in Talliofi uses readonly properties. Combined with branded types, this means monetary values cannot be created incorrectly (branded constructor) or mutated incorrectly (readonly fields). Two fences, one enclosure.

5. The converted: boolean pattern pays for itself. Anywhere your code produces a “best effort” result, tag it with a confidence signal. Do not make downstream consumers guess whether the value is real or approximate.

Start Here

If you take one thing from this article, take the twelve lines. Copy the Cents type and the cents() constructor into your next TypeScript project that touches money. Write the arithmetic wrappers. Make your compiler do the auditing.

The pattern is small. The protection is not.

The best type systems do not just describe your code. They make entire categories of mistakes impossible to write.

Visit Talliofi website

The Webhook Failure Modes Nobody Warns You About

The Webhook Failure Modes Nobody Warns You About

Until you’re staring at a 78-hour Stripe retry schedule wondering why your handler never fired.

Webhook integrations look simple. You point Stripe at your endpoint, you get a 200 OK, you’re done.

Then something breaks in production — and you have no idea what. Was it the payload? The signature? Your server? Stripe’s retry queue? Something downstream?

This isn’t a guide to building webhook endpoints. It’s a field guide to the failures developers actually hit — and how to debug them faster.

Failure Mode 1: The Silent Drop

Your endpoint returns 200 OK to Stripe. But your handler never fires.

What actually happened: your server accepted the request, but the payload was routed to a different part of your application that silently failed. Or the event was filtered by a middleware. Or the webhook was received but the database write failed and the error was swallowed.

The classic debug move: pepper your handler with console.log(). Deploy. Wait for Stripe to retry (up to 78 hours). Check logs. Repeat.

Faster way: capture the raw webhook before it reaches your server. See exactly what was sent, what your endpoint returned, and when — without touching your application code.

Failure Mode 2: The Empty Payload

Stripe says it sent the event. Your handler receives it. But event.data.object is null or {}.

This is almost always an API version mismatch — your endpoint is using a different Stripe API version than the one that generated the event. Stripe doesn’t warn you; it just sends what it has.

The catch: you can’t see the raw payload your endpoint received. You only see what your code parsed. And if the parsing failed silently, the debug path starts from the wrong place.

Fix: log the raw request body at the very top of your handler, before any parsing, before any middleware.

Failure Mode 3: The Signature Validation Loop

You’re validating the Stripe signature. It keeps failing. You regenerate your webhook secret. It still fails. You hardcode the raw payload to test — it works.

The issue is almost always that you’re validating the parsed body instead of the raw body. Stripe’s signature is computed over the raw request body, not the JSON object your framework deserialized.

// Wrong — app.use(bodyParser.json()) already parsed this
app.post('/webhook', (req, res) => {
  const sig = req.headers['stripe-signature'];
  stripe.webhooks.constructEvent(req.body, sig, secret); // req.body is PARSED
});

// Right — use raw body
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = req.headers['stripe-signature'];
  stripe.webhooks.constructEvent(req.body, sig, secret); // req.body is RAW
});

If you’ve ever fought a Stripe signature validation error for an hour, you know this pain.

Failure Mode 4: The Infrastructure Block

Your endpoint is behind an ngrok tunnel that expired. Your firewall is blocking incoming requests from Stripe’s IP range. Your server restarted and the process isn’t back up yet.

Stripe reports delivery failures. Your server never received the event. Nobody knows why.

The problem: you have no visibility into whether your endpoint is actually reachable from the public internet. You find out something is wrong when Stripe emails you about failed deliveries.

Failure Mode 5: The Retry Wait

Your handler has a bug. You fix it. You need to test it.

Stripe’s retry schedule: 1 hour, 12 hours, 72 hours. That’s potentially 78 hours of waiting to verify your fix works.

For payment webhooks, Stripe will eventually retry — but waiting that long in development is brutal. The alternative is triggering test events manually through the Stripe CLI, which works but doesn’t capture the full production scenario.

The Better Debug Workflow

The common thread in all these failures: you can’t see what’s actually happening.

A webhook debugging tool gives you a dedicated endpoint that sits between the sender (Stripe, GitHub, etc.) and your server:

  1. Capture — the sender hits your debug endpoint instead of your server directly
  2. Inspect — you see the full raw payload, headers, and timing in a dashboard
  3. Replay — you resend the exact payload to your server instantly, without waiting for retries

This means you catch failures in seconds, not hours. You see what your server actually received — not what you hope it received.

Hooklog — Webhook Debugging Without the Wait

I built Hooklog to solve exactly this. It’s free (10k events/month), requires no signup, and gives you:

  • A unique webhook URL you can point any service at immediately
  • Full payload inspection: headers, body, response, timing
  • One-click replay to test your handler without waiting for retries
  • Email alerts when your endpoint returns 4xx or 5xx

Get started: https://hooklog.c0c58bd.p.egbe.app

Free tier: 10,000 events/month, 3-day retention, up to 3 endpoints. No credit card required.

What’s your worst webhook debugging story? Drop it in the comments — I read every one.

Security news weekly round-up – 27th March 2026

While cybersecurity defenders are looking for innovative ways to keep Internet users safe, cybercriminals are doing the opposite — to hurt users by stealing their money or information that can lead to theft or other things that are valuable to the user. It’s upon me and you to always know the threat out there and act accordingly.

Hackers Use Fake Resumes to Steal Enterprise Credentials and Deploy Crypto Miner

It’s a phishing attack. To complicate issues, if you fall for it, it takes around 25 seconds from script execution to credential exfiltration.

Here is what’s going on:

The initial dropper file is a Visual Basic Script (VBScript) that, upon opening, displays a bogus French-language error message, fooling message recipients into thinking that the file is corrupted.

…the heavily obfuscated script runs a series of checks to evade sandboxes and enters into a persistent User Account Control (UAC) loop that prompts users to run it with administrator privileges.

As soon as the dropper obtains administrative privileges, it wastes no time disabling security controls and covering up its tracks

Convicted spyware chief hints that Greece’s government was behind dozens of phone hacks

While reading the article, one thing is clear: someone hacked the phones of government officials and journalists. The spyware chief was sentenced to eight years in prison and now he claims he will not be a “scapegoat.”

From the article:

Several senior officials in the Greek government, including the head of Greece’s national intelligence agency and a senior aide to the Prime Minister Kyriakos Mitsotakis, resigned in the wake of revelations that several journalists’ phones had been hacked.

No government officials have been convicted in connection with the surveillance, and critics have accused the Mitsotakis government of a cover-up.

WebRTC Skimmer Bypasses CSP to Steal Payment Data from E-Commerce Sites

At the time of writing, Adobe has patched the vulnerability that allowed this to happen, but it appears that the patch is yet to reach production websites.

The following is how the skimmer works:

The skimmer is designed as a self-executing script that establishes a WebRTC peer connection to a hard-coded IP address (“202.181.177[.]177”) over UDP port 3479 and retrieves JavaScript code that’s subsequently injected into the web page for stealing payment information.

The use of WebRTC marks a significant evolution in skimmer attacks, as it bypasses Content Security Policy (CSP) directives.

Apple made strides with iOS 26 security, but leaked hacking tools still leave millions exposed to spyware attacks

The article title says it all. If you’re not in the loop in the past few weeks, let me update you. The hacking tools in question are DarkSword and Coruna. The former was leaked on GitHub, making it easy for anyone to launch attacks on older iOS users.

From the article:

The discovery of Coruna and DarkSword suggest that memory-based attacks could continue to plague users of older iPhones and iPads that lag behind the newer, more memory-safe models.

Experts working for iVerify and Lookout, two cybersecurity companies that have a commercial stake in selling security products for mobile devices, say Coruna and DarkSword may also challenge the long-held assumption that iPhone hacks are rare.

Credits

Cover photo by Debby Hudson on Unsplash.

That’s it for this week, and I’ll see you next time.

6 Async JavaScript Patterns That Prevent Partial Failures in Production

Most async code works fine until one step fails halfway through a workflow. Then you get double charges, missing data, or silent corruption.
These patterns fix that.

1. Replace Sequential Awaits With Compensated Steps

Sequential code looks clean but breaks on partial failure.

Before

async function processOrder(orderId: string) {
  const order = await fetchOrder(orderId)
  const payment = await chargeCustomer(order.customerId, order.total)
  const shipment = await createShipment(order.items, order.address)

  return { order, payment, shipment }
}

If shipment fails, payment is already done. No rollback.

After

async function processOrder(orderId: string) {
  const order = await fetchOrder(orderId)

  let payment
  try {
    payment = await chargeCustomer(order.customerId, order.total)
  } catch {
    throw new Error('PAYMENT_FAILED')
  }

  try {
    const shipment = await createShipment(order.items, order.address)
    return { order, payment, shipment }
  } catch {
    await refundPayment(payment.id).catch(() => {
      logger.fatal('REFUND FAILED', { orderId })
    })

    throw new Error('SHIPMENT_FAILED')
  }
}

You explicitly define rollback logic. This is what real systems do.

2. Start Independent Promises Early

Most developers accidentally serialize independent work.

Before

async function loadData(userId: string) {
  const user = await fetchUser(userId)
  const analytics = await fetchAnalytics(userId)
  return { user, analytics }
}

Total time = sum of both calls.

After

async function loadData(userId: string) {
  const analyticsPromise = fetchAnalytics(userId)

  const user = await fetchUser(userId)
  const analytics = await analyticsPromise

  return { user, analytics }
}

You save latency without changing logic. This pattern shows up in senior interviews .

3. Guard Multi-Call Flows With Promise.allSettled

Dashboards should not fail entirely because one API is down.

Before

const [users, orders, stats] = await Promise.all([
  fetchUsers(),
  fetchOrders(),
  fetchStats()
])

One failure kills everything.

After

const results = await Promise.allSettled([
  fetchUsers(),
  fetchOrders(),
  fetchStats()
])

const [users, orders, stats] = results.map(r =>
  r.status === 'fulfilled' ? r.value : null
)

Now partial data renders. Failures become observable, not catastrophic.

4. Retry Only Transient Errors With Backoff

Retrying everything is worse than not retrying.

Before

await fetch('/api/payment')

One network hiccup breaks the flow.

After

async function retry(fn, attempts = 3) {
  for (let i = 0; i < attempts; i++) {
    try {
      return await fn()
    } catch (e) {
      if (e.status && e.status < 500) throw e
      await new Promise(r => setTimeout(r, 2 ** i * 100))
    }
  }
  throw new Error('FAILED_AFTER_RETRIES')
}

await retry(() => fetch('/api/payment'))

You retry only when it makes sense. This avoids hammering APIs.

This becomes critical when dealing with flaky external systems like described in the Node.js memory leak debugging scenarios where retries amplify system pressure if done wrong.

5. Cancel Stale Requests With AbortController

Race conditions are one of the most common production bugs.

Before

useEffect(() => {
  fetch(`/api/search?q=${query}`)
    .then(r => r.json())
    .then(setResults)
}, [query])

Old responses overwrite new ones.

After

useEffect(() => {
  const controller = new AbortController()

  fetch(`/api/search?q=${query}`, {
    signal: controller.signal
  })
    .then(r => r.json())
    .then(setResults)
    .catch(err => {
      if (err.name !== 'AbortError') throw err
    })

  return () => controller.abort()
}, [query])

Now only the latest request wins. No stale UI.

6. Limit Concurrency Instead of Flooding APIs

Firing 100 requests at once will get you rate-limited or banned.

Before

await Promise.all(ids.map(id => fetchItem(id)))

Unbounded concurrency.

After

async function limit(tasks, concurrency) {
  const results = []
  let i = 0

  async function worker() {
    while (i < tasks.length) {
      const current = i++
      results[current] = await tasks[current]()
    }
  }

  await Promise.all(
    Array.from({ length: concurrency }, worker)
  )

  return results
}

const tasks = ids.map(id => () => fetchItem(id))
await limit(tasks, 5)

You control throughput. Systems stay stable under load.

Closing

Take one of your existing async flows and add compensation or cancellation today. That alone removes an entire class of production bugs.
If your code assumes the happy path, it is already broken.

When /pair approve Bypasses the Scope Guard

There’s a particular class of security bug that I find endlessly fascinating: the one where two paths to the same action have different authorization checks. One path is locked down tight. The other… someone forgot.

#55995 is exactly that. CVSS 9.9. Critical. And the fix is 8 lines of code.

The Setup

OpenClaw’s device pairing system lets you connect phones, tablets, and other “nodes” to your gateway. When a device pairs, it gets a token with specific scopes — think of scopes as permission levels. operator.pairing lets you manage device connections. operator.admin lets you do… everything.

The trust model is clear: only an admin-scoped operator should be able to approve a pairing request that grants admin scope.

This is enforced in the core approveDevicePairing function. It accepts an optional callerScopes parameter. When present, it checks: does this caller have sufficient scope? If not, rejection. Good design. There are tests for it.

The Bypass

The device-pair plugin exposes a /pair approve slash command:

if (action === "approve") {
  // Coarse check: does the caller have *any* pairing-related scope?
  if (gatewayClientScopes &&
      !gatewayClientScopes.includes("operator.pairing") &&
      !gatewayClientScopes.includes("operator.admin")) {
    return { text: "Requires operator.pairing" };
  }
  // Approve WITHOUT forwarding callerScopes
  const approved = await approveDevicePairing(pending.requestId);
}

The slash command checks “do you have some pairing scope?” but calls approveDevicePairing() without passing callerScopes. The core function only enforces the scope guard when callerScopes is present.

So an operator with just operator.pairing can /pair approve a pending request asking for operator.admin. Privilege escalation complete.

Why This Pattern Keeps Happening

This is the dual-path authorization gap:

Path A (RPC/API): Carefully designed, passes all context for authorization.

Path B (convenience layer): Built later as a friendly wrapper. Calls the same core function but forgets to thread through one critical parameter.

The core function made callerScopes optional. That optionality became the vulnerability.

  • Core function assumes: “if callerScopes is missing, the caller is trusted”
  • Plugin assumes: “if the coarse scope check passes, we’re good”
  • Neither assumption is wrong in isolation. Together, they’re a CVSS 9.9.

The Fix

Eight lines. Pass callerScopes through. Plus a test.

Lessons for Agent Builders

  1. Optional security parameters are dangerous. If callerScopes were required, the plugin author would have been forced to think about what to pass.

  2. Every path to a privileged action needs the same checks. RPC, slash command, API endpoint, cron handler — test each independently.

  3. Convenience layers are where auth bugs hide. Core infra built solid authorization. The plugin built a nice UX wrapper. Nobody checked the wrapper preserved the security properties.

  4. Pairing and provisioning are trust-granting operations. They deserve the same scrutiny as authentication.

Found via #55995. Fix in #55996.

I Was Hand-Writing Every AI Tool. Then I Discovered MCP Servers.

What tool calling and MCP actually mean, and how they fit together when you’re building real AI products.

I’ve been building Pulse, a voice AI co-pilot for engineering work that talks to Jira and GitHub. The idea is simple: speak a command, Claude figures out what to do, your project management tools respond.

To make it work, I had to give Claude the ability to interact with Jira and GitHub. So I did what most people do when they start building with LLMs: I wrote the tools by hand.

tools: [
  { name: "create_jira_ticket", description: ""...", input_schema: { ... } },"
  { name: "get_jira_issue", description: ""...", input_schema: { ... } },"
  { name: "update_jira_status", description: ""...", input_schema: { ... } },"
]

Three tools. Done. It worked fine.

Then I learned what an MCP server actually is, and I realised I had been building with a teaspoon when a fire hose was sitting right there.

First, what is tool calling?

When you build an LLM application, the model lives in a box. It can think, reason, and generate text, but it cannot do anything in the real world on its own.

Tool calling is how you fix that. You define a set of functions and tell the model they exist. When the model decides one is needed, it calls it with the right arguments. Your code executes the function and passes the result back to the model.

User gives instruction
      ↓
Claude decides which tool to call
      ↓
Your code executes it
      ↓
Claude gets the result and responds

It’s powerful. But you write every tool yourself. You define the schema, you maintain it, you add new ones when you need them.

So what is an MCP server?

MCP stands for Model Context Protocol. The simplest way I can explain it:

Tool calling is giving Claude a telephone. MCP is giving Claude a universal remote control.

The telephone analogy works because with hand-rolled tools, you’re wiring up each number yourself. You decide what Claude can call, you write the definition, you maintain it forever.

An MCP server is a running process that speaks a standard protocol. Claude connects to it and asks: “what tools do you have?” The server responds with a full list. Claude now knows everything it can do without you having written a single tool definition.

More concretely:

Tool Calling MCP Server
What it is Functions you define manually A running server Claude connects to
Setup You write each function and schema Server exposes its tools automatically
Reusability Tied to your app Any AI that speaks MCP can use it
Maintenance Yours forever The server owner’s problem

Jira has an MCP server. GitHub has an MCP server. Instead of my 3 hand-rolled tools, I can point Pulse at both servers and Claude automatically gets access to the full API surface of each product.

Sprints. PRs. Worklogs. Reviews. Branches. Comments. Issue history.

Same voice interface. Dramatically larger capability surface.

The part that actually blew my mind

Claude can reason across multiple MCP servers simultaneously.

So instead of three isolated tool calls, you can say:

“Find all Jira tickets marked Done this sprint, check if the linked PR was actually merged, and flag any that say Done but the PR is still open.”

That used to be a custom script someone had to write, test, and maintain. With MCP, it’s a voice command.

And when Jira ships new API features? The MCP server updates. Claude automatically has access. You change nothing.

The broader point

Most people are still thinking about AI as a chatbot. You ask it something, it answers.

What I’ve been building toward is composing intelligence over systems. Claude isn’t just answering questions. It’s reasoning across your Jira board, your GitHub history, and your internal data, then taking action.

The tools that make that possible are not complicated individually. What takes work is understanding how they fit together and when to use each one.

That’s what I’m figuring out in public, one project at a time.

I’m Rue, a Full Stack and AI Engineer building Pulse and writing about what I learn along the way. Follow along on Instagram at @rue.on.ai or connect with me on LinkedIn.

Wrapping Up Bloom After: Week 4 Polish & Full Sprint Retro

We finally made it to Week 4! This last week of the sprint was all about crossing the t’s, dotting the i’s and making sure the app actually feels good to use.

Before I get into the bugs I squashed this week, let’s zoom out and talk about what my team and I spent the last month building.

What is Bloom After?

Dealing with postpartum depression (PPD) can feel incredibly lonely. While there is plenty of medical advice online, finding a single and calming place with helpful resources, verified specialists and a community of moms who actually get it is really hard. That’s why we built Bloom After. It’s a safe digital space for mothers facing maternal mental health challenges, featuring an educational hub, a clinic finder, and a story library where moms can read and share their journeys. We designed the entire platform to be calming, supportive and super easy to use for someone who might already be feeling overwhelmed.

What I Shipped This Week

The heavy lifting for the Stories feature and the Admin dashboard was finished last week by my team. So, my main goal for Week 4 was to polish the app, test it, and make sure everything flowed perfectly. Here is what I did:

  • Revamped the Lifestyle & Crisis Pages: I completely refined the UI and functionality for the Lifestyle and Crisis landing pages. For the Crisis page, I made sure the 24/7 helplines were pinned to the very top and swapped out a clunky separate page for a fast and instant-loading modal so moms can get help immediately.

  • End-to-End Testing: I put on my QA hat (which almost didn’t fit) and clicked through the entire app from start to finish, acting like a real user to see what would break.

  • Bug Squashing & UI Polish: I spent hours fixing alignment issues, spacing and mobile glitches (like a stubborn bug on 360px mobile screens!). I went through several pages, including the Admin dashboard, to fix bugs and make sure the design looked premium and consistent everywhere.

Key Technical Lessons from the Sprint

  1. CSS Mobile Quirks are Real: I learned that mobile browsers handle screen heights weirdly. Using dvh (dynamic viewport height) instead of standard vh is a lifesaver so your modals don’t get hidden behind the mobile address bar.

  2. The Devil is in the Details: Polishing UI takes just as much time as building the feature in the first place. A tiny 1px gap or a misaligned button can ruin the whole vibe of a page.

  3. E2E Testing is Eye-Opening: You think your code works perfectly until you actually try to use it. Testing the full flow caught so many tiny bugs and bad user experiences that I would have completely missed if I only looked at the code.

Full-Sprint Retrospective

Looking back at the whole month of building Bloom After:

  • What went well: We stuck to our vision. We wanted to build a platform that felt deeply empathetic and safe, and we never compromised on that UX, even when it meant doing tedious CSS work.

  • What I would do differently: I’d set up a better modular folder structure from Day 1. Moving from a messy flat folder system to a clean and organized one late in the project meant rewriting a ton of file paths.

  • What surprised me: How much complex code goes into making things look “simple.” Creating a clean and calming interface actually requires a lot of behind-the-scenes work.

What’s Next for Me?

Now that this sprint is over, I’m shifting my focus to leveling up my core engineering skills. I really want to master the fundamentals, so I’m going to dive deep into advanced JavaScript and TypeScript. I also plan to tackle Data Structures and Algorithms (DSA) to sharpen my problem-solving abilities. Instead of just getting stuck in “tutorial hell,” my main goal is to build out a few solid projects from scratch using JS and TS to put all of these concepts into actual practice.

Team Shoutouts

Building this isn’t a one-person job. A massive thank you to the amazing people building this with me:

  • Chijioke Uzodinma (Backend Lead):Read his Week 4 article here.

  • Nanji Lakan (Product Lead): Read her Week 4 article here.

  • Agugua Genevieve (Design Lead): Read her Week 4 article here.

  • Prisca Onyemaechi (Lead Maintainer): Read her Week 4 article here.

  • Adejola Esther (Frontend Engineer): Read her Week 2 article here.

Visit our live site.
Check out our Github Repo.