DEV Community

Cover image for Dev Log: 2026-08-18 — everything that had to survive being called twice
Nasrul Hazim
Nasrul Hazim

Posted on

Dev Log: 2026-08-18 — everything that had to survive being called twice

Eight commits across four repos, and by evening they'd all quietly converged on the same question: what happens when this exact request arrives a second time?

A lead form submitted twice. A staff app draining a queued write it already drained. An agent calling a check-in tool on a participant who's already inside. Different systems, different teams-of-one, same failure mode — the second call undoing the first call's work.

1. Deduping at the point of capture

The big one, and it has its own write-up.

Short version: a CRM grew a fourth intake channel — a headless JSON endpoint that external apps POST leads to directly, so the visitor never leaves the app they're already in. Token in the path is the credential, 202 on accept because the lead gets queued rather than created inline, always-JSON responses because everyone integrating is writing their own client and half of them will forget the Accept header.

The endpoint was ninety minutes. The merge rules took the rest of the afternoon:

  • Repeat submission matches on email, then phone. Exact match only — fuzzy matching on public input gets you two humans collapsed into one record, which is the one mistake you can't undo.
  • The master record wins. Fill blank fields, never overwrite populated ones.
  • Never reset stage or owner. A form submission is not a reason to yank a live deal back to "New".
  • Tags append rather than replace, and append in a way that still fires the tag-added event — the merge should be invisible to the data model and fully visible to the automations.
  • The inquiry message, which previously had nowhere to live and was silently dropped, becomes an inbound note on the timeline.

The only state change a repeat submission is allowed to make: promoting someone who has never been in the pipeline. Never-in → in is a safe one-way upgrade. Anything → anything is not.

Which also gave the endpoints a subscriber mode: capture the contact, skip the owner round-robin, skip the stage, skip the first task. Because generating a sales task for every "notify me when this course opens" click is how you train a team to ignore their task list.

2. Idempotency for an app that works offline

Different system, same shape of problem. A staff app for on-site event operations queues its writes locally when there's no signal and drains them when signal returns. A drain interrupted halfway gets retried — so the same walk-in registration, or the same cash-payment confirmation, can legitimately arrive twice.

Without protection: two registrations for one person at the door, or an order marked paid twice.

public function handle(Request $request, Closure $next): Response
{
    $key = trim((string) $request->header('Idempotency-Key'));

    if ($key === '') {
        return $next($request);
    }

    $cacheKey = $this->cacheKey($request, $key);

    if (is_array($stored = Cache::get($cacheKey))) {
        return response()->json(
            array_merge($stored['body'], ['idempotent' => true]),
            $stored['status'],
        );
    }

    $response = $next($request);

    // Only successful responses are replayable. A 422 caused by a bad
    // payload must stay retryable — the app fixes the payload and
    // resends under the same key.
    if ($response->isSuccessful() && $response instanceof JsonResponse) {
        Cache::put($cacheKey, [
            'status' => $response->getStatusCode(),
            'body'   => $response->getData(true),
        ], self::TTL_SECONDS);
    }

    return $response;
}
Enter fullscreen mode Exit fullscreen mode

Three details worth stealing:

The header is optional. No key, no replay protection, straight through. Keeps curl usable during debugging and doesn't force every client to care.

Only successes are cached. Caching a 422 would mean a client that fixes its payload and retries under the same key gets the old validation error back forever. Failures must stay retryable; that's the whole point of retrying.

The key is scoped to user + method + path, not used raw. So one device's key can't return another device's response, and the same UUID reused against a different endpoint is correctly treated as a different operation. A raw idempotency key is a global namespace shared with people you don't control.

TTL is a working day — long enough to cover a venue that was offline all afternoon, short enough that the cache doesn't grow without a ceiling.

3. An access rule that lived in two places, about to live in three

Same platform. Who can open a paid recording or a downloadable material was implemented once in the recordings component and once in the materials component. Adding a bearer-token API would have made it three copies.

Three copies of an access rule is three chances to disagree about whether a paid recording is free.

So it collapsed into a resolver every surface asks:

