Reading Between the Lines

In the last article, I asked you to take the Clarity brief and write down every question you would want answered before you started building. If you did that exercise, you probably found somewhere between five and fifteen questions depending on how deeply you read into it.

Here is what I have found over the years: the first time developers do that exercise, they mostly surface technical questions. Things like “should I use a polymorphic relationship?” or “what database should I use?”. Those are not bad questions, but they are the wrong starting point. They are implementation questions dressed up as requirements questions, and they put the cart firmly in front of the horse.

The questions that actually matter before you write a line of code are business questions. And learning to ask them is one of the most valuable skills you can develop as you move towards mid-level.

The Brief, Revisited

Let us look at the Clarity brief again:

“We need a tool where our clients can submit project requests, and our team can manage and track them. Clients should be able to see the status of their requests, and we need to be able to assign them to developers.”

On the surface, this looks like a reasonable starting point. You could probably sketch out a rough data model from it in ten minutes. But read it again, and this time treat every noun and every verb with suspicion.

Every noun is a potential entity. Every verb is a potential action. And every place where the brief is vague is a place where an unstated assumption is hiding.

Interrogating the Nouns

Let us pull out the nouns: clients, tool, project requests, team, status, developers.

These seem obvious until you start asking questions about them.

Clients. Are clients individual people, or organisations? Can a single organisation have multiple client users? If a client submits a request, can another person from the same organisation see it? This alone could mean the difference between a simple users table and a full organisations -> users relationship.

Project requests. What actually constitutes a request? Is it just a title and a description? Does it have attachments? Can it have a priority? Can a client submit multiple requests at the same time, and if so, are those requests independent or grouped somehow?

Team. Who is “our team”? Are these people with admin access to everything, or do different team members have different permissions? Can a developer only see requests assigned to them, or all requests?

Status. This one word is carrying an enormous amount of weight. Is “status” a simple label (pending, in progress, done) or is it a proper workflow with defined transitions? Can a client change the status, or only view it? Can a developer set any status, or only move it forward?

Developers. Are developers a separate user type from clients, or just users with a different role? Can a developer also be a client on a different project?

None of these are trick questions. They are the kind of thing a product manager or a client would answer in a thirty-minute call. But if you skip straight to building, you are making silent choices about all of them, and those choices will come back to cost you.

Interrogating the Verbs

Now the verbs: submit, manage, track, see, assign.

Submit. Can a client edit a request after submitting it? Can they delete it? Is there a draft state before submission, or is it submit-or-nothing?

Manage and track. These are vague by nature. What does managing a request look like? Adding notes? Updating status? Logging time? The brief does not say, which means this is a gap you need to close before you start.

See. Can clients only see the status, or can they see the full request detail and any notes added by the team? Is there a concept of private notes that clients should not see?

Assign. Can a request be assigned to multiple developers? What happens when the assigned developer is unavailable? Is assignment visible to the client?

The Questions Worth Asking

When I am working from a brief like this, I structure my questions into three categories.

The first is scope questions. These define the boundaries of what we are building. What is explicitly included? What is explicitly out of scope? For Clarity, that might be: are we building client authentication ourselves, or are clients invited via a link?

The second is actor questions. These define who can do what. For every type of user in the system, I want to know what actions they can take, what they can see, and what restrictions apply to them.

The third is data questions. These define the shape of the information we are working with. What does each entity look like? What are its required fields? What are its optional fields? What are the relationships between entities?

You do not need formal workshops or long documents to work through these. A simple shared notes document and a conversation with whoever owns the brief is often enough. The goal is not to write a specification novel. It is to surface the decisions that have already been made implicitly, and make them explicit before they become mistakes.

Applying This to Clarity

After running this process on the Clarity brief, here is what I would want confirmed before I drew a single diagram:

On actors:

  • There are two distinct user types: clients and team members
  • Clients belong to an organisation, and organisations can have multiple client users
  • Team members have a role of either developer or admin
  • Admins can manage everything; developers can manage requests assigned to them

On requests:

  • A request has a title, description, and optional file attachments
  • Requests are submitted by a client user and belong to their organisation
  • Requests move through a defined set of statuses: submitted, in review, in progress, on hold, completed
  • Only team members can change the status of a request
  • Clients can edit their request while it is in the submitted state; after that, it is read-only for them

On assignment:

  • A request can be assigned to one developer at a time
  • Assignment is visible to the client
  • Developers can see all requests, not just their own

On comments:

  • Both clients and team members can add comments to a request
  • Team members can mark a comment as internal, which hides it from clients

These are the confirmed assumptions we will carry forward through the rest of this series. Notice that none of them are technical decisions. We have not decided on a database, a framework, a caching strategy, or a queue driver. We have simply defined the shape of the problem we are solving.

That comes next.

Putting It Into Practice

Take a project you are currently working on, or one you have worked on recently. Go back to the original brief or requirements you were given. Now apply the noun-and-verb interrogation to it. How many questions can you surface that were never explicitly answered? How many of those questions did you answer yourself, silently, while you were building?

Write them down. You might be surprised how many decisions were made without anyone realising a decision was being made.

In the next article, we will take the confirmed Clarity assumptions and turn them into properly structured user stories, and explore what the difference between a task and a feature actually means in practice.

Relationships, Cardinality, and the Questions Your Schema Is Asking

In the last article we drew the Clarity ERD and ended up with a clean diagram covering five entities and seven relationships. If you followed along and drew one for your own project, you probably found a few things that surprised you. That is normal, and it is exactly the point.

Now I want to go deeper. Drawing an ERD is one skill. Reading what it is telling you is another, and the second one is where the real value lives.

In this article we are going to look at the three relationship types in detail, walk through the most common mistakes I see developers make when modelling data, and show you how to catch them at the diagram stage before they become painful migrations.

The Three Relationships, Properly

We covered these briefly in the last article, but they deserve more attention.

One-to-One

A one-to-one relationship means one record in table A maps to exactly one record in table B, and that mapping is unique on both sides.

These are less common than people expect. When a developer reaches for a one-to-one, I always ask: why is this data in a separate table? Sometimes there is a good reason. You might have a users table and a user_profiles table where the profile data is large, infrequently accessed, and cleaner to separate. Or you might have a base users table that is extended by either a client_profiles table or a team_member_profiles table depending on role.

But a lot of the time, a one-to-one relationship is a sign that the data should just live in the same table. Before you commit to one, ask yourself whether there is a genuine reason for the separation or whether you are adding complexity without adding value.

In Clarity, we do not have any one-to-one relationships. The model is clean enough that everything fits in its own entity without needing auxiliary tables.

One-to-Many

This is the workhorse of relational data modelling. One record in table A is referenced by many records in table B. The foreign key lives on the “many” side.

In Clarity: one organisation has many users, so organisation_id lives on the users table. One request has many comments, so request_id lives on the comments table. The pattern is consistent.

