<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Russell Jones</title>
    <description>The latest articles on DEV Community by Russell Jones (@jonesrussell).</description>
    <link>https://dev.to/jonesrussell</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F136661%2Fd812786d-8ef0-4b08-9421-35be6f99b174.png</url>
      <title>DEV Community: Russell Jones</title>
      <link>https://dev.to/jonesrussell</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jonesrussell"/>
    <language>en</language>
    <item>
      <title>Treat content withdrawal as a reversible migration</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Tue, 03 Nov 2026 14:09:20 +0000</pubDate>
      <link>https://dev.to/jonesrussell/treat-content-withdrawal-as-a-reversible-migration-1bog</link>
      <guid>https://dev.to/jonesrussell/treat-content-withdrawal-as-a-reversible-migration-1bog</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;Removing a page from a website is easy. Withdrawing it safely is not.&lt;/p&gt;

&lt;p&gt;A real content withdrawal has to change every public surface while preserving the material needed for review, recovery, or migration. This post uses a recent &lt;a href="https://github.com/jonesrussell/rhtcircle/commit/bba42a11b9f3eac2a49321dbc305278403c700b0" rel="noopener noreferrer"&gt;RHT Circle change&lt;/a&gt; to show how to treat that work as a reversible migration instead of a deletion.&lt;/p&gt;

&lt;h2&gt;
  
  
  Define what withdrawal means
&lt;/h2&gt;

&lt;p&gt;Start with an explicit contract. In this case, five managed articles and four static pages had to leave the public site before their material moved elsewhere.&lt;/p&gt;

&lt;p&gt;Withdrawal meant all of the following:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Existing public URLs return 404&lt;/li&gt;
&lt;li&gt;News lists and community hubs stop linking to the material&lt;/li&gt;
&lt;li&gt;Static and generated sitemaps omit the URLs&lt;/li&gt;
&lt;li&gt;Search indexing drops the old public chunks during the normal reindex&lt;/li&gt;
&lt;li&gt;Managed article bodies remain stored as unpublished records&lt;/li&gt;
&lt;li&gt;Original seed files and static templates remain available for migration&lt;/li&gt;
&lt;li&gt;No redirect exists until a verified destination is actually live&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point avoids a common mistake. A planned destination is not a working destination. Redirecting early turns a controlled withdrawal into a broken handoff.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preserve content, remove publication state
&lt;/h2&gt;

&lt;p&gt;The managed articles were not deleted. Their slugs were added to an explicit &lt;code&gt;unpublishedSlugs()&lt;/code&gt; list, and the seeder created them with &lt;code&gt;status: false&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;in_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nv"&gt;$slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;ArticleSeedData&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;unpublishedSlugs&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This keeps the body and record available while removing anonymous access. It also gives new installations the same state as production instead of letting a fresh seed accidentally republish withdrawn material.&lt;/p&gt;

&lt;p&gt;The refresh path needed a second guard. A slug cannot be both withdrawn and eligible for publication refresh. The seeder now skips any unpublished slug, and the test suite asserts that the unpublished and publication-refresh lists do not intersect.&lt;/p&gt;

&lt;p&gt;This is the critical invariant:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Re-running installation or seed refresh must not reverse the withdrawal.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Without that rule, the next deployment can quietly undo the change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handle managed and static content differently
&lt;/h2&gt;

&lt;p&gt;The site had two kinds of public material:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Content type&lt;/th&gt;
&lt;th&gt;Withdrawal action&lt;/th&gt;
&lt;th&gt;Preserved evidence&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Managed articles&lt;/td&gt;
&lt;td&gt;Keep the entity, set it unpublished&lt;/td&gt;
&lt;td&gt;Database record, body, revision history, seed source&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Static pages&lt;/td&gt;
&lt;td&gt;Remove the route from public catalogues&lt;/td&gt;
&lt;td&gt;Twig template and Git history&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Trying to force both through one mechanism would hide important differences. A managed article has publication state. A static route does not, so the public route registration itself has to disappear.&lt;/p&gt;

&lt;p&gt;The static templates stayed in the repository. That makes the withdrawal reversible without leaving the old URLs reachable. It also keeps the material available for a later migration where the destination may use a different content model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Remove every discovery path
&lt;/h2&gt;

&lt;p&gt;Returning 404 from the old URL is necessary, but it is not enough. A withdrawn page can still appear public if the rest of the site advertises it.&lt;/p&gt;

&lt;p&gt;The change removed references from:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Home and news listings&lt;/li&gt;
&lt;li&gt;Community cards and accountability hubs&lt;/li&gt;
&lt;li&gt;Manually maintained sitemap XML&lt;/li&gt;
&lt;li&gt;The application's generated sitemap route list&lt;/li&gt;
&lt;li&gt;Contextual links embedded in other content&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Search requires separate attention because an index is another copy of the content. The release procedure relies on the normal &lt;code&gt;app:reindex&lt;/code&gt; deployment step to remove old page chunks, then calls for production verification that those chunks are gone.&lt;/p&gt;

&lt;p&gt;This is why content withdrawal belongs in release planning. Code can remove routes and links, but the deployed search corpus is runtime state that still has to be checked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test absence and preservation together
&lt;/h2&gt;

&lt;p&gt;The strongest regression test does not merely ask whether a page disappeared. It proves that the page is unavailable while its source remains intact.&lt;/p&gt;

&lt;p&gt;The integration test runs article migration, boots the application, and then checks each withdrawn article:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Exactly one stored record still exists&lt;/li&gt;
&lt;li&gt;Its publication status is false&lt;/li&gt;
&lt;li&gt;Its body is not empty&lt;/li&gt;
&lt;li&gt;Its public URL returns 404&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It also requests the home page, news list, community hub, and sitemap, then asserts that none contains a withdrawn path.&lt;/p&gt;

&lt;p&gt;The test name calls out repeated seeding because idempotency is the real risk. A one-time successful withdrawal is not enough. The same release operations must keep producing the same private state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the scope auditable
&lt;/h2&gt;

&lt;p&gt;The release note records what moved and what did not. Other authors' articles, petition records, and consent instruments were explicitly outside the withdrawal.&lt;/p&gt;

&lt;p&gt;That boundary matters for operational safety. A vague request to remove related content can grow into an accidental purge. A slug list, route list, and named exclusions give reviewers a finite change to inspect.&lt;/p&gt;

&lt;p&gt;A safe withdrawal therefore leaves four kinds of evidence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Scope:&lt;/strong&gt; the exact records and routes being withdrawn&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State:&lt;/strong&gt; unpublished records and preserved source files&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Public absence:&lt;/strong&gt; 404 responses, clean listings, and clean sitemaps&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Operational follow-up:&lt;/strong&gt; reindexing and production verification&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Deletion destroys options. A reversible migration removes publication authority while keeping the source and history needed for whatever comes next.&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>contentmanagement</category>
      <category>migrations</category>
      <category>testing</category>
      <category>publishing</category>
    </item>
    <item>
      <title>Separate AI drafting from publication authority</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Mon, 02 Nov 2026 14:05:30 +0000</pubDate>
      <link>https://dev.to/jonesrussell/separate-ai-drafting-from-publication-authority-51ed</link>
      <guid>https://dev.to/jonesrussell/separate-ai-drafting-from-publication-authority-51ed</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;This blog used to put the whole publishing job inside one GitHub Actions workflow. The job checked capacity, called an AI CLI, wrote the article, built the site, committed the result, and handed social delivery to Buffer. That was convenient until the hosted generator became the least reliable part of the system.&lt;/p&gt;

&lt;p&gt;The replacement did not move the entire pipeline onto my computer. It split the pipeline at a more useful boundary: judgment happens locally, while publication authority stays behind deterministic checks.&lt;/p&gt;

&lt;p&gt;That distinction matters. An AI agent is useful for reading source changes, deciding whether a topic deserves an article, and drafting prose. It should not get to redefine the conditions under which that prose becomes public.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the cloud gate read-only
&lt;/h2&gt;

&lt;p&gt;The old &lt;code&gt;content-autopilot.yml&lt;/code&gt; was a 250-line publishing workflow with repository write access. The replacement is a small, manually dispatched preflight with read-only repository permissions.&lt;/p&gt;

&lt;p&gt;Its job is to inspect current state and return evidence:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the Toronto calendar date&lt;/li&gt;
&lt;li&gt;the exact &lt;code&gt;main&lt;/code&gt; commit checked&lt;/li&gt;
&lt;li&gt;whether an article was already published that day&lt;/li&gt;
&lt;li&gt;whether the social outbox is still pending&lt;/li&gt;
&lt;li&gt;whether Buffer has room&lt;/li&gt;
&lt;li&gt;which mined issues are still unprocessed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result is uploaded as a JSON artifact. It includes a caller-supplied request ID, so a local run can find the exact workflow execution after an uncertain network response instead of dispatching a duplicate.&lt;/p&gt;

&lt;p&gt;The artifact is not merely a list of suggestions. It is a short-lived authorization tied to a date and a Git commit. The local publication tool rejects it after two hours, rejects it on the wrong Toronto day, and rejects it if the checkout no longer matches the recorded &lt;code&gt;baseSha&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That turns a potentially fuzzy handoff into a concrete contract:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;preflight artifact + exact checkout + selected candidate = eligible draft
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If any term changes, the run goes back through preflight.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep judgment local
&lt;/h2&gt;

