DEV Community

Cover image for Dev Log: 2026-08-09 — 72 classes that should have been 8 rows, and a compliance clock
Nasrul Hazim
Nasrul Hazim

Posted on

Dev Log: 2026-08-09 — 72 classes that should have been 8 rows, and a compliance clock

Sixteen commits, all on one private deployment platform, spread across a very long day — a batch just after midnight and then a second run in the late afternoon.

The big architectural thread of the day — drivers that admit what they can't do — is written up properly in a separate post. This is everything else, which turned out to have its own through-line: most of it was about making a claim checkable.

1. Managed services as data, not as 72 classes

The catalogue seeds 24 managed service types. Four of them had provisioners. So a blueprint asking for MongoDB, RabbitMQ or MinIO got refused at validation — a clear failure, but a missing capability.

Filling that gap the obvious way meant 20 types × 3 backends = 60 more near-identical classes, each a copy of its two siblings, with one service's image tag and port living in three files that immediately start drifting apart.

Each of those classes was already about fifty lines of pure declaration — image, port, env, command, readiness probe, connection string — sitting inside a base class that held all the actual behaviour. That's not a class. That's a row.

final readonly class ComponentDefinition
{
    public function __construct(
        public string $slug,
        public string $image,
        public int $port,
        public string $connection,
        public array $env = [],
        public array $command = [],
        public ?array $k8sCommand = null,
        public ?string $readiness = null,
        // ...
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

Twelve hand-written provisioners became eight definitions and three thin adapters that answer their base class's seams from the data. Net: 679 lines added, 742 removed, and adding a service is now an entry, not a directory.

Three things the format had to admit rather than paper over, and they're the interesting part:

  • k8sCommand exists because the shells genuinely differ. Docker needs sh -c to expand $VAR; kubelet expands $(VAR) itself and a shell there is an extra process for nothing. Redis is the case. Pretending one command fits both would have shipped a broken Redis on one backend. When two things are 95% the same, the escape hatch for the 5% is not a design failure — pretending the 5% doesn't exist is.
  • No readiness probe resolves to false, never to ready. Nothing to check must not mean nothing to wait for.
  • A slug that isn't in the registry stays refused. An entry that merely guessed an image and a port would provision something that starts, accepts connections, and stores nothing where anyone expects. Kafka and friends are honestly absent, and a test asserts they are.

The refactor caught two things I'd have shipped otherwise, both because I kept the old tests running against the new implementation: a readiness probe I had quietly tightened, and one backend's --entrypoint argument that the definition initially applied only on the other. Both restored byte-identical.

A refactor that changes behaviour is not a refactor. The old tests are the only thing that can tell you which one you did.

There's also a test that walks every definition against the seeded catalogue, so a definition for a slug the picker never offers can't masquerade as coverage.

2. Scanning the images that are actually running

Vulnerability scanning went from a fake scanner to a real one wired to a CLI scanner, with severity as a proper enum and a scheduled command.

The framing in the commit title is the bit worth keeping: the images that are actually running. Not the images in the manifest, not the images in the registry, not the base images someone listed in a spec once. A CVE report about an image you aren't running is noise that trains people to ignore the report.

It also grew a scanner-availability check, because a security scan that silently doesn't run is — you'll recognise the pattern by now — worse than one that isn't installed.

3. Finishing the alert lifecycle

Alert rules and incidents already existed. What didn't exist was the part where anything happens: evaluation, notification, escalation.

So: an evaluator, a notifier, a mailable, an escalated_at timestamp on the response record, and a scheduled command to drive it. An alert rule with no evaluator is a monitoring dashboard's version of the same lie — it looks like coverage.

Paired with a real Prometheus / Grafana / Loki adapter to replace the simulated one, and a blueprint change that provisions the observability and identity layers as part of a deployment rather than as a thing you remember to do afterwards.

4. A compliance clock, and what a clock has to be

PDPA work: data residency validation, data subject export, and breach notification.

Two design notes generalise well beyond one jurisdiction's privacy law:

Residency is validated at blueprint time, not at audit time. The check went into the blueprint validation step, so a deployment that would place data in the wrong region fails before it exists. Compliance you can only discover after the fact isn't a control, it's a report.

A breach clock needs a start it didn't choose. The notification window runs from when the breach was detected, and detection has to be recorded by whatever noticed it — not set by the person who later has to explain the delay. A field that the interested party fills in is a field you can't rely on.

Subject access got a console command as well as a UI path, which is the pragmatic call: these requests arrive rarely, urgently, and often to someone who isn't going to click through an admin panel under time pressure.

5. Three checks before the deploy button

The onboarding checklist happily sends a new user to the deploy button, where three well-documented failure modes are waiting — all of which look like the application being broken:

  • No queue worker on the right connection: the UI says "provisioning started, this page updates live", and then nothing happens. Forever.
  • No scheduler: certificates lapse and rotation windows never close, silently.
  • A provider that resolves to fakes: the deployment reports Active with no infrastructure behind it.

A preflight check answers all three before the last step, and each answer carries what to do about it — a failing check that doesn't say how to fix it is just a red badge.

The thing that took the actual work was making each check answerable rather than plausible:

  • Laravel records nothing when a scheduled task fires, so the scheduler stamps a heartbeat every minute. Without it the check could only ever return "unknown", which is a guard that never fires wearing a checkmark.
  • The queue check asks whether an unclaimed job has been sitting for two minutes — not whether a worker is configured. A configured worker that isn't running looks identical from the config.
  • The provider check had to move from per-type to per-provider, because an adopted VM is real with credentials and fake without them. The type-level answer would have told an operator their half-configured provider could deploy.

That last one is a good general rule: check the instance, not the class. Configuration is a claim; state is the answer.

6. Helm chart, and bootstrap templates as data

Two smaller ones. A Helm chart so the platform itself installs onto Kubernetes the way anything else does — dogfooding, and also the fastest way to find out which of your config values were secretly required.

And bootstrap templates moved into seeds with the form able to set the rules they carry, which is the same move as §1: a template is data, and data belongs in a table where you can see all of it at once.

What's next

The definition registry covers eight services out of twenty-four. The remaining sixteen are now a data-entry problem with a test that refuses to let me fake it, which is roughly the best shape an unfinished feature can be in.

Top comments (0)