The mistake I see most often with one-to-many is putting the foreign key on the wrong side. If you put user_ids (as some kind of serialised array) on the organisations table instead of organisation_id on the users table, you have inverted the relationship and created a mess. You cannot join efficiently, you cannot enforce referential integrity, and you will hit a wall the moment you need to query it properly.

The rule is simple: the foreign key always goes on the child. The “many” side. If you are unsure which side is the child, ask yourself which record is owned by the other. A user is owned by an organisation. A comment is owned by a request. The owned record holds the foreign key.

Many-to-Many

A many-to-many relationship means records on both sides can be connected to multiple records on the other side. You cannot model this directly with a foreign key on either table. You need a pivot table that sits between them and holds the connection.

Clarity does not have a many-to-many in the current design, so let me add one to illustrate the point. Suppose we wanted to add tagging to requests, where a request can have many tags and a tag can be applied to many requests. That is a classic many-to-many.

The entities involved:

Request {
    uuid id
    string title
    ...
}

Tag {
    uuid id
    string name
    string colour
}

RequestTag {
    uuid request_id
    uuid tag_id
}

The RequestTag pivot table is the relationship. It holds nothing but the two foreign keys (and sometimes additional data about the relationship itself, like a created_at timestamp or a user who applied the tag).

In Laravel, this maps to a belongsToMany relationship on both models:

// Request model
public function tags(): BelongsToMany
{
    return $this->belongsToMany(Tag::class, 'request_tags');
}

// Tag model
public function requests(): BelongsToMany
{
    return $this->belongsToMany(Request::class, 'request_tags');
}

The common mistake here is not recognising when you have a many-to-many. Developers often start by modelling it as a one-to-many and then discover mid-build that the constraint they assumed does not hold. When you spot a noun in your application that sounds like it could belong to many different records of another type, treat it as a signal to check whether you are looking at a pivot.

Common ERD Mistakes

Let me walk through the mistakes I see most often and how to spot them before they cause problems.

Storing arrays in a column

If you ever find yourself writing tags: string or assigned_to: json on an entity, stop. That is almost always a sign that the data belongs in a related table. Arrays in columns cannot be indexed properly, cannot be joined against, and are a maintenance headache the moment the data inside them grows or needs to be queried individually.

In the Clarity ERD, assigned_to is a single foreign key pointing at one user. That is correct because our business rule says a request has one assignee. But if the requirement had been “a request can be assigned to multiple developers”, the right answer would be an assignments pivot table, not a JSON array of user IDs in a column.

The missing pivot

Related to the above: when you draw a many-to-many relationship between two entities without drawing the pivot table, you have an incomplete diagram. Always draw the pivot explicitly. It forces you to think about what data the relationship itself carries, and it makes the migration obvious when you get to that stage.

Nullable foreign keys as a shortcut

It is tempting to make a foreign key nullable when you are not sure whether a relationship will always exist. Sometimes that is correct. In Clarity, assigned_to on Request is nullable because a request might not have an assignee yet. That is a real business rule.

But sometimes a nullable foreign key is a sign that the relationship is modelled incorrectly. If you find yourself with a record that should always belong to a parent but the foreign key keeps being null in practice, the relationship direction might be wrong, or the data might belong in a different table entirely.

Conflating user roles with user types

This one is specific to applications like Clarity, but it comes up constantly. When your system has multiple types of users (clients and team members, customers and staff, students and teachers), there is a temptation to use a single role column and call it done.

Sometimes that is fine. But if the two user types have genuinely different data requirements, different relationships to other entities, or different constraints on what they can do, a single role column will not hold the weight. You end up with columns that are only relevant to one type of user, nullable columns everywhere, and logic scattered across your codebase to handle the differences.

In the Clarity design I chose a single users table with a role column and a nullable sub_role. That works for our use case because the data requirements for clients and team members are similar enough. But it is a decision worth examining on your own projects. If the profiles for your two user types are genuinely different shapes, a polymorphic users table or a base users table with separate profile tables might serve you better.

Revisiting the Clarity ERD

Now that we have covered these in detail, let me take one more look at the Clarity diagram and point out the decisions that are worth noting.

The Request entity has two foreign keys pointing at User: submitted_by and assigned_to. This is intentional, but it means the Eloquent relationships on Request need to be named explicitly to avoid ambiguity:

public function submitter(): BelongsTo
{
    return $this->belongsTo(User::class, 'submitted_by');
}

public function assignee(): BelongsTo
{
    return $this->belongsTo(User::class, 'assigned_to');
}

If you wrote $this->belongsTo(User::class) without the second argument, Laravel would look for a user_id column that does not exist. Naming the foreign key explicitly in the relationship definition is the correct approach here, and the ERD is what made this obvious before we wrote a single line of code.

The Comment entity has is_internal as a boolean. This is a simple field, but it carries real access control logic: when is_internal is true, the comment must be excluded from any query that is serving data to a client user. That is an application-layer concern rather than a schema concern, but spotting it on the diagram is a reminder to handle it consistently across every query that touches comments.

Putting It Into Practice

Go back to the ERD you drew in the last exercise. Now look at every relationship line and ask these questions for each one:

  • Is the foreign key on the correct side?
  • Is this truly a one-to-many, or could it become a many-to-many as the application grows?
  • Are there any arrays or JSON fields that should be a related table?
  • Are there any nullable foreign keys that feel like shortcuts rather than genuine business rules?

Fix what you find. A diagram is cheap to change. A migration is not.

In the next article, we are going to step back from the data model and look at the bigger picture: what application architecture actually means for backend developers, and how to draw a simple system diagram that shows how the pieces of Clarity fit together.

Your First ERD

Up until now, everything we have done has been about understanding the problem. We interrogated the brief, surfaced the assumptions, wrote user stories, and shaped the features into bounded pieces of work. That is a lot of thinking before touching anything technical, and it is exactly the right order to do things in.

Now we get to use that thinking. Because once you know what your application needs to do, the next question is: what does it need to remember?

That question is what an Entity Relationship Diagram is designed to answer.

What an ERD Actually Is

An ERD is a visual map of the data your application works with. It shows you the things your system needs to store (entities), the properties those things have (attributes), and the connections between them (relationships).

Before you write a single migration, before you think about table names or column types, drawing an ERD gives you a bird’s-eye view of your entire data model. It lets you spot problems, ask questions, and make decisions on paper, where changing your mind costs nothing.

I have seen developers skip this step and pay for it later. A misunderstood relationship between two entities can mean a painful migration weeks into a build, or worse, a data model that quietly produces wrong results and nobody notices until a client reports it. Fifteen minutes with a pencil and a blank page is a much cheaper way to find those problems.

The Building Blocks

Every ERD is made of three things.

Entities are the nouns in your system. The things your application tracks and stores. In Clarity, the entities we can already see from our user stories are: organisations, users, requests, comments, and attachments.