&lt;p&gt;The local Codex session handles the part that benefits from context and judgment. It reads the candidate issue as a lead, opens the actual source commit, searches published work for overlap, and can decide that no candidate is worth publishing.&lt;/p&gt;

&lt;p&gt;That final option is important. A daily schedule is permission to look, not an obligation to manufacture an article.&lt;/p&gt;

&lt;p&gt;When a topic does merit a post, the local run creates only the article and its three social variants. A result file names the source issue, slug, expected paths, and the HTTPS sources that were actually checked. The staging script then validates the result against the preflight before it touches publication records.&lt;/p&gt;

&lt;p&gt;The validation is intentionally boring. It checks path shapes, frontmatter, date, source links, social sections, canonical URLs, duplicate issue numbers, daily limits, and a pending outbox. It also refuses to overwrite an existing article. The prose still requires judgment, but the publication invariants do not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Commit one bounded transaction
&lt;/h2&gt;

&lt;p&gt;Staging produces five related changes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;the Hugo page bundle&lt;/li&gt;
&lt;li&gt;the social copy&lt;/li&gt;
&lt;li&gt;a dated publication record&lt;/li&gt;
&lt;li&gt;the processed-issue ledger&lt;/li&gt;
&lt;li&gt;the Buffer outbox&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Those files are committed together through a pull request. This is the durable boundary of the run. If the process stops before that commit, there is no published record to reconcile. If it stops after the commit, the ledger and outbox describe exactly what remains.&lt;/p&gt;

&lt;p&gt;The pull request receives a separate &lt;code&gt;Content Validation&lt;/code&gt; check. That check installs dependencies from scratch, runs the Node test suite, validates every saved publication record, and builds the production Hugo site. The local agent can propose a change, but it cannot declare its own proposal deployable.&lt;/p&gt;

&lt;p&gt;The merge is also pinned to the reviewed head commit. If the branch changes after validation, the merge command fails instead of silently accepting a different revision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let deployment unlock delivery
&lt;/h2&gt;

&lt;p&gt;Merging the article still does not send social posts. Pages must successfully deploy the exact merge first.&lt;/p&gt;

&lt;p&gt;Only then does Buffer Posting read the durable outbox. Before it mutates anything, it verifies that each canonical article URL is publicly reachable and that the rendered page contains canonical publication evidence. A successful Git commit is not treated as proof that readers can reach the article.&lt;/p&gt;

&lt;p&gt;Social delivery has its own reconciliation rules. The delivery script scans scheduled, sending, sent, failed, and draft posts. It matches exact normalized copy, rejects multiple matches, and records a receipt as it works. If a create response is uncertain, the next run inspects Buffer instead of blindly creating another post.&lt;/p&gt;

&lt;p&gt;After all three channels are verified, the workflow appends the receipt to a delivery ledger and clears the outbox. Until that happens, the next preflight refuses to authorize another article.&lt;/p&gt;

&lt;h2&gt;
  
  
  Authority belongs in the reproducible layer
&lt;/h2&gt;

&lt;p&gt;Moving AI work out of GitHub Actions solved a billing and reliability problem, but the architectural improvement is broader.&lt;/p&gt;

&lt;p&gt;The local agent owns interpretation: what changed, what is useful, what overlaps, and how to explain it. Versioned scripts and cloud checks own authority: whether the source state is current, whether the records are consistent, whether the site builds, whether the article is live, and whether delivery already happened.&lt;/p&gt;

&lt;p&gt;That separation makes the pipeline easier to resume because every boundary leaves evidence. It also makes the AI replaceable. The drafting tool can change without moving the gates, ledgers, deployment checks, or delivery contracts that keep publication safe.&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>automation</category>
      <category>githubactions</category>
      <category>ai</category>
      <category>hugo</category>
    </item>
    <item>
      <title>Expose validated registries without a second authority</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Sun, 01 Nov 2026 14:09:42 +0000</pubDate>
      <link>https://dev.to/jonesrussell/expose-validated-registries-without-a-second-authority-747</link>
      <guid>https://dev.to/jonesrussell/expose-validated-registries-without-a-second-authority-747</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;A framework can validate a registry during boot and still create a security problem if later consumers receive a different instance. Validation only protects the object that was actually validated.&lt;/p&gt;

&lt;p&gt;This post covers a versioned Waaseyaa fix that exposed the kernel's role repository to provider factories. The pattern applies to permission catalogues, route snapshots, schema registries, and other boot-time authorities.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep one owner for validated state
&lt;/h2&gt;

&lt;p&gt;Waaseyaa service providers contribute roles and permissions during kernel boot. The kernel collects those contributions, validates them, and retains a role repository for runtime use.&lt;/p&gt;

&lt;p&gt;Provider factories also need to resolve that repository. The dangerous shortcuts are easy to imagine:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Build another repository from provider declarations.&lt;/li&gt;
&lt;li&gt;Let a sibling provider register its own repository binding.&lt;/li&gt;
&lt;li&gt;Resolve a partially composed repository during provider registration.&lt;/li&gt;
&lt;li&gt;Call contribution methods again after validation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each shortcut creates a second authority or changes the inputs after they were checked.&lt;/p&gt;

&lt;p&gt;The fix kept ownership with the kernel. The provider service bus received a lazy accessor to the kernel's repository instead of a factory that could reconstruct it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Return nothing before composition is complete
&lt;/h2&gt;

&lt;p&gt;Timing is part of the contract.&lt;/p&gt;

&lt;p&gt;During provider registration, role contributions have not finished composing. Returning a repository at that point would expose incomplete or unvalidated state. The accessor therefore returns &lt;code&gt;null&lt;/code&gt; before the kernel has completed catalogue composition.&lt;/p&gt;

&lt;p&gt;After composition, provider boot hooks receive the exact repository retained by the kernel. The service bus does not synthesize a fallback.&lt;/p&gt;

&lt;p&gt;This produces a useful lifecycle rule:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Phase&lt;/th&gt;
&lt;th&gt;Repository result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Provider registration&lt;/td&gt;
&lt;td&gt;Unavailable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Catalogue collection and validation&lt;/td&gt;
&lt;td&gt;Kernel-owned work in progress&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Provider boot&lt;/td&gt;
&lt;td&gt;Validated kernel instance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runtime factory resolution&lt;/td&gt;
&lt;td&gt;Same kernel instance&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Making the pre-composition state unavailable is better than returning an object that looks usable but has not crossed the validation gate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give kernel authority precedence over provider bindings
&lt;/h2&gt;

&lt;p&gt;A provider may try to register a binding for the same repository type. That binding must not replace the kernel authority.&lt;/p&gt;

&lt;p&gt;The service bus checks the role repository type before consulting sibling providers. If the kernel accessor is absent or not ready, resolution returns &lt;code&gt;null&lt;/code&gt;. It does not fall through to a provider-owned replacement.&lt;/p&gt;

&lt;p&gt;That precedence rule matters because dependency injection containers often treat the nearest binding as authoritative. For validated infrastructure, ownership must be encoded explicitly. Registration order should not decide which security catalogue wins.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preserve identity, not only equivalent values
&lt;/h2&gt;

&lt;p&gt;Two repositories can contain equal-looking roles and still represent different authority.&lt;/p&gt;

&lt;p&gt;Identity proves that provider factories consume the object that passed kernel validation. Rebuilding an equivalent repository loses that proof and can hide timing bugs, stale data, or a second call to stateful contribution code.&lt;/p&gt;

&lt;p&gt;The qualification used a provider whose &lt;code&gt;roles()&lt;/code&gt; result changes on every call. The tests proved that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The contribution method was consulted exactly once.&lt;/li&gt;
&lt;li&gt;The repository was unavailable during registration.&lt;/li&gt;
&lt;li&gt;The provider received the kernel repository during boot.&lt;/li&gt;
&lt;li&gt;Repeated service resolution returned the same instance.&lt;/li&gt;
&lt;li&gt;A sibling replacement binding was ignored.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The stateful fixture is deliberate. A provider that always returns the same values would not expose an accidental second collection pass.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wire every construction path
&lt;/h2&gt;

&lt;p&gt;The service bus was constructed in several kernel paths, including provider discovery, access-policy discovery, field-read installation, schedule registration, and query validation.&lt;/p&gt;

&lt;p&gt;The fix passed the same lazy repository accessor through each path. Missing one construction site would create phase-dependent behavior where a factory resolves correctly in one subsystem and fails or falls back in another.&lt;/p&gt;

&lt;p&gt;When you add a kernel-owned service to a shared resolver, search for every place that resolver is constructed. Test the identity and refusal rules through the public boot lifecycle, not only through a directly instantiated helper.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test authority as a lifecycle invariant
&lt;/h2&gt;

