LEStra - the vibe coded version of VestacP
It all started with a discussion about vibe coding, was it good or bad, ethical or unethical?
From that thread, a challenge was accepted in the cest-pit ... and a new thread were created about this project. https://lowendspirit.com/discussion/11268/vibe-coding-a-vestacp-alternative
That thread is the discussion thread about this project. This thread is only for the documentation and story abou the project.
As one of the main things said in the beginning was to keep manual entries at a minimum, most of the underlaying sources are created by Claude Code.
After each Plan phase and each Code phase, updates are timestamped and posted in a Decision.log.md file. That is the main source.
At first the plan was also to release this as seperate blog posts, but already, a couple of days into the project, we already have 10 parts, each part is aprox. a 10 minute read.
So this thread was created (and closed), to have everything without comments cluttering up the thread.
If you have a comment about it, use the thread mentioned above.
“Technology is best when it brings people together.” – Matt Mullenweg




Comments
Not a patch. A demolition.
VestaCP has a function called
search_objects. Ask it for a domain's IP address and it does not parse a config file. It runsevalon the config file. The value you asked for gets executed as shell code on its way back to you, on every list screen, every single time. This is not a bug report about one bad line. It's the actual read path for the entire panel, and it's the reason this series exists.This is the first post in a new series: the build log for LESta, a from-scratch Laravel and React rewrite of VestaCP, written almost entirely through prompts to Claude Code rather than by hand. Each part covers real ground the project has already crossed, not a roadmap of what's planned. This part covers why the rewrite happened at all, which means it starts with what's wrong with the thing being replaced.
What Vesta actually is
If you've spent any time in the budget-VPS end of hosting, you've probably run into VestaCP already, or at least a fork of it (HestiaCP and others exist for a reason, and that reason is on this page). It's a free, open-source control panel: web server, DNS, mail, databases, cron, backups, all managed from one dashboard, no cPanel license fee attached. For a lot of small hosts and hobbyists, it's been the default answer to "how do I give someone a panel without paying per account for the privilege."
It's also, underneath the dashboard, a React single-page app talking to a PHP API that does nothing but build shell command strings, which get handed to 379 bash scripts, which read and write their state as flat text files under
/usr/local/vesta/data. Four tiers, three languages, and the type system evaporates completely at every single boundary between them.Part of why this project exists at all is that Vesta has been quietly load-bearing for the cheap end of hosting for well over a decade. It's the reason HestiaCP forked off it in 2017 rather than starting from nothing, and a fair few one-person hosting operations you've probably bought a VPS from at some point are running it, or a fork of it, right now, on a box you're paying five dollars a month for. That's not a knock. It's exactly why the state of the code underneath it is worth taking seriously instead of writing off as "old software, who cares."
A full engineering pass against the codebase, pinned at commit
5f4ee2efonoutroll/vestaso every number below is checked against one exact snapshot rather than "whatever's on GitHub today", came back with some numbers worth sitting with before touching any code:That last number is the one to actually worry about, but it's not even the strangest one on the list.
Reading a record means running it
Here's the read path, straight out of
func/main.sh:341:Every record on disk is a line of shell variable assignments. Reading one back means feeding it to
eval, twice, in the same function. This isn't a one-off shortcut somebody forgot to clean up. It's the load-bearing mechanism for the entire persistence layer, and a dedicated grep for it turns up 105 sites doing exactly this acrossbin/andfunc/, out of 191 totalevalcalls in the tree once you count every use rather than just the record reads.The format validators that are supposed to keep a stored value safe to
evalare blocklists of shell metacharacters, not allowlists, and they're inconsistently applied between resource types. Which means the actual security boundary between "text a tenant typed into a form" and "code that runs as root the next time anyone lists that resource" is: whatever characters somebody remembered to blocklist for that particular field, checked once, at write time, years ago.The read path for every record in the panel, in one function.
The second bug makes the first one invisible
Every single one of Vesta's
v-list-*scripts builds its JSON output withecho, no escaping. A mail autoreply body, a cron command line, a user's last name, anything with a stray quote or backslash in it corrupts the JSON. That's 73 of the 80v-list-*scripts, silently capable of shipping broken output the moment a tenant types an apostrophe somewhere reasonable, like their own name.Normally that would at least throw a visible error. It doesn't, because line 2 of essentially every one of Vesta's API endpoint files reads
error_reporting(NULL). PHP'sjson_decodegets the malformed JSON, returnsnull, the endpoint tries to iterate it anyway, and the whole thing fatals behind a wall that was specifically built to hide fatal errors from the person trying to debug them. The operator sees a blank page. The log stays empty. Nothing anywhere tells you what just happened, because that's the one thing this line of code was written to prevent.This is also, not coincidentally, the reason a bug in the field that reports which file extensions get cached by the reverse proxy has apparently been shipping wrong output to every user of that feature for who knows how long, with zero errors logged, because nobody could see it happen.
And then there's the one that just writes files
No path constraint. None.
Buried in the API bootstrap, at
web/api/index.php:105and its byte-identical twinweb/api/v1/index.php:105(they're the same file, copy-pasted, which is its own small horror), there's a branch calledv-make-tmp-file. It takes$_POST['arg2']as a path and$_POST['arg1']as content, and writes the second to the first. No path constraint. None. It sits behind authentication, which is the only thing standing between "logged in as a normal tenant" and "arbitrary file write anywhere the web server's PHP process can reach."Put those three together, the eval-based reads, the error suppression that hides the JSON bug, and the unconstrained file write, and you get a fairly complete picture of why the answer here isn't "patch a few lines." And that's before getting to the missing locks. Every write in the panel is a full read-modify-write of a flat file, and there isn't a single
flockanywhere in the 103 scripts that mutate one. Picture two admins on the same reseller account creating a domain each within the same second, which happens constantly on a busy panel. Both scripts read the account's current domain count, both add one, both write back the same "new" total. One domain gets created for real. The counter only ever heard about one of them. Nothing crashes, nothing logs an error, the quota just quietly drifts from reality until someone's disk usage stops adding up months later and support has no idea why.None of this is a hypothetical read off a summary somewhere either. Every claim above was independently re-checked against that one pinned commit before it went into any planning document, because trusting a report about a codebase is exactly the kind of thing this project has opinions about. More on that discipline specifically in part 3, once there's an actual threat model to show for it.
So, instead of fixing it
The obvious move for a lot of these findings is: turn on error reporting, add locks, delete the duplicate file, ship a patch. That's genuinely what a fix-forward pass on Vesta would look like, and honestly not a bad plan if the goal were keeping Vesta alive.
That wasn't the goal here. The actual first move on this project wasn't opening an editor. It was a prompt, and a fairly blunt one: act like a senior technical lead who's going to own this thing for five years, don't just implement what's asked, challenge weak decisions, and work through the problem as four distinct roles in the same conversation, an Architect who designs, an Engineer who specifies concrete artifacts, a Reviewer who attacks the result for security and operational failure modes, and an Optimizer who checks it for long-term maintenance cost. Worth being precise about what that actually is: one model, Claude Code, reasoning through a design from four different angles in sequence, not four separate AIs arguing with each other. But the framing works, because it's the same discipline a real review process would apply, applied consistently instead of skipped under deadline pressure.
Four roles, one model, taken in sequence instead of skipped.
What came back from that first prompt was the governing plan LESta still measures itself against: Laravel 13 and React 19 on the control plane, holding every bit of relational desired state, quotas, audit history, and provisioning jobs. Nothing about the actual host, the web server config, the mail queue, the firewall, gets touched by Laravel directly. Every host change goes through a separately deployed Go node agent, over a versioned protocol, and only through named, schema-validated, idempotent operations. No shell string ever crosses that boundary.
The plan also wrote down, right at the start, a short list of things that are simply not allowed to exist in this codebase, ever, regardless of how convenient they'd be in a pinch: no generic
RunCommand, no arbitrary command-plus-arguments API, noexeccalls from a controller, no wildcard sudo, no raw user filesystem paths, no secrets in props or logs or job payloads, and no executable queue files. Each one maps to something specific found above. No raw filesystem paths is the direct answer tov-make-tmp-filetaking a POST field straight tofopen(). No wildcard sudo answers the fact that everyv-*script runs as root regardless of what it actually needs to touch, so a bug anywhere in 379 scripts is a bug with full host privilege. No executable queue files is the broadest one, closer to a principle than a citation. It generalizes the actual root cause underneath all of them, stored data getting treated as code, so the same failure mode can't quietly reappear somewhere else in LESta under a different name, in a queue payload or a config template instead of a.conffile.What's coming
Part 2 covers what actually got built first, and it isn't a web hosting feature. It's the rules for how the underlying host gets set up in the first place, an entire installation architecture milestone, decided and documented before a single tenant-facing feature existed. Part 3 covers the moment a background research agent went and actually read the old Vesta source with intent, and came back with real product-policy questions nobody had thought to ask yet. Part 4 is where planning stops and code starts, including the four real bugs the first implementation actually shipped with, and how they got caught.
For now: a rewrite whose entire premise is "stop treating stored data as executable code" being substantially written by a large language model is either the funniest possible way to fix this, or the only genuinely sane one. The rest of this series is basically the argument for the second reading.
“Technology is best when it brings people together.” – Matt Mullenweg
Part 1 covered why VestaCP got condemned rather than patched: a panel that reads its own config files back into the shell with
eval, at 105 sites, with zero write locks anywhere, plus an authenticated arbitrary file write sitting in the public API. This part covers what LESta actually built first, and it wasn't a web hosting feature. It was an entire milestone on how a bare Ubuntu server gets turned into a working node in the first place, decided and written down before a single tenant-facing screen existed.If you've only ever installed a panel with
curl | bash, this is going to feel like overkill. That's the point.The gap the plan left open
The governing rewrite plan from part 1 already committed to some big things: a Go node agent as the only thing allowed to touch a host, staged config rendering with syntax validation and atomic activation, and a "replace curl-to-root installation with a signed, pinned deployment process" line buried near the end. What it didn't say was how a blank Ubuntu box gets nginx, MariaDB, BIND, Exim, and the rest onto it in the first place, or who owns the resulting files on disk for the rest of the product's life.
That gap became the second real prompt in this project, and it's worth quoting the framing directly, because it's a good example of what "vibe coding with actual discipline" looks like in practice rather than just typing "build me an installer" and hoping:
That last line matters more than it looks like it should. A prompt that explicitly tells the model when to stop and ask, rather than when to guess, is doing a real chunk of the risk management for this whole project before a single design decision gets made.
Bootstrap and runtime, drawn as a hard line
The single most important decision to come out of this pass was drawing an explicit boundary between two things that Vesta's
curl-based installer conflates completely: one-time host bootstrap (install packages, lay down a hardened baseline) and continuous runtime provisioning (render a tenant's config on every mutation, forever, for the life of the node).The rule that fell out of that split: Laravel, the queue workers, the controllers, and the node agent itself never invoke installation logic at request time. Ever. Not "shouldn't", not "there's a permission check", the mechanism doesn't exist for the control plane to reach it at all. That single line is what actually closes off the entire category of bug this series keeps circling back to. It's one thing to say "no
execfrom controllers." It's another to make sure the thing controllers might have wanted toexecisn't even reachable from where they live.A directory with an actual contract
The installation logic itself lives in a new
.installdirectory at the repo root, and it comes with a written contract every service installer has to satisfy, checked, not just aspired to. The short version: no interactive prompts, no network fetch of unpinned code, nocurl | bashunder any circumstances, explicit version pinning and checksum verification on anything downloaded, deterministic exit codes, structured JSON output the control plane can actually record, a required dry-run mode, and a preflight check that verifies OS release, architecture, free disk, ports, and conflicting packages before a single byte gets written to disk.Every service also declares its dependencies and provided capabilities as data, in a manifest, rather than as implicit script ordering that only the person who wrote it understands. Dependency order gets checked by walking that manifest graph, not by trusting that whoever adds a new service remembered to put it in the right place in a shell script.
There was even a small, genuinely funny moment of paranoia in this step: before anything else got built, the prompt specifically asked whether a dot-prefixed directory like
.installmight get silently dropped by.gitignore,.gitattributes, export-ignore rules, or the Vite/Composer build pipeline, and asked for that to be verified rather than assumed. It had, in fact, needed a.gitattributesfix to guarantee it survives a source archive build. Small thing. Also exactly the kind of thing that quietly bites you eighteen months later if nobody checks it on day one.Four decisions that don't get to change their minds later
By the third prompt, the project was at the point where a few genuinely irreversible choices had to get made before any of this could actually run against a real service. These weren't left to drift, they got put in front of mikho as explicit options with a recommendation attached, and approved in one reply:
WebProvisionercontract, withweb.nginx.v1andweb.apache.v1as separate capabilities underneath it, plus a combined profile where nginx owns the public ports 80 and 443 and Apache sits behind it, loopback-only, on127.0.0.1:8080.That last one is worth sitting with for a second, because it's a direct answer to something Vesta does clumsily: supporting nginx-as-reverse-proxy-in-front-of-Apache as basically a special case bolted on top of two otherwise-separate code paths. Here it's one contract two implementations satisfy, decided up front, instead of a special case discovered halfway through building the second web server.
Deciding the order things get built in, on purpose
The fourth and final task in this pass wasn't a technical decision at all, it was a sequencing one: given three real strategies (build every installer first and the UI on top, build the UI first against fake adapters and wire up real services later, or take one service all the way to done, installer through UI through tests, before starting the next one), which order actually makes sense for one person building this alone, without a staffed team to parallelize across.
The chosen answer was the third one, a vertical slice per service, and the reasoning is worth spelling out because it's the kind of judgment call that's easy to get wrong under pressure to "just start building." Installing every service first risks designing the wrong contract before any real service has actually been touched by the UI that's supposed to use it, and risks a half-finished installation layer rotting before anything ever exercises it for real. Building the UI first against fakes risks the opposite: a beautiful frontend coupled to adapter contracts nobody's validated against an actual host yet. One service, start to finish, means every layer gets proven against something real before the next one starts, and it matches what the original governing plan already committed to for the web-hosting vertical slice in part 2's phase numbering. Web hosting first, fake
WebProvisioneradapter before the real Go capability, then everything else follows the same pattern.What actually shipped
For all that decision-making, the amount of executable code that shipped in this milestone was, deliberately, close to zero. No installer script, no shell gateway, no service implementation. What got committed as
11a1525on 2026-08-26 was the ADR documenting all of the above, the.installdirectory skeleton with its README, manifest schema, and per-service metadata stubs for every planned service, from the base layer through mail, backups, and node health.The verification list for that commit reads more like something you'd expect from a security audit than a first commit: every installation JSON file parses, web-profile metadata matches its declared listener rules, the installer contract explicitly defines dry-run, preflight, pinned artifacts, checksums, signatures, deterministic exit codes, and rollback expectations, and, checked directly rather than assumed, no executable installer, shell gateway, generic
RunCommand, or service implementation exists anywhere in the diff. The working tree was clean after the push. Local and remotemainpoint at the same commit.A milestone almost entirely made of decisions and a verification list, no features yet.
A fresh session checks the homework
Everything above happened across three prompts run through GitHub Copilot Chat. The next session that touched this project was a fresh Claude Code session with zero memory of any of that prior work, starting cold from just the repo as it sat on disk. That's a genuinely useful setup for catching mistakes, since a model with no investment in its own prior reasoning has no reason to wave a questionable decision through.
It found three places where the existing ADR claimed to be "resolving a conflict" with the governing plan that, on an actual re-read, wasn't a conflict at all. The plan never mentioned PostgreSQL anywhere near the phase the ADR claimed it did. The plan already named both Ubuntu versions explicitly. The plan had already fully specified the nginx-or-Apache-or-both selection model the ADR was claiming credit for resolving. None of these were real disagreements, the ADR's own "resolutions" were just restating decisions the plan had already made, which is a small thing on its own, but exactly the kind of small thing that quietly rots into genuine confusion about what was actually decided versus what was merely repeated.
It also found a real defect, not a documentation nit: the firewall service manifest produced a capability named
firewall.baseline.v1, but the base layer had separately self-declared its own same-purpose capability,base.firewall.v1, and nginx, Apache, and BIND had all been wired to depend on the wrong one. In plain terms, the dedicated firewall gate that the ADR's own ordering rationale describes as a hard prerequisite could be silently bypassed, because the dependency graph was pointing at a capability nothing actually produced. Two more services, mail and MariaDB, both of which open inbound ports, were missing that dependency entirely. All of it got fixed in the same pass, along with a stalenginx/README.mdthat still claimed nginx was the only supported web server, months after the three-profile decision had been made everywhere else.That same session also settled a decision the earlier prompts had left open: whether the control plane's own MariaDB and the MariaDB LESta offers to tenants should be the same instance with logical separation, or genuinely separate. The answer landed on two separate server instances, the control plane on port 3306, tenant databases on port 3307, each with its own data directory, credentials, and resource limits, sharing the same physical node only because there's currently just the one node to share. Cleaner blast radius, worse resource density, a trade a hosting control panel should probably always take.
None of this is a dramatic story. It's a fresh pass finding three overclaimed "fixes" and one real bug in documentation that had already been reviewed once. Worth noting anyway, because it's the first concrete example in this series of a pattern that shows up again, and more sharply, in part 4: work that looked finished still had a bug in it, and the thing that caught it wasn't more careful writing, it was a second, independent pass actually checking the work against the source rather than trusting the summary of it.
What's coming
Part 3 covers the moment this project stopped designing in the abstract and actually went back to read the condemned Vesta codebase on purpose, resource by resource, to figure out exactly what a working panel needs to preserve and what it should quietly correct. That pass is where a background research agent came back with real product-policy questions nobody had thought to ask yet, the kind of thing that's a lot more interesting than "we found more bugs." Part 4 is where all of this planning finally turns into a database schema, and where the first real code shipped with four small bugs that a plan, however careful, was never going to catch on its own.
For a project that hadn't written a single feature yet at this point, it already had a stricter installer contract than most panels currently in production. Vesta included.
“Technology is best when it brings people together.” – Matt Mullenweg
Part 2 ended with LESta having an entire installation architecture and not one tenant-facing feature. That changed here, but not by opening a code editor. It changed by going back to the codebase that started this whole series, the one with
evalin its read path and zero write locks, and actually reading it closely, on purpose, resource by resource, to work out exactly what a working hosting panel needs to do and which parts of how Vesta does it should never make it into LESta at all.Verify first, design second
Before any of this work started, the project cloned
outroll/vestalocally as a read-only reference, pinned at the same commit as part 1's teardown,5f4ee2ef, and explicitly not added to the LESta repo or pushed anywhere. That distinction matters more than it sounds like it should. The plan requires every vulnerability claim to be confirmed against a pinned commit before it's allowed to inform a real decision, not accepted because a prior document said so.That discipline paid off almost immediately. The original teardown from part 1 reported
error_reporting(NULL)across "all 136 endpoints." A direct, independent re-check against the pinned commit for this pass came back with a slightly different number: 133 files underweb/api/v1/specifically. Not a meaningfully different finding, still essentially the entire API surface with error visibility switched off, but a genuinely different number, confirmed by actually counting rather than by trusting the earlier count. A project that's this fussy about re-verifying its own prior claims about a legacy codebase is, not coincidentally, the same project that later catches its own bugs in part 4 rather than shipping them and finding out from a support ticket.Reading Vesta with intent
The actual field-by-field extraction, resource fields, lifecycle verbs, quotas, suspension rules, role visibility, was handed to a background research agent rather than done inline, and it came back with more than a restatement of what the code does. It surfaced genuine lifecycle-contract gaps that the original teardown pass hadn't been scoped to look for, because it was reading for security bugs, not for product-policy bugs. A few of the sharper ones:
Editing a package's quotas, say lowering the web-domain limit from 100 to 10, runs a propagation script that force-cascades the new limit to every current subscriber, with a force flag that specifically skips the check that would otherwise reject the change if a subscriber is already over the new limit. So a subscriber sitting at 40 domains, under a package that just got edited down to a limit of 10, ends up 30 domains over quota, silently, with no rejection, no warning, no auto-suspension, nothing at all until they try to add domain number forty-one and hit a limit check for what feels like the first time.
v-unsuspend-userunconditionally reactivates every child resource belonging to an account, regardless of whether any of them were individually suspended before the account-level suspend happened. Suspend one domain by hand for a terms-of-service issue, then suspend the whole account for a billing problem, then resolve the billing problem and unsuspend the account, and that domain comes back online too, with its prior suspended state silently discarded.And a genuinely unpleasant one: suspending a mail account doesn't flip a flag. It overwrites the account's real password hash in the Exim credentials file with the literal string
SUSPENDED. State gets encoded directly into a credential field. Which is not merely inelegant, it's the kind of design where "restore the mailbox" and "restore the mailbox's actual password" become two different, easy-to-forget operations.Four decisions that had no obviously correct answer
Most of what came out of this pass was an easy call: a live foreign-key reference for packages instead of the copy-on-assign snapshot Vesta uses, explicit suspended flags instead of overloading a credential field, transactional multi-step mutations instead of Vesta's unguarded fan-out. A real relational database with real transactions removes the technical reason Vesta did any of that in the first place, so there wasn't really a decision to make.
Four things genuinely were real product-policy questions though, the kind with no single correct answer, and they got put in front of mikho explicitly rather than resolved by whichever option Claude happened to reach for:
What's actually notable here isn't any single answer, it's that the model doing the planning knew the difference between a bug to fix and a policy question to escalate, and treated them differently. An engineering correction like "packages should be a live reference, not a snapshot" got adopted without ceremony, because a real transactional database makes the old approach pointless. A genuine judgment call like "what should happen to a subscriber who's already over a newly lowered quota" got surfaced as a numbered decision with the reasoning laid out, and left for mikho to actually decide. That's a distinction a lot of AI-assisted work skips right past, quietly picking whichever answer sounds most reasonable and moving on. Here it didn't, and the read-only support view in particular, decision four, only exists in LESta today because that question got asked instead of assumed.
One fan-out, eight scripts, zero rollback
The single clearest illustration of why any of this matters shows up in how Vesta handles "add a web domain." It isn't one operation. It's up to eight separate script invocations chained together with no transaction and no rollback: the web domain itself, an optional DNS zone, DNS records for every alias, an optional mail domain, SSL or Let's Encrypt scheduling, a stats config, and an FTP account per entry, each one firing off its own credentials email as it goes.
If step four fails, the domain now exists with no DNS zone behind it, and nothing cleans that up automatically. Enabling Let's Encrypt on top of all that silently creates a system cron job for certificate renewal that doesn't count against the account's own cron quota and doesn't show up in the account's own cron list either, a resource that exists, consumes a cron slot on the box, and is invisible to the one person who'd need to know about it.
That's not an abstract argument for "we should use database transactions", it's the concrete, specific failure mode that makes the case for LESta's outbox-based provisioning model on its own. A partial multi-step mutation with no rollback and no visibility is exactly the shape of bug that a transactional record, one row progressing cleanly through a defined lifecycle, is built to make structurally impossible rather than merely less likely.
Five boundaries, each one earned
All of this fed into a formal threat model covering the five trust boundaries the original plan named back in part 1: browser to Laravel, Laravel to the node agent, agent to the operating system, tenant to tenant, and backup and secret storage. What makes it worth reading rather than boilerplate is that every boundary is grounded in a specific, cited legacy failure rather than a generic "here's what could go wrong in theory."
Tenant-to-tenant isolation in Vesta, for instance, isn't an enforced authorization model at all, it's a filesystem-path convention,
data/users/<user>/*.conf, backed by nothing except the assumption that every script correctly resolves whose data it's touching. Combined with the error suppression from part 1, an authorization bug in that path would be unusually hard to even notice in production, since a failure just renders as an empty screen instead of a visible error. LESta's answer is that every query service scopes by ownership at the query level, using indexed fields, and nothing ever trusts a client-supplied account or tenant identifier as an authorization signal on its own.The document closes with a section that's easy to skip past but is actually the whole point: a threat model isn't considered satisfied by being written and merged. Per the plan's own acceptance criteria, a capability doesn't get to claim it meets this threat model until the tests proving it actually exist and pass. Writing down the rule is the easy part. Part 4 is where that rule gets tested for real, against actual code, for the first time.
The protocol that closes the lost-update bug
The same pass also produced a first draft of the actual wire contract between Laravel and the Go node agent, which is worth pausing on, because it's the piece that turns "no
execfrom controllers" from a policy into something structurally impossible to violate. There is no generic operation type in this protocol at all. A message names one specific, versioned capability,web.nginx.v1,dns.bind9.v1, and so on, and the agent rejects anything naming a capability it never registered, before the payload even gets parsed. There's no field anywhere shaped like "command plus arguments" for a bug to hide inside.Every operation carries a resource ID, a desired-state version, an idempotency key, a deadline, and a digest of the request itself, and every result the agent sends back echoes those same identifiers plus an explicit status: applied, already-applied, rejected, failed, or degraded.
already_appliedis the interesting one. If the same(resource_id, idempotency_key)pair shows up twice, because a response got lost on the wire and the control plane retried, the agent doesn't redo the mutation. It returns the stored result from the first attempt. If the retried request's content digest doesn't match what was stored the first time, the agent refuses outright rather than guessing which version is the real one.And operations addressing the same resource are strictly serialized, one at a time, regardless of how the control plane happened to dispatch them. That's the direct, specific fix for the exact bug walked through in part 1: two concurrent writers to the same counter, both reading the old value, both writing the same "new" one, one update silently vanishing. A protocol that refuses to process two operations against one resource at the same time doesn't need every future engineer to remember to add a lock. The lock is the protocol.
What's coming
Part 4 is where four phases of planning finally turn into a running Laravel application, a real relational schema, real policies, a real dispatch mechanism for provisioning operations. It's also where the story stops being about a legacy codebase's mistakes and starts being about this one's, including a session that correctly refused to write any code at all because a safety rail was still active, and four genuine bugs the first real implementation shipped with. None of them were caught by planning. All four were caught by something that actually ran.
“Technology is best when it brings people together.” – Matt Mullenweg
Three parts into this series and LESta still didn't have a database schema. It had a teardown of everything wrong with VestaCP, an installation architecture, a threat model, and a capability matrix full of decisions about how suspension state and package quotas ought to behave. All of it correct, none of it code. This part is where that changes, and where the series stops being a story about a legacy codebase's mistakes and starts being honest about this one's.
The session that refused to write anything
The plan approval for this phase came back about as clean as a green light gets: "Yes, and auto-accept," meaning proceed without pausing for confirmation on every individual step. Implementation should have started immediately.
It didn't. Claude Code has a plan mode, a state where the tool itself will not let the model touch a single file, regardless of what the surrounding conversation says, until someone explicitly exits it. That's a harness-level restriction, not a preference the model can reason its way around, the same category of guardrail as a filesystem permission rather than a suggestion. The session was still in that state when the approval above came in, and it stayed in that state. Told to implement, the delegated agent correctly refused to touch the repository at all, restriction still active, approval or no approval. What it produced instead was a fully resolved, execution-ready version of the plan checked against the real codebase, with exact code for every file it would eventually write, and ten explicitly flagged places where the original plan had been silent or shorthand. Small things: which models implement a marker interface the plan only described in prose, how an "impersonation stop" action finds the session it needs to restore, a named exception class for a decision the plan stated but never gave a concrete error type to. None of them changed what the plan intended. All of them were gaps a plan, however carefully written, leaves for the first person who actually has to type out every line.
All ten got reviewed and agreed with. Plan mode got exited a second time, properly. Only then did a second implementation pass actually write the code: 53 new files, 4 changed files, a real relational schema for accounts, memberships, roles, permissions, packages, nodes, node capabilities, audit events, idempotency receipts, and provisioning operations, plus the policies, the outbox and dispatch mechanism, and a full Pest test suite.
None of that "self-reported success" got taken at face value either.
migrate:freshgot re-run independently: 12 migrations, clean. Pint: clean. PHPStan at Larastan level 7: zero errors. The full suite: 63 tests, 197 assertions, all passing, run again rather than just read about. Two files got opened and read directly rather than trusted on description alone, the authorization service provider and the account policy, specifically to confirm the one thing that mattered most: a provider admin with no membership on an account is provably denied by the policy, on its own, with no gate bypass leaking onto it. That's decision four from part 3, the read-only support view, actually holding.Four bugs, none of them caught by planning
Here's where the series earns its subtitle. Four separate defects surfaced only once the code actually ran, not during any of the design or review passes covered in the last three parts. Each one got caught by an automated gate, a database constraint, an engine-specific limitation, a static analyzer, a flaky test failing, rather than by a person spotting it while reading. That's not a coincidence. It's the entire argument for running migrate, Pint, PHPStan, and the full test suite as a required gate instead of trusting a plan's correctness on paper, made concrete instead of abstract. And it's worth naming plainly what none of these four are: not one of them is an
eval-on-stored-data bug, a missing lock, or an unconstrained file write. Three parts of threat modeling and non-negotiable prohibitions bought exactly what they were supposed to buy, the first real code shipped with ordinary, boring defects instead of the category of bug this whole series started with.A missing
NOT NULLvalue.idempotency_receipts.correlation_idis a required column, correctly, every row should be traceable back to whatever caused it. The plan's own code for creating a receipt never actually set it. That would have thrown a database exception on the very first real call. Fixed by generating a UUID at creation time, the same pattern already used everywhere else in the schema, once the gap was actually visible.A query that works on MariaDB and silently doesn't on SQLite. The package-quota-violation check from part 3's decision one, reject an edit that would put a subscriber over the new limit, got written as a completely ordinary Eloquent pattern:
withCount('memberships')->having('memberships_count', '>', $limitValue). SQLite rejects aHAVINGclause on a query with noGROUP BY. MariaDB, the actual production target, tolerates it fine. Local development runs on SQLite, production runs MariaDB, and this is exactly the kind of latent database-portability bug the project had already been careful about elsewhere, the.installmanifests from part 2 specifically avoid database-specific column features for this same reason. It surfaced here only because a test actually exercised the code path, not because anyone reviewed for it in advance. Fixed with a portablewhereHasequivalent, verified to produce identical results.A type mismatch from a configuration choice made in a completely different part of the app, long before this session. A result object was typed as
Illuminate\Support\Carbon. This specific Laravel app configuresDate::use(CarbonImmutable::class)globally, so every call tonow()anywhere in the codebase actually returns aCarbonImmutable, not aCarbon. A type hint written from general Laravel knowledge, without checking this app's own configuration first, could never have accepted what the rest of the app was actually going to hand it. Fixed by retyping to the sharedCarbonInterfaceboth classes implement, so it stops mattering which one shows up.A flaky test that wasn't actually a bug in the code under test. Two factory calls in a cascade-suspension test both drew a capability value from the same three-item random pool, and occasionally collided against the model's own unique constraint. It failed on the very first run, intermittent rather than a real defect, but exactly the kind of flakiness that quietly erodes trust in a test suite if it's left alone. Fixed by pinning explicit, distinct values in that one test.
None of these four changed what the plan intended. They're the ordinary gap between a design, however carefully reasoned through four roles and a threat model, and the first time it actually runs against a real database and a real static analyzer.
The commit that wasn't quite what it said it was
Everything above got committed and pushed as three separate commits rather than one, so the Phase 1 commit's message would stay accurate to only Phase 1. Right after, a fifth issue turned up that's more instructive than the first four, because it isn't a code defect at all.
bootstrap/providers.phphad been correctly modified in the working tree, registering the two new service providers Phase 1 needed. It never made it into the Phase 1 commit. A plaingit addmistake, nothing more exotic than that. Every verification command in the section above had genuinely passed, because the working tree, the actual files on disk, still had the fix. Nothing caught the gap untilgit statusgot checked again after the commit, againstHEAD, and showed that one file still modified relative to what had supposedly just been committed.Fixed with a follow-up commit. Then, deliberately, the entire verification suite got run a second time, against the now-clean working tree matching exactly what had actually been pushed, not against a working tree that happened to already contain the fix. That distinction is worth sitting with on its own: passing tests locally proves the working tree is correct. It does not, by itself, prove the commit is correct. Those are only the same claim once the working tree and
HEADactually match, and that's a thing worth checking explicitly rather than assuming.The plot twist that already had an ending
A few days later, mikho pasted in a GitHub Actions log showing seven failed tests from CI, two distinct error signatures, an authorization exception on the node and package policy tests, and a container binding failure on the provisioning-dispatch tests.
The cause was exactly the bug from the section above: with the two service providers unregistered, the scoped admin bypass never runs, so a provider admin gets denied like anyone else, and nothing is bound to the
Provisionerinterface, so the container can't build one. This wasn't a new bug. It was the same bug, already found, already fixed, being reported for the second time by a system that was simply slower to notice than the person who'd already caught it.Checked directly against
gh run listandgh run viewrather than assumed from the timestamps alone: the CI run for the original Phase 1 commit shows failure, with the exact same seven test names and the exact same two error signatures as the pasted log. The very next CI run, for the follow-up commit registering the providers, shows success, one job, zero failures.mainhad already been green for a while by the time the failure got reported. The pasted log wasn't a new problem surfacing. It was CI finally catching up to, and independently confirming, a bug that had already been found and fixed in-session, roughly two minutes after it was introduced.CI, moving at its own pace, arriving to report a bug that had already been closed.
That's the cleanest example this project has produced so far of the thing this whole series keeps circling: a working tree that passed every local check while its commit quietly didn't match it, caught by discipline, checking the actual state of things rather than trusting a summary, rather than by luck.
What's actually next
Phase 1 is done: relational foundation, deny-by-default policies, the outbox and idempotency mechanism, all verified, all pushed, CI green. Per the plan from part 1, next up is Phase 2, the actual web hosting vertical slice, a fake
WebProvisionerfirst, then the real Go nginx and Apache capabilities behind the shared contract decided back in part 2. That's the point where LESta stops being schema and policy and starts being something you could actually point a domain at.Four parts in, the thesis of this whole series hasn't changed since the first paragraph of part 1. Vibe coding, done properly, doesn't mean skipping the boring parts. It means the boring parts,
migrate:fresh, Pint, PHPStan, the full test suite, rereadinggit statusafter the commit instead of trusting the commit message, are exactly what make trusting the fast parts reasonable. This project has, so far, caught every one of its own mistakes before a tenant ever would have. Part 5 picks up whenever Phase 2 has enough of a story to tell.“Technology is best when it brings people together.” – Matt Mullenweg
Four parts in, LESta could suspend an account, deny an unauthorized admin, and dispatch a provisioning operation to nothing in particular, and it still couldn't do the one thing a hosting panel is actually for: let a customer point a domain at a server. ADR 0002 names the exact bar for this phase plainly: "a customer can manage a domain and see honest provisioning state." This part is where LESta clears it.
It's also the first time this project's own React frontend has had to do any real work. Everything through part 4 lived entirely in the database, the policies, and the outbox mechanism, backend all the way down, provable with
php artisan testand nothing else. A customer doesn't experience a policy class or a migration. They experience a page that either lets them add a domain or doesn't, and tells them the truth about what state it's actually in while it does. That's a different kind of correctness to get right, and it's the reason this phase is where the series' first real UI bugs show up too.Two decisions before any schema got written
Exploring the codebase for this phase turned up two real gaps, not bugs, just things nobody had needed yet. This app's Inertia frontend uses a
<Form>render-prop component bound through Wayfinder everywhere, never the more commonuseFormhook, and there was no reusable confirmation-dialog component anywhere, every destructive action so far had rolled its own. More pressing, Pest's own browser-testing plugin, the one the plan actually requires for this phase (pestphp/pest-plugin-browser), and a table/pagination UI for the domains list, neither existed in the install yet.Both are dependency decisions, and this project's own rule is no dependency change without explicit approval, so both went to mikho as real options rather than getting quietly assumed. Browser test coverage: add it now, since the plan explicitly requires real coverage for this phase, not deferred to some later cleanup pass. The list UI: hand-built in plain HTML and Tailwind, a simple prev/next paginator off Laravel's own paginator, a debounced search input from the existing plain
Inputcomponent, no new frontend package of any kind. The shadcn CLI would have been the faster route, standard components, a small Radix package or two pulled in along with them. It lost anyway, on purpose, to keep this phase free of a dependency it didn't strictly need, one more small example of the project choosing the more deliberate option over the one that saves an afternoon.The
<Form>-over-useFormconvention mattered for a quieter reason too: it meant the new domain create/edit/delete pages could match the app's existing shape exactly rather than introducing a second pattern for form state. A hand-writtenTextareacomponent (plain Tailwind, no shadcn CLI) had to be added along the way too, since the domain alias field needed one and none existed yet, small, but the same "match what's already here" instinct rather than reaching for a new dependency to solve a one-component gap.The tension the design pass caught on its own
The schema design surfaced a real question it wasn't explicitly asked: should deleting an account cascade to delete its web domains at the database level, or should that relationship refuse to let the database do it silently? It picked the second,
restrictOnDelete()on every foreign key fromweb_domainstoaccounts,nodes, andip_allocations, without needing to be told why that mattered.The reason is a direct callback to part 3. A silent database cascade deleting a tenant's domains the moment their account gets deleted would recreate, at the schema level, the exact "unguarded fan-out" failure mode documented in legacy Vesta's own eight-script, zero-rollback domain creation. With
restrictOnDelete()instead,DeleteAccounthas to explicitly delete each ownedWebDomainthrough the real, auditedDeleteWebDomainaction before the account row can go. Forget that step in some future change, and the database throws an error instead of quietly orphaning provisioning state nobody meant to lose.A third genuine product question came out of the same pass: when a package has no configured quota row at all for
web_domains, not an explicit unlimited row, just nothing, should creating one be allowed or blocked? Blocked, was the answer, the stricter reading of deny-by-default. An explicit row with a null limit still means unlimited, unchanged from how Phase 1 already worked, but the complete absence of a row now means zero. Every package that's supposed to support web hosting needs to say so explicitly, even if what it says is "no limit," and every test or factory that creates a web domain has to remember to attach a limit row too, a deliberate constraint on the test suite itself, not an oversight if a test trips over it later.None of the three questions above got resolved by picking whichever answer sounded more reasonable in the moment. Each one got framed as an actual choice, with the tradeoff stated, before an answer got picked. That's worth naming plainly, because it would have been easy, four phases into a project with an established track record, to start trusting the model's judgment on calls like these instead of still asking. It didn't happen here.
What actually got built
53 new files, 12 changed. Three migrations (
ip_allocations,web_domains,web_domain_aliases), two enums, three models, the node/capability targeting rule (prefer nginx over Apache whenever a node has both registered, matching the public-listener rule from part 2), five domain actions, the exact cascade edits to the account-suspend and account-delete flows, a policy with no admin bypass, the full HTTP layer, three frontend pages, and the complete test list the plan called for. No newWebProvisionerPHP interface was needed either, Phase 1's provisioner mechanism was already capability-agnostic, exactly as it was designed to be back when there was nothing yet for it to provision.None of that got taken on the implementation report's word.
migrate:freshre-run independently: 18 migrations, clean, in order. Pint: clean. PHPStan at its actual configured scope: zero errors.npm run build: clean, the first real frontend build this project has ever needed. The full test suite: 114 tests, 334 assertions, up from Phase 1's 63. Three files got read directly rather than trusted on description: the quota check genuinely distinguishes "no limit row" from "an explicit null-value row," the account-suspend cascade only touches domains that weren't already individually suspended and does it through the real audited action, and the policy has no admin bypass leaking onto it.Five small deviations from the plan's literal text showed up along the way, all reviewed, none changing intent. The neatest one: the plan called for
morphMany()->latestOfMany()to fetch a domain's latest provisioning operation, but this Laravel version'sMorphManygenuinely doesn't supportlatestOfMany(), onlyHasOne,MorphOne, andHasOneThroughdo.morphOne()->latestOfMany()is the framework's actual mechanism for exactly this, documented inline with the reasoning rather than silently swapped in.WebDomainalso picked up agetRouteKeyName()override returninguuid, an addition the plan implied by giving the model a uuid column at all but never spelled out explicitly, route binding by internal auto-increment id would have been the actual inconsistency here. A handful of PHPStan-driven type and docblock corrections rounded out the rest, no behavioral change, just the static analyzer catching an overly loose array shape and a validation closure's signature before they could rot into something that mattered later. AndCLAUDE.md's own PHP version line corrected itself from 8.5 to 8.4 as a side effect of Laravel Boost's post-update hook firing after the one authorized composer change, not a manual edit, left as-is because it was simply accurate.The bugs that only showed up once someone actually clicked
Here's where this post earns its place in the series. The two browser test files existed, were wired into the test suite's own configuration, and looked complete. They still couldn't run, because
pestphp/pest-plugin-browserneeds Playwright, an npm package plus downloaded browser binaries, which hadn't been part of the one dependency change already approved for this task. Installing it was its own small decision, put to mikho rather than assumed, and approved.Running the two browser tests for real, not just confirming they existed, found two genuine bugs in five minutes that reading the plan, the component code, and the test code in isolation had all missed completely. The domain edit page's suspend button has no confirmation dialog, delete is the only action that gets one, so one click is always enough. The test clicked it twice anyway. The first click suspended the domain and the button re-rendered as "Unsuspend." The second click, landing on that same now-relabeled button, silently undid the first action. The test failed expecting a suspended domain and got an active one.
Fixed that, ran again, hit a second bug immediately. The delete section has a heading that reads "Delete domain" directly above a button that also reads "Delete domain." A text-based click matched the heading, not the button, so the confirmation dialog never opened and nothing actually got deleted. Pest's browser plugin auto-saves a screenshot on failure, and the screenshot made the problem obvious at a glance, no dialog anywhere in frame. The fix was to target the component's existing
data-testattributes instead of matching on visible text that wasn't unique, the same convention the codebase already used elsewhere for exactly this reason.Both fixes together are a clean, small-scale replay of this whole series' argument. Neither bug was visible from reading anything in isolation. Both were visible in about ten seconds of watching the actual interaction fail. Re-ran the full browser suite twice after the fixes, two passes, two greens, no flakiness.
Shipped
Everything got committed as
da02ae9, following the same working-tree-must-match-HEADdiscipline part 4 learned the hard way:git statusclean before the push, then the entire verification sequence,migrate:fresh, Pint, PHPStan, the Feature and Unit suite, and now the Browser suite too, re-run a second time against the exact committed state rather than a working tree that happened to already be right.Then, rather than assume a push implies a passing build, the actual GitHub Actions run got watched to completion. Green, one job, under a minute. Phase 2 is done, verified, committed, pushed, and confirmed green in CI, the same bar Phase 1 had to clear, held for a second phase in a row rather than relaxed now that the pattern is familiar.
The point where LESta stopped being schema and policy and started being something you could point a domain at.
One honest gap, flagged rather than quietly left: CI's own workflow still only runs
composer ci:check, which never touches the browser suite, deliberately excluded fromphpunit.xml. The browser tests are real, reviewable, and passing, but only locally, until CI gets a browser-binary setup step of its own. Not a silent hole. A named one, for whenever it's actually worth the CI minutes to close.What's coming
Part 6 steps back from the build log for one post to look at a pattern across the whole project so far: every single question this project has actually put to mikho, gathered in one place, and what it says about how "vibe coding" and "careful coding" turned out not to be opposites here. After that, the story picks back up wherever the actual Go agent work lands.
“Technology is best when it brings people together.” – Matt Mullenweg
Five parts of this series have been a build log: teardown, plan, code, bugs, ship. This one is different. After Phase 2 shipped, the project did something it hadn't done before, went back over its own entire history and pulled out, in one place, every single question it had actually put to mikho, exactly as asked, with every option that was on the table and the answer that got picked. Sixteen questions, eight separate sessions, one real pattern sitting underneath all of it.
Why this exists
The prompt that produced it was simple: document the process and all questions and answers in the vault. What made it worth doing rather than busywork is what the audit turned up first. The project's own decision log, the same one this series has been drawing from since part 1, had been recording the earliest questions as narrative, the substance was all there, but not as the literal question and the literal options offered. Later entries, Phase 2's dependency and quota questions, already had that exact form. The earlier ones didn't.
Rather than rewrite history to make old entries match the new style, retroactively cleaner but less honest about how the record actually developed, the project wrote a second document instead:
Questions and Answers.md, every question from the beginning, chronological, in one consistent shape, sitting alongside the narrative log rather than replacing it. The decision log stays the story. This is the reference underneath it.The sixteen
They run from the very first ecosystem defaults (database engine, Ubuntu support, ACME mode, whether mail ships enabled by default) through the capability-matrix product calls covered in part 3, to Phase 2's dependency and quota questions covered in part 5. Every one keeps the same shape: the question as actually asked, every option that was genuinely on the table with a short reason for each, which one carried the "Recommended" label, and which one got picked. Reading the delete-workflow question from part 3 in that shape makes something visible that prose narrative tends to smooth over: "automatic unsuspend-then-delete" was recommended specifically because it matches the legacy UX, already effectively one action there, while "require explicit unsuspend first" was on the table as the stricter alternative and got named as one, not silently dropped for not being chosen. The road not taken is still on the page, which is exactly the point of building a document like this instead of just keeping the narrative.
A few of the sixteen are worth pulling out on their own.
One question didn't get answered by picking from the options offered at all. Asked how to source the legacy Vesta reference material for the capability matrix, the choices on the table were: clone the repo locally, use the earlier Claude-generated review artifact, or defer the whole thing. What actually happened was neither, clone locally and use the review artifact together, the checkout for direct verification, the artifact for an initial map of where to look. Worth noting for what it says about the shape of these questions generally: they were real options with tradeoffs, not a menu a model was quietly steering toward a foregone conclusion, and the record shows at least one case where the actual answer wasn't even on the menu.
Most, though, did follow the labeled recommendation. Twelve of sixteen. MariaDB over MySQL. Reject the quota edit outright. Preserve suspension state. Add the read-only support view. Add Playwright when the browser tests couldn't run without it. In each of those, the recommended option and the chosen option were the same thing, which is exactly what a recommendation is for when the reasoning behind it actually holds up.
One of the twelve deserves a second look on its own, because it's easy to misread as boring when it's actually the same instinct as the four exceptions below, just already wearing the recommended label. Asked whether mail should be enabled by default on a freshly bootstrapped node or require an explicit operator opt-in, the recommendation was opt-in, on the reasoning that a node with mail off has no mail attack surface at all until someone deliberately turns it on. That's not a case where the safer option happened to also be labeled "recommended" by coincidence. It's the same underlying preference this project keeps landing on, smaller default surface over fewer setup steps, showing up in a question where, this time, the recommendation had already done the conservative reasoning first.
Where documentation itself was allowed to live
A separate, smaller cluster of three questions is worth its own mention, not because any one of them was dramatic, but because all three landed on the same rule without that rule ever being stated as a rule first. Should the architecture decision record that was already committed and pushed to the repo also get mirrored into the Obsidian vault? Should the two JSON Schema files the node protocol document references move to the vault alongside the narrative that explains them? Should the
.installdirectory's own README, which describes the installer contract itself, move too?All three: no. Keep it in the repo. The reasoning given each time was the same reasoning, not three separate justifications that happened to agree: an ADR is conventionally versioned with the code it explains, a schema is a normative artifact future code will actually test against, and an installer README is the contract itself, not commentary about the contract. Prose that explains a decision belongs in the vault. Anything a machine will actually parse, validate, or enforce stays exactly where the code that depends on it lives. Nobody wrote that distinction down as a policy before these three questions came up. It only became visible as a pattern once all three answers sat next to each other in the same document.
That's part of the actual value of building
Questions and Answers.mdat all. A single question and its answer is just a decision. Sixteen of them, read together, are a value system, whether or not anyone set out to write one.The four that didn't
Four times, the choice went the other way, and it's worth listing all four together because they don't look like four unrelated exceptions once you do:
Four times, chosen deliberately: the route with more steps, not the shorter one.
Every one of these four trades a bit of near-term convenience for either a smaller blast radius or a more explicit, harder-to-misuse default. None of them make the system faster to build. All four make it slower to misconfigure later. That's not a coincidence repeated four times by chance, it's a legible preference showing up consistently enough across genuinely separate decisions, made in different sessions, about different parts of the system, to call it a real pattern rather than a story imposed on the data after the fact.
What this says about "recommended"
It would be easy to read "12 of 16 went with the recommendation" as evidence the recommendations were just being rubber-stamped, and the four exceptions as the only real thinking that happened. That reading doesn't survive contact with the actual list. The twelve that matched weren't twelve identical rubber stamps, they were twelve separate moments where a stated tradeoff got weighed and the labeled option held up under that weighing. Ubuntu, ACME, the list UI, and the quota default aren't twelve-minus-four either, they're the four cases where the same weighing process concluded the recommendation, reasonable as it was, wasn't actually the right call for what this specific project needed. A recommendation that gets overridden a quarter of the time by someone actually reading it isn't decoration. It's doing its job, giving a real default that a real decision can disagree with when it should.
The more accurate way to state the pattern isn't "the recommendation usually won" or "the stricter option usually won." It's that one specific outcome, the smaller default surface, the more explicit configuration requirement, the option with more steps up front and fewer ways to misconfigure later, kept winning regardless of which column it happened to sit in. Twelve times it was already the recommended column. Four times it wasn't, and the recommendation lost anyway. Mail's opt-in default and the web-domain quota's blocked-until-configured default are the same preference wearing two different labels, "Recommended" on one, "not the recommended option" on the other. The label never was the thing actually deciding it.
Why a build log needed a post like this
The first five parts of this series have all made some version of the same argument through different evidence: plan carefully, verify independently, catch your own mistakes before they ship. This one is different in kind, not degree. It's not a claim that the process worked. It's the receipts, in one place, checkable against the actual options offered at the time rather than reconstructed after the fact from memory of how the reasoning probably went. Sixteen questions, four overridden recommendations, one visible preference for the stricter, more explicit, more work-up-front option every single time a real choice got made. That's not a story this series is telling about itself. It's what the questions-and-answers list actually shows, whether or not anyone had written a build log to go with it.
It also closes a loop this series opened back in part 4: an implementation report isn't trusted on its own word,
migrate:freshand Pint and PHPStan and the actual test suite get re-run before anything counts as done. The same standard applies one level up. A build log's own claim that "the project consistently chose the stricter option" isn't trustworthy just because a build log said so either.Questions and Answers.mdis what makes that specific claim checkable rather than just asserted, the exact same distinction, applied to a decision-making pattern instead of a database migration.Questions and Answers
- Purpose: every question put to the user across this project's Claude Code sessions, with the exact options offered and the answer given, in one place.
- Where the exact time isn't recorded: the practice of taking a real timestamp via the shell's
## 2026-08-26, during the service-installation and build-sequencing review Q1. Which database engine should the first implementation target as primary?Decision Log.mdis the narrative process record; this is the clean reference underneath it, for anyone later writing a full account of how this project's decisions actually got made.datecommand started partway through the project (seeDecision Log.md's note on this). Entries from before that point are dated but not timed; nothing here is a guessed time.- MariaDB 11.4 LTS only (Recommended). Matches Ubuntu's default repositories, one package/version to pin and test.
- MySQL 8.4 LTS only. Matches broader panel-ecosystem familiarity, diverges from Ubuntu's default database.
- Both, MariaDB primary + MySQL verified. Doubles the install/test matrix with no benefit yet.
Answer: MariaDB 11.4 LTS only. Q2. Which Ubuntu LTS release(s) should the first implementation support?- 24.04 LTS only (Recommended). Halves integration-test cost for a single-developer project.
- Both 24.04 and 26.04 simultaneously. Matches what the manifests already declared, doubles the test matrix from day one.
Answer: Both 24.04 and 26.04 simultaneously. Q3. Which ACME challenge mode(s) should the web/ACME capability support first?- HTTP-01 only (Recommended). No dependency on the DNS capability, smaller failure surface.
- HTTP-01 and DNS-01 both. Needed for wildcard certificates, but requires the DNS capability first.
Answer: HTTP-01 and DNS-01 both. Q4. Should mail be enabled by default on a bootstrapped node, or opt-in?- Explicit operator opt-in (Recommended). A freshly bootstrapped node has no mail attack surface until deliberately turned on.
- Enabled by default once all gates pass. Fewer steps for the operator, more default attack surface.
Answer: Explicit operator opt-in (Recommended). ## 2026-08-26, before scoping the "move documentation to the vault" request Q5. The ADR is already committed and pushed to GitHub. Should it also move to the Obsidian vault, or stay in the repo?- Keep the ADR in the repo (Recommended). ADRs are conventionally versioned alongside the code they explain.
- Move the ADR to the vault too. Full consistency: every explanatory document lives in Obsidian.
Answer: Keep the ADR in the repo (Recommended). Q6. The node protocol doc references two JSON Schema files. Move those with the narrative, or keep them in the repo as testable contract files?- Keep schemas in the repo (Recommended). Normative, machine-validated artifacts future code will test against, not prose.
- Move everything to the vault. Keeps all node-protocol material in one place.
Answer: Keep schemas in the repo (Recommended). Q7. The.installdirectory's own README and per-service READMEs describe the installer contract itself. Move those too, or leave them in place?- Leave
- Move these too.
Answer: Leave.install/untouched (Recommended). Not standalone documentation, the installer contract itself..install/untouched (Recommended). ## 2026-08-26, before extracting the Vesta capability matrix Q8. How should I source the legacy Vesta reference material for the capability matrix?- Clone
- Use your earlier Claude review artifact instead.
- Defer the capability matrix, start elsewhere.
Answer (a custom combination, not one of the listed options as-is): clone locally, read-only, never push to this repo, and use the Claude review artifact too, since it had since been added to the vault as "Engineering teardown · Vesta Control Panel". Both sources were used together: the checkout for direct verification, the teardown for the initial map of where to look. ## 2026-08-26, four capability-matrix product decisions Q9. When an admin edits a package and existing subscribers would exceed the new, lower quota, what should happen?outroll/vestalocally, read-only (Recommended). Pin to a commit, read purely for contract extraction.- Reject the edit outright (Recommended). No account is ever left in a silently inconsistent quota state.
- Allow it, flag affected accounts for admin review.
- Allow it silently (matches the legacy gap this review had just found, not recommended).
Answer: Reject the edit outright (Recommended). Q10. When an account that was suspended is unsuspended, should resources individually suspended beforehand stay suspended?- Preserve prior individual state (Recommended). Anything suspended before the account-level action stays suspended.
- Always reactivate everything (matches legacy behavior).
Answer: Preserve prior individual state (Recommended). Q11. Should deleting a suspended resource require an explicit separate unsuspend step first, or happen automatically as one action?- Automatic unsuspend-then-delete (Recommended). Matches the legacy UX, already effectively one action there.
- Require explicit unsuspend first. Stricter, an extra safety checkpoint.
Answer: Automatic unsuspend-then-delete (Recommended). Q12. Should a provider admin be able to view a tenant's resources without impersonating them?- Add a read-only support view (Recommended). Distinct from and logged separately from full audited impersonation.
- Impersonation only, matching legacy exactly.
Answer: Add a read-only support view (Recommended). ## 2026-08-27, before designing the Phase 2 schema Q13. Browser test coverage requirespestphp/pest-plugin-browser, not actually installed. Add it now for Phase 2?- Add it now (Recommended). Satisfies the plan's own browser-coverage requirement for this phase.
- Defer browser tests, cover this phase with Feature-level tests only.
Answer: Add it now (Recommended). Q14. The domains list page needs a table and pagination UI, neither exists in this shadcn install. Add via shadcn CLI, or hand-build without a table primitive?- Add via shadcn CLI (Recommended). Standard components, may pull in a small Radix package or two.
- Build without a table primitive. No new npm packages at all.
Answer: Build without a table primitive (the non-recommended option, chosen deliberately to keep this phase free of any new frontend dependency). ## 2026-08-27, the Phase 2 quota-default decision Q15. When a package has no configured quota row at all forweb_domains, should creation be unlimited or blocked until a limit is set?- Unlimited until configured (Recommended). Absence of a row means no restriction, matching how an explicit
- Blocked until configured. Stricter, literal deny-by-default; every new package needs explicit setup before it's usable.
Answer: Blocked until configured (the non-recommended, stricter option). ## 2026-08-27, mid-implementation of Phase 2 Q16. The two browser test files exist and are wired up, but Playwright isn't installed, so they can't actually run. Install it now?nulllimit already means unlimited elsewhere in the schema.- Install it now (Recommended). Completes what the plan's own browser-coverage decision asked for.
- Defer, commit as-is.
Answer: Install it now (Recommended).phpunit.xmlalready excludes them from the default run and CI either way.What's coming
Part 5 already named where the story goes next: wherever the real Go agent work lands, the point where LESta's provisioning stops targeting a fake adapter and starts talking to an actual node. That's a bigger jump than anything covered so far, real infrastructure instead of a database record describing intent, and it's the part of this project that hasn't happened yet. This series picks it up once it does.
“Technology is best when it brings people together.” – Matt Mullenweg