class ContentAccessResolver
{
    /**
     * @return array<int, int> ids of the items this user may open
     */
    public function accessibleIds(
        User $user, Event $event, Collection $items, string $contentType
    ): array {
        if ($this->hasFullAccess($user, $event, $contentType)) {
            return $items->pluck('id')->all();
        }

        $free = $items->where('access_type', 'free')->pluck('id')->all();

        // Only recordings carry a preview flag; materials have no
        // such column.
        $previews = $contentType === self::TYPE_RECORDING
            ? $items->where('is_preview', true)->pluck('id')->all()
            : [];

        $attendeeOnly = $this->isAttendee($user, $event)
            ? $items->where('access_type', 'free_for_attendees')->pluck('id')->all()
            : [];

        return array_values(array_unique(array_merge($free, $previews, $attendeeOnly, /* … */)));
    }
}
Enter fullscreen mode Exit fullscreen mode

Access comes from several independent sources — a legacy event-level grant, the item being free, the item being a preview, free-for-attendees when the user holds a paid ticket, or an explicit per-item purchase — and any one is sufficient. Which is exactly the kind of OR-chain that drifts when it's duplicated, because someone adds a fifth source in one copy and not the other.

One wart preserved deliberately: the stored content_type values are asymmetric — singular recording, plural materials. Ugly. But those are the values production rows already use, and "fixing" the spelling would orphan every existing grant. A constant with a comment explaining why it's inconsistent beats a migration that quietly revokes people's access.

4. Push notifications must never break the thing they announce

Same platform again, new FCM channel on Laravel's notification system. The design rule fits in one sentence:

A push is a courtesy on top of an operation that already succeeded, and a dead Firebase must not turn a successful check-in into a 500.

So nothing in the channel is allowed to bubble. Not configured? Return. No device tokens? Return. Send threw? Log a warning and move to the next device. The check-in already happened; the participant is already inside the hall.

The one thing it does act on decisively is a token FCM reports as permanently dead — the app was uninstalled, or the token rotated. That row gets deleted, because keeping it means retrying a guaranteed failure on every subsequent send, forever. Push token tables that are never pruned are how a notification job that should take two seconds starts taking four minutes.

5. Auditing what the agents read, not just what they wrote

Twelve new MCP tools landed on the same platform — bulk check-in, bulk registration approval, SMS blast, venue capacity, exhibitor leads, participant self-service.

The interesting piece isn't the tools, it's the middleware underneath them. Model auditing already captured successful writes. It could not see the two things that matter most in an incident review:

  • A PII read. Listing every participant's email address leaves no trace at all.
  • A denied attempt. An agent probing a tool it lacks the ability for writes nothing, anywhere.

Both now land in an invocation log, and it's HTTP middleware rather than a per-tool hook on purpose — so a tool added next month is audited without anyone remembering to opt it in. An audit log you have to remember to call is incomplete exactly where it matters.

Arguments are hashed, never stored. Enough to prove two calls were identical; not enough to make the audit table a second copy of the participant list.

And the bulk tools exist for a specific reason: the MCP endpoint is rate-limited to 60 requests a minute, and a door rush is precisely when an agent needs to move faster than that. One call carrying 100 identifiers turns a throttle-induced failure into a single round trip — with a per-item result, because "3 of 40 failed, here's which three" is actionable and a blanket error isn't.

6. The flow builder got a real canvas

Lighter note to end on: an automation flow builder moved off a list-shaped editor onto a proper React Flow canvas — drag nodes, draw edges, see the branch structure. Mounted as an island inside a Livewire page, which is the pattern I keep coming back to: Livewire owns the state and the persistence, the JS component owns the direct-manipulation surface, and they talk through a narrow, explicit interface rather than sharing a mental model.

An automation builder is one of the few UIs where the visual layout is the mental model. A branching sequence rendered as a nested list is technically complete and practically unreadable.


The thread through all of it: none of today's work added a feature you'd put on a pricing page. It's all about the second call — the retry, the resubmit, the duplicate. Which is roughly the ratio I'd expect from any system that's stopped being a demo and started having users.

Top comments (0)