&lt;p&gt;The important assertion is not simply that dependency resolution returns a repository. Test the full boundary:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Contributions are collected once.&lt;/li&gt;
&lt;li&gt;Invalid grants fail before provider boot.&lt;/li&gt;
&lt;li&gt;Pre-composition resolution returns nothing.&lt;/li&gt;
&lt;li&gt;Post-composition resolution returns the validated object.&lt;/li&gt;
&lt;li&gt;Provider bindings cannot replace it.&lt;/li&gt;
&lt;li&gt;Every consumer receives the same identity.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Validated registries need both content checks and custody checks. Validate the catalogue, keep one owner, expose it only after composition, and preserve its identity everywhere it is consumed.&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>php</category>
      <category>architecture</category>
      <category>dependencyinjection</category>
      <category>testing</category>
    </item>
    <item>
      <title>Delegate account management without granting admin</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Sat, 31 Oct 2026 13:01:53 +0000</pubDate>
      <link>https://dev.to/jonesrussell/delegate-account-management-without-granting-admin-ofg</link>
      <guid>https://dev.to/jonesrussell/delegate-account-management-without-granting-admin-ofg</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;A staff member may need to update accounts in one approved roster without gaining every power carried by an administrator role. That is a delegation problem, not a reason to widen the administrator boundary.&lt;/p&gt;

&lt;p&gt;This post covers a versioned Waaseyaa authorization change that added consumer-scoped account management, then narrowed it through review. The useful pattern is to delegate exact decisions while keeping privileged fields, account creation, deletion, and credentials behind their existing boundaries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with the authority you actually need
&lt;/h2&gt;

&lt;p&gt;A broad permission such as &lt;code&gt;administer users&lt;/code&gt; can authorize much more than the workflow requires. It may cover role changes, account deletion, and targets outside the consumer's roster.&lt;/p&gt;

&lt;p&gt;Write down the smaller decision instead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which authenticated actor may act?&lt;/li&gt;
&lt;li&gt;Which exact account may they update?&lt;/li&gt;
&lt;li&gt;Which operation may they perform?&lt;/li&gt;
&lt;li&gt;Which fields must remain unavailable?&lt;/li&gt;
&lt;li&gt;What evidence binds the target to the consumer's scope?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The framework extension introduced an optional delegate inside the canonical user access policy. The consumer supplies the roster and consent decision. The framework continues to compose the final authorization result.&lt;/p&gt;

&lt;p&gt;Without a delegate binding, the old refusal remains. This makes delegation an explicit application choice instead of a new default permission.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pass immutable authorization input
&lt;/h2&gt;

&lt;p&gt;The delegate receives an authenticated authorization principal and the exact target account. It does not receive a mutable account object that application code can alter before asking for a decision.&lt;/p&gt;

&lt;p&gt;That distinction matters in retained workers and long-running processes. Authorization should depend on a snapshot of identity, roles, and permissions for the current request. It should not retain an actor from an earlier request.&lt;/p&gt;

&lt;p&gt;The provider-discovery tests exercised both bound and unbound policies. They also sent different actors through the same retained handler and then returned to the first actor. That proves the delegate binding survives discovery without turning request identity into service state.&lt;/p&gt;

&lt;p&gt;Direct legacy callers still pass through a runtime type guard. An authenticated object is not enough. Delegated decisions require the immutable principal contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep creation behind a separate intake boundary
&lt;/h2&gt;

&lt;p&gt;The first version of the delegate included an actor-only account creation decision. Review removed it.&lt;/p&gt;

&lt;p&gt;An actor-only predicate can prove that a clerk is generally trusted, but it cannot prove that this new account belongs to an approved roster entry or verified consent record. There is no target yet, so the most important subject of the decision is missing.&lt;/p&gt;

&lt;p&gt;The hardened design keeps generic account creation administrator-only. Consumer creation needs a separate provisioning boundary with an explicit approved-intake reference.&lt;/p&gt;

&lt;p&gt;This is a useful review question for any delegated API: &lt;strong&gt;does the method receive enough evidence to authorize the thing it creates?&lt;/strong&gt; If it receives only the actor, it may be too broad for the workflow it claims to protect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do not release privileged fields
&lt;/h2&gt;

&lt;p&gt;Permission to update an account does not imply permission to edit every account field.&lt;/p&gt;

&lt;p&gt;The delegate cannot authorize edits to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Roles or permissions&lt;/li&gt;
&lt;li&gt;Email verification state&lt;/li&gt;
&lt;li&gt;Password or legacy password material&lt;/li&gt;
&lt;li&gt;Two-factor secrets and recovery data&lt;/li&gt;
&lt;li&gt;Session-generation data&lt;/li&gt;
&lt;li&gt;Generic active-status fields&lt;/li&gt;
&lt;li&gt;Account deletion&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those restrictions stay in the canonical field and entity policies. A consumer cannot bypass them by returning &lt;code&gt;true&lt;/code&gt; from its delegate.&lt;/p&gt;

&lt;p&gt;The initial candidate allowed delegated active-status edits through the generic field surface. The integrated behavior moved that change to a dedicated lifecycle operation. This is the safer boundary because disabling an account has a required side effect: existing sessions must be revoked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Couple status changes to session revocation
&lt;/h2&gt;

&lt;p&gt;The account lifecycle service checks two independent decisions before changing status:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The actor may update this exact target.&lt;/li&gt;
&lt;li&gt;The actor may perform the dedicated active-status operation.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It also refuses self-administration through this path.&lt;/p&gt;

&lt;p&gt;When disabling an account, the service increments the session generation and changes the active flag before one repository save. Existing sessions bound to the previous generation become invalid with the same durable write that disables the account.&lt;/p&gt;

&lt;p&gt;Re-enabling the account does not restore the old generation. An unchanged status produces no write, and an exhausted generation counter fails instead of wrapping.&lt;/p&gt;

&lt;p&gt;That is why the generic field write was not enough. A security-sensitive state change belongs behind the operation that can enforce all of its invariants.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the refusals as part of the contract
&lt;/h2&gt;

&lt;p&gt;The useful tests are not limited to the admitted clerk and target. They also prove that the boundary refuses:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Another actor&lt;/li&gt;
&lt;li&gt;Another target&lt;/li&gt;
&lt;li&gt;Anonymous input&lt;/li&gt;
&lt;li&gt;A mutable legacy account object&lt;/li&gt;
&lt;li&gt;Account creation without approved intake&lt;/li&gt;
&lt;li&gt;Privileged and credential fields&lt;/li&gt;
&lt;li&gt;Deletion&lt;/li&gt;
&lt;li&gt;Status changes without both delegate decisions&lt;/li&gt;
&lt;li&gt;Self-disable through the lifecycle service&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keep those negative cases beside the success path. They describe the authority the delegate does not have, which is the most important part of a narrow delegation contract.&lt;/p&gt;

&lt;p&gt;Delegation works when it adds one bounded capability without creating a second administrator role. Pass immutable request identity, bind decisions to exact targets, keep creation tied to verified intake, and route security-sensitive mutations through services that enforce their required side effects.&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>php</category>
      <category>authorization</category>
      <category>security</category>
      <category>testing</category>
    </item>
    <item>
      <title>Prove SMTP authentication on every connection</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Fri, 30 Oct 2026 13:06:21 +0000</pubDate>
      <link>https://dev.to/jonesrussell/prove-smtp-authentication-on-every-connection-27j9</link>
      <guid>https://dev.to/jonesrussell/prove-smtp-authentication-on-every-connection-27j9</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;An SMTP server advertising authentication is not the same as the client successfully authenticating. The first fact describes a capability. The second is security state that belongs to one connection.&lt;/p&gt;

&lt;p&gt;This post follows a versioned Waaseyaa mail transport from its initial authenticated SMTP implementation through review-driven hardening. The useful lesson is broader than one library: require fresh authentication evidence after every handshake, including reconnects.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make transport selection explicit
&lt;/h2&gt;

&lt;p&gt;Email configuration should name the transport it intends to use. Do not infer production delivery from a loose collection of environment variables.&lt;/p&gt;

&lt;p&gt;The implementation accepted a bounded set of transports, including local capture, an in-memory test transport, a provider transport, and authenticated SMTP. SMTP configuration was validated before a connection could be attempted. It required a valid host, port, username, secret source, and supported authentication method.&lt;/p&gt;

&lt;p&gt;Fail early on incomplete or ambiguous configuration. A value that looks unusual, such as the username &lt;code&gt;0&lt;/code&gt;, must not disappear because a truthiness check mistakes it for an absent value.&lt;/p&gt;

&lt;h2&gt;
  
  
  Establish a verified encrypted channel
&lt;/h2&gt;

&lt;p&gt;Authentication credentials should only cross a channel whose peer has been verified.&lt;/p&gt;

&lt;p&gt;The transport required STARTTLS and configured the socket to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Verify the peer certificate&lt;/li&gt;
&lt;li&gt;Verify the peer name&lt;/li&gt;
&lt;li&gt;Reject self-signed certificates&lt;/li&gt;
&lt;li&gt;Limit TLS negotiation to supported modern protocol versions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The socket itself also had to be a valid stream before TLS options were applied. That check turns an unexpected runtime value into a controlled failure instead of letting it reach lower-level networking calls.&lt;/p&gt;

&lt;p&gt;Encryption is a prerequisite here, not proof of authentication. Keep those states separate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Track completed authentication, not advertised capability
&lt;/h2&gt;

&lt;p&gt;The first implementation checked that the server advertised the requested AUTH mechanism before sending mail. That is a useful capability check, but review exposed a missing boundary: a reconnect creates a new protocol session.&lt;/p&gt;

&lt;p&gt;A server can advertise AUTH on one connection, accept authentication, and then behave differently on the next connection. It can omit AUTH or attempt an EHLO-to-HELO downgrade. If the client remembers only what a previous server greeting advertised, it may confuse old connection state with current authorization.&lt;/p&gt;