Attributes are the properties of each entity. A user has a name, an email address, and a role. A request has a title, a description, and a status. Attributes map directly to the columns you will eventually write in your migrations.

Relationships are the connections between entities. An organisation has many users. A user can submit many requests. A request can have many comments. These connections are what give your data model its shape, and getting them right is the most important part of the exercise.

Cardinality

When you draw a relationship between two entities, you need to describe how many of one thing can be connected to how many of another. This is called cardinality, and there are three basic types.

One-to-one. One record in table A corresponds to exactly one record in table B. These are relatively rare. An example might be a user having one profile, where the profile data lives in a separate table.

One-to-many. One record in table A corresponds to many records in table B. This is the most common relationship in most applications. One organisation has many users. One user has many requests. One request has many comments.

Many-to-many. Many records in table A can be connected to many records in table B. This always requires a pivot table. An example in Clarity might be if we allowed a request to be tagged, where a request can have many tags and a tag can belong to many requests.

Getting cardinality wrong is the most expensive ERD mistake. If you model a one-to-many relationship as a one-to-one, you will hit a wall the moment a second record tries to attach itself somewhere it cannot go. Drawing it out first makes these mistakes obvious before they are baked into your schema.

Drawing the Clarity ERD

Let me walk through how I would build the Clarity ERD from our user stories and shaped features.

I start by listing every entity I can see:

  • Organisation
  • User
  • Request
  • Comment
  • Attachment

Then for each entity, I list its attributes. I am not worrying about data types yet, just the fields that need to exist.

Organisation: id, name, created_at

User: id, organisation_id, name, email, password, role (client or team_member), sub_role (developer or admin, nullable), invited_at, created_at

Request: id, organisation_id, submitted_by (user_id), assigned_to (user_id, nullable), title, description, status, created_at, updated_at

Comment: id, request_id, user_id, body, is_internal, created_at

Attachment: id, request_id, user_id, filename, path, mime_type, created_at

Now the relationships:

  • An Organisation has many Users
  • An Organisation has many Requests
  • A User (client) submits many Requests
  • A User (developer) is assigned to many Requests
  • A Request has many Comments
  • A Request has many Attachments
  • A User posts many Comments

In Mermaid syntax, which I would recommend learning because it lets you write diagrams as code and store them in version control alongside your project, this looks like:

erDiagram
    Organisation {
        uuid id
        string name
        timestamp created_at
    }

    User {
        uuid id
        uuid organisation_id
        string name
        string email
        string role
        string sub_role
        timestamp invited_at
        timestamp created_at
    }

    Request {
        uuid id
        uuid organisation_id
        uuid submitted_by
        uuid assigned_to
        string title
        text description
        string status
        timestamp created_at
        timestamp updated_at
    }

    Comment {
        uuid id
        uuid request_id
        uuid user_id
        text body
        boolean is_internal
        timestamp created_at
    }

    Attachment {
        uuid id
        uuid request_id
        uuid user_id
        string filename
        string path
        string mime_type
        timestamp created_at
    }

    Organisation ||--o{ User : "has many"
    Organisation ||--o{ Request : "has many"
    User ||--o{ Request : "submits"
    User ||--o{ Comment : "posts"
    Request ||--o{ Comment : "has many"
    Request ||--o{ Attachment : "has many"

Render that in any Mermaid viewer and you get a complete picture of the Clarity data model. Every entity, every attribute, every relationship, on a single page.

Reading the Diagram

The notation on the relationship lines is worth understanding. In Mermaid ERDs, each side of a relationship is described with two symbols: one for the minimum cardinality and one for the maximum.

|| means exactly one. o{ means zero or more. So ||--o{ reads as: exactly one on the left, zero or more on the right.

Put together, Organisation ||--o{ User reads as: one organisation has zero or more users. An organisation can exist with no users yet (useful when you first create it), but every user belongs to exactly one organisation.

What the ERD Is Telling You

The real value of drawing this out is what you notice when you step back and look at it.

I can already see a potential question in the Clarity diagram. The Request entity has two foreign keys pointing at User: submitted_by and assigned_to. That is fine and intentional, but it means when you build the Eloquent relationships on the Request model, you will need named relationships rather than a single belongsTo. Something like submitter() and assignee() rather than just user(). That is a small thing, but it is much better to notice it here than halfway through writing a controller.

I can also see that Comment and Attachment both have a user_id. That means both clients and team members can post comments and add attachments, which matches our user stories. If the stories said only team members could add attachments, that user_id column would need a constraint we would need to enforce at the application layer. The diagram surfaces that decision.

This is what ERDs are actually for. Not documentation. Not formality. They are a thinking tool that forces you to look at your data model as a whole before you start building pieces of it in isolation.

Putting It Into Practice

Take the project from the previous exercises and draw an ERD for it. Start with just the entities and relationships, then add attributes once the structure feels right.

Use Mermaid if you want to keep it in code, or draw.io if you prefer a visual tool. The format matters less than the act of drawing it out and looking at what it tells you.

Pay particular attention to your foreign keys. For every relationship line you draw, ask yourself: does this direction make sense? What happens if the parent record is deleted? Is there anything here that I assumed was a one-to-many that might actually be a many-to-many?

In the next article, we are going to go deeper on relationships and cardinality, look at some of the most common ERD mistakes, and talk about how to catch the wrong relationship on paper before it becomes the wrong migration in production.

Introduction To Systems Architecture

We have a clear problem definition, a set of shaped features, and a data model we trust. At this point, a lot of developers would consider the design work done and start writing code. And honestly, for a project the size of Clarity, you probably could. The application is small enough that its structure is mostly implied by the framework.

But this series is about building the habits that carry you through larger, more complex projects. And on those projects, skipping the architecture diagram is where things start to quietly go wrong.

In this article I want to introduce you to what architecture actually means for application developers, and show you how to draw a simple system diagram for Clarity that captures how the pieces fit together.

What Architecture Means at This Level

When most developers hear “architecture”, they think about large distributed systems, microservices, cloud infrastructure diagrams with dozens of boxes and arrows going everywhere. That is one kind of architecture, and it is not what we are talking about here.

For application developers, architecture is about a simpler set of questions. What are the major components of this system? How does data flow between them? What are the boundaries between different concerns? Where do external dependencies plug in?

You do not need to be designing a distributed system to benefit from answering those questions. Even a straightforward Laravel application has meaningful architecture: a web layer that handles HTTP, a business logic layer that does the actual work, a data layer that talks to the database, and often a set of external integrations sitting alongside all of that. Understanding where those layers are and how they connect is what lets you make good decisions about where code should live.

Components vs Layers

There are two ways to think about application structure, and both are useful in different contexts.

Layers describe a vertical slice through your application. Think of the classic presentation layer, business logic layer, data access layer split. Data comes in at the top, passes through the layers, and comes out as a response. Each layer only talks to the layer directly below it. This is a useful mental model for thinking about separation of concerns inside a single application.

Components describe horizontal groupings of related functionality. Authentication is a component. The request management system is a component. Notifications are a component. A component might span multiple layers internally, but it has a clear boundary and a clear responsibility.

In practice, most applications use both mental models at once. The layers tell you how code flows inside a component. The components tell you how the system is divided at a higher level.

Drawing a System Diagram

A system diagram does not need to be elaborate. Its job is to show the major components of your system, how they connect, and where external dependencies sit. You are not trying to document every class or every database query. You are drawing a map that helps you and your team understand the shape of what you are building.

Here is a Mermaid diagram for Clarity:

flowchart TD
    Browser["Browser / Client"]

    subgraph App ["Clarity Application"]
        Web["Web Layern(Routes, Controllers, Middleware)"]
        Auth["Auth Componentn(Login, Invitation, Sessions)"]
        Requests["Request Managementn(Submit, Status, Assignment)"]
        Comments["Commentsn(Post, Internal Flag)"]
        Attachments["Attachmentsn(Upload, Storage)"]
        Notifications["Notificationsn(Status Changes, Comments)"]
    end

    subgraph Data ["Data Layer"]
        DB[("PostgreSQL")]
        Storage["File Storage"]
        Cache["Cache"]
    end

    Browser --> Web
    Web --> Auth
    Web --> Requests
    Web --> Comments
    Web --> Attachments
    Requests --> Notifications
    Comments --> Notifications
    Auth --> DB
    Requests --> DB
    Comments --> DB
    Attachments --> DB
    Attachments --> Storage
    Auth --> Cache

This is not a detailed technical specification. It is a conversation starter. It shows that Clarity has a web layer handling incoming requests, five functional components sitting behind it, a database and file storage for persistence, a cache layer, and a notifications pathway that gets triggered by changes in the request and comment components.

What the Diagram Reveals

Even a simple diagram like this surfaces useful questions.

The notifications component appears as a box but is currently undefined. What does it actually do? Does it send emails? Does it push in-app notifications? Does it use a queue? That is a decision we have deferred, and the diagram makes that deferral visible. For the initial version of Clarity, we scoped email notifications on comments out of the first cycle. The component box is still there, which means when we come back to build it, we have a clear place for it to sit.

The file storage box is separate from the database. That is intentional. Attachments are stored on disk or in an object store, not as blobs in the database. The diagram captures that decision explicitly. A developer joining the project later can see at a glance that attachments have their own storage concern, rather than discovering it buried in a model.

The cache layer connects only to the auth component in this version. That is a starting point, not a permanent constraint. As Clarity grows and request list queries become more expensive, caching will expand to cover other components. Having it on the diagram from the start means that growth is natural rather than bolted on.

Architecture as a Communication Tool

One of the things I value most about architecture diagrams is that they create a shared vocabulary for a team. When everyone can point at the same diagram and say “the request management component” or “the web layer”, discussions become sharper. You spend less time talking past each other and more time talking about the actual problem.

That shared vocabulary is especially valuable when something goes wrong. If a bug is affecting comments but not requests, a team with a clear component diagram can focus their investigation quickly. If performance is degrading under load, having the data flow documented means you can reason about where the bottleneck is likely to be.

This is one of the reasons mid-level developers tend to be better debugging partners than juniors, even when the junior has been on the codebase longer. It is not that they know more code. It is that they think in components and flows rather than individual files and functions. The architecture diagram is how you start building that mental model.

Clarity’s Architecture in Plain Language

Let me describe the Clarity architecture in prose, the way you might explain it to a new team member.

Clarity is a single Laravel application served over HTTP. All incoming requests pass through the web layer, which handles routing, authentication middleware, and request validation. Behind the web layer, the application is divided into four main functional areas: request management, comments, attachments, and authentication.

Request management is the core of the application. It covers the full lifecycle of a client request from submission through completion, including status transitions and developer assignment. Comments sit alongside request management and share the same request context, with the addition of the internal flag that controls client visibility. Attachments handle file uploads and connect to external file storage rather than the database.

Authentication covers client login via password, team member login, and the invitation flow. Session state is cached to reduce database load on authenticated requests.

Notifications are triggered by events in the request and comment components. In the first version of Clarity, notifications are out of scope. The component is acknowledged in the architecture but not built yet.

Data persistence uses PostgreSQL for relational data and a file storage service for attachments. The application does not use a search index or a message queue in the initial version.

Putting It Into Practice

Draw a system diagram for your own project using the same approach. Start with the major components you can identify, then add the data layer and any external dependencies. Draw arrows to show how data flows between them.

Then write a plain-language description of the architecture the way I did above. If you struggle to write it in a few clear paragraphs, the diagram probably needs more work. The prose and the diagram should tell the same story.

In the next article, we are going to look at separation of concerns in more detail, understand why the “fat controller” problem is really an architectural symptom, and start thinking about what belongs in each layer of a Laravel application.

🗄️Solana Accounts Are Just Files — and Programs Are Just People Who Can Read Them

The analogy that rewired my brain – The Filing Cabinet

Imagine a room filled with filing cabinets. Each drawer has a random label. Inside each drawer is a folder.

On the front of the folder is written:

  • Owner — who can open and change the contents
  • Executable — is this a set of instructions or just data?
  • Data — the actual contents (a number, JSON, raw bytes)

Anyone can walk up to any drawer and read the label. Only the Owner can modify what’s inside.

This room is Solana. The drawers are accounts. The folders are account data. The owners are programs.

💡 What This Explains

There is no “users table.” Your wallet is a file owned by the System Program. The data inside is one number — your SOL balance. “Sending SOL” means authorizing the System Program to decrease the number in your file and increase it in someone else’s.

You can’t modify every account. Only the owning program can. This isn’t a restriction — it’s the entire security model. If anyone could modify any account, the network would be chaos. The runtime enforces ownership at the validator level, not in application code.

Programs are stateless. A program is like a person who knows how to read specific file formats. The System Program reads simple balance files. The Token Program reads mint metadata and token balances. Your custom program reads whatever format you define. The program itself stores nothing — state lives in separate accounts it owns. Think of it like a web server (the program) that reads and writes to a database (data accounts) rather than keeping everything in memory.

📋 The Four Fields

Every account on Solana has the same structure:

Field What it is
Lamports The SOL balance (always in lamports — 1 SOL = 1,000,000,000)
Data Raw bytes — file contents, whatever the owner program decides
Owner The program address that controls this account
Executable Is this file a program (code) or just data?

Here’s what they look like in practice:

$ solana account TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA

Public Key: TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA
Balance: 0.00028296 SOL
Owner: BPFLoaderUpgradeab1e11111111111111111111111
Executable: true
Data: (compiled program bytecode)

A wallet, a token program, a sysvar — all use the same four fields. Different values. That’s the entire state model.

💰 Rent Exemption

Keeping a file on-chain costs storage space. Solana charges rent — a small deposit proportional to your account’s data size. Deposit enough (~2 years worth) and your account is “rent-exempt” — it stays forever.

solana rent 0     # 0.00089088 SOL (basic account)
solana rent 100   # cost for 100 bytes of data

For a basic wallet with no extra data, it’s roughly 0.00089 SOL. This deposit is refundable when you close the account. Not a bug — it’s the price of storing data in a globally replicated system.

🔑 Three Things That Suddenly Make Sense

1. Ownership rules. Only the owner program can modify an account’s data or deduct lamports. Anyone can credit lamports to any writable account. Simple, powerful, enforced by every validator.

2. PDAs. Sometimes a program needs a file it controls but no private key can sign for it. Program-Derived Addresses are deterministic drawer labels — computed from program ID + seeds — that only the program can authorize. Like a combination lock where only the program knows the combination.

3. Composability. Every file declares its owner. Anyone can read. Only the owner can write. Programs chain together: Program A reads a Token account to check your balance, then asks the System Program to move SOL. All in one atomic transaction. That’s composability.

🎯 Why This Matters

Before this analogy clicked, every Solana concept felt like learning a new language from scratch. After it clicked, the documentation became readable. Error messages became decipherable. I stopped asking “what is an account?” and started asking “what kind of file is this, and who owns it?”

If you’re coming from Web2 and struggling with Solana’s account model, try the filing cabinet. You’ll know it clicked when you stop thinking about tables and start thinking about drawers.

This is the final post in a 4-part series on unlearning Web2 mental models for Solana:

  1. 📊 Solana Is a Public Spreadsheet — Not Your Private Database
  2. 🔌 The “Connect Wallet” Button Replaced My Entire Auth System
  3. ✍️ A Solana Transaction Is a Signed Check — Not an API Request
  4. You’re reading it.

This is a submission for the 100 Days of Solana Writing Challenge, running from 15 May to 22 May.

From Diagram To Implementation Plan

We have covered a lot of ground in this series. We started with a vague client brief, interrogated it until the real requirements surfaced, wrote user stories with acceptance criteria, shaped those stories into bounded features, drew an ERD, built a system diagram, and looked at how layers give every piece of code a clear home.

That is the full design process. Now we need to translate it into something a developer can actually execute.

This article is about sequencing. Taking everything we have designed and turning it into an ordered build plan that respects dependencies, minimises wasted work, and lets you deliver value as early as possible.

Why Sequence Matters

A common junior developer instinct is to start with the most interesting part. The feature that sounds fun to build, the component that uses a technology you want to learn, the part of the system that feels most novel. That is understandable, but it is usually the wrong starting point.

The right starting point is the foundation. The code that everything else depends on. If you build the request management system before you have authentication in place, you will either need to stub out user identity in ways that do not reflect reality, or you will need to go back and retrofit it later. Both options cost time you cannot get back.

Sequencing is the discipline of asking: what has to exist before this can be built? Work backwards from every feature until you hit something that has no dependencies, and that is where you start.

Identifying Dependencies

Let me map the dependencies for the Clarity features we shaped in article four.

Client authentication and onboarding has no dependencies on other features. It needs the database and the user model, but those are foundational. This goes first.

Team member management depends on authentication being in place (admins need to be logged in to invite people) and on the user model having role support. It is almost as foundational as authentication itself, and it needs to exist before any meaningful testing of the application can happen.

Request submission depends on authentication (to know who is submitting) and on organisations existing (to associate the request correctly). Team member management should also be done first so there is at least one admin user to test with.

Request status and assignment depends on requests existing. It also depends on team members existing, because you cannot assign a request to a developer who does not exist in the system. This comes after submission.

Comment thread depends on requests existing. It could technically be built in parallel with request status and assignment, but sharing the request detail page means they are better built in sequence to avoid integration friction.

Request list views depend on requests existing and on the basic request detail page being in place. This should be the last feature built, because it is a read view over data that all the other features create.

The Build Order

With dependencies mapped, the sequence becomes clear:

  1. Foundations: database, models, migrations, and base application structure
  2. Authentication: client login, team member login, invitation flow
  3. Team member management: invite, role assignment, deactivation
  4. Request submission: form, validation, attachment upload, status initialisation
  5. Request status and assignment: status transitions, developer assignment, client visibility
  6. Comment thread: posting, internal flag, client-facing view
  7. Request list views: client list, team list, basic status filtering

Each step builds on the last. Each step produces something testable before the next step begins. And each step delivers a piece of working software rather than a collection of half-built components.

Writing the Tickets

A build order is not yet a set of tickets. You still need to break each step into discrete pieces of work that a developer can pick up and complete in a day or two.

Here is how I would break down step four, request submission, into individual tickets.

Ticket: Request model and migration
Create the requests table migration with all columns from the ERD. Create the Request Eloquent model with the HasUlids trait, fillable columns, and the submitter and assignee relationships. Write a model factory for use in tests.

Ticket: Attachment model and migration
Create the attachments table migration. Create the Attachment model with its relationship back to Request. Configure the file storage driver. Write a model factory.

Ticket: Submit request endpoint
Create the StoreRequestRequest form request with validation rules and a payload() method. Create the StoreRequestPayload DTO. Create the SubmitRequest action. Create the StoreController. Wire up the route. Write feature tests covering successful submission, validation failures, and attachment handling.

Ticket: Request detail page
Create the Livewire component for the request detail view. Display title, description, status, assignee, and attachments. Scope the view so clients only see their own requests. Write feature tests covering client access and unauthorised access attempts.

Four tickets for one feature, each independently completable and testable. A developer picking up any one of these knows exactly what done looks like, because the acceptance criteria from the user stories map directly onto the test cases.

The Relationship Between Stories and Tickets

User stories describe what the system should do from a user’s perspective. Tickets describe the technical work required to make that happen. They are not the same thing, and conflating them is a common source of confusion.

A single user story often maps to multiple tickets. The “submit a request” story from article three maps to at least the four tickets above. That is fine. The story is the goal. The tickets are the path.

When you write a ticket, you should be able to point to the user story it is serving. If a ticket cannot be connected to a user story, ask whether it needs to exist. Infrastructure work (setting up CI, configuring deployment, writing base test helpers) is an exception, but application feature work should always be traceable back to a user need.

Checking the Plan Against the ERD

Before you start building, do one final check. Go back to your ERD and make sure every entity and every relationship has a corresponding ticket.

In the Clarity plan, let me verify:

  • Organisation: covered in the authentication step
  • User: covered in authentication and team member management
  • Request: covered in request submission
  • Comment: covered in the comment thread step
  • Attachment: covered in request submission
  • All relationships: each foreign key maps to a ticket that creates the parent entity before the child entity

If any entity in your ERD does not have a corresponding ticket, you have a gap. Either you have forgotten something, or that entity is not actually needed for the current scope. Either way, better to find that now than two weeks into the build.

The Plan as a Living Document

A build plan is not a contract. Requirements change, technical discoveries shift priorities, and features that seemed simple turn out to be more complex than they appeared. A good plan is one that absorbs those changes without falling apart.

The way you make a plan resilient is by keeping the dependencies clear. If you know that request status depends on request submission, and request submission depends on authentication, then when authentication takes longer than expected you can immediately see what is blocked and communicate that clearly. You are not surprised. You are just updating the schedule with an accurate picture of what is affected.

That kind of clarity is what teams rely on mid-level developers to provide. Not perfect predictions, but an accurate, up-to-date map of where the work stands and what it connects to.

Putting It Into Practice

Take the project you have been working with throughout this series. Map the dependencies between your shaped features. Write a build order. Then break the first two steps into individual tickets.

For each ticket, make sure you can answer these questions: what does done look like? What does it depend on? Which user story does it serve?

If any of those questions is hard to answer, the ticket needs more work before anyone picks it up.

In the final article, we are going to step back from Clarity and talk about what this entire process represents: the shift in thinking that defines a mid-level developer, and where to take these skills from here.

Writing Features Not Tasks

If you have ever looked at a ticket in your backlog and felt genuinely unsure where to start, there is a good chance the ticket was written as a task rather than a feature. It is one of the most common sources of confusion for junior developers, and it is almost never talked about directly.

The difference matters more than you might think. Tasks describe what to build. Features describe why it needs to exist and what it should enable. That distinction changes everything from how you estimate work, to how you test it, to how you know when you are actually done.

In this article we are going to look at what a properly written feature actually looks like, introduce the concept of user stories as a tool for capturing them, and apply all of it to Clarity using the confirmed assumptions we established in the last article.

Tasks vs Features

Let me show you the difference with a concrete example.

Here is a task:

Add a status column to the requests table.

Here is the same thing written as a feature:

As a client, I want to see the current status of my submitted request, so that I know whether the team has started working on it.

The task tells a developer to do something. The feature tells everyone involved why it matters, who benefits from it, and what outcome it is trying to produce.

That distinction has real consequences. If I hand you a task, you can complete it and technically be done. You can add the column, run the migration, and close the ticket. But you have not built anything a user can actually interact with. The feature, on the other hand, only counts as done when a client can log in, find their request, and read a meaningful status value. That is a fundamentally different definition of done.

The format I just used has a name. It is a user story.

What a User Story Actually Is

A user story follows a simple template:

As a [type of user], I want to [do something], so that [I achieve some goal].

Each part of that template is doing a specific job.

The actor (“as a client”) anchors the story to a real person in your system with a real perspective. It forces you to think about who is actually going to use this thing, rather than thinking about it in the abstract.

The action (“I want to see the current status”) describes the behaviour the system needs to support. It is written from the user’s point of view, not the developer’s. You will notice it says “see the status”, not “query the status from the database”. That is deliberate. Implementation details do not belong in a user story.

The goal (“so that I know whether the team has started working on it”) is the part most people skip, and it is often the most valuable. The goal is what gives you a basis for questioning whether you are building the right thing. If you cannot articulate a meaningful goal for a feature, that is a signal worth paying attention to.

Acceptance Criteria

A user story on its own is not quite enough to build from. You also need acceptance criteria: a concrete list of conditions that must be true for the story to be considered complete.

Acceptance criteria answer the question: “how do we know this is done?”

Here is the status visibility story with acceptance criteria added:

Story: As a client, I want to see the current status of my submitted request, so that I know whether the team has started working on it.

Acceptance criteria:

  • The request detail page displays the current status
  • The status reflects one of the defined values: submitted, in review, in progress, on hold, completed
  • The status updates in real time when a team member changes it, without requiring a page refresh
  • The client cannot change the status themselves; the field is read-only for them

Now you have something you can actually build against. You have a clear definition of done, a set of testable conditions, and a user perspective that keeps you grounded in what actually matters.

Writing Stories for Clarity

Let us take the confirmed assumptions from the last article and turn them into a proper set of user stories. I am going to organise them by actor, because that is the most natural way to think about the scope of work.

Client stories

Submitting a request

As a client, I want to submit a project request with a title, description, and optional attachments, so that I can communicate what I need from the team.

Acceptance criteria:

  • The submission form requires a title and description
  • Attachments are optional and support common file types
  • After submission, the request status is set to “submitted” automatically
  • The client is redirected to the request detail page after a successful submission

Editing a request

As a client, I want to edit my request while it is in the submitted state, so that I can correct mistakes or add information before the team begins reviewing it.

Acceptance criteria:

  • Edit controls are visible only when the request is in the “submitted” state
  • Once the status moves past “submitted”, the edit controls are hidden
  • Edits do not reset the request status

Tracking request status

As a client, I want to see the current status of my request and who it has been assigned to, so that I have visibility into where things stand without needing to chase the team.

Acceptance criteria:

  • The request detail page shows the current status and the assigned developer’s name
  • If no developer has been assigned yet, a placeholder is shown rather than a blank field
  • Status changes are reflected without requiring a page refresh

Adding a comment

As a client, I want to add a comment to my request, so that I can ask questions or provide additional context as the work progresses.

Acceptance criteria:

  • Clients can post a comment on any request they own
  • Comments appear in chronological order
  • Internal comments marked by team members are not visible to clients

Team member stories

Reviewing and updating request status

As a team member, I want to update the status of a request, so that clients and colleagues have an accurate picture of where the work stands.

Acceptance criteria:

  • Team members can move a request to any valid status
  • Status changes are logged with a timestamp and the team member’s name
  • The client is notified when the status changes

Assigning a request

As a team member, I want to assign a request to a developer, so that ownership is clear and the developer knows what they are responsible for.

Acceptance criteria:

  • Any team member can assign a request
  • Only one developer can be assigned at a time
  • Reassigning a request replaces the previous assignment rather than stacking them

Adding an internal comment

As a team member, I want to mark a comment as internal, so that I can communicate with colleagues about a request without the client seeing the discussion.

Acceptance criteria:

  • Internal comments are visually distinct in the team view
  • Internal comments are completely hidden in the client view
  • A team member can toggle the internal flag before submitting a comment

Admin stories

Managing team members

As an admin, I want to invite and manage team members, so that I can control who has access to the system and what role they hold.

Acceptance criteria:

  • Admins can invite users by email
  • Invitations expire after 48 hours if not accepted
  • Admins can change a team member’s role or deactivate their account

What This Set of Stories Gives You

Look at what we have built here. From a single vague paragraph in a client brief, we now have a complete set of structured, testable, actor-anchored stories that describe the entire surface area of the Clarity application.

Every story has a clear definition of done. Every story is written from a user’s perspective. And every story is independent enough that a developer could pick one up and work on it without needing to understand the whole system first.

That last point matters a lot when you are working in a team. Vague tasks create dependencies and confusion. Well-written stories create clarity (no pun intended) and autonomy.

Putting It Into Practice

Take the project from the last article’s exercise and write three user stories from it. Pick stories from different actors if you can. For each story, write at least three acceptance criteria.

Then ask yourself honestly: could someone else on your team pick up one of those stories and know exactly what done looks like? If the answer is no, the story needs more work.

In the next article, we are going to move from user stories to feature shaping, and look at how the Shape Up methodology gives you a practical framework for deciding what to build and how much time it is worth.

Applied for a job and found an edge case in their code they had not covered

I applied to a company for developer role. However, when I usually apply for jobs I usually add the company name to my email. For instance [email protected] becomes [email protected]. Totally valid way to keep track of who perhaps leaked your email.

So now this company sent me a link to a test they wanted me to take with my email address as a query parameter. Question to the class, does anyone see the problem in this link?

https://linktotest.com/[email protected]

No? Let’s talk about URL encoding. URLs use specific characters for structure. For instance, :, /, ?, = and + are all valid structural parts of this URL

https://www.mywebsite.com/path?type=star+wars

But what happens when we want to use those same characters as data and not structure in the URL?

Say that in the query params, we want to pass the email(Very insecure, do not do this)

but this user is like me and has a + in their email so now we have

plaintext

These query params are not the same.
For the first one translates to

email = "[email protected]"

plaintext
And the second

email = "user [email protected]"

plaintext

The + symbol encodes a single whitespace in the URL IFF(if and only if) it occurs in query params!!! When + occurs before the ? then it is just a plus symbol. And this explains why it causes bugs sometimes.

Path:     /files/hello+world.txt     → + is a LITERAL plus sign
Query:    ?search=hello+world        → + is a SPACE

plaintext

So now the link the company sent me kept raising an error saying this email doesn’t exist.

The fix?

Encode the + symbol. so

https://emailfinder.com?email=user%[email protected]

You can read a breakdown on URL Encoding here here

After making that substitution, it worked and I was able to schedule my interview. Fingers crossed I pass the interview, but it was a fun edge case to run into.

Cloud Engineer Journey #6 — EC2 Explained Simply & Launching Your First Cloud Server

After understanding:

  • Linux fundamentals
  • AWS basics
  • and Cloud Computing concepts,

it’s time to work with one of the most important AWS services:

☁️ Amazon EC2

EC2 is one of the core services in AWS and is heavily used in:

  • Cloud Engineering
  • DevOps
  • Hosting applications
  • Automation
  • CI/CD
  • Docker & Kubernetes environments

In this post, we’ll understand:

  • what EC2 actually is,
  • why companies use it,
  • and how to launch your first cloud server step by step.

I’ll keep everything beginner-friendly and practical.

☁️ What Is EC2?

EC2 stands for:

Elastic Compute Cloud

EC2 allows you to create virtual servers in the cloud.

Think of EC2 like:
🖥️ renting a computer/server online whenever you need it.

Instead of buying physical hardware, AWS lets you launch servers within minutes.

These servers can run:

  • websites,
  • applications,
  • databases,
  • APIs,
  • automation tools,
  • and many cloud workloads.

🚀 Why EC2 Is Important

Most modern cloud applications run on servers.

EC2 helps companies:

  • deploy applications quickly,
  • scale resources,
  • reduce hardware costs,
  • and manage infrastructure more easily.

It is one of the most commonly used AWS services.

🧠 Real-World Example

Imagine you want to host:

  • a website,
  • a backend application,
  • or a Jenkins server.

Instead of buying a physical machine:
👉 you can launch an EC2 instance in AWS within minutes.

This is one of the main reasons cloud computing became so popular.

🖥️ What Is an EC2 Instance?

When you launch a server in AWS, it is called an:

EC2 Instance

Each instance includes:

  • CPU
  • Memory (RAM)
  • Storage
  • Networking
  • Operating System

Just like a real computer.

📦 Important EC2 Concepts

Before launching an EC2 instance, there are a few important concepts to understand.

🧾 1. AMI (Amazon Machine Image)

An AMI is a preconfigured operating system template.

Example:

  • Amazon Linux
  • Ubuntu
  • Red Hat

Think of it like:
💿 selecting which operating system you want to install on your server.

⚡ 2. Instance Type

Instance type decides:

  • CPU power
  • RAM size
  • performance level

Example:

“`bash id=”22j9mo”
t2.micro




This is commonly used in AWS Free Tier.

---

# 🔐 3. Key Pair

AWS uses SSH keys for secure login.

When creating an EC2 instance, AWS generates:

* a public key
* and a private key

The private key (`.pem` file) is used to connect to the server securely.

---

# 🛡️ 4. Security Groups

Security Groups act like virtual firewalls.

They control:

* incoming traffic
* outgoing traffic

Example:

* Allow SSH (port 22)
* Allow HTTP (port 80)

Without proper Security Group rules, you cannot access the server.

---

# 🌍 5. Region

AWS has multiple regions worldwide.

Example:

* Mumbai
* Virginia
* Singapore
* London

Choosing a region closer to users improves performance and reduces latency.

---

# 🚀 Launching Your First EC2 Instance

Basic steps:

### 1. Open AWS Console

Search for:



```bash id="mf4ib7"
EC2

2. Click “Launch Instance”

3. Select an AMI

Example:

  • Amazon Linux 2023

4. Choose Instance Type

Example:

“`bash id=”7rk0p7″
t2.micro




---

### 5. Create or Select Key Pair

Download the `.pem` file safely.

---

### 6. Configure Security Group

Allow:

* SSH (22)

Optional:

* HTTP (80)
* HTTPS (443)

---

### 7. Launch Instance

AWS will now create your cloud server.

---

# 🔗 Connecting to the EC2 Instance

Once the instance is running, connect using SSH.

Example:



```bash id="m1v2p2"
ssh -i key.pem ec2-user@your-public-ip

Now you are connected to your cloud server 🚀

☁️ Why EC2 Matters in Cloud & DevOps

EC2 is heavily used in:

  • application hosting,
  • automation,
  • CI/CD pipelines,
  • Docker setups,
  • Kubernetes clusters,
  • monitoring tools,
  • and cloud infrastructure.

Understanding EC2 is one of the biggest first steps in Cloud Engineering.

🛠️ Mini Challenge

Try this on AWS:

Task:

  1. Launch an EC2 instance
  2. Use Amazon Linux AMI
  3. Select t2.micro
  4. Create a key pair
  5. Configure Security Group for SSH
  6. Connect to the instance using SSH

👉 In the next post, I’ll explain the solution and common beginner mistakes step by step.

🎯 Final Thoughts

EC2 may sound advanced at first, but the core idea is simple:

👉 AWS gives you virtual servers on demand.

Instead of managing physical infrastructure, you can launch servers within minutes and use them for real-world applications.

This is one of the most important foundations in AWS and Cloud Engineering ☁️

If you are learning AWS, Linux, or Cloud basics and need help with even small doubts, feel free to connect with me through LinkedIn or email — always happy to learn and grow together 🚀

CloudEngineerJourney #AWS #EC2 #CloudComputing #DevOps #Linux #BeginnerFriendly

Email Monitoring with MailWebhook and Zabbix

SMTP checks can tell you whether a server appears healthy. They do not always tell you whether a real message made it through the path your users depend on.

This recipe uses MailWebhook as the arrival detector and Zabbix as the monitoring system. The goal is simple: send a tagged canary email, report success only when that email arrives, and let Zabbix alert when the success heartbeat goes missing.

Pattern

  1. A cron job sends a canary email through an external sender.
  2. The sender resolves MX and delivers to the mailbox under test.
  3. MailWebhook watches that mailbox.
  4. When the canary arrives, MailWebhook calls Zabbix history.push().
  5. Zabbix alerts if no OK heartbeat arrives within the expected window.

The key idea is to alert on missing success, not on every possible delivery failure.

When the canary arrives, MailWebhook calls Zabbix history.push(). Zabbix alerts only if no OK heartbeat arrives within the expected window.

The key idea is to alert on missing success, not on every possible delivery failure.

Canary tagging

Do not send a generic test email. Send a tagged canary:

Subject: MW-CANARY v=1 env=prod mailbox=mx1-prod sender=postmark id=<uuid> ts=<utc>

Each tag has a job:

MW-CANARY      route this email as a canary
env            avoid mixing prod/staging
mailbox        map the OK to the right Zabbix item
sender         identify the external sender path
id             correlate sent vs received canary
ts             keep sent time for troubleshooting or future latency checks

For the first version, Zabbix only needs the OK heartbeat. This canary:

MW-CANARY v=1 env=prod mailbox=mx1-prod sender=postmark id=7f3a9c ts=2026-05-18T12:00:00Z

maps to this Zabbix item key:

mail.canary.ok[prod,mx1-prod]

Zabbix setup

Create a host:

Host: mail-deliverability

Create one Zabbix trapper item per mailbox path:

Name: Canary OK: prod mx1-prod
Key: mail.canary.ok[prod,mx1-prod]
Type: Zabbix trapper
Type of information: Numeric unsigned

Then add a trigger expression using nodata():

nodata(/mail-deliverability/mail.canary.ok[prod,mx1-prod],10m)=1

Example tuning:

Send interval: 1 minute
Warning: nodata(...,5m)=1
Critical: nodata(...,10m)=1

For noisier paths:

Send interval: 5 minutes
Warning: nodata(...,15m)=1
Critical: nodata(...,30m)=1

This avoids flapping because one delayed email does not immediately create an incident. Zabbix only alerts when the OK heartbeat has been missing for longer than the threshold.

MailWebhook endpoint

Create a MailWebhook endpoint pointing to the Zabbix JSON-RPC API:

https://zabbix.example.com/zabbix/api_jsonrpc.php

Add a custom header:

Authorization: Bearer <ZABBIX_API_TOKEN>

MailWebhook route

Use MailWebhook route rules to match only canary emails:

{
  "to_emails": ["[email protected]"],
  "from_domains": ["your-canary-sender.example"],
  "subject_regex": [
    "^.*\bMW-CANARY\b.*\benv=[A-Za-z0-9_-]+\b.*\bmailbox=[A-Za-z0-9_-]+\b.*$"
  ]
}

MailWebhook custom JSON payload

Use map.custom_json to emit the Zabbix history.push() request. The expressions below use the MailWebhook JsonLogic-style DSL for regex.replace and cat.

{
  "pipeline": {
    "steps": [
      {
        "name": "map.custom_json",
        "args": {
          "version": "v1",
          "vars": [
            {
              "name": "env",
              "expr": {
                "regex.replace": {
                  "value": { "var": "message.subject" },
                  "pattern": "^.*\benv=([A-Za-z0-9_-]+)\b.*$",
                  "with": "\1"
                }
              }
            },
            {
              "name": "mailbox_id",
              "expr": {
                "regex.replace": {
                  "value": { "var": "message.subject" },
                  "pattern": "^.*\bmailbox=([A-Za-z0-9_-]+)\b.*$",
                  "with": "\1"
                }
              }
            },
            {
              "name": "zabbix_key",
              "expr": {
                "cat": [
                  "mail.canary.ok[",
                  { "var": "vars.env" },
                  ",",
                  { "var": "vars.mailbox_id" },
                  "]"
                ]
              }
            }
          ],
          "output": {
            "jsonrpc": "2.0",
            "method": "history.push",
            "params": [
              {
                "host": "mail-deliverability",
                "key": { "var": "vars.zabbix_key" },
                "value": 1
              }
            ],
            "id": 1
          }
        }
      }
    ]
  }
}

For the example subject above, MailWebhook sends this to Zabbix:

{
  "jsonrpc": "2.0",
  "method": "history.push",
  "params": [
    {
      "host": "mail-deliverability",
      "key": "mail.canary.ok[prod,mx1-prod]",
      "value": 1
    }
  ],
  "id": 1
}

Canary sender

Here is a minimal cron sender:

#!/usr/bin/env bash
set -euo pipefail

ENV="prod"
MAILBOX_ID="mx1-prod"
SENDER_ID="postmark"
TO="[email protected]"
FROM="[email protected]"

CANARY_ID="$(uuidgen)"
TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)"

SUBJECT="MW-CANARY v=1 env=${ENV} mailbox=${MAILBOX_ID} sender=${SENDER_ID} id=${CANARY_ID} ts=${TS}"

sleep "$(( RANDOM % 20 ))"

printf 'deliverability canaryn' | mail 
  -s "$SUBJECT" 
  -r "$FROM" 
  "$TO"

Cron:

* * * * * /opt/mail-canary/send-canary.sh

The jitter prevents all checks from landing at the exact same second.

Operational notes

Simple start:

One mailbox path -> one Zabbix trapper item -> one nodata trigger

Add dimensions only when needed:

mail.canary.ok[prod,mx1-prod]
mail.canary.ok[prod,mx1-prod,postmark]
mail.canary.ok[prod,mx1-prod,ses]

Multi-sender checks are useful when you need to distinguish mailbox-path failures from sender-path failures.

One caveat: direct delivery to Zabbix is the simplest implementation, but Zabbix will not validate MailWebhook webhook signatures or a canary HMAC token by itself. For stricter security, put a tiny gateway in front: verify the MailWebhook signature, optionally validate a canary token, then forward history.push() to Zabbix.