&lt;p&gt;The hardened transport instead records successful authentication only after the server returns the expected success response. It clears that state whenever a fresh greeting begins. Before a message is sent, the current connection must have completed its own authentication exchange.&lt;/p&gt;

&lt;p&gt;That produces a simple invariant:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Every connection that sends a message must independently prove successful authentication.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If a reconnect cannot satisfy that invariant, delivery stops. The client does not continue through a weaker HELO path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Protect both the envelope and the failure path
&lt;/h2&gt;

&lt;p&gt;Mail safety extends beyond the connection handshake.&lt;/p&gt;

&lt;p&gt;The transport rejected reserved routing headers supplied through message metadata so application input could not quietly replace envelope decisions. It also translated provider and SMTP exceptions into a stable delivery failure without attaching the original exception. That prevents credentials, server transcripts, and provider details from leaking through an exception chain.&lt;/p&gt;

&lt;p&gt;Keep detailed diagnostics in an appropriately protected operational channel. Public application errors should describe the failed operation without replaying secrets or protocol conversations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test protocol transitions, not only successful delivery
&lt;/h2&gt;

&lt;p&gt;A happy-path SMTP test proves very little about reconnect behavior. Use a disposable test server that can control each protocol exchange and record what the client sent.&lt;/p&gt;

&lt;p&gt;The qualification covered:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;LOGIN and XOAUTH2 success&lt;/li&gt;
&lt;li&gt;Missing or mismatched AUTH capabilities&lt;/li&gt;
&lt;li&gt;Invalid and falsey configuration values&lt;/li&gt;
&lt;li&gt;TLS verification failures&lt;/li&gt;
&lt;li&gt;A second connection that omits AUTH&lt;/li&gt;
&lt;li&gt;A second connection that tries to fall back from EHLO to HELO&lt;/li&gt;
&lt;li&gt;Redaction of the public failure surface&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;TLS tests ran the mail client in a separate PHP process so certificate authority configuration was present when the runtime initialized. That is a good general rule for tests involving process-start configuration: exercise the real boundary instead of mutating global state after initialization and assuming the result is equivalent.&lt;/p&gt;

&lt;p&gt;The important outcome was not merely that authenticated SMTP worked. It was that authentication could not be inherited across connections, inferred from an advertisement, or bypassed through downgrade behavior.&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>php</category>
      <category>email</category>
      <category>security</category>
      <category>testing</category>
    </item>
    <item>
      <title>Separate installation from production activation</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Thu, 29 Oct 2026 13:05:46 +0000</pubDate>
      <link>https://dev.to/jonesrussell/separate-installation-from-production-activation-2a7a</link>
      <guid>https://dev.to/jonesrussell/separate-installation-from-production-activation-2a7a</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;A successful installer does not prove that an application is ready to serve production traffic. It proves that the installer completed its own bounded job.&lt;/p&gt;

&lt;p&gt;This post covers a production qualification pattern from a versioned Waaseyaa candidate: test installation, activation evidence, and HTTP runtime as separate gates, then stop at the first failing boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give the installer narrow authority
&lt;/h2&gt;

&lt;p&gt;An installer may need powers that ordinary application boot must not have. It can create the configured database, apply migrations, synchronize entity schemas, and initialize configuration.&lt;/p&gt;

&lt;p&gt;Keep that authority attached to the installation command. Help output, invalid options, unexpected arguments, and unrelated commands should not create storage as a side effect.&lt;/p&gt;

&lt;p&gt;The qualified candidate tested these boundaries directly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A valid production install created the selected SQLite database.&lt;/li&gt;
&lt;li&gt;Repeating installation preserved an existing content witness and configuration activation rows.&lt;/li&gt;
&lt;li&gt;An invalid production secret failed before creating storage.&lt;/li&gt;
&lt;li&gt;Existing foreign schema drift caused a refusal without deleting the foreign row.&lt;/li&gt;
&lt;li&gt;Ordinary commands still refused an absent database instead of creating it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is more useful than one broad end-to-end success assertion. Each check tells you which command owns a mutation and which commands must remain read-only.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do not manufacture activation evidence during setup
&lt;/h2&gt;

&lt;p&gt;Installation and activation answer different questions.&lt;/p&gt;

&lt;p&gt;Installation asks whether the application can create and initialize its operational state. Field-access activation asks whether the exact installed framework, schema, scanner generation, and field classifications have been reviewed and are ready for production reads.&lt;/p&gt;

&lt;p&gt;The installer intentionally did not create the field-access activation artifact. A separate preflight command inspected the live installation. Read-only execution reported its result without writing the artifact. Only an explicit artifact-writing option published the evidence later consumed by normal boot.&lt;/p&gt;

&lt;p&gt;That separation prevents setup from approving itself. It also lets you prove several refusal cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Missing evidence blocks activation.&lt;/li&gt;
&lt;li&gt;A malformed artifact is rejected.&lt;/li&gt;
&lt;li&gt;A different scanner generation is rejected.&lt;/li&gt;
&lt;li&gt;A framework or schema identity mismatch is stale.&lt;/li&gt;
&lt;li&gt;A changed checksum or readiness flag is invalid.&lt;/li&gt;
&lt;li&gt;Any unresolved blocker keeps the result not ready.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important part is not the filename or command name. The important part is that reviewed evidence is produced explicitly and bound to the inputs it actually describes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Qualify each installation independently
&lt;/h2&gt;

&lt;p&gt;Two installations can produce similar results without sharing the same identity.&lt;/p&gt;

&lt;p&gt;In the recorded qualification, development and no-dev installations had different discovered fields, schema fingerprints, and preflight checksums. Each installation used its own vendor tree, database, schema, scan, and artifact. No artifact was copied from one environment to the other.&lt;/p&gt;

&lt;p&gt;That matters because a passing result is not portable merely because the output looks familiar. Reuse requires source, dependency, schema, scanner, and execution equivalence. When those inputs differ, generate and review evidence again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the actual runtime after the CLI gates
&lt;/h2&gt;

&lt;p&gt;Once installation and activation pass, exercise the same server path production will use.&lt;/p&gt;

&lt;p&gt;The candidate qualification checked cold, warm, and restarted Apache with PHP-FPM. It confirmed production mode, disabled debugging, disabled development fallback, and a real application-owned response marker. A separate no-dev installation also proved that the runtime did not depend on PHPUnit.&lt;/p&gt;

&lt;p&gt;The sequence was deliberate:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Install the application.&lt;/li&gt;
&lt;li&gt;Run the read-only activation preflight.&lt;/li&gt;
&lt;li&gt;Explicitly write the reviewed activation artifact.&lt;/li&gt;
&lt;li&gt;Confirm ordinary CLI boot consumes it.&lt;/li&gt;
&lt;li&gt;Start the production web stack.&lt;/li&gt;
&lt;li&gt;Test cold, warm, and restarted requests.&lt;/li&gt;
&lt;li&gt;Run negative controls and restore the exact environment.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Do not collapse those checks into one green label. A prerequisite, a generated artifact, and a runtime response are separate pieces of evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preserve the first failing boundary
&lt;/h2&gt;

&lt;p&gt;The versioned qualification first reached a homepage only after the activation artifact existed. Its next unknown-route request returned a generic 500 instead of the required 404. The record preserved that result and handed the failure to the issue that owned error handling and diagnostics.&lt;/p&gt;

&lt;p&gt;That is the right stopping rule. Do not patch an unrelated subsystem inside a qualification run just to make the journey green. Record:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The exact source and dependency identity&lt;/li&gt;
&lt;li&gt;The environment and server mode&lt;/li&gt;
&lt;li&gt;The command or request that failed&lt;/li&gt;
&lt;li&gt;The public response&lt;/li&gt;
&lt;li&gt;The private diagnostic evidence that is safe to retain&lt;/li&gt;
&lt;li&gt;The limits of what the evidence proves&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A clean production qualification is not a story about everything working. It is a chain of bounded claims that stops honestly when the next claim cannot be made.&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>php</category>
      <category>deployment</category>
      <category>testing</category>
      <category>security</category>
    </item>
    <item>
      <title>Track campaign conversions without storing contact details</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Wed, 28 Oct 2026 13:10:26 +0000</pubDate>
      <link>https://dev.to/jonesrussell/track-campaign-conversions-without-storing-contact-details-gd9</link>
      <guid>https://dev.to/jonesrussell/track-campaign-conversions-without-storing-contact-details-gd9</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;Campaign attribution usually starts with a simple question: which shared link led to a useful action? The implementation gets risky when analytics and contact data start bleeding into each other.&lt;/p&gt;

&lt;p&gt;This post covers a narrower design: count landings and saved contact messages for registered campaigns while keeping names, email addresses, messages, raw query strings, and visitor identifiers out of the conversion table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Register campaigns instead of accepting arbitrary tags
&lt;/h2&gt;

&lt;p&gt;Do not treat every UTM value as trusted analytics data. Use a finite registry that binds one internal campaign key to the exact public route and exact tag set you expect.&lt;/p&gt;

&lt;p&gt;That boundary gives you several useful properties:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Unknown tags never become campaign labels.&lt;/li&gt;
&lt;li&gt;Duplicate or partial parameters do not create attribution.&lt;/li&gt;
&lt;li&gt;A valid tag set on the wrong route is rejected.&lt;/li&gt;
&lt;li&gt;Test campaigns can be marked and excluded from production totals.&lt;/li&gt;
&lt;li&gt;Click IDs and unrelated query parameters never enter storage.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The browser only carries the internal key after the complete registered link matches. This keeps reporting labels under editorial control instead of letting arbitrary request data shape your analytics.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep attribution short-lived and same-tab
&lt;/h2&gt;

&lt;p&gt;For a contact form reached after a landing page, you need a small amount of continuity. You do not need a durable visitor profile.&lt;/p&gt;

&lt;p&gt;The implementation stores a registered campaign key and a 30-minute expiry in same-tab session storage. A second valid campaign replaces the first. Invalid explicit campaign tags clear the old value. When session storage is unavailable, attribution can remain in memory for the current page and then disappear.&lt;/p&gt;

&lt;p&gt;This is deliberately limited:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;There is no analytics cookie.&lt;/li&gt;
&lt;li&gt;There is no cross-tab identity.&lt;/li&gt;
&lt;li&gt;There is no name, email, message, or raw URL in the attribution state.&lt;/li&gt;
&lt;li&gt;Do Not Track or Global Privacy Control clears the state and disables collection.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those limits mean the report is not a complete readership count. That is acceptable when the goal is directional campaign measurement, not surveillance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Count conversions only after the contact is saved
&lt;/h2&gt;

&lt;p&gt;A public analytics collector should never be allowed to claim that a conversion happened. The server already knows when the contact form passes validation, rate limiting, bot checks, and persistence.&lt;/p&gt;

&lt;p&gt;Record the conversion at that trusted boundary. In this implementation, the contact row and conversion event are written inside the same database transaction. If the conversion write fails, the tracked contact save rolls back too. The response cannot report success for a message while silently losing its associated measurement.&lt;/p&gt;

&lt;p&gt;The conversion event contains only:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The event type&lt;/li&gt;
&lt;li&gt;A known campaign key or &lt;code&gt;null&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;A random event key&lt;/li&gt;
&lt;li&gt;A timestamp&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The contact record keeps the personal information needed for a reply. The analytics table does not receive the contact row ID or any contact content.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deduplicate landings without creating a visitor profile
&lt;/h2&gt;

&lt;p&gt;Repeated delivery of the same pageview can happen through retries or browser behaviour. A random pageview ID can be hashed into an event key so the same landing is counted once.&lt;/p&gt;

&lt;p&gt;That key is scoped to deduplication. It is not reused to join the landing to a person, a contact record, or activity on another day. The report aggregates campaign landings and saved messages without exposing an individual journey.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the refusal paths
&lt;/h2&gt;

&lt;p&gt;The useful tests are not only the happy path. Prove that the boundaries hold when inputs are incomplete or hostile.&lt;/p&gt;

&lt;p&gt;Cover at least these cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Unknown, duplicate, partial, expired, and wrong-route campaign tags are rejected.&lt;/li&gt;
&lt;li&gt;Do Not Track and Global Privacy Control prevent analytics while the contact form still works.&lt;/li&gt;
&lt;li&gt;Bots, invalid forms, honeypots, and rate-limited requests do not count as conversions.&lt;/li&gt;
&lt;li&gt;The public collector cannot submit a conversion event.&lt;/li&gt;
&lt;li&gt;Repeated delivery of one pageview counts one landing.&lt;/li&gt;
&lt;li&gt;Analytics failure rolls back a tracked contact save.&lt;/li&gt;
&lt;li&gt;Stored analytics rows contain no email address or message text.&lt;/li&gt;
&lt;li&gt;Test campaigns stay out of production totals.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This design does not prove that someone saw the original social post. Reshares keep the same registered tag, scripts can be blocked, and privacy choices reduce the count. Report visits and saved messages for a link, not unique people or causal certainty.&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>php</category>
      <category>analytics</category>
      <category>privacy</category>
      <category>testing</category>
    </item>
    <item>
      <title>Repair stale digest pins without weakening verification</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Tue, 27 Oct 2026 13:05:53 +0000</pubDate>
      <link>https://dev.to/jonesrussell/repair-stale-digest-pins-without-weakening-verification-1g55</link>
      <guid>https://dev.to/jonesrussell/repair-stale-digest-pins-without-weakening-verification-1g55</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;A strict digest check is supposed to stop old configuration from silently selecting changed generated output. When a reviewed generator changes, that protection can also make a reference fixture fail until its pinned identity is migrated.&lt;/p&gt;

&lt;p&gt;This post shows how to repair that failure without weakening the integrity check, using a &lt;a href="https://github.com/waaseyaa/framework" rel="noopener noreferrer"&gt;Waaseyaa&lt;/a&gt; reference consumer as the example.&lt;/p&gt;

&lt;h2&gt;
  
  
  Understand what the digest protects
&lt;/h2&gt;

&lt;p&gt;The reference application selects a generated recipe in YAML:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;recipes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;published_content&lt;/span&gt;
    &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
    &lt;span class="na"&gt;capability&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;published_content&lt;/span&gt;
    &lt;span class="na"&gt;artifact_digest&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cf8f1932dea7f7dc08ccf2cefbf42465d5c2838b1d0e1fbd3cf62a63da580f11&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The digest identifies the reviewed recipe artifact, not just the recipe name and version. If the recipe's generated files or contract change, an older selection should not claim that it still describes the installed output.&lt;/p&gt;

&lt;p&gt;That refusal is useful. It turns a silent compatibility mismatch into an explicit migration decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Classify the failure before changing code
&lt;/h2&gt;

&lt;p&gt;The hosted reference consumer failed because its checked-in answers still pinned the previous recipe digest. A fresh installed consumer reproduced the same mismatch through &lt;code&gt;project:init --dry-run&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There are two very different possible causes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cause&lt;/th&gt;
&lt;th&gt;Correct response&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Installed recipe changed without an approved contract update&lt;/td&gt;
&lt;td&gt;Stop and repair the producer or release process&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reviewed recipe changed but its reference fixture kept the old digest&lt;/td&gt;
&lt;td&gt;Migrate the fixture and prove it against the installed recipe&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Do not make digest comparison optional just because the failure appeared in a test fixture. That would also allow genuinely stale applications to accept changed generated output.&lt;/p&gt;

&lt;p&gt;In this case, the reviewed recipe already owned the new digest. The missing change was in the reference consumer's selection.&lt;/p&gt;

&lt;h2&gt;
  
  
  Update the selection, not the verifier
&lt;/h2&gt;

&lt;p&gt;The repair changed one value in the reference answers file: the previous digest was replaced with the digest of the reviewed installed recipe.&lt;/p&gt;

&lt;p&gt;The validation rules stayed strict. Runtime boot, installation authority, persistence behavior, and normal refusal controls did not need to change.&lt;/p&gt;

&lt;p&gt;This is an important boundary for code generation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the producer defines the artifact identity;&lt;/li&gt;
&lt;li&gt;the consumer pins the identity it reviewed;&lt;/li&gt;
&lt;li&gt;a changed identity requires an explicit consumer migration;&lt;/li&gt;
&lt;li&gt;the verifier continues refusing mismatches.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you instead loosen the verifier, you erase the evidence that the consumer has reviewed the new generated contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the real reference fixture
&lt;/h2&gt;

&lt;p&gt;Updating a hash by hand can make the immediate error disappear without proving that the complete reference configuration works. Add a contract test that loads the actual fixture and sends it through the installed recipe:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="na"&gt;#[Test]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;theReferenceSelectionRendersWithTheInstalledPublishedContentRecipe&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;dirname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;__DIR__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/ReferenceConsumer/site.answers.yaml'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$manifest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SiteManifestParser&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;file_get_contents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;assertNotEmpty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PublishedContentRecipe&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$manifest&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This test checks the real answers file rather than a smaller duplicate fixture. It exercises three things together:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the reference YAML parses as a site manifest;&lt;/li&gt;
&lt;li&gt;its pinned recipe identity matches the installed recipe;&lt;/li&gt;
&lt;li&gt;the recipe can render output for that manifest.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The test was captured failing with the stale digest before the fixture was updated. That proves the regression test detects the original mismatch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the negative test
&lt;/h2&gt;

&lt;p&gt;A successful real-fixture test is only half of the contract. Keep a separate test that substitutes the wrong digest and expects refusal.&lt;/p&gt;

&lt;p&gt;The two tests answer different questions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test&lt;/th&gt;
&lt;th&gt;Evidence&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Actual reference selection renders&lt;/td&gt;
&lt;td&gt;The maintained fixture matches the installed reviewed recipe&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Substituted digest is refused&lt;/td&gt;
&lt;td&gt;Integrity checking still rejects an unreviewed identity&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Without the first test, fixture drift can reach hosted qualification. Without the second, a permissive implementation could make every fixture pass.&lt;/p&gt;

&lt;h2&gt;
  
  
  Requalify the new identity
&lt;/h2&gt;

&lt;p&gt;Once the fixture and regression test change, you have a new candidate. Run the focused contract tests, the broader governed preflight, and the hosted consumers again against the exact new commit.&lt;/p&gt;

&lt;p&gt;Do not reuse the old green results. They describe the old candidate. Do not repair a failed installed consumer in place either. A fresh consumer gives you evidence about the new fixture without mixing it with state from the failed attempt.&lt;/p&gt;

&lt;p&gt;The final checklist is short:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Reproduce the digest mismatch from a clean consumer.&lt;/li&gt;
&lt;li&gt;Confirm whether the producer or the pinned fixture is stale.&lt;/li&gt;
&lt;li&gt;Update only the reviewed consumer selection when it is a fixture migration.&lt;/li&gt;
&lt;li&gt;Add a test that renders the actual reference manifest.&lt;/li&gt;
&lt;li&gt;Retain a negative test for a substituted digest.&lt;/li&gt;
&lt;li&gt;Requalify the new commit through fresh consumers.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Strict digest checks create migration work on purpose. Preserve that boundary, migrate the fixture explicitly, and test both acceptance and refusal.&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>php</category>
      <category>testing</category>
      <category>integrity</category>
      <category>codegeneration</category>
    </item>
    <item>
      <title>Make integration tests independent of your local .env</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Mon, 26 Oct 2026 13:07:02 +0000</pubDate>
      <link>https://dev.to/jonesrussell/make-integration-tests-independent-of-your-local-env-l4e</link>
      <guid>https://dev.to/jonesrussell/make-integration-tests-independent-of-your-local-env-l4e</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;An integration test can pass on your computer because your local &lt;code&gt;.env&lt;/code&gt; quietly supplies a database path, application secret, or storage directory. The same test then fails in CI, or worse, touches a real local resource that was never meant to be part of the test.&lt;/p&gt;

&lt;p&gt;This post shows how to make a PHP integration test own its complete environment, using a &lt;a href="https://github.com/waaseyaa/framework" rel="noopener noreferrer"&gt;Waaseyaa&lt;/a&gt; media publishing test as the concrete example.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hidden configuration is a test dependency
&lt;/h2&gt;

&lt;p&gt;The test boots the real CLI, initializes a database, writes media files, and exercises the HTTP publishing path. That means it depends on more than the values named in the test method.&lt;/p&gt;

&lt;p&gt;Its runtime needs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an explicit application environment;&lt;/li&gt;
&lt;li&gt;debug mode disabled;&lt;/li&gt;
&lt;li&gt;a database path;&lt;/li&gt;
&lt;li&gt;valid application and JWT secrets;&lt;/li&gt;
&lt;li&gt;isolated upload and file-storage roots.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If even one value falls through to your shell or &lt;code&gt;.env&lt;/code&gt;, the test is not self-contained. A developer with a configured application may get a pass while a clean runner gets a boot error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Save every variable the test owns
&lt;/h2&gt;

&lt;p&gt;Before changing the process environment, save the original value of every variable the test will replace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cd"&gt;/** @var array&amp;lt;string, string|false&amp;gt; */&lt;/span&gt;
&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$originalEnvironment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="s1"&gt;'APP_ENV'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'APP_DEBUG'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'WAASEYAA_DB'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'WAASEYAA_APP_SECRET'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'WAASEYAA_JWT_SECRET'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'WAASEYAA_MEDIA_UPLOADS_DIR'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'WAASEYAA_FILES_ROOT'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;originalEnvironment&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;getenv()&lt;/code&gt; returns &lt;code&gt;false&lt;/code&gt; when a variable was absent. Keep that distinction. An absent variable and a variable set to an empty string can take different configuration paths.&lt;/p&gt;

&lt;p&gt;The list should be explicit. It documents the environment boundary of the test and makes new dependencies visible in review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Create unique temporary resources
&lt;/h2&gt;

&lt;p&gt;A fixed test database or upload directory creates collisions between runs and leaves stale state available to the next test. Generate one suffix, then use it for every related temporary path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$suffix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;bin2hex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;random_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;databasePath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;sys_get_temp_dir&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/rhtcircle-media-'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$suffix&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'.sqlite'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;uploadsDir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;sys_get_temp_dir&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/rhtcircle-media-'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$suffix&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nb"&gt;mkdir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;uploadsDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mo"&gt;0700&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives the database and file storage a shared run identity without letting one run reuse another run's files. The restrictive directory mode also matches the fact that these are private test artifacts, not public fixtures.&lt;/p&gt;

&lt;h2&gt;
  
  
  Supply valid test-only secrets
&lt;/h2&gt;

&lt;p&gt;Do not make the test depend on development credentials. Generate values that satisfy the application's real configuration rules:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nb"&gt;putenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'APP_ENV=testing'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;putenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'APP_DEBUG=false'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;putenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'WAASEYAA_DB='&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;databasePath&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;putenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'WAASEYAA_APP_SECRET=base64:'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nb"&gt;base64_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;random_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;span class="nb"&gt;putenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'WAASEYAA_JWT_SECRET='&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nb"&gt;bin2hex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;random_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;span class="nb"&gt;putenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'WAASEYAA_MEDIA_UPLOADS_DIR='&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;uploadsDir&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;putenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'WAASEYAA_FILES_ROOT='&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;uploadsDir&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These secrets exist only for the test process. They are random, valid for the boot path being exercised, and unrelated to local or production values.&lt;/p&gt;

&lt;p&gt;Avoid a short placeholder such as &lt;code&gt;test-secret&lt;/code&gt; when the application expects a specific strength or format. A weak fixture can bypass the same validation that protects the real system, which means your integration test no longer represents a valid installation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Restore absence as absence
&lt;/h2&gt;

&lt;p&gt;Teardown must restore both kinds of original state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;originalEnvironment&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;putenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'='&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Calling &lt;code&gt;putenv($name)&lt;/code&gt; removes a variable. Calling &lt;code&gt;putenv($name . '=')&lt;/code&gt; leaves it present with an empty value. That difference is easy to miss and can leak configuration into later tests.&lt;/p&gt;

&lt;p&gt;The test also removes its database and upload files. Environment restoration and filesystem cleanup belong in the same lifecycle because both are part of the temporary application state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Watch for process-global state
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;putenv()&lt;/code&gt; changes the environment for the current PHP process. This is practical for a test that launches application entrypoints, but it is still global mutable state.&lt;/p&gt;

&lt;p&gt;Use these controls when your suite grows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;run environment-mutating tests in separate processes or a serialized group;&lt;/li&gt;
&lt;li&gt;give every run unique database and storage paths;&lt;/li&gt;
&lt;li&gt;restore variables in teardown even after an assertion fails;&lt;/li&gt;
&lt;li&gt;keep the owned-variable list next to setup and teardown;&lt;/li&gt;
&lt;li&gt;never read secrets from the developer's real &lt;code&gt;.env&lt;/code&gt; as test fixtures.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;PHPUnit calls &lt;code&gt;tearDown()&lt;/code&gt; after each completed test lifecycle, but setup code can fail too. If resource creation becomes more complex, wrap partial setup in guarded cleanup or move it into helpers that can undo their own work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify the boundary
&lt;/h2&gt;

&lt;p&gt;A clean test should answer these questions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Expected result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Local &lt;code&gt;.env&lt;/code&gt; is missing&lt;/td&gt;
&lt;td&gt;Test still boots&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Developer secrets are different&lt;/td&gt;
&lt;td&gt;Test behavior is unchanged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two runs use temporary storage&lt;/td&gt;
&lt;td&gt;Paths do not collide&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A later test reads the environment&lt;/td&gt;
&lt;td&gt;It sees the original values&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The test fails halfway through&lt;/td&gt;
&lt;td&gt;Temporary state is still cleaned up&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The point is not simply to make CI green. It is to make the test prove the same thing everywhere. Once the test owns its full configuration, a pass describes application behavior instead of the accidental state of one developer machine.&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>php</category>
      <category>testing</category>
      <category>environment</category>
      <category>sqlite</category>
    </item>
    <item>
      <title>Preserve failed test evidence before you retry</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Sun, 25 Oct 2026 13:09:26 +0000</pubDate>
      <link>https://dev.to/jonesrussell/preserve-failed-test-evidence-before-you-retry-ik0</link>
      <guid>https://dev.to/jonesrussell/preserve-failed-test-evidence-before-you-retry-ik0</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;A qualification harness has a harder job than an ordinary test suite. It must tell you whether a real installation worked, but it also has to preserve enough evidence to explain a failure without leaking private inputs.&lt;/p&gt;

&lt;p&gt;This post covers five controls I used while building a published-consumer qualification lane for the &lt;a href="https://github.com/waaseyaa/framework" rel="noopener noreferrer"&gt;Waaseyaa framework&lt;/a&gt;. The examples come from a specific PHP and Docker harness, but the design applies to any CI job that launches real services and produces evidence for later review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start the clock before setup
&lt;/h2&gt;

&lt;p&gt;If a harness starts its timeout after checkout and dependency installation, those steps are outside its claimed execution budget. A slow setup can leave too little time to stop services, export state, encrypt it, and upload the result.&lt;/p&gt;

&lt;p&gt;The workflow records its start time in the first executable step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Start the absolute qualification and custody clock&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;echo "J1_JOB_STARTED_AT=$(date +%s)" &amp;gt;&amp;gt; "$GITHUB_ENV"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The PHP harness then derives one deadline from that timestamp:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$started&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'J1_JOB_STARTED_AT'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;need&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;ctype_digit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$started&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$started&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s1"&gt;'Workflow start identity required'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;deadline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$started&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every subprocess receives only the time remaining. Finalization gets a separate reserved window for stopping and exporting containers, sealing files, and writing the result. The workflow still has a larger outer timeout, so runner cleanup and artifact upload have room to finish.&lt;/p&gt;

&lt;p&gt;The useful rule is simple: &lt;strong&gt;the budget begins when work begins, not when the interesting test begins&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Record resource ownership before launch
&lt;/h2&gt;

&lt;p&gt;Container startup can return an uncertain result. The client may time out even though the daemon created the container. If your cleanup list is updated only after a successful launch, that container disappears from your accounting.&lt;/p&gt;

&lt;p&gt;The harness first proves that each run-owned container name is absent. It then records the name before calling &lt;code&gt;docker run&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;containers&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'container-start'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'docker'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'run'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'-d'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'--name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'--memory'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'2g'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'--cpus'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'2'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'j1-s1:'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nb"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'GITHUB_RUN_ID'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Finalization inspects every possibly created name. An absent name becomes an explicit custody record. A present container is stopped, inspected, and exported. If inspection itself fails, custody remains incomplete.&lt;/p&gt;

&lt;p&gt;This avoids a dangerous assumption: a failed launch command does not prove that no resource exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate public evidence from private recovery state
&lt;/h2&gt;

&lt;p&gt;A useful failure report and a restorable snapshot have different audiences.&lt;/p&gt;

&lt;p&gt;The public evidence stream contains sanitized JSON records, step timings, hashes, and failure classifications. The private snapshot contains the full application bind, environment files, database side files, raw process streams, and exported container filesystems. That snapshot is encrypted before upload.&lt;/p&gt;

&lt;p&gt;Redaction also has to match the encoding used by the output format. Replacing only the literal secret is not enough when JSON can escape characters such as &lt;code&gt;/&lt;/code&gt;. The harness replaces both forms:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;secrets&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$secret&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$escaped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;json_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$secret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;JSON_THROW_ON_ERROR&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;str_replace&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nv"&gt;$secret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$escaped&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s1"&gt;'[REDACTED]'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A regression test uses a synthetic secret containing &lt;code&gt;/&lt;/code&gt; and &lt;code&gt;+&lt;/code&gt; to prove that the JSON representation is redacted. The private source remains inside the encrypted snapshot rather than being copied into the public report.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preserve the first failure
&lt;/h2&gt;

&lt;p&gt;An automatic retry can turn a useful failure into a misleading pass. It can also mutate the application you need to inspect.&lt;/p&gt;

&lt;p&gt;The qualification lane records the first failure, freezes any possibly created resources, and seals the original state. A new attempt needs a fresh application and a new snapshot identity. The failed instance is not repaired into success.&lt;/p&gt;

&lt;p&gt;That distinction matters when a test harness is qualifying a release rather than merely checking a code path. You need to know what the first published installation did, not what it did after an unrecorded recovery sequence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the observer against reality
&lt;/h2&gt;

&lt;p&gt;Two early failures were caused by the harness, not the framework.&lt;/p&gt;

&lt;p&gt;The first expected SQLite's &lt;code&gt;database_list&lt;/code&gt; pragma to return exactly one row. A real connection can contain one file-backed &lt;code&gt;main&lt;/code&gt; database and an unbacked &lt;code&gt;temp&lt;/code&gt; schema. The corrected predicate allows that normal &lt;code&gt;temp&lt;/code&gt; row while still refusing additional attached databases, path escapes, symlinks, incorrect pragmas, and duplicate rows.&lt;/p&gt;

&lt;p&gt;The second expected an entire log event to be JSON. The actual formatter writes the message as a text prefix and only the context as JSON. The corrected parser anchors extraction to the exact critical channel and message, then decodes the following context. Tests feed it real formatter output and prove that warnings, truncated data, and unrelated JSON are ignored.&lt;/p&gt;

&lt;p&gt;In both cases, the right repair was to correct the observer and preserve the failed run. Changing runtime behavior to satisfy a mistaken harness would have hidden the real contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical checklist
&lt;/h2&gt;

&lt;p&gt;Before trusting a qualification lane, check that it can answer these questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;When did the total budget begin?&lt;/li&gt;
&lt;li&gt;Which resources might have been created, even after an uncertain command result?&lt;/li&gt;
&lt;li&gt;Can every resource be stopped, inspected, and exported?&lt;/li&gt;
&lt;li&gt;Which evidence is safe to publish?&lt;/li&gt;
&lt;li&gt;Is private recovery state encrypted and integrity-checked?&lt;/li&gt;
&lt;li&gt;Does a retry create a fresh identity instead of modifying the failed instance?&lt;/li&gt;
&lt;li&gt;Are parsers and topology checks tested against output from the real tools?&lt;/li&gt;
&lt;li&gt;Can the final result say that custody is incomplete instead of claiming a clean failure?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The goal is not to collect as many logs as possible. It is to make each result explainable. A pass should identify exactly what was tested. A failure should preserve the first state that failed. An uncertain cleanup should remain uncertain.&lt;/p&gt;

&lt;p&gt;That is what turns a CI job into qualification evidence.&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>testing</category>
      <category>ci</category>
      <category>docker</category>
      <category>security</category>
    </item>
    <item>
      <title>CLI help must not mutate your filesystem</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Sat, 24 Oct 2026 13:06:40 +0000</pubDate>
      <link>https://dev.to/jonesrussell/cli-help-must-not-mutate-your-filesystem-1h5b</link>
      <guid>https://dev.to/jonesrussell/cli-help-must-not-mutate-your-filesystem-1h5b</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;Running &lt;code&gt;install:init --help&lt;/code&gt; should print help. It should not create a database.&lt;/p&gt;

&lt;p&gt;That sounds obvious, but command selection often happens before input validation. If your console kernel sees a mutating command name and boots the application immediately, help and invalid options can trigger storage setup before the CLI framework decides not to execute the command.&lt;/p&gt;

&lt;p&gt;A recent &lt;a href="https://github.com/waaseyaa/framework/commit/08fbc6199b39a7772303d183dfffe8da6f4ae60b" rel="noopener noreferrer"&gt;Waaseyaa installer repair&lt;/a&gt; separates command selection from validated execution. This post covers the boundary and the real-entrypoint tests that prove it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Selection is not execution
&lt;/h2&gt;

&lt;p&gt;Console kernels often inspect the first argument to choose a boot mode:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getFirstArgument&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'install:init'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Boot with installation authority.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The token identifies the requested command, but it does not prove the command will run.&lt;/p&gt;

&lt;p&gt;Symfony Console still needs to handle cases such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;install:init --help&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;install:init -h&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;an unknown option;&lt;/li&gt;
&lt;li&gt;an unexpected argument;&lt;/li&gt;
&lt;li&gt;missing required input.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Help exits successfully without executing the handler. Invalid input exits with a usage error. Neither case should boot a mode that can create storage.&lt;/p&gt;

&lt;p&gt;The original Waaseyaa path selected installation boot from the raw command token. That boot could prepare the configured database directory before Symfony processed help or rejected the input.&lt;/p&gt;

&lt;p&gt;The visible command never ran, but its boot path had already mutated the filesystem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put authority inside the execution callback
&lt;/h2&gt;

&lt;p&gt;The repaired kernel builds a small console application containing the canonical &lt;code&gt;install:init&lt;/code&gt; command, then replaces its execution callback:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$application&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WaaseyaaConsoleApplication&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;VersionResolver&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;projectRoot&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$command&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MigrateServiceProvider&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;installInitCommand&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$command&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$output&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;initializingDatabase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;bootForSchemaSync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;initializingDatabase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ConsoleApplicationFactory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;kernel&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;container&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;buildHandlerContainer&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;providers&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getProviders&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$output&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nv"&gt;$application&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addCommand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$command&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$application&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$output&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Symfony parses the input, renders help, and validates arguments and options before entering the callback. Installation boot now happens only on the execution path.&lt;/p&gt;

&lt;p&gt;The important part is not &lt;code&gt;setCode()&lt;/code&gt; itself. The important part is where the authority begins.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Phase&lt;/th&gt;
&lt;th&gt;May create storage?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Read command name&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Render help&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Validate arguments and options&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enter the validated execution callback&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Complete or fail execution&lt;/td&gt;
&lt;td&gt;Authority restored in &lt;code&gt;finally&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This makes the mutation boundary match the command framework's lifecycle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Share the command definition
&lt;/h2&gt;

&lt;p&gt;Moving validation before boot can tempt you to build a second parser in the kernel. That creates drift between pre-boot validation and the command registered after boot.&lt;/p&gt;

&lt;p&gt;Waaseyaa avoids that by exposing one command factory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;installInitCommand&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;HandlerCommand&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;HandlerCommand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'install:init'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Initialize a fresh installation: apply migrations, synchronize schema, and activate the initial configuration generation'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;InstallInitHandler&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'execute'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both provider discovery and the pre-boot dispatch path use this definition. Arguments, options, description, and handler ownership stay in one place.&lt;/p&gt;

&lt;p&gt;If the command changes later, help and validation change with it. The kernel does not need a parallel list of accepted input.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep elevated boot authority scoped
&lt;/h2&gt;

&lt;p&gt;Fresh installation is unusual because it may be the only allowed path that creates a missing production database. Ordinary production boot should still refuse that state.&lt;/p&gt;

&lt;p&gt;The kernel represents this exception with a private installation mode. The callback enables it only around schema boot and restores it in &lt;code&gt;finally&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That gives you two protections:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;help and invalid input never receive creation authority;&lt;/li&gt;
&lt;li&gt;a boot exception cannot leave the kernel in installation mode.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Temporary authority should always have an obvious start, an obvious end, and failure-safe cleanup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the real entrypoint and negative space
&lt;/h2&gt;

&lt;p&gt;A unit test of the callback is not enough. The defect lived in the ordering between the executable, the kernel, Symfony Console, and application boot.&lt;/p&gt;

&lt;p&gt;The integration test invokes the real CLI entrypoint with a database path under a directory that does not exist. It checks four non-executing inputs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Input&lt;/th&gt;
&lt;th&gt;Expected exit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--help&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-h&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;unknown option&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;unexpected argument&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;After every invocation, the test proves the absence of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the configured SQLite file;&lt;/li&gt;
&lt;li&gt;its &lt;code&gt;-wal&lt;/code&gt; sidecar;&lt;/li&gt;
&lt;li&gt;its &lt;code&gt;-shm&lt;/code&gt; sidecar;&lt;/li&gt;
&lt;li&gt;the selected parent directory;&lt;/li&gt;
&lt;li&gt;the ordinary storage directory.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those absence checks matter. An exit code can be correct even after the application created an empty directory or touched a database file.&lt;/p&gt;

&lt;p&gt;Test both the response and the negative space left behind.&lt;/p&gt;

&lt;h2&gt;
  
  
  Review pre-boot dispatch carefully
&lt;/h2&gt;

&lt;p&gt;Any CLI with multiple boot modes can have this problem. Look for code that grants authority from raw argument tokens before the command framework validates input.&lt;/p&gt;

&lt;p&gt;Pay extra attention to commands that can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;create databases or directories;&lt;/li&gt;
&lt;li&gt;acquire migration locks;&lt;/li&gt;
&lt;li&gt;open network connections;&lt;/li&gt;
&lt;li&gt;load production secrets;&lt;/li&gt;
&lt;li&gt;modify caches or generated files.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For each one, prove that help, malformed options, unexpected arguments, and missing input perform no mutation.&lt;/p&gt;

&lt;p&gt;The command name tells you what the user selected. Only the validated execution callback tells you what the program is allowed to do.&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>php</category>
      <category>cli</category>
      <category>testing</category>
      <category>security</category>
    </item>
    <item>
      <title>Do not let generated content replace your homepage</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Fri, 23 Oct 2026 13:05:35 +0000</pubDate>
      <link>https://dev.to/jonesrussell/do-not-let-generated-content-replace-your-homepage-mk5</link>
      <guid>https://dev.to/jonesrussell/do-not-let-generated-content-replace-your-homepage-mk5</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;A content generator can produce valid routes and still erase the application you started with.&lt;/p&gt;

&lt;p&gt;The failure appears when a content type uses a root-level detail route such as &lt;code&gt;/{slug}&lt;/code&gt;. If the generator derives the collection route by removing the slug segment, it gets &lt;code&gt;/&lt;/code&gt;. Registering that collection at the root can replace the homepage provided by your starter application.&lt;/p&gt;

&lt;p&gt;A recent &lt;a href="https://github.com/waaseyaa/framework/commit/17bf3bb78fa494fcd325a5753795d93952ede184" rel="noopener noreferrer"&gt;Waaseyaa route-generation repair&lt;/a&gt; makes the ownership rule explicit. This post covers the route policy and the tests that keep a generated content index from taking over &lt;code&gt;/&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The detail route is not the collection route
&lt;/h2&gt;

&lt;p&gt;Consider three content types:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Content type&lt;/th&gt;
&lt;th&gt;Detail route&lt;/th&gt;
&lt;th&gt;Expected collection&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Page&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/{slug}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/page&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;News&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/news/{slug}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/news&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Archive&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/news/archive/{slug}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/news/archive&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Removing the terminal slug works for the two prefixed routes. It does not work for the root-level route because the empty parent becomes &lt;code&gt;/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The original derivation followed that logic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$route&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;preg_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'#/\{[^/]+\}$#'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$detailRoute&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$route&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'/'&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$route&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both the homepage and the generated page collection then claimed the same literal URL. The router could serve a valid response while still serving the wrong owner.&lt;/p&gt;

&lt;p&gt;That distinction matters. An HTTP 200 does not prove your homepage survived generation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give the root collection a stable name
&lt;/h2&gt;

&lt;p&gt;The repaired recipe passes the bundle identifier into its collection-route function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;indexRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$detailRoute&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$bundle&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$route&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;preg_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'#/\{[^/]+\}$#'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$detailRoute&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$route&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'/'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$bundle&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$route&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The policy is small:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a root-level detail route keeps its authored &lt;code&gt;/{slug}&lt;/code&gt; pattern;&lt;/li&gt;
&lt;li&gt;its collection moves to &lt;code&gt;/&amp;lt;bundle-id&amp;gt;&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;a prefixed detail route keeps its parent as the collection;&lt;/li&gt;
&lt;li&gt;the application homepage remains at &lt;code&gt;/&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the default &lt;code&gt;page&lt;/code&gt; bundle, individual pages still resolve from paths such as &lt;code&gt;/about&lt;/code&gt;, while the collection lives at &lt;code&gt;/page&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is better than inventing a global &lt;code&gt;/content&lt;/code&gt; prefix. The bundle already provides a stable, declared name, and prefixed routes do not change unnecessarily.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reserve literal collection paths first
&lt;/h2&gt;

&lt;p&gt;Moving the collection exposes another possible collision. A root-level &lt;code&gt;page&lt;/code&gt; bundle now owns &lt;code&gt;/page&lt;/code&gt;, so another content type cannot also declare &lt;code&gt;/page/{slug}&lt;/code&gt; and derive the same collection.&lt;/p&gt;

&lt;p&gt;The recipe records each collection route while rendering definitions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$indexRoutes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$indexRoute&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;InvalidArgumentException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s2"&gt;"Published content index route collision: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$indexRoute&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;$indexRoutes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$indexRoute&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This refuses the ambiguous site before publishing generated files. It also reserves the literal collection path ahead of the broad &lt;code&gt;/{slug}&lt;/code&gt; detail matcher.&lt;/p&gt;

&lt;p&gt;Route ordering can hide a collision during one request. Generation-time refusal makes the contract independent of registration order.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test ownership, not only response codes
&lt;/h2&gt;

&lt;p&gt;The focused recipe tests cover the route table directly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/{slug}&lt;/code&gt; produces &lt;code&gt;/page&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/news/{slug}&lt;/code&gt; produces &lt;code&gt;/news&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/news/archive/{slug}&lt;/code&gt; produces &lt;code&gt;/news/archive&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;no generated collection may equal &lt;code&gt;/&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;a second collection at &lt;code&gt;/page&lt;/code&gt; is refused.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The stronger integration check verifies the composed application:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Generate and install the minimal published-content recipe.&lt;/li&gt;
&lt;li&gt;Request &lt;code&gt;/&lt;/code&gt; from the real HTTP runtime.&lt;/li&gt;
&lt;li&gt;Assert the response contains a marker owned by the application's homepage template.&lt;/li&gt;
&lt;li&gt;Request &lt;code&gt;/page&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Assert the response contains the generated &lt;code&gt;page_index&lt;/code&gt; listing.&lt;/li&gt;
&lt;li&gt;Repeat the homepage request cold, warm, and after restarting the server process.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The marker is important. Checking only for status 200 would accept the original bug because the generated listing also returned 200 at &lt;code&gt;/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;You need evidence about &lt;em&gt;which component answered&lt;/em&gt;, not just whether something answered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Version the generated contract
&lt;/h2&gt;

&lt;p&gt;Changing generated URLs is observable application behavior. Waaseyaa includes the collection policy in the recipe digest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s1"&gt;'index_routes'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'root_detail'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'bundle_path'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'prefixed_detail'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'parent_path'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That prevents an older generated selection from silently claiming the new output. Existing applications retain their routes until a reviewed regeneration adopts the changed recipe.&lt;/p&gt;

&lt;p&gt;This is the right upgrade boundary for code generation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;fix future output at the producer;&lt;/li&gt;
&lt;li&gt;record the new generation contract;&lt;/li&gt;
&lt;li&gt;refuse conflicting routes before writing;&lt;/li&gt;
&lt;li&gt;require existing applications to regenerate deliberately.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do not patch generated metadata by hand to make verification pass. The generator, its digest, and the application output should move together.&lt;/p&gt;

&lt;h2&gt;
  
  
  A route-generation checklist
&lt;/h2&gt;

&lt;p&gt;Before you trust a generated route set, prove these boundaries:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Boundary&lt;/th&gt;
&lt;th&gt;Minimum proof&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Homepage ownership&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/&lt;/code&gt; renders an application-owned marker after generation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Root detail routes&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/{slug}&lt;/code&gt; keeps details at the root without creating a root collection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prefixed routes&lt;/td&gt;
&lt;td&gt;Parent collection paths remain unchanged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Literal collisions&lt;/td&gt;
&lt;td&gt;Duplicate collection URLs fail before publication&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Matcher precedence&lt;/td&gt;
&lt;td&gt;Collection literals are reserved ahead of broad slug routes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Upgrade behavior&lt;/td&gt;
&lt;td&gt;A changed route policy changes the generation contract&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Generated code is part of your application's public interface. Treat route ownership as a contract, and test the composed application that users will actually reach.&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>php</category>
      <category>routing</category>
      <category>testing</category>
      <category>waaseyaa</category>
    </item>
  </channel>
</rss>
