<?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: Jeremy Longshore</title>
    <description>The latest articles on DEV Community by Jeremy Longshore (@jeremy_longshore).</description>
    <link>https://dev.to/jeremy_longshore</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%2F3842419%2Ff5d02b54-daf0-4520-9aef-118fbd0c24ac.jpeg</url>
      <title>DEV Community: Jeremy Longshore</title>
      <link>https://dev.to/jeremy_longshore</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jeremy_longshore"/>
    <language>en</language>
    <item>
      <title>Erasure Requests: Model Provider Cleanup From Reservations</title>
      <dc:creator>Jeremy Longshore</dc:creator>
      <pubDate>Tue, 03 Nov 2026 12:02:12 +0000</pubDate>
      <link>https://dev.to/jeremy_longshore/erasure-requests-model-provider-cleanup-from-reservations-51dh</link>
      <guid>https://dev.to/jeremy_longshore/erasure-requests-model-provider-cleanup-from-reservations-51dh</guid>
      <description>&lt;p&gt;When a product has to honor an erasure request, the cleanup obligation owed to a hosted model provider has to come from what was possibly sent, not from what we can prove was sent. We had it derived from the wrong signal, and the two directions of that failure were not equally bad: one asks a vendor for a cleanup of data it never received, the other can leave personal data sitting at a vendor outside our control.&lt;/p&gt;

&lt;p&gt;What follows is the rule that replaced it, the SQL condition that implements it, a reversible migration (a database change you can roll back) that installs it, and a regression module that reproduces both defects.&lt;/p&gt;

&lt;p&gt;The product here is an onboarding system. Its assessment path sends applicant data to a hosted model provider to score assessments, and its reply interpreter uses the same provider. When a person asks to be erased, the system computes a handoff list: the external parties that now owe a cleanup because they may hold that person's data. The model provider is one entry on that list, written as &lt;code&gt;model_provider&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Our own database holds narrower evidence about a provider call than it does about a row. We record that we reserved a request before sending it, and we record that a provider call consumed requests. Between those two records sits the part we cannot resolve: whether a reserved request that never completed actually crossed to the vendor. Any obligation we owe that vendor has to be derived from the wider of the two records, the reservation rather than the consumption count, because the two ways of getting it wrong cost very different amounts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The old rule asked only whether a run row existed
&lt;/h2&gt;

&lt;p&gt;The old rule lived in a catalog module (the Python file that generates the erasure function's SQL), inside the SQL body of the function that builds the handoff list. It looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;  &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;assessments_assessmentrun&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;application_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;ANY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v_apps&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt;
    &lt;span class="n"&gt;v_handoffs&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;v_handoffs&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;'model_provider'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;-- what the model provider kept of a request, if anything&lt;/span&gt;
  &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any assessment run row at all for the erased person's applications was enough. No column was compared. The query could not tell a run that consumed a provider call from a run that was created and then refused before a call was ever made.&lt;/p&gt;

&lt;p&gt;Both defects came out of that one line, and they point in opposite directions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;False positive.&lt;/strong&gt; An assessment refused on suspension produced a run row and no provider call. The old rule still reported &lt;code&gt;model_provider&lt;/code&gt;. The vendor owed nothing, and it would have been asked to clean up data it never received.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;False negative.&lt;/strong&gt; A person's inbound reply can be reserved for interpretation by the provider while no assessment run exists for them. The legacy predicate found no run row, so the entry was omitted. The provider might have received the reply, and nobody would have asked it to drop anything.&lt;/p&gt;

&lt;p&gt;Neither defect is something you find by reading the SQL twice. The condition looks conservative, and conservative is the right instinct here. It is just conservative in the wrong place: careful about the row's existence, careless about the request's fate.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I derive a model provider cleanup obligation?
&lt;/h2&gt;

&lt;p&gt;We derive it from what was possibly sent, not from what we can prove was sent. A reserved provider call counts as evidence of disclosure even when the call never completed, because an unfinished run is not evidence of absence. An abandoned call is the harder case, because there the run exists and did not finish, and the reason is that the provider's side of the call is not something our database can resolve. When a run is refused before anything is reserved, no handoff is owed.&lt;/p&gt;

&lt;p&gt;The new predicate lives in &lt;code&gt;erasure/catalog.py&lt;/code&gt; as &lt;code&gt;MODEL_PROVIDER_HANDOFF&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;MODEL_PROVIDER_HANDOFF&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;EXISTS (SELECT 1 FROM public.assessments_assessmentinvocation i
             WHERE i.application_id = ANY(v_apps) AND i.reserved_requests &amp;gt; 0)
     OR EXISTS (SELECT 1 FROM public.assessments_assessmentrun r
                WHERE r.application_id = ANY(v_apps) AND r.requests_used &amp;gt; 0)
     OR EXISTS (SELECT 1 FROM public.correspondence_replyinterpretation i
                WHERE i.application_id = ANY(v_apps) AND i.reserved_requests &amp;gt; 0)&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three signal sources and two different columns:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;What it establishes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Assessment invocation (assessment and clarification rounds)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;reserved_requests &amp;gt; 0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A provider call was reserved. A possible disclosure, even if the call never completed.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Assessment run&lt;/td&gt;
&lt;td&gt;&lt;code&gt;requests_used &amp;gt; 0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A provider call consumed requests. A confirmed disclosure.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reply interpretation&lt;/td&gt;
&lt;td&gt;&lt;code&gt;reserved_requests &amp;gt; 0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reply-interpretation provider work was reserved. A possible disclosure.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rule now reads, as recorded in the internal launch plan: a queued or suspension-refused run that reserved nothing and recorded nothing owes no model provider handoff. Positive assessment and clarification reservations, recorded assessment requests and reply interpretation reservations keep the handoff, including abandoned calls whose provider outcome is uncertain. Reply-only calls count even without an assessment run.&lt;/p&gt;

&lt;p&gt;The inversion that matters is that a reservation became a first-class signal instead of an implementation detail. Previously a provider call surfaced only as an assessment run row. The reservation was always recorded and the old rule never looked at it. Now the rule reads that column, so the ambiguous middle (reserved, then interrupted, then abandoned) is answered from a recorded number instead of inferred from the mere existence of a run row.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not count only confirmed calls, or ask the provider?
&lt;/h2&gt;

&lt;p&gt;The obvious fix is to count only confirmed calls: drop the reservation checks, keep &lt;code&gt;requests_used &amp;gt; 0&lt;/code&gt;, and let the ambiguous cases fall on the floor. It removes the false positive entirely and the query gets shorter.&lt;/p&gt;

&lt;p&gt;It also creates a silent false negative, and that is the bad trade. A call that was reserved and then interrupted leaves no confirmed record, but the provider may have received it. The absence of a finished run is not evidence of absence of disclosure. When the evidence is missing rather than negative, the only safe reading is that a disclosure is possible, and a possible disclosure still creates a cleanup obligation.&lt;/p&gt;

&lt;p&gt;The other obvious approach is to ask the provider. Read the vendor's logs for the person's identifiers and use the answer. That makes the vendor a runtime dependency of an erasure request, it assumes the vendor can match our identifiers to what it received, and it assumes the vendor keeps logs long enough to answer. None of those hold in general, and the cost when they do not hold is the person's data still sitting there.&lt;/p&gt;

&lt;p&gt;So the obligation is deliberately a superset of confirmed disclosures, and we accept the false positive the new rule keeps. That false positive costs a message to a vendor and some noise in our own logs. The false negative can cost a person their right to be forgotten.&lt;/p&gt;

&lt;p&gt;One more reason the set has to lean wide: we do not get to know what the provider does with a request after we send it. Retention, training and logging on their side, if they happen at all, happen outside our database. Our own records tell us a request was reserved for sending, and a confirmed call (&lt;code&gt;requests_used &amp;gt; 0&lt;/code&gt;) tells us the provider consumed it. Neither tells us it kept anything. The reservation is our evidence that it might have gone out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installing it without rewriting history
&lt;/h2&gt;

&lt;p&gt;The change ships as a reversible migration, &lt;code&gt;erasure/migrations/0008_model_handoff_evidence.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;9kg.73: queued or refused model work alone creates no provider cleanup obligation.

Reservations remain conservative evidence of possible disclosure, including abandoned calls
and reply interpretation without an assessment run. Reinstall both erasure functions without
rewriting rows, signed records, function ownership or grants.
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="c1"&gt;# imports elided
&lt;/span&gt;
&lt;span class="n"&gt;LEGACY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;EXISTS (SELECT 1 FROM public.assessments_assessmentrun r WHERE r.application_id = ANY(v_apps))&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;install&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;schema_editor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;  &lt;span class="c1"&gt;# retention and existing lookups elided
&lt;/span&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;replay&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;statement&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;catalog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;erase_sql&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retention&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;replay&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;replay&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;statement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;catalog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MODEL_PROVIDER_HANDOFF&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="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unexpected model handoff definition&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;statement&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;statement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;catalog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MODEL_PROVIDER_HANDOFF&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;LEGACY&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="n"&gt;schema_editor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;statement&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="c1"&gt;# forwards, backwards and the Migration class elided
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things are worth pulling out of that file.&lt;/p&gt;

&lt;p&gt;The loop runs over both function variants, the normal erasure and the restore replay. The two have to carry the same rule, because a restored database can bring back rows for a person erased after the backup, and the replay re-runs the erasure against those rows. What a restore leaves behind is covered in &lt;a href="https://startaitools.com/posts/database-restore-does-not-undo-outward-effects/" rel="noopener noreferrer"&gt;A Database Restore Does Not Undo Sent Email or Envelopes&lt;/a&gt;. If replay derived the entry differently, the replay would answer a different question than the erasure it replays.&lt;/p&gt;

&lt;p&gt;The count check keeps the installed SQL and the catalog in agreement. The function body is generated from the catalog, so the migration asserts that the new predicate appears exactly once in the generated statement. A later edit that drops the clause or duplicates it fails the migration instead of shipping a function that quietly disagrees with the source it came from.&lt;/p&gt;

&lt;p&gt;The reverse path swaps the new predicate back to the legacy one, which is what makes the migration reversible. The old rule is carried in the file as a constant, so reversing is a substitution instead of a guess. Ownership, grants, historical rows and signed erasure records survive the reinstall, so no signed record is rewritten.&lt;/p&gt;

&lt;p&gt;Nothing else moved. Provider activation, rate limits and token policy are untouched.&lt;/p&gt;

&lt;h2&gt;
  
  
  The regression that reproduces both defects
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;tests/test_erasure_model_handoffs.py&lt;/code&gt; landed at 150 lines with four test functions in it. The first of them covers the asymmetry in a single body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@pytest.mark.parametrize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;replay&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;erase&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;replay&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="nd"&gt;@pytest.mark.parametrize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;called&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;refused&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;provider-called&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_9kg_73_a_suspended_assessment_owes_no_provider_handoff_but_a_real_call_does&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;monkeypatch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;decided&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;replay&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;called&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;called&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;assessed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;monkeypatch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;succeeded&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;calls&lt;/span&gt;&lt;span class="sh"&gt;"&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="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;AssessmentInvocation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;queued&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;monkeypatch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CATALYST_ASSESSMENT_MODEL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;none&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="nf"&gt;run_worker&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;refresh_from_db&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;refused&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;calls&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;AssessmentInvocation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;closer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;grant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;staff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model-erasure-closer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;close_application&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;closure&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close_application&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;closer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;closed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Synthetic erasure regression&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;assert &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model_provider&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;handoffs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;committed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;replay&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;called&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;called&lt;/code&gt; parameter doubles as the expected answer, which is why the last line can assert &lt;code&gt;is called&lt;/code&gt; instead of branching on it. The &lt;code&gt;refused&lt;/code&gt; branch builds the case that used to produce the false positive. Setting the model to &lt;code&gt;none&lt;/code&gt; is the suspension condition, so the worker runs, the run is refused and the fake provider records zero calls. The &lt;code&gt;provider-called&lt;/code&gt; branch is the control, where a real call happened and the handoff is owed. The &lt;code&gt;closure.close_application&lt;/code&gt; call closes the application through the staff closure helper before the erasure runs. The second function, &lt;code&gt;test_9kg_73_reply_only_model_reservations_keep_the_provider_handoff&lt;/code&gt;, covers the false negative. It builds a reply interpretation that reserved provider work, in an application with no assessment run, and asserts across both erasure paths that the handoff appears when something was reserved and stays absent when the reservation count is zero.&lt;/p&gt;

&lt;p&gt;The third test covers the ambiguous middle, the case the old predicate could not tell apart from a run that reserved nothing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@pytest.mark.parametrize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;started&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;abandoned&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_9kg_73_an_uncertain_reserved_call_still_owes_provider_cleanup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;privileged_reset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;decided&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;reserved_run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model_provider&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;handoffs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;privileged_reset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A call that was reserved and then started or abandoned still owes a model provider cleanup, because the provider's side of that call is not something our database can resolve.&lt;/p&gt;

&lt;p&gt;The fourth function, &lt;code&gt;test_9kg_73_both_functions_reverse_and_reapply_without_changing_rows_or_grants&lt;/code&gt;, reverses the migration and reapplies it, then checks that the rows, the function ownership and the grants survive the reinstall.&lt;/p&gt;

&lt;p&gt;The helper three of the four tests call is what ties the two paths together. It runs the installed &lt;code&gt;catalyst_erase&lt;/code&gt; function for the normal path and the installed &lt;code&gt;catalyst_erase_replay&lt;/code&gt; function for the restore path, so whichever path is under test is held to the same rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result
&lt;/h2&gt;

&lt;p&gt;The eight-case regression, the module's first eight cases, four from the suspended-assessment matrix and four from the reply-only matrix, reproduced both defects before the change: 4 expected failures and 4 passing controls, in 30.32 seconds. Making the guard prove it can fail before trusting that it passed is the same rule as in &lt;a href="https://startaitools.com/posts/the-gate-that-could-not-fail/" rel="noopener noreferrer"&gt;Make the Guard Prove It Can Fail&lt;/a&gt;. After the fix, all 15 focused checks passed in 102.60 seconds. The coverage included actual worker and fake-provider cases, both erasure paths, uncertain calls, reversal and reapplication with exact row, function and grant preservation, the installed SQL checked against the catalog, schema inventory and model synchronization, and the existing complete erasure-reversal check.&lt;/p&gt;

&lt;p&gt;Ruff, formatting, Markdown, document index and diff checks pass. Neither a live provider nor a production database took part in this change or in its tests, and nothing is deployed yet, so no vendor ever received one of these cleanup requests. Full CI was the merge gate, and the change is merged.&lt;/p&gt;

&lt;p&gt;Deployment and the live handover to the new version remain separate open work. The internal launch plan and the approved migration set in &lt;code&gt;tests/test_database.py&lt;/code&gt; were updated with the change, so the next person reads the rule instead of rediscovering which signal was wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Also shipped
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A sibling fix from the same day (&lt;a href="https://github.com/intent-solutions-io/catalyst-onboarding-v2/pull/178" rel="noopener noreferrer"&gt;PR #178&lt;/a&gt;) requires restore-recovery evidence to be timestamped at or after the current transaction's start, not just to carry a matching transaction ID, so a rebuilt cluster cannot reuse an old transaction ID as fresh authorization.&lt;/li&gt;
&lt;li&gt;Restore replay now matches normalized inbound senders against the signed erasure record (&lt;a href="https://github.com/intent-solutions-io/catalyst-onboarding-v2/pull/177" rel="noopener noreferrer"&gt;PR #177&lt;/a&gt;), so restoring a backup cannot resurrect inbound mail and intake counters for a person erased after that backup was taken.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Use this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Derive any obligation to ask an external party to delete data from what was possibly sent, not from the existence of a row. A reservation you wrote before the call is the evidence that a request might have reached the vendor, and a refusal that reserved nothing is evidence that nothing did.&lt;/li&gt;
&lt;li&gt;Give the delete path and the restore path the same predicate, then test both against the installed functions. A restore that replays an erasure with a different rule answers a different question than the erasure it replays.&lt;/li&gt;
&lt;li&gt;Carry the old predicate in the migration as a constant, assert the new one appears exactly once in the generated SQL, and reverse by substitution. A signed erasure record is never rewritten, and a catalog edit cannot ship a function that disagrees with its own source.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The change is in &lt;a href="https://github.com/intent-solutions-io/catalyst-onboarding-v2/pull/179" rel="noopener noreferrer"&gt;catalyst-onboarding-v2 PR #179&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is the difference between a reserved provider call and a confirmed one?
&lt;/h3&gt;

&lt;p&gt;A reservation is the record your system writes before it sends the request, so the request may have reached the provider. A confirmed disclosure is the record that requests were consumed. A call that was reserved and then started or abandoned still owes a cleanup, because the provider's side of that call is not something your database can resolve.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why not count only confirmed provider calls?
&lt;/h3&gt;

&lt;p&gt;Counting only confirmed calls removes the false positive and shortens the query, but it creates a silent false negative: an interrupted call leaves no confirmed record while the provider may have received it. Asking the provider instead makes the vendor a runtime dependency of an erasure request, and none of those assumptions hold in general. So the obligation is deliberately a superset of confirmed disclosures.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I test that an erasure derives the right cleanup set?
&lt;/h3&gt;

&lt;p&gt;Parametrize the case over both erasure paths and over whether the provider was actually called, so the parameter doubles as the expected answer. Cover the false negative with a reply interpretation that reserved provider work, and the ambiguous middle with a call reserved and then started or abandoned. Assert against the installed functions, so the path under test and the restore path are held to the same rule.&lt;/p&gt;

&lt;p&gt;{&lt;br&gt;
  "&lt;a class="mentioned-user" href="https://dev.to/context"&gt;@context&lt;/a&gt;": "&lt;a href="https://schema.org" rel="noopener noreferrer"&gt;https://schema.org&lt;/a&gt;",&lt;br&gt;
  "@type": "FAQPage",&lt;br&gt;
  "mainEntity": [&lt;br&gt;
    {&lt;br&gt;
      "@type": "Question",&lt;br&gt;
      "name": "What is the difference between a reserved provider call and a confirmed one?",&lt;br&gt;
      "acceptedAnswer": {&lt;br&gt;
        "@type": "Answer",&lt;br&gt;
        "text": "A reservation is the record your system writes before it sends the request, so the request may have reached the provider. A confirmed disclosure is the record that requests were consumed. A call that was reserved and then started or abandoned still owes a cleanup, because the provider's side of that call is not something your database can resolve."&lt;br&gt;
      }&lt;br&gt;
    },&lt;br&gt;
    {&lt;br&gt;
      "@type": "Question",&lt;br&gt;
      "name": "Why not count only confirmed provider calls?",&lt;br&gt;
      "acceptedAnswer": {&lt;br&gt;
        "@type": "Answer",&lt;br&gt;
        "text": "Counting only confirmed calls removes the false positive and shortens the query, but it creates a silent false negative: an interrupted call leaves no confirmed record while the provider may have received it. Asking the provider instead makes the vendor a runtime dependency of an erasure request, and none of those assumptions hold in general. So the obligation is deliberately a superset of confirmed disclosures."&lt;br&gt;
      }&lt;br&gt;
    },&lt;br&gt;
    {&lt;br&gt;
      "@type": "Question",&lt;br&gt;
      "name": "How do I test that an erasure derives the right cleanup set?",&lt;br&gt;
      "acceptedAnswer": {&lt;br&gt;
        "@type": "Answer",&lt;br&gt;
        "text": "Parametrize the case over both erasure paths and over whether the provider was actually called, so the parameter doubles as the expected answer. Cover the false negative with a reply interpretation that reserved provider work, and the ambiguous middle with a call reserved and then started or abandoned. Assert against the installed functions, so the path under test and the restore path are held to the same rule."&lt;br&gt;
      }&lt;br&gt;
    }&lt;br&gt;
  ]&lt;br&gt;
}&lt;/p&gt;

&lt;h2&gt;
  
  
  Related Posts
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://startaitools.com/posts/database-restore-does-not-undo-outward-effects/" rel="noopener noreferrer"&gt;A Database Restore Does Not Undo Sent Email or Envelopes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://startaitools.com/posts/a-green-recovery-drill-can-still-be-lying/" rel="noopener noreferrer"&gt;A Green Recovery Drill Can Still Be Lying&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://startaitools.com/posts/working-is-not-proven/" rel="noopener noreferrer"&gt;Every Claim Needs a Shipped Source and an Executable Proof&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>architecture</category>
      <category>testing</category>
      <category>debugging</category>
    </item>
    <item>
      <title>A Database Restore Does Not Undo Sent Email or Envelopes</title>
      <dc:creator>Jeremy Longshore</dc:creator>
      <pubDate>Tue, 03 Nov 2026 11:30:13 +0000</pubDate>
      <link>https://dev.to/jeremy_longshore/a-database-restore-does-not-undo-sent-email-or-envelopes-nh3</link>
      <guid>https://dev.to/jeremy_longshore/a-database-restore-does-not-undo-sent-email-or-envelopes-nh3</guid>
      <description>&lt;p&gt;Restore a database to last night's backup and the rows go back in time. The email your app sent at 2 a.m. does not. Neither does the e-signature envelope it created, the mailbox password it rotated or the message it appended to a Sent folder. The restored app has no memory of any of them, so it can do them again: a second contract envelope to the same signer, a duplicate confirmation email to the same applicant.&lt;/p&gt;

&lt;p&gt;This post covers a three-part pattern for that problem. First, a registry: every function that calls an outside service, tagged with how a restore recovers it. Second, a test that reads the source code and fails the build when that registry and the code disagree. Third, for an effect nothing reads back and the review does not close, a check after the restore that asks the outside service what it created since the backup.&lt;/p&gt;

&lt;p&gt;The app here is an onboarding system. It sends mail over SMTP and through Django's mailer, creates and distributes agreements through Documenso (a self-hosted e-signature service), writes to a mailbox host, appends to IMAP folders and calls LLM agents. It also keeps an effect journal (an append-only log of outward actions, the side effects a rollback cannot reach, kept outside the database). After a restore, a post-restore review reads that journal and the providers, then decides whether to lift the restore hold (release the restored system for normal use).&lt;/p&gt;

&lt;h2&gt;
  
  
  What a restore forgets
&lt;/h2&gt;

&lt;p&gt;The journal only helps if it covers every effect. Before this work, that coverage was a hand-written claim in a design document, written after a code review. Nothing checked it.&lt;/p&gt;

&lt;p&gt;A hand-written list of covered effects drifts in one direction. Code adds a new provider call. Nobody updates the list. The list still reads as complete, and the next restore drill passes because the drill never exercises the new call. &lt;a href="https://startaitools.com/posts/a-green-recovery-drill-can-still-be-lying/" rel="noopener noreferrer"&gt;A Green Recovery Drill Can Still Be Lying&lt;/a&gt; covers other ways a restore check reports green.&lt;/p&gt;

&lt;p&gt;That is the failure mode to design against. The restore itself is fine. The claim about what the restore can recover is the part that rots.&lt;/p&gt;

&lt;h2&gt;
  
  
  The registry: one recovery class per function and family
&lt;/h2&gt;

&lt;p&gt;The fix starts with a data file, &lt;code&gt;workflow/effects.py&lt;/code&gt;. It registers 40 functions, two of them markers rather than effects, grouped in seven families: journal writes, SMTP, Django mail, Documenso writes, mailbox host writes, IMAP appends and model runs.&lt;/p&gt;

&lt;p&gt;Each function gets one class for each family it touches, plus a reason. Six classes make up the recovery vocabulary:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;journaled_before:&lt;/strong&gt; a journal entry is written and fsynced before the effect becomes a fact. Restrictive effects (such as a staff deactivation or an access revocation) are prepared this way. If that write fails, only the erasure and custody records refuse the effect; see below.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;journaled_after:&lt;/strong&gt; the entry is written on commit, once the effect is a fact. A crash can lose it, but it can never invent an effect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;provider_read:&lt;/strong&gt; no entry. A named post-restore function reads the effect back at the provider.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;checked_before:&lt;/strong&gt; no entry. The action reads the provider before it writes, so a repeated run is idempotent: it finds the effect and skips it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;covered_by_review:&lt;/strong&gt; nothing reads it back. The post-restore review closes what the effect handed out, such as every live confirmation, password-setup and withdrawal link.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;marker:&lt;/strong&gt; an entry about the journal itself, such as the restore point. Not an effect.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three entries copied from the registry as the first PR shipped it show the shape. The &lt;code&gt;gap&lt;/code&gt; on the first one comes back below.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agreements.handlers:CreateAgreement.call&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;documenso&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Site&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PROVIDER_READ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a restored instance&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s envelope is read by id, or found by its external key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;by&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;READ_ENVELOPES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gap&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;9kg.76&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)},&lt;/span&gt;
&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agreements.handlers:DistributeAgreement.call&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;documenso&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Site&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CHECKED_BEFORE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;re-reads the envelope and skips one already PENDING&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;by&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agreements.handlers:DistributeAgreement.call&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)},&lt;/span&gt;
&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agreements.keystore:destroy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;journal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Site&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JOURNALED_BEFORE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;custody key destruction, appended before the unlink&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;custody_key_destroyed&lt;/span&gt;&lt;span class="sh"&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 key is the module plus the function's qualified name. The value maps a family to a &lt;code&gt;Site&lt;/code&gt;. The &lt;code&gt;by&lt;/code&gt; field names the function responsible for recovery, so a reviewer can follow the claim to code instead of trusting a sentence.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Site&lt;/code&gt; is a frozen dataclass, copied from the registry with its comments:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Site&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;cls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;why&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;kinds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# journal kinds written (the journal family only)
&lt;/span&gt;    &lt;span class="n"&gt;via&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;  &lt;span class="c1"&gt;# the registered function whose journal entry covers a provider or model call
&lt;/span&gt;    &lt;span class="n"&gt;by&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;  &lt;span class="c1"&gt;# the function that reads the effect back, checks it first, or closes it in the review
&lt;/span&gt;    &lt;span class="n"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;  &lt;span class="c1"&gt;# the bead for a known uncovered part
&lt;/span&gt;    &lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# kinds of a journaled-before site written after the effect
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;gap&lt;/code&gt; field matters more than it looks. It lets the registry admit an uncovered effect without lying about it, and it must carry a tracked-task ID. The &lt;code&gt;after&lt;/code&gt; field lists kinds a journaled_before site writes once the effect is done, such as an outcome record, and the test checks it names some but not all of that site's kinds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not a runtime decorator?
&lt;/h2&gt;

&lt;p&gt;The obvious design is a decorator: mark each effectful function &lt;code&gt;@effect("smtp")&lt;/code&gt; and check coverage at runtime. The PR chose call-site discovery instead, for two reasons.&lt;/p&gt;

&lt;p&gt;First, discovery needs no change to application code. The registry is data and the test is static.&lt;/p&gt;

&lt;p&gt;Second, a decorator only knows about functions somebody remembered to decorate. That is the same failure as the hand-written list, moved into the source. Discovery reads the code and finds the provider call whether or not anyone thought to mark it. A new &lt;code&gt;send_mail&lt;/code&gt; with no registry entry fails the build.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test: find every outward call with &lt;code&gt;ast&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;tests/test_effect_registry.py&lt;/code&gt; parses every source file into an abstract syntax tree with &lt;a href="https://docs.python.org/3/library/ast.html" rel="noopener noreferrer"&gt;Python's &lt;code&gt;ast&lt;/code&gt; module&lt;/a&gt; and builds a map of &lt;code&gt;{function: {family: calls}}&lt;/code&gt;. Its import resolver handles module imports, aliases, relative imports and by-name imports, because &lt;code&gt;from django.core.mail import send_mail as sm&lt;/code&gt; is still a mail call.&lt;/p&gt;

&lt;p&gt;It has nine test functions. The first three check the registry against the source, and the fourth checks that the scanner knows every provider write method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;test_9kg62_every_function_with_an_effect_is_registered_and_no_registration_is_stale(sites)
test_9kg62_each_class_matches_what_the_function_does(sites)
test_9kg62_every_journal_kind_has_a_writer_and_each_restrictive_kind_is_prepared(sites)
test_9kg62_the_scanner_knows_every_write_method_of_each_provider_client()
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Of the other five, four test the scanner on synthetic source (import spellings, kind resolution, lookalike calls, an unreviewed outward module). One checks the real code against the outward-module allow-list.&lt;/p&gt;

&lt;p&gt;Between them, the tests fail when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a function with an effect is unregistered, an entry is stale, or the family is wrong;&lt;/li&gt;
&lt;li&gt;the class does not match the calls the function makes;&lt;/li&gt;
&lt;li&gt;a journaled_before entry is written after the provider call it precedes in the same function;&lt;/li&gt;
&lt;li&gt;a checked_before action writes before it reads;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;by&lt;/code&gt; or &lt;code&gt;via&lt;/code&gt; is missing, or &lt;code&gt;gap&lt;/code&gt; is not a tracked-task ID;&lt;/li&gt;
&lt;li&gt;a journal kind does not resolve to a literal, a constant or a literal caller argument;&lt;/li&gt;
&lt;li&gt;the provider client has a write method the scanner does not know;&lt;/li&gt;
&lt;li&gt;any module outside a reviewed allow-list of twelve imports a network or process module, or writes, moves or removes a file.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last two are the drift guards for the scanner itself. If someone adds a new write method to the Documenso client, the scanner has no rule for it, and the fourth test fails until a human teaches it. If a thirteenth module starts opening sockets or deleting files, the allow-list test fails.&lt;/p&gt;

&lt;p&gt;The ordering rule is the least obvious one. "Journaled before" is only true if the journal write precedes the provider call in source order. Here is the idea, reduced to a hypothetical sketch (not the shipped code):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Hypothetical reduction of the ordering check.
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;first_call_line&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lineno&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
             &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Call&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;journal_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;first_call_line&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;is_journal_write&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;provider_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;first_call_line&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;is_provider_call&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;journal_at&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;provider_at&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;journal_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;provider_at&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This only decides order inside one function. Order across functions is not checked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying the AST test with hand mutations
&lt;/h2&gt;

&lt;p&gt;A static test that passes on day one proves nothing about whether it can fail. The same rule for a CI gate is in &lt;a href="https://startaitools.com/posts/the-gate-that-could-not-fail/" rel="noopener noreferrer"&gt;Make the Guard Prove It Can Fail&lt;/a&gt;. So the verification was mutation by hand: break the code or the registry on purpose, one change at a time, and confirm the intended test catches it.&lt;/p&gt;

&lt;p&gt;The registry test and the repository's test-selection test together ran 28 passed, 0 failed. Then 21 hand mutations, each caught by the test meant to catch it. The first round of twelve covered a dropped site, a stale entry, the wrong class, the wrong kind, a bad &lt;code&gt;via&lt;/code&gt;, a missing &lt;code&gt;via&lt;/code&gt;, a dropped family, a marker that was really a record, an undeclared kind, a restrictive kind never prepared, an empty reason and an injected unregistered send.&lt;/p&gt;

&lt;p&gt;The second round of nine: a dropped Django mail site, a missing reader, an absent &lt;code&gt;by&lt;/code&gt;, a non-task &lt;code&gt;gap&lt;/code&gt;, a removed erasure completion caller, &lt;code&gt;FileCopy&lt;/code&gt; writing before it reads, the &lt;code&gt;send_begun&lt;/code&gt; journal write moved after SMTP, a dynamic journal kind and a removed staff-role prepare. Separately from the 21, an injected module with outward calls was also caught.&lt;/p&gt;

&lt;h2&gt;
  
  
  What writing the registry and its QA pass found
&lt;/h2&gt;

&lt;p&gt;The first version of the registry was incomplete, and a separate QA agent pass (static reading only) showed it. That pass returned seven findings. Five were fixed in the PR. One was filed as a tracked task and one was partly fixed. The PR's own list of five findings, below, draws on both the writing and the QA pass and only partly overlaps the QA agent's seven. Each one says what happened to it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three applicant mails were missing (fixed).&lt;/strong&gt; The contact confirmation, the password setup and the withdrawal link go through &lt;a href="https://docs.djangoproject.com/en/stable/topics/email/" rel="noopener noreferrer"&gt;Django's mailer&lt;/a&gt;, not the app's correspondence transport. The first version of the registry missed all three. Django mail is its own family in the registry. The post-restore review covers them by superseding every live link they carry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;FileCopy&lt;/code&gt; was misclassified (fixed).&lt;/strong&gt; It was registered as read back by the Sent-folder reader. That reader skips filed copies. What &lt;code&gt;FileCopy&lt;/code&gt; actually does is search the Sent folder before it files, so it is now checked_before, with an order check behind it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The revocation's host calls are outside the journal (registered as covered_by_review).&lt;/strong&gt; Password rotation and forwarder removal run before the transaction that prepares the revocation entry. The review's revocation round covers them, so they are registered covered_by_review.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A failed restrictive prepare does not stop its effect (recorded, not changed).&lt;/strong&gt; The failure is logged, counted and refused at readiness. Only the erasure and custody records refuse their effect outright. This was recorded, not changed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A second envelope after a restore had no coverage at all (filed, then closed by a follow-up PR).&lt;/strong&gt; An agreement created and distributed after the backup has no row once you restore. Nothing read its envelope. The external key is random, so the restored system would create a new agreement and distribute a second envelope to the same signer. The registry could only mark this as a gap, because a runtime fix did not belong in a test-only PR.&lt;/p&gt;

&lt;p&gt;That last one was the main finding. Writing and reviewing the registry forced every function through the question "who reads this back?", and for envelopes created after the backup the honest answer was "nobody". The test could not fail on this. It could only carry the gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing the gap with a bounded Documenso envelope listing
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://github.com/intent-solutions-io/catalyst-onboarding-v2/pull/119" rel="noopener noreferrer"&gt;follow-up PR&lt;/a&gt; added an unknown-envelope scan to the post-restore Documenso read, &lt;code&gt;agreements.restore.read_envelopes&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It lists the Documenso team's envelopes from every template, newest first, back to one hour before the backup's start, so a template the restored tables do not know is covered too. Any envelope with the app's key shape (&lt;code&gt;ag_&lt;/code&gt; plus 32 hex characters) that no restored agreement or envelope attempt names, by envelope id or external key, opens one review item. The item is keyed by a digest of the envelope id, and the raw id is stored in neither the item nor its audit event. It carries the application only when exactly one verified applicant is a signer.&lt;/p&gt;

&lt;p&gt;The listing is bounded by a configured page limit (the PR's risk note puts it at 20 pages of 100). If it hits that limit before reaching the backup's start, it reports &lt;code&gt;incomplete:listing_limit&lt;/code&gt;, and any status other than &lt;code&gt;ok&lt;/code&gt; refuses to lift the restore hold. A truncated listing is treated as incomplete, never as clean. The registry entry's &lt;code&gt;gap&lt;/code&gt; was then removed, as the task's acceptance required. That PR's targeted run was 82 passed. The gap is closed in code, pending the provider check described below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not journal every create instead?
&lt;/h2&gt;

&lt;p&gt;The alternative was a journal entry per envelope create. The listing won for two reasons. It already existed for reconciliation, so no new write path. And it finds a create whose journal append was lost, which a journal entry cannot do by definition.&lt;/p&gt;

&lt;p&gt;Envelopes without the app's key shape belong to another writer on the same Documenso team and are ignored. The cost of that team-wide listing is volume: another writer's envelopes count toward the page limit. Hitting it fails closed until the system's operator raises the limit or resolves the items by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Known blind spots
&lt;/h2&gt;

&lt;p&gt;The test's docstring lists its own blind spots:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;receivers held in attributes or reached through &lt;code&gt;getattr&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;a database write that should be journaled and is not;&lt;/li&gt;
&lt;li&gt;whether a named reader actually reads what the entry says.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The ordering check also works only inside one function, as noted above.&lt;/p&gt;

&lt;p&gt;Documenso's real behaviour is also unverified. Its list ordering, default status filter and field names are not confirmed against the installed version, so the scan checks order itself and never trusts a page count. A canary against the real provider is where that gets settled. Resolving an unknown envelope is currently by the operator's note alone, and whether a note is enough is the operator's call.&lt;/p&gt;

&lt;p&gt;The test is brittle by design. Rename an effectful function, add a provider write method or add a file-writing module and the build fails until someone reviews the change. That is intended: a failing build is the moment the hand-written list used to go stale silently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Also shipped
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;journal-ship&lt;/code&gt; service copies the effect journal off the production host, so the journal survives the host it describes. It runs with &lt;code&gt;network_mode: none&lt;/code&gt; because it only reads and writes files (&lt;a href="https://github.com/intent-solutions-io/catalyst-onboarding-v2/pull/123" rel="noopener noreferrer"&gt;PR #123&lt;/a&gt;, 239 passed in its targeted run).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Use this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;List every function in your app that calls a provider (mail, e-signature, payments, mailbox APIs) and give it a recovery class for each provider it touches, plus the name of the function that recovers it.&lt;/li&gt;
&lt;li&gt;Write a static test that discovers those calls from source, including aliased and relative imports, and fails on any call it can resolve that the registry has not classified; then mutate it by hand until each failure mode has been caught once.&lt;/li&gt;
&lt;li&gt;For an effect that nothing reads back and the review does not close, add a post-restore provider listing with a time floor just before the backup's start and a page limit, make a truncated listing keep the restore hold in place, and confirm the provider's list behaviour against the real service.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The registry and its AST test are in &lt;a href="https://github.com/intent-solutions-io/catalyst-onboarding-v2/pull/117" rel="noopener noreferrer"&gt;catalyst-onboarding-v2 PR #117&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is an effect journal?
&lt;/h3&gt;

&lt;p&gt;An effect journal is an append-only log of outward actions, kept outside the database. After a restore, a post-restore review reads that journal and the providers, then decides whether to lift the restore hold.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do you stop a restored app from repeating an email or envelope?
&lt;/h3&gt;

&lt;p&gt;Give every function that calls an outside service one recovery class per provider family in a registry, and name the function that recovers it. A checked_before action reads the provider before it writes, so a repeated run finds the effect and skips it. Where nothing reads an effect back and the review does not close it, a bounded provider listing looks for envelopes the restored database does not name.&lt;/p&gt;

&lt;h3&gt;
  
  
  How can a test catch an effect nobody registered?
&lt;/h3&gt;

&lt;p&gt;An AST test parses every source file, follows aliased and relative imports, and fails the build when a function with an effect is unregistered, stale or in the wrong class. It was checked with 21 hand mutations, each caught by the test meant to catch it. It still cannot see receivers held in attributes or reached through getattr.&lt;/p&gt;

&lt;p&gt;{&lt;br&gt;
  "&lt;a class="mentioned-user" href="https://dev.to/context"&gt;@context&lt;/a&gt;": "&lt;a href="https://schema.org" rel="noopener noreferrer"&gt;https://schema.org&lt;/a&gt;",&lt;br&gt;
  "@type": "FAQPage",&lt;br&gt;
  "mainEntity": [&lt;br&gt;
    {&lt;br&gt;
      "@type": "Question",&lt;br&gt;
      "name": "What is an effect journal?",&lt;br&gt;
      "acceptedAnswer": {&lt;br&gt;
        "@type": "Answer",&lt;br&gt;
        "text": "An effect journal is an append-only log of outward actions, kept outside the database. After a restore, a post-restore review reads that journal and the providers, then decides whether to lift the restore hold."&lt;br&gt;
      }&lt;br&gt;
    },&lt;br&gt;
    {&lt;br&gt;
      "@type": "Question",&lt;br&gt;
      "name": "How do you stop a restored app from repeating an email or envelope?",&lt;br&gt;
      "acceptedAnswer": {&lt;br&gt;
        "@type": "Answer",&lt;br&gt;
        "text": "Give every function that calls an outside service one recovery class per provider family in a registry, and name the function that recovers it. A checked_before action reads the provider before it writes, so a repeated run finds the effect and skips it. Where nothing reads an effect back and the review does not close it, a bounded provider listing looks for envelopes the restored database does not name."&lt;br&gt;
      }&lt;br&gt;
    },&lt;br&gt;
    {&lt;br&gt;
      "@type": "Question",&lt;br&gt;
      "name": "How can a test catch an effect nobody registered?",&lt;br&gt;
      "acceptedAnswer": {&lt;br&gt;
        "@type": "Answer",&lt;br&gt;
        "text": "An AST test parses every source file, follows aliased and relative imports, and fails the build when a function with an effect is unregistered, stale or in the wrong class. It was checked with 21 hand mutations, each caught by the test meant to catch it. It still cannot see receivers held in attributes or reached through getattr."&lt;br&gt;
      }&lt;br&gt;
    }&lt;br&gt;
  ]&lt;br&gt;
}&lt;/p&gt;

&lt;h2&gt;
  
  
  Related Posts
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://startaitools.com/posts/working-is-not-proven/" rel="noopener noreferrer"&gt;Every Claim Needs a Shipped Source and an Executable Proof&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://startaitools.com/posts/the-drills-passed-reality-did-not/" rel="noopener noreferrer"&gt;The Drills Passed. Reality Did Not.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://startaitools.com/posts/rehearse-before-production-catches-what-tests-cant/" rel="noopener noreferrer"&gt;Rehearse the deploy, not the test suite&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>testing</category>
      <category>python</category>
      <category>architecture</category>
      <category>devops</category>
    </item>
    <item>
      <title>SSRF Egress Proxy: Deny Your Own Public Production Hosts</title>
      <dc:creator>Jeremy Longshore</dc:creator>
      <pubDate>Tue, 03 Nov 2026 11:30:10 +0000</pubDate>
      <link>https://dev.to/jeremy_longshore/ssrf-egress-proxy-deny-your-own-public-production-hosts-1lo1</link>
      <guid>https://dev.to/jeremy_longshore/ssrf-egress-proxy-deny-your-own-public-production-hosts-1lo1</guid>
      <description>&lt;p&gt;An SSRF filter (a guard against server-side request forgery, which stops a server fetching URLs an attacker chose) that admits only public addresses still lets your server-side fetcher reach your own production hosts, because those hosts have public addresses. This write-up ends with a deny check for an egress proxy that refuses those hosts by name, by every address they resolve to, and again by the address the socket actually connected to, without letting a DNS hiccup lock all egress shut.&lt;/p&gt;

&lt;p&gt;The case is an onboarding app that fetches links applicants submit as evidence. All of that traffic leaves through an egress proxy (the one process allowed to open outbound connections). The proxy already refused private, loopback, link-local and cloud metadata addresses (the endpoint that hands out instance credentials). Anything with an ordinary public address (global unicast, in IP terms) went through.&lt;/p&gt;

&lt;p&gt;That rule keeps a fetcher off your internal network. It says nothing about the hosts you run on the public internet. An applicant's link, or a redirect from a harmless-looking page, could aim the fetcher straight at them.&lt;/p&gt;

&lt;p&gt;Real evidence fetching is still switched off in production. This check had to land before it could be switched on. How the fetcher role is split out of the web container is covered in &lt;a href="https://startaitools.com/posts/docker-hardening-your-test-suite-cannot-see/" rel="noopener noreferrer"&gt;Docker Container Security Hardening: What Unit Tests Miss&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The naive fixes are worse than the gap. Remember every address a production name ever resolved to, and the set grows until the guard refuses everything. Refuse whenever a lookup fails, and one flaky nameserver stops all fetching. The first version of this change did both, and a review agent caught it before merge.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can a public-address SSRF filter still reach your own production hosts?
&lt;/h2&gt;

&lt;p&gt;Yes. An SSRF filter that admits only public addresses still lets your fetcher reach your own production hosts, because those hosts have public addresses. A public-address classifier cannot say "public, but ours", so the fix is a second list of host names. The proxy checks that list by name, by every resolved address and by the connected peer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the public-address rule cannot express "mine"
&lt;/h2&gt;

&lt;p&gt;A global unicast classifier answers one question: is this address on the public internet? Your production hosts answer yes. The classifier has no way to say "public, but ours", so a second list is needed.&lt;/p&gt;

&lt;p&gt;That list has to name your hosts specifically, and it has to hold up when DNS moves underneath it. The same mismatch, a check that validated a hostname while the fetch routed by address, came up earlier in the custom-feed field of an RSS bar plugin for Omarchy (an opinionated Arch Linux desktop setup by DHH). See &lt;a href="https://startaitools.com/posts/a-green-result-only-covers-what-it-ran/" rel="noopener noreferrer"&gt;Every Verdict Carries the Scope It Actually Ran&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the deny list file must contain
&lt;/h2&gt;

&lt;p&gt;The deny list is a secret-free file of logical host names, pointed to by an environment variable. Format rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ASCII only, at most 64 KiB and 256 names.&lt;/li&gt;
&lt;li&gt;One dotted DNS name per line, with &lt;code&gt;#&lt;/code&gt; comments.&lt;/li&gt;
&lt;li&gt;Each entry denies itself and every name under it.&lt;/li&gt;
&lt;li&gt;An IP address, a single label, or a label written as a &lt;code&gt;0x&lt;/code&gt; hex number (such as &lt;code&gt;0x7f&lt;/code&gt;) makes the file malformed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What three checks does the egress proxy run on each request?
&lt;/h2&gt;

&lt;p&gt;The proxy checks every request three times against that list:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;By name, before any lookup.&lt;/strong&gt; The requested host is compared with the deny names. A match is refused with &lt;code&gt;host_denied&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;By resolved address.&lt;/strong&gt; Every address the target resolves to is compared with every address the deny names resolve to. A match is refused with &lt;code&gt;address_denied&lt;/code&gt;. This catches a different name pointing at the same machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;By connected peer.&lt;/strong&gt; After the socket connects, the peer address is classified again and compared with a fresh resolution of the deny names. A match is refused with &lt;code&gt;peer_denied&lt;/code&gt;. This catches a deny name whose DNS answer moves onto the target's address between the check and the connection. The target itself is not resolved again: the proxy connects to the addresses it already checked.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here is the request path from &lt;code&gt;assessments/egress.py&lt;/code&gt; (the name check happens inside &lt;code&gt;policy.target()&lt;/code&gt;), cut off once the tunnel is accepted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Policy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;upstream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;addresses&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;extra&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;target&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;read_head&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;addresses&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;extra&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;addresses&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;Refusal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;address_not_allowed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;denied&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;denied_addresses&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;denied&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;addresses&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;Refusal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;address_denied&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;address&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;addresses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# every answer already passed classify; try each until one connects
&lt;/span&gt;            &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;upstream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_connection&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;CONNECT_SECONDS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;break&lt;/span&gt;
            &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;OSError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;upstream&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;Refusal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;502&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connect_failed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;peer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;upstream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getpeername&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;peer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;extra&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;Refusal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;peer_not_allowed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;peer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;denied_addresses&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;  &lt;span class="c1"&gt;# resolved afresh: a DNS change since the check is caught here
&lt;/span&gt;            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;Refusal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;peer_denied&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;peer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;# ... tunnel splice, except and finally omitted
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter. The address and peer checks run after the public-address check, so they only ever narrow what was already allowed. And &lt;code&gt;denied_addresses()&lt;/code&gt; runs twice per connection, one lookup pass of the deny names each time. Each pass runs in one thread and is bounded on its own by the existing 10-second deadline.&lt;/p&gt;

&lt;h2&gt;
  
  
  One spelling per address
&lt;/h2&gt;

&lt;p&gt;Address comparison by string fails quietly. &lt;code&gt;::ffff:203.0.113.10&lt;/code&gt; (an IPv4-mapped IPv6 address, defined in &lt;a href="https://www.rfc-editor.org/rfc/rfc4291#section-2.5.5.2" rel="noopener noreferrer"&gt;RFC 4291 section 2.5.5.2&lt;/a&gt;), &lt;code&gt;::ffff:cb00:710a&lt;/code&gt; and &lt;code&gt;203.0.113.10&lt;/code&gt; name one IPv4 host. A link-local IPv6 address can also carry a zone suffix such as &lt;code&gt;%eth0&lt;/code&gt;. If the deny set holds one spelling and the target arrives in another, the set membership test misses.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;One spelling per address: the zone dropped and an IPv4-mapped IPv6 address unwrapped, so `::ffff:a.b.c.d`, its
    hex form and `a.b.c.d` compare equal. Anything that is not an address is &lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;addr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ipaddress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ip_address&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;%&lt;/span&gt;&lt;span class="sh"&gt;"&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;addr&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="mi"&gt;6&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;addr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ipv4_mapped&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;addr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;addr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ipv4_mapped&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;addr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both sides of every comparison go through this function. The tests cover a production address arriving as IPv4, IPv6, &lt;code&gt;::ffff:a.b.c.d&lt;/code&gt; and &lt;code&gt;::ffff:hhhh:hhhh&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not list the production addresses instead?
&lt;/h2&gt;

&lt;p&gt;A file of IP addresses looks simpler. It breaks the first time a host moves, and it silently stops protecting the new address. Logical names survive a move, and the file stays free of anything sensitive.&lt;/p&gt;

&lt;p&gt;Resolving at check time handles DNS changes in both directions. A name that moves to a new address is caught by the fresh answer. An address the name pointed at recently stays denied from memory until newer addresses push it out of a capped history. A change between the check and the connect is caught at the peer.&lt;/p&gt;

&lt;p&gt;The other obvious choice was to exit at startup when the file is broken. That was rejected too. Exiting leaves the fetcher with nothing to act on. A live proxy answering &lt;code&gt;503 deny_list_unavailable&lt;/code&gt; tells the fetcher to retry the item instead of closing it, and the failing healthcheck marks the container unhealthy where an operator can see it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first version could shut all egress
&lt;/h2&gt;

&lt;p&gt;The implementation ran from a Claude Opus 5.5 session coordinating specialist agents. Its first version kept every address a denied name had ever resolved to. The commit message called it a never-shrinking union, chosen deliberately to cover DNS rebinding in both directions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;denied_addresses&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;frozenset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;answers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;resolve_names&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deny&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;addresses&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;addresses&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;()):&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;Refusal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;502&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;deny_list_unresolved&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;fresh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;addresses&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;addresses&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;denied_seen&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;DENY_SEEN_LIMIT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;Refusal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;502&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;deny_list_unresolved&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;denied_seen&lt;/span&gt; &lt;span class="o"&gt;|=&lt;/span&gt; &lt;span class="n"&gt;fresh&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;frozenset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;denied_seen&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it as an operator would. Two lines can stop all egress. The lookup-failure one recovers only once DNS answers again; the size limit never recovers without a restart.&lt;/p&gt;

&lt;p&gt;A read-only security and privacy review agent, dispatched on the open pull request, returned these as its first three findings, each rated medium:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The latch.&lt;/strong&gt; Once the remembered set would pass &lt;code&gt;DENY_SEEN_LIMIT&lt;/code&gt; (4096), every request returns 502 until a restart. A deny name served from a rotating or geo-DNS pool keeps growing the set. The result stops all fetching, well beyond the one host it meant to block. The review also noted each CONNECT started one thread per name, twice, so an operator error in the list could cause thread storms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One flaky name blocks everything.&lt;/strong&gt; Any SERVFAIL, timeout or &lt;code&gt;EAI_AGAIN&lt;/code&gt; on any deny name made every fetch return 502. A decommissioned name whose nameserver stopped answering, or a resolver outage, becomes a lasting denial of service. The review noted it could never become a silent allow, which is the correct direction; the price was all fetching.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A non-resolving name denies nothing, silently.&lt;/strong&gt; NXDOMAIN and NODATA become an empty answer. A typo in the list, or a split-horizon name the egress container cannot resolve, gives zero protection and raises nothing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Fail-closed and stay-available pull in opposite directions. The first draft chose closed at every fork, including the forks where closed meant closing everything. Each review finding got its own fix-up commit before merge, and each code finding its own test. The third is only partly closed, as the next section says.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bounded version: per-name memory with last-good answers
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;denied_addresses&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;frozenset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;answers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;resolve_names&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deny&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;failed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;addresses&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;addresses&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;remembered&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;failed&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;Refusal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;502&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;deny_list_unresolved&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;addresses&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;addresses&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remember&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;addresses&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;failed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;egress deny_lookup_stale&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;denied&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;frozenset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ip&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;kept&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;remembered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;kept&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;denied&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# a list in which no name resolves denies nothing at all: refuse rather than pretend
&lt;/span&gt;            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;Refusal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;503&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;deny_list_resolves_nothing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;denied&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What changed, finding by finding:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Memory is bounded per name, with no global limit.&lt;/strong&gt; &lt;code&gt;remember()&lt;/code&gt; keeps a name's fresh answer in full, plus earlier addresses up to 64 in all (a fresh answer larger than 64 is kept whole). The oldest address missing from the fresh answer is evicted first. The file is capped at 256 names on load. No global limit remains that could latch the proxy shut.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A failed lookup keeps that name's last good answer.&lt;/strong&gt; The fallback is logged as one outcome code, &lt;code&gt;deny_lookup_stale&lt;/code&gt;, never the name. Resolution now runs as one lookup pass in a single thread.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refusal is reserved for unknowns.&lt;/strong&gt; A name whose lookup has never succeeded (SERVFAIL or a timeout, not a clean "no such name") still refuses with &lt;code&gt;502 deny_list_unresolved&lt;/code&gt;, which the fetcher retries. If that name's lookups keep failing, every request keeps refusing until one succeeds; the refusal is retryable, and the startup count shows the gap. If no name in the list resolves at all, every request gets &lt;code&gt;503 deny_list_resolves_nothing&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Startup reports the gap.&lt;/strong&gt; A pass at start seeds each name's last good answer and logs &lt;code&gt;deny_names_unresolved=N&lt;/code&gt;, a count without names.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One name that does not exist, inside a list where others do resolve, still denies nothing. The startup count is the only signal for that case, so it is worth alerting on.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the deny file is missing or malformed
&lt;/h2&gt;

&lt;p&gt;A missing, unreadable, oversized, empty or malformed file sends every request a &lt;code&gt;503 deny_list_unavailable&lt;/code&gt;. The fetcher treats that as transient and retries. The code is logged once at start, and the healthcheck (&lt;code&gt;python -m assessments.egress --check&lt;/code&gt;) fails while the list does not load.&lt;/p&gt;

&lt;p&gt;Staging bind-mounts its deny file instead of baking it into the image. Production is meant to mount the operator's file the same way, so staging exercises that path. The real production list is still a pending host-preparation item.&lt;/p&gt;

&lt;p&gt;A separate test asserts that egress refusal codes never reach the model request the clarification path starts from, any mail, or a non-staff template.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits that remain
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Peer refusal comes after the handshake.&lt;/strong&gt; A &lt;code&gt;peer_denied&lt;/code&gt; connection completes its TCP handshake with the denied host before it is closed. Zero bytes are sent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The resolver is trusted.&lt;/strong&gt; A resolver that answered the deny names falsely would hide their addresses. Network isolation, the host allow-list and TLS hostname verification remain the controls for that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Changes need a restart.&lt;/strong&gt; The file is read at start. A replaced bind-mounted file keeps a stale inode, so mount the directory or restart.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hex labels cost a real name.&lt;/strong&gt; A host such as &lt;code&gt;0xab.example.org&lt;/code&gt; cannot be listed. Listing it makes the whole file malformed, so every request gets &lt;code&gt;503 deny_list_unavailable&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Verification
&lt;/h2&gt;

&lt;p&gt;The change added 25 tests in &lt;code&gt;tests/test_egress.py&lt;/code&gt;. They cover a name and a name under it refused before lookup, the four address spellings, a DNS change between requests and between check and connect, per-name bounds without a latch, the last good answer, the never-resolved name, one thread per pass, the all-unresolved list with its startup count, and missing, empty, comment-only, malformed, non-ASCII and oversized files.&lt;/p&gt;

&lt;p&gt;The full suite on the final head passed 1744 tests in 50 min 21 s. The run before the review, which already held the first version's tests, passed 1733.&lt;/p&gt;

&lt;p&gt;The staging check runs against the compose stack on the dev box. A public stand-in plays the production host. It was refused by name, a name under it was refused, and &lt;code&gt;1.1.1.1.nip.io&lt;/code&gt; and &lt;code&gt;1.0.0.1.nip.io&lt;/code&gt; (names that resolve to the address written in them) were refused by resolved address. A control request to an ordinary public name, which the staging stack allows, tunnelled through.&lt;/p&gt;

&lt;p&gt;Staging also caught one more wobble. The first run after the review scored 132 PASS, 0 FAIL, 1 NOT RUN: a by-address case missed a single transient lookup. The probe now retries before reporting; it already printed NOT RUN rather than FAIL when the proxy cannot reach public DNS. The final run on a clean tree scored 133 PASS, 0 FAIL, 0 NOT RUN, with the egress healthcheck passing &lt;code&gt;compose up --wait&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The merged change, one commit per review finding, is at &lt;a href="https://github.com/intent-solutions-io/catalyst-onboarding-v2/pull/101" rel="noopener noreferrer"&gt;https://github.com/intent-solutions-io/catalyst-onboarding-v2/pull/101&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How does an SSRF deny list avoid blocking all egress when DNS fails?
&lt;/h3&gt;

&lt;p&gt;A failed lookup keeps the last good answer for that name, and the proxy logs only the outcome code &lt;code&gt;deny_lookup_stale&lt;/code&gt;, never the name. A name whose lookup has never succeeded (SERVFAIL or a timeout, not a clean "no such name") refuses with &lt;code&gt;502 deny_list_unresolved&lt;/code&gt;, which the fetcher retries. If no name in the list resolves at all, every request gets &lt;code&gt;503 deny_list_resolves_nothing&lt;/code&gt;. A name that does not exist, inside a list where others resolve, still denies nothing, so the startup count is the signal to alert on.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does a deny list miss an IPv4-mapped IPv6 address?
&lt;/h3&gt;

&lt;p&gt;One host can carry several spellings: &lt;code&gt;::ffff:203.0.113.10&lt;/code&gt;, &lt;code&gt;::ffff:cb00:710a&lt;/code&gt; and &lt;code&gt;203.0.113.10&lt;/code&gt; all name the same IPv4 host. A string comparison against a single spelling misses the others. The proxy unwraps IPv4-mapped IPv6 and drops any zone suffix such as &lt;code&gt;%eth0&lt;/code&gt; on both sides of every comparison, and the tests cover the IPv4, IPv6, &lt;code&gt;::ffff:a.b.c.d&lt;/code&gt; and &lt;code&gt;::ffff:hhhh:hhhh&lt;/code&gt; forms.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can a DNS change between the check and the connection get past an SSRF deny list?
&lt;/h3&gt;

&lt;p&gt;Not the target's own DNS: the proxy connects to the target addresses it already checked, so the target name is not resolved again. If a deny name's DNS answer moves onto that address in the meantime, the peer check catches it as long as that fresh lookup succeeds (a failed lookup falls back to the last good answer): it compares the peer with a fresh resolution of the deny names and refuses with &lt;code&gt;peer_denied&lt;/code&gt; before any bytes are sent. The TCP handshake with the denied host still completes first.&lt;/p&gt;

&lt;p&gt;{"&lt;a class="mentioned-user" href="https://dev.to/context"&gt;@context&lt;/a&gt;": "&lt;a href="https://schema.org" rel="noopener noreferrer"&gt;https://schema.org&lt;/a&gt;", "@type": "FAQPage", "mainEntity": [{"@type": "Question", "name": "How does an SSRF deny list avoid blocking all egress when DNS fails?", "acceptedAnswer": {"@type": "Answer", "text": "A failed lookup keeps the last good answer for that name, and the proxy logs only the outcome code deny_lookup_stale, never the name. A name whose lookup has never succeeded (SERVFAIL or a timeout, not a clean \"no such name\") refuses with 502 deny_list_unresolved, which the fetcher retries. If no name in the list resolves at all, every request gets 503 deny_list_resolves_nothing. A name that does not exist, inside a list where others resolve, still denies nothing, so the startup count is the signal to alert on."}}, {"@type": "Question", "name": "Why does a deny list miss an IPv4-mapped IPv6 address?", "acceptedAnswer": {"@type": "Answer", "text": "One host can carry several spellings: ::ffff:203.0.113.10, ::ffff:cb00:710a and 203.0.113.10 all name the same IPv4 host. A string comparison against a single spelling misses the others. The proxy unwraps IPv4-mapped IPv6 and drops any zone suffix such as %eth0 on both sides of every comparison, and the tests cover the IPv4, IPv6, ::ffff:a.b.c.d and ::ffff:hhhh:hhhh forms."}}, {"@type": "Question", "name": "Can a DNS change between the check and the connection get past an SSRF deny list?", "acceptedAnswer": {"@type": "Answer", "text": "Not the target's own DNS: the proxy connects to the target addresses it already checked, so the target name is not resolved again. If a deny name's DNS answer moves onto that address in the meantime, the peer check catches it as long as that fresh lookup succeeds (a failed lookup falls back to the last good answer): it compares the peer with a fresh resolution of the deny names and refuses with peer_denied before any bytes are sent. The TCP handshake with the denied host still completes first."}}]}&lt;/p&gt;

&lt;h2&gt;
  
  
  Also shipped
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Same app: an authorized erasure path with an audited tombstone exception and a durable record kept outside the backup.&lt;/li&gt;
&lt;li&gt;bobs-big-brain-plugin: installer cleanup confined to validated targets, with recoverable data preserved.&lt;/li&gt;
&lt;li&gt;claude-code-plugins: five unsupported SaaS tutorial packs retired, and the security plugin's HTTP method probes constrained to owned fixtures.&lt;/li&gt;
&lt;li&gt;diagnostic-pro: homepage contrast and keyboard access fixed.&lt;/li&gt;
&lt;li&gt;intent-whop-products: new repo scaffolded with CI and releases on a self-hosted runner.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Use this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;List your production hosts by name, and compare addresses in one form.&lt;/strong&gt; Resolve the names at check time, unwrap IPv4-mapped IPv6 and drop the zone on both sides, and refuse subdomains of each entry too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the connected peer, after the resolved answer.&lt;/strong&gt; Run &lt;code&gt;getpeername()&lt;/code&gt; through the same classifier and a fresh deny resolution, so a deny name that moves onto the target's address between check and connect is refused before a byte is sent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bound what you remember, per name.&lt;/strong&gt; Keep the fresh answer plus a capped history, fall back to the last good answer on a failed lookup, and refuse only for names whose lookup never succeeded, a list that resolves to nothing, or a file that does not load, each with a retryable code.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Related posts
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://startaitools.com/posts/docker-hardening-your-test-suite-cannot-see/" rel="noopener noreferrer"&gt;Docker Container Security Hardening: What Unit Tests Miss&lt;/a&gt;: the same app's staging deploy, five hardening controls that passed their own tests, and the review question behind each.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://startaitools.com/posts/a-green-result-only-covers-what-it-ran/" rel="noopener noreferrer"&gt;Every Verdict Carries the Scope It Actually Ran&lt;/a&gt;: every verdict covers only what it ran, including a request-forgery class closed by deleting the feature.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://startaitools.com/posts/rate-limit-signup-form-without-refusing-anyone/" rel="noopener noreferrer"&gt;Rate Limit a Django Signup Form Without Refusing Anyone&lt;/a&gt;: the same app again, deferring mail instead of refusing applicants, a fail-safe that does not punish users.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>security</category>
      <category>python</category>
      <category>architecture</category>
      <category>testing</category>
    </item>
    <item>
      <title>Docker Container Security Hardening: What Unit Tests Miss</title>
      <dc:creator>Jeremy Longshore</dc:creator>
      <pubDate>Fri, 23 Oct 2026 11:30:10 +0000</pubDate>
      <link>https://dev.to/jeremy_longshore/docker-container-security-hardening-what-unit-tests-miss-2ihj</link>
      <guid>https://dev.to/jeremy_longshore/docker-container-security-hardening-what-unit-tests-miss-2ihj</guid>
      <description>&lt;p&gt;A unit test exercises the function. It does not exercise the deployment.&lt;/p&gt;

&lt;p&gt;That gap is where a staging container ships with security controls that pass everything they are handed and are still wrong in the image that ends up running. Across repeated review rounds on one staging deployment I found five of them. By the end of this you can name each one and the single review question that catches it before it reaches a container.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why do container security tests pass while the container is still wrong?
&lt;/h2&gt;

&lt;p&gt;A unit test proves the function behaves as written. It cannot prove the deployment has the property you wanted, because that property lives in a layer the code does not own. Each of the five findings below sits in a different layer, and each one is caught by a deployment step or a review question rather than by the test that went green.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Control&lt;/th&gt;
&lt;th&gt;What the test suite proved&lt;/th&gt;
&lt;th&gt;What the running container did&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Host guard&lt;/td&gt;
&lt;td&gt;The hosts on the list were refused&lt;/td&gt;
&lt;td&gt;Any host nobody had imagined got a 200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Health command&lt;/td&gt;
&lt;td&gt;Exit 0 when there were no findings&lt;/td&gt;
&lt;td&gt;Exit 1 for a real anomaly and exit 1 when the check itself broke&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gunicorn socket umask&lt;/td&gt;
&lt;td&gt;The string &lt;code&gt;117&lt;/code&gt; was present in the Dockerfile&lt;/td&gt;
&lt;td&gt;The socket landed at 0612 instead of 0660&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Secrets&lt;/td&gt;
&lt;td&gt;The settings module read each value&lt;/td&gt;
&lt;td&gt;Every value was readable in &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/environ&lt;/code&gt; and &lt;code&gt;docker exec env&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Process roles&lt;/td&gt;
&lt;td&gt;The role variable was set&lt;/td&gt;
&lt;td&gt;A web process came up holding an empty signing key&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What the green test suites could not see in the container
&lt;/h2&gt;

&lt;p&gt;The health, startup, guard and web security suites passed, 86 of them. So did the confirmation, staff, provisioning, agreement and intake suites, 227 of them. Later, the image, startup, worker and guard suites passed, 134 of them. Every control described here shipped with a green run behind it.&lt;/p&gt;

&lt;p&gt;Not one of those suites was wrong. Each asked a precise question about code and got a precise answer. The questions just stopped one layer short of the process.&lt;/p&gt;

&lt;p&gt;A guard is a function when the test runs it. It is a deployment when a hostname arrives from the network, when the kernel applies a umask to a socket inode, when someone types &lt;code&gt;docker exec&lt;/code&gt;, when a supervisor starts a second process in the same container. Those are four different things, and only the first one is unit-testable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 1: the host guard that failed open
&lt;/h2&gt;

&lt;p&gt;The first version of the host guard denied exactly one host, the public one. Everything else passed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# what shipped first
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;host_allowed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;PUBLIC_HOST&lt;/span&gt;          &lt;span class="c1"&gt;# every other host passes
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tests passed because they tested the hosts the authors already knew about. A &lt;a href="https://startaitools.com/posts/every-safety-gate-has-a-failure-direction/" rel="noopener noreferrer"&gt;deny-list is only as long as the list of threats somebody imagined&lt;/a&gt;. Any hostname nobody thought of gets a 200.&lt;/p&gt;

&lt;p&gt;The replacement names the hosts that are allowed, and normalizes the host before it decides. It now takes the path as well, because a host check with no path cannot tell a health probe from a staff screen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;host_allowed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;is_health_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;LOCAL_HOSTS&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;is_staff_screen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nf"&gt;is_admin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;STAFF_HOST&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;                        &lt;span class="c1"&gt;# 404 otherwise
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A separate guard refuses a missing staff host, and refuses a wildcard, a domain pattern or the public host standing in as the staff or local host. It refuses configuration that is easy to write and means nothing.&lt;/p&gt;

&lt;p&gt;The branch was replaced rather than patched. Adding the hosts somebody remembered to add would have produced a longer deny-list with the same failure direction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Review question: what does this serve when the host is one I have never heard of?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 2: exit 1 that meant two different things
&lt;/h2&gt;

&lt;p&gt;The health command prints JSON with counts and signal names, never a version, a commit or a reason. It exited 0 for clean, 1 for anomaly. It also exited 1 when the check itself blew up.&lt;/p&gt;

&lt;p&gt;So the two events that most need different reactions, page now and ignore because the tooling is broken, arrived through the same door.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# before: a failed check and a real anomaly share an exit code
&lt;/span&gt;&lt;span class="n"&gt;findings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;findings&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

&lt;span class="c1"&gt;# after: the command wraps every system check, including the ones it skips
&lt;/span&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;findings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;collect_all_signals&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;          &lt;span class="c1"&gt;# handled AND skipped
&lt;/span&gt;&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;CheckFailed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;                                 &lt;span class="c1"&gt;# the check failed
&lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;findings&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit 2 is the answer that buys you the rest of the night. A monitor can treat 0 as healthy, 1 as an anomaly worth a page, and 2 as the instrumentation being down, which is a different alert to a different person.&lt;/p&gt;

&lt;p&gt;The same review round found the signal set was counting things that are not anomalies. Provisioning health was counting open items, and open items include a person doing their job: a checklist waiting on a name review. The fix split them in two.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Problem reasons, alert immediately:&lt;/strong&gt; conflict, failure, found or late mailbox, setup, activation, revocation, withdrawal link.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Routine waiting, alert only past the backlog target:&lt;/strong&gt; checklist, name review, blocked.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A check that pages on ordinary work does not get fixed. It gets muted, and then it also stops carrying the real signal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Review question: what does this exit code mean when the check itself is broken, and who is on the other end of it?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 3: the umask that was a decimal number
&lt;/h2&gt;

&lt;p&gt;The socket was created with the wrong permissions. The number in the flag looked correct.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nt"&gt;--umask&lt;/span&gt; 117
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Socket permissions are 0777 minus the umask. Written the intended way for the gunicorn &lt;code&gt;--umask&lt;/code&gt; flag, &lt;code&gt;0o117&lt;/code&gt;, the socket lands at 0660: group read and write, nothing for other. Written bare, &lt;code&gt;117&lt;/code&gt; is parsed as decimal 117, which is octal 0165, which leaves the socket at 0612. Owner read and write, group execute only, other write only.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# 0660 socket. a bare 117 is decimal, which leaves 0612 instead.&lt;/span&gt;
&lt;span class="c"&gt;# The comment has to sit above CMD: inside the continuation chain a # is a parameter.&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["gunicorn", \&lt;/span&gt;
     "--umask", "0o117", \
     "--worker-tmp-dir", "/dev/shm", \
     "--error-logfile", "-", \
     "--access-logfile", "", \
     "config.wsgi:application"]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No unit test touches this. The test suite can assert the string in the Dockerfile. It cannot assert the mode on an inode, because no test creates the socket. The verification for this one is a single &lt;a href="https://startaitools.com/posts/cap-drop-all-broke-the-gate-socket/" rel="noopener noreferrer"&gt;&lt;code&gt;ls -l&lt;/code&gt; against the running container&lt;/a&gt;: &lt;code&gt;660 catalyst:catalyst&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The worker temp directory is there for the same family of reason. The root filesystem runs read only, so the temp directory gunicorn wants has to be a tmpfs mount.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Review question: what mode does this actually produce on the inode, and did I write that number in the base it is read in?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 4: secrets in the environment
&lt;/h2&gt;

&lt;p&gt;The obvious way to get a secret into a Django settings module is to export it as an environment variable. The entrypoint reads an env file and exports the values, the settings module reads &lt;code&gt;os.environ&lt;/code&gt;, and the container runs.&lt;/p&gt;

&lt;p&gt;Every value that lands in the environment of a process stays readable. &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/environ&lt;/code&gt; is readable by anything with the right ptrace relationship to that process. &lt;code&gt;docker exec &amp;lt;container&amp;gt; env&lt;/code&gt; prints the container config's environment outright. The export does not stop at the process boundary the way it does in a shell you close.&lt;/p&gt;

&lt;p&gt;The settings read each secret from its own file instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;SECRETS_DIR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CATALYST_SECRETS_DIR&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;     &lt;span class="c1"&gt;# the one variable, a path
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SECRETS_DIR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;fh&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;rstrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                 &lt;span class="c1"&gt;# drop the line ending only
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;secret_problems&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;SECRET_NAMES&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a secret is present in the environment&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isdir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SECRETS_DIR&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;the secrets directory is missing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Settings and the guard share one list of secret names, so a secret added to one is checked in the other. A file that cannot be read fails closed without printing its bytes, because a startup error that quotes the value is a startup error that copies the value into your logs. &lt;code&gt;.dockerignore&lt;/code&gt; excludes secret-shaped files at any depth, and a deployment carrying mounted secrets may not set &lt;code&gt;CATALYST_ENV=development&lt;/code&gt;, where the strictness is switched off.&lt;/p&gt;

&lt;p&gt;The verification is not a test result. It is starting the production image and reading &lt;code&gt;docker inspect&lt;/code&gt;, then reading the environment again from inside, and confirming nothing is there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Review question: if this container died and somebody could run exactly one command, what would they read?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 5: the fetcher in the web container
&lt;/h2&gt;

&lt;p&gt;The image ran one application process with a role variable, and a second process for a background job that fetches evidence. The security re-check found the environment-only role let a web process come up holding an empty signing key.&lt;/p&gt;

&lt;p&gt;An empty key is not a deny. It is a key. Anything signed with it is signed with the same thing anybody else has, so the signed verification links the product hands out could be forged by anyone who read the code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;process_role_problems&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;role&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;web&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fetcher&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unknown CATALYST_PROCESS_ROLE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;role&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;web&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CATALYST_VERIFICATION_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;the web process is carrying the fetcher signing key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three rules, and each one closes a hole the others leave open: an unknown role is refused, so a typo does not silently mean none; the fetcher role is refused inside the web process, declared in &lt;code&gt;config.wsgi&lt;/code&gt; before Django loads, so nothing can import around it; and an empty key is refused everywhere except the fetcher. The fetcher starts without the key and receives it only where it belongs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Review question: which process is supposed to hold this key, and what happens if it is present but empty?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not just write more tests
&lt;/h2&gt;

&lt;p&gt;Every finding here got a test. Four of the five got one in the same commit as the fix. That is the right move and it is also the wrong answer to the question I was actually asking.&lt;/p&gt;

&lt;p&gt;Two of these cannot be pinned by a test at all. The umask is an inode mode produced by a syscall, so the test can only ever assert that a string is present in a Dockerfile, which is what the original string already satisfied. The environ claim is a property of the container runtime, so proving it means running the container, which means the check is now &lt;a href="https://startaitools.com/posts/rehearse-before-production-catches-what-tests-cant/" rel="noopener noreferrer"&gt;a deployment test, which is a different tool with a different cost and a different failure story&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The third is subtler. A deny-list cannot be tested for fail-open behavior without enumerating every hostname the authors failed to think of, which is the enumeration that was wrong in the first place. The test proves the list is the list. It cannot prove the list is complete.&lt;/p&gt;

&lt;p&gt;That is the general shape. A test tells you the code does what the code does. It cannot tell you the deployment has the property you wanted, because that property lives in a layer the code does not own.&lt;/p&gt;

&lt;p&gt;So the fixes leaned the other way where they could. The allow-list changes the failure direction instead of documenting it. The exit code makes the two cases addressable. The startup guard refuses an unset, wildcard or public staff host before the app serves anything, which is stronger than a check that catches it here. A startup refusal is the artifact that helps the next person; a green run is only evidence that it happened to work on the machine it ran on.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the running container was verified
&lt;/h2&gt;

&lt;p&gt;The image, startup, worker and guard suites passed 134 of them, and the guard and startup pair passed 93 on their own after the role split. Then the production image ran with &lt;code&gt;--read-only&lt;/code&gt;, tmpfs mounts, &lt;code&gt;cap-drop ALL&lt;/code&gt; and secrets mounted read only. The socket came up &lt;code&gt;660 catalyst:catalyst&lt;/code&gt;. &lt;code&gt;/healthz&lt;/code&gt; answered 200 over that socket, which is the part that matters, since a healthy HTTP check over TCP says nothing about a unix socket with the wrong mode. No secret appeared in the environment or in &lt;code&gt;docker inspect&lt;/code&gt;. &lt;code&gt;pip-audit&lt;/code&gt; found no known vulnerability in the base requirements.&lt;/p&gt;

&lt;p&gt;Two of those lines are worth keeping as the shape of the whole exercise. &lt;code&gt;/healthz&lt;/code&gt; returning 200 proves the application works. It does not prove the socket is only writable by the two users who should write to it. Both were true, at the same time, and only one of them was visible to the test suite.&lt;/p&gt;

&lt;p&gt;The deployment work is public at &lt;a href="https://github.com/intent-solutions-io/catalyst-onboarding-v2" rel="noopener noreferrer"&gt;intentsolutions-io/catalyst-onboarding-v2&lt;/a&gt;, with the guards and the health command in &lt;code&gt;config/guards.py&lt;/code&gt;, &lt;code&gt;config/system_health.py&lt;/code&gt; and &lt;code&gt;config/health.py&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Questions this answers
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How do I keep secrets out of a Docker container's environment?
&lt;/h3&gt;

&lt;p&gt;The obvious route is to export the secret into the environment, where it stays readable by anything with the right ptrace relationship to the process and by &lt;code&gt;docker exec &amp;lt;container&amp;gt; env&lt;/code&gt;. Read each secret from its own file instead and export only the path to the directory that holds them, so the settings module opens files by name and nothing secret-shaped is ever in the environment. Verification is not a test result. Start the production image, read &lt;code&gt;docker inspect&lt;/code&gt;, then read the environment from inside the container and confirm nothing is there.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does gunicorn create a socket with the wrong permissions?
&lt;/h3&gt;

&lt;p&gt;The socket mode is 0777 minus whatever umask gunicorn is given, so the flag is worth reading twice. Gunicorn reads it as octal, and a bare &lt;code&gt;117&lt;/code&gt; arrives as decimal, which is octal 0165. That lands the socket at 0612 with write access for other, instead of the 0660 you intended. The suite cannot see it either way, because the string was already sitting in the Dockerfile when the tests went green. The check is a single &lt;code&gt;ls -l&lt;/code&gt; against the running container.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why do my security tests pass while the container is still insecure?
&lt;/h3&gt;

&lt;p&gt;A unit test exercises the function, not the deployment. The properties that matter live in a layer the code does not own, and that layer holds a hostname arriving off the network, a kernel umask applied to a socket inode, the container runtime's environment, and a second process sharing the container. The suites were not wrong. Each one stopped a layer short of the process, so these get caught by a deployment check and a review question rather than by a unit test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ask what the guard does with the input nobody thought of.&lt;/strong&gt; If the answer is a list of known bad names, the failure direction is open and the tests cannot see it, because they share the list. Name the allowed hosts instead, and refuse the wildcard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Split the exit codes so the broken instrument has its own number.&lt;/strong&gt; One code for an anomaly and one for a failing check is one code that trains on-call to ignore the first. Then separate the alerts that mean something is wrong from the alerts that mean a person is working.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify the container, not the module.&lt;/strong&gt; Read the socket's real mode with &lt;code&gt;ls -l&lt;/code&gt;, read the environment with &lt;code&gt;docker exec&lt;/code&gt;, and prove secrets are absent from &lt;code&gt;docker inspect&lt;/code&gt;. For the signing key, name the one process allowed to hold it, and confirm an empty value fails at startup.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Related posts
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://startaitools.com/posts/wrong-mode-green-is-not-a-gate/" rel="noopener noreferrer"&gt;Wrong-Mode Green Is Not a Gate&lt;/a&gt;: a checker that ran in the wrong mode and still printed all-green, the same shape as a guard that lists the wrong hosts.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://startaitools.com/posts/a-green-result-only-covers-what-it-ran/" rel="noopener noreferrer"&gt;Every Verdict Carries the Scope It Actually Ran&lt;/a&gt;: what a green result proves, which is the scope it ran and nothing past it.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://startaitools.com/posts/bash-2-devnull-pipefail-first-deploy/" rel="noopener noreferrer"&gt;A bash redirect that hides stderr but not the exit code&lt;/a&gt;: a deploy killed by an exit code nobody was reading, the plumbing cousin of the health command's exit codes.

{
&amp;amp;quot;&lt;a class="mentioned-user" href="https://dev.to/context"&gt;@context&lt;/a&gt;&amp;amp;quot;: &amp;amp;quot;&amp;lt;a href="https://schema.org"&amp;gt;https://schema.org&amp;lt;/a&amp;gt;&amp;amp;quot;,
&amp;amp;quot;@type&amp;amp;quot;: &amp;amp;quot;FAQPage&amp;amp;quot;,
&amp;amp;quot;mainEntity&amp;amp;quot;: [
{
  &amp;amp;quot;@type&amp;amp;quot;: &amp;amp;quot;Question&amp;amp;quot;,
  &amp;amp;quot;name&amp;amp;quot;: &amp;amp;quot;How do I keep secrets out of a Docker container&amp;amp;#39;s environment?&amp;amp;quot;,
  &amp;amp;quot;acceptedAnswer&amp;amp;quot;: {
    &amp;amp;quot;@type&amp;amp;quot;: &amp;amp;quot;Answer&amp;amp;quot;,
    &amp;amp;quot;text&amp;amp;quot;: &amp;amp;quot;The obvious route is to export the secret into the environment, where it stays readable by anything with the right ptrace relationship to the process and by docker exec env. Read each secret from its own file instead and export only the path to the directory that holds them, so the settings module opens files by name and nothing secret-shaped is ever in the environment. Verification is not a test result. Start the production image, read docker inspect, then read the environment from inside the container and confirm nothing is there.&amp;amp;quot;
  }
},
{
  &amp;amp;quot;@type&amp;amp;quot;: &amp;amp;quot;Question&amp;amp;quot;,
  &amp;amp;quot;name&amp;amp;quot;: &amp;amp;quot;Why does gunicorn create a socket with the wrong permissions?&amp;amp;quot;,
  &amp;amp;quot;acceptedAnswer&amp;amp;quot;: {
    &amp;amp;quot;@type&amp;amp;quot;: &amp;amp;quot;Answer&amp;amp;quot;,
    &amp;amp;quot;text&amp;amp;quot;: &amp;amp;quot;The socket mode is 0777 minus whatever umask gunicorn is given, so the flag is worth reading twice. Gunicorn reads it as octal, and a bare 117 arrives as decimal, which is octal 0165. That lands the socket at 0612 with write access for other, instead of the 0660 you intended. The suite cannot see it either way, because the string was already sitting in the Dockerfile when the tests went green. The check is a single ls -l against the running container.&amp;amp;quot;
  }
},
{
  &amp;amp;quot;@type&amp;amp;quot;: &amp;amp;quot;Question&amp;amp;quot;,
  &amp;amp;quot;name&amp;amp;quot;: &amp;amp;quot;Why do my security tests pass while the container is still insecure?&amp;amp;quot;,
  &amp;amp;quot;acceptedAnswer&amp;amp;quot;: {
    &amp;amp;quot;@type&amp;amp;quot;: &amp;amp;quot;Answer&amp;amp;quot;,
    &amp;amp;quot;text&amp;amp;quot;: &amp;amp;quot;A unit test exercises the function, not the deployment. The properties that matter live in a layer the code does not own, and that layer holds a hostname arriving off the network, a kernel umask applied to a socket inode, the container runtime&amp;amp;#39;s environment, and a second process sharing the container. The suites were not wrong. Each one stopped a layer short of the process, so these get caught by a deployment check and a review question rather than by a unit test.&amp;amp;quot;
  }
}
]
}
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>security</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Move the deploy runner onto the tailnet, skip Tailscale OIDC</title>
      <dc:creator>Jeremy Longshore</dc:creator>
      <pubDate>Mon, 19 Oct 2026 11:22:44 +0000</pubDate>
      <link>https://dev.to/jeremy_longshore/move-the-deploy-runner-onto-the-tailnet-skip-tailscale-oidc-11gg</link>
      <guid>https://dev.to/jeremy_longshore/move-the-deploy-runner-onto-the-tailnet-skip-tailscale-oidc-11gg</guid>
      <description>&lt;p&gt;When a deploy needs a fresh login token every run, and the broker that issues those tokens stops being free, the token call returns 402 Payment Required and the rest of the job still reports success. Production stays on the previous build for days. What you have at the end of this post is a GitHub Actions deploy workflow with two settings any caller can flip (join-tailnet: false, and send-revision: false), a mode where the runner is already a member of the private network and so does not need to mint a token at all, and the order in which to migrate each repo that calls this workflow one at a time.&lt;/p&gt;

&lt;p&gt;On 2026-10-14, every deploy through one shared VPS deploy workflow failed the same way: a 402 Payment Required from Tailscale on the first call to mint a short-lived network token, because the Tailscale account (the VPN that brokers access to a private mesh network, called a tailnet) had been moved off its free trial the same day. The run summary said &lt;code&gt;success&lt;/code&gt; on every check. The merged commits landed in GitHub. Production stayed on the previous build until someone read the deploy log line by line, two days later.&lt;/p&gt;

&lt;p&gt;This is what a paid-tier expiry looks like when the only path from the runner to the production host is a token-mint handshake. The fix is not renewing the trial. The fix is making network membership the runner's job, not the workflow's.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the old deploy job actually did
&lt;/h2&gt;

&lt;p&gt;The old deploy shape, repeated across every consumer of the reusable workflow, looked roughly like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Mint an ephemeral Tailscale auth key by exchanging a GitHub Actions OIDC token against a federated trust.&lt;/li&gt;
&lt;li&gt;Use the key to bring the hosted runner onto the tailnet as a node tagged &lt;code&gt;tag:ci&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;SSH to the production host as the deploy user, run the VPS-resident deploy script.&lt;/li&gt;
&lt;li&gt;The auth key expires. The runner node drops off the tailnet.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 1 is a single API call. Step 2 needs the key in hand. Step 3 is the actual deploy. The whole thing runs in roughly a minute.&lt;/p&gt;

&lt;p&gt;When step 1 returned 402, step 2 never ran. The runner stayed off the tailnet. Step 3 ran in the wrong context (the runner was never on the tailnet, so it could not reach the VPS over its Tailscale hostname) and the deploy job reported &lt;code&gt;success&lt;/code&gt; anyway, because the SSH step was wrapped in a way that did not propagate the upstream 402.&lt;/p&gt;

&lt;p&gt;(SSH_ORIGINAL_COMMAND is the variable an SSH forced-command script reads for the command the client tried to run.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The reusable workflow change
&lt;/h2&gt;

&lt;p&gt;The reusable workflow lives in &lt;code&gt;.github&lt;/code&gt; (the repo that holds the shared CI assets). Two new inputs declared on &lt;code&gt;on.workflow_call&lt;/code&gt;:&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;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;workflow_call&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;inputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;string&lt;/span&gt;
        &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ubuntu-latest'&lt;/span&gt;
      &lt;span class="na"&gt;join-tailnet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;boolean&lt;/span&gt;
        &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;runs-on&lt;/code&gt; (string, default &lt;code&gt;ubuntu-latest&lt;/code&gt;) picks the deploy job's runner. &lt;code&gt;join-tailnet&lt;/code&gt; (boolean, default &lt;code&gt;true&lt;/code&gt;) gates the two Tailscale steps. With &lt;code&gt;join-tailnet: false&lt;/code&gt;, the OIDC trust exchange and the &lt;code&gt;tailscale up&lt;/code&gt; step are skipped entirely, and a plain TCP check of the VPS SSH port over the runner's own tailnet route replaces them. The two Tailscale inputs (&lt;code&gt;TS_OIDC_CLIENT_ID&lt;/code&gt;, &lt;code&gt;TS_AUDIENCE&lt;/code&gt;) become optional in that mode because the mode never uses them.&lt;/p&gt;

&lt;p&gt;The default stays &lt;code&gt;true&lt;/code&gt;. Every caller pins the workflow by SHA, so callers migrate one at a time, each with its own deploy as proof. That is the design choice worth defending below.&lt;/p&gt;

&lt;h2&gt;
  
  
  The TCP-only health check
&lt;/h2&gt;

&lt;p&gt;When &lt;code&gt;join-tailnet&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt;, the workflow replaces the OIDC exchange and the &lt;code&gt;tailscale up&lt;/code&gt; step with a single TCP probe of the VPS SSH port over the runner host's own tailnet route. The shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Inside the deploy job, when inputs.join-tailnet is false.&lt;/span&gt;
nc &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$VPS_HOST&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 22 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"::error::cannot reach &lt;/span&gt;&lt;span class="nv"&gt;$VPS_HOST&lt;/span&gt;&lt;span class="s2"&gt;:22 over tailnet"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The probe is intentionally minimal. It mints nothing, authenticates nothing, logs into nothing. It only proves the runner can reach the VPS SSH port over its own tailnet route, which is exactly the precondition step 3 needs.&lt;/p&gt;

&lt;p&gt;This is the no-join fallback. The self-hosted runner is already a tailnet member because it lives on the runner host, which has been a tailnet member since before this workflow existed. The runner reaches the VPS over the same Tailscale hostname the hosted runner would have reached after a successful &lt;code&gt;tailscale up&lt;/code&gt;. The TCP probe is the gate that proves the route exists, then the deploy SSH step runs the same way it always has.&lt;/p&gt;

&lt;h2&gt;
  
  
  The send-revision toggle
&lt;/h2&gt;

&lt;p&gt;A second caller-facing boolean landed the same day, also declared on &lt;code&gt;on.workflow_call&lt;/code&gt;:&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;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;workflow_call&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;inputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;send-revision&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;boolean&lt;/span&gt;
        &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Default &lt;code&gt;true&lt;/code&gt;. When &lt;code&gt;true&lt;/code&gt;, the deploy step passes the merged PR's HEAD SHA to the VPS-side script as &lt;code&gt;github.sha&lt;/code&gt;. When &lt;code&gt;false&lt;/code&gt;, the deploy step connects with no remote command, so &lt;code&gt;SSH_ORIGINAL_COMMAND&lt;/code&gt; is empty.&lt;/p&gt;

&lt;p&gt;Why: at least one consumer's VPS-resident deploy script is a hardened forced command that accepts only an empty command. It captures current main itself, preflights the live site, and deploys that exact commit with a database backup and rollback path. The first deploy on the self-hosted runner exited 64 because the reusable workflow always sends &lt;code&gt;github.sha&lt;/code&gt;. The exit code propagated up; the run summary finally showed red.&lt;/p&gt;

&lt;p&gt;The opt-out preserves the hardening. The VPS-side script is owned by its own repo's release process and refuses arguments on purpose. Changing it to accept an argument would weaken the contract that the manual-install path depends on. Changing the workflow to send nothing when a caller asks for nothing is the smaller change.&lt;/p&gt;

&lt;h2&gt;
  
  
  The opt-in, not the flip
&lt;/h2&gt;

&lt;p&gt;The deploy workflow change is two inputs, both default to the old behaviour, both opt-in per caller. That is the load-bearing design choice, and it is worth stating the alternatives.&lt;/p&gt;

&lt;p&gt;Flipping the default would migrate every caller at once. That is also the change that hides failures: a caller who skipped the changelog would see a deploy run on an unexpected runner, and the next time a real bug shows up, no one can tell which runner ran it. Every caller pins the workflow by SHA. They are the unit of migration. Each caller flips &lt;code&gt;join-tailnet&lt;/code&gt; on its own deploy, watches its own run, and only then does the next caller move.&lt;/p&gt;

&lt;p&gt;Keeping the default at &lt;code&gt;true&lt;/code&gt; also keeps the change self-documenting in caller repos. A &lt;code&gt;with: join-tailnet: false&lt;/code&gt; line in a caller workflow is the visible signal that this caller is on the new mode. A blanket default flip would erase that signal.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first consumer: startaitools.com
&lt;/h2&gt;

&lt;p&gt;The first consumer to flip was startaitools.com itself. Its deploy workflow already called the shared &lt;code&gt;.github&lt;/code&gt; reusable deploy, so flipping was a single workflow-input change plus the consumer-side runner label so GitHub knew where to dispatch the job. The merged change shipped the same day, the deploy landed on the self-hosted runner, and the run summary finally showed a deploy that did not require a working Tailscale account to even start.&lt;/p&gt;

&lt;p&gt;That is the proof pattern. One caller migrates, one deploy proves the mode works for that caller, and only then does the next caller move. The runner host is the existing self-hosted runner platform and is already a tailnet member, so the change at the runner side was zero new code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not just renew the trial
&lt;/h2&gt;

&lt;p&gt;Renewing the Tailscale plan restores this single outage. The reason to still install the opt-in is that "renew this one credential" is not a class-of-failure fix.&lt;/p&gt;

&lt;p&gt;Every OIDC handshake that mints a short-lived credential is a vendor handshake. Tailscale OIDC is one example. A custom OIDC issuer pointed at your cloud rotates every 90 days. A GitHub App installation token can be revoked by an org admin who never thinks about your deploy. An IAM trust policy edit on the cloud side breaks the handshake the same way: 403, 401, or 402, deep in the run summary, with the rest of the deploy job looking fine. Any of these would have cost the same two days of stale production.&lt;/p&gt;

&lt;p&gt;Moving the runner onto the network removes the handshake from the deploy path. The deploy becomes a TCP probe plus an SSH. The TCP probe depends on the runner host still being a tailnet member, which is a runner-host lifecycle question, not a vendor question. The SSH depends on the deploy user's SSH key, which the operator has regardless of vendor state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not edit the VPS-side script instead
&lt;/h2&gt;

&lt;p&gt;The send-revision toggle exists because at least one VPS-resident deploy script is a hardened forced command that only accepts an empty command. The natural reflex is to relax it: "accept an optional argument, ignore it if empty, run the deploy with whatever revision the runner sends." That is the wrong direction.&lt;/p&gt;

&lt;p&gt;The hardening is owned by the repo whose deploy path it is. That repo's release process depends on the script knowing it always runs an empty command, because the script's whole behaviour (capture main, preflight, deploy, back up the database, roll back on smoke failure) assumes the caller is not in control of what gets shipped. Letting a caller pass a revision would re-introduce the divergence loophole the script was built to close. The VPS-resident script is the manual fallback path, and the manual fallback path needs to be the thing that decides what it deploys.&lt;/p&gt;

&lt;p&gt;The reusable workflow is the shared asset that every caller already pins by SHA. Adding an opt-out is the smaller change, and it puts the decision at the right layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Also shipped
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Four new tests cover the runner-mode change; the reusable workflow's suite now reports 10 passing.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;send-revision&lt;/code&gt; boolean for callers whose VPS-side deploy script only accepts an empty command; default unchanged.&lt;/li&gt;
&lt;li&gt;startaitools.com is the first consumer to migrate, in the same day's release v1.33.24.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Use this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Add a &lt;code&gt;join-tailnet&lt;/code&gt; boolean to your reusable deploy workflow, default &lt;code&gt;true&lt;/code&gt;, with a TCP-only health check on the runner's own tailnet route as the no-join fallback.&lt;/li&gt;
&lt;li&gt;Add a &lt;code&gt;send-revision&lt;/code&gt; boolean for callers whose VPS-side deploy script only accepts an empty command, default &lt;code&gt;true&lt;/code&gt;, so the workflow can connect without sending &lt;code&gt;github.sha&lt;/code&gt; when a caller asks.&lt;/li&gt;
&lt;li&gt;Migrate consumers one at a time, each with its own deploy as proof; never flip the default for every caller in one change.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Reference: the reusable workflow at &lt;code&gt;https://github.com/jeremylongshore/.github/blob/main/.github/workflows/vps-deploy.yml&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What does a hosted runner's Tailscale OIDC 402 look like inside a GitHub Actions deploy?
&lt;/h3&gt;

&lt;p&gt;The OIDC step POSTs to the Tailscale issuer and receives a 402 Payment Required. The failure happens inside a sub-step that the run summary does not propagate. The rest of the deploy job continues, the job result still reports &lt;code&gt;success&lt;/code&gt;, the merged commit lands in GitHub, and production stays on the previous build until the run log is read end to end.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why is the opt-in design safer than flipping the default for every caller at once?
&lt;/h3&gt;

&lt;p&gt;Every caller pins the reusable workflow by SHA, and the unit of migration is the caller. Each caller flips the toggle on its own deploy, watches its own run, and only then does the next caller move. A blanket default flip hides which caller is on which mode; the visible &lt;code&gt;with: join-tailnet: false&lt;/code&gt; line per caller is what keeps the migration self-documenting.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is a self-hosted runner that is already a tailnet member?
&lt;/h3&gt;

&lt;p&gt;A runner whose host machine has joined the Tailscale network as a long-lived node. The runner reaches other tailnet nodes over their Tailscale hostnames without any per-deploy handshake. The runner host used for this change is already configured this way, which is why no Tailscale account is needed for the new mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related Posts
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://startaitools.com/posts/when-third-party-ci-credentials-expire-the-deploy-fallback-lives-on-the-vps/" rel="noopener noreferrer"&gt;When a Third-Party CI Credential Expires, the Deploy Fallback Has to Already Live on the VPS&lt;/a&gt;: the doctrinal predecessor; VPS-resident deploy script plus SHA-pin guard for the day a third-party CI credential expires.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://startaitools.com/posts/scope-federated-tailscale-trust-to-auth-keys/" rel="noopener noreferrer"&gt;Tailscale Federated Trust: Scope to Auth Keys, Not All&lt;/a&gt;: narrows every deploy federated trust to &lt;code&gt;auth_keys&lt;/code&gt; scope, the trust posture this change assumes is still in force.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://startaitools.com/posts/vps-as-the-home-day-1-eight-deploy-iterations/" rel="noopener noreferrer"&gt;VPS-as-the-Home Day 1: Eight Deploy Iterations&lt;/a&gt;: the original reusable VPS deploy workflow plus Tailscale OIDC, eight iterations to land, the architecture this change extends.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&amp;lt;br&amp;gt;
{&amp;lt;br&amp;gt;
  &amp;amp;quot;&lt;a class="mentioned-user" href="https://dev.to/context"&gt;@context&lt;/a&gt;&amp;amp;quot;: &amp;amp;quot;&amp;lt;a href="https://schema.org"&amp;gt;https://schema.org&amp;lt;/a&amp;gt;&amp;amp;quot;,&amp;lt;br&amp;gt;
  &amp;amp;quot;@type&amp;amp;quot;: &amp;amp;quot;FAQPage&amp;amp;quot;,&amp;lt;br&amp;gt;
  &amp;amp;quot;mainEntity&amp;amp;quot;: [&amp;lt;br&amp;gt;
    {&amp;lt;br&amp;gt;
      &amp;amp;quot;@type&amp;amp;quot;: &amp;amp;quot;Question&amp;amp;quot;,&amp;lt;br&amp;gt;
      &amp;amp;quot;name&amp;amp;quot;: &amp;amp;quot;What does a hosted runner&amp;amp;#39;s Tailscale OIDC 402 look like inside a GitHub Actions deploy?&amp;amp;quot;,&amp;lt;br&amp;gt;
      &amp;amp;quot;acceptedAnswer&amp;amp;quot;: {&amp;lt;br&amp;gt;
        &amp;amp;quot;@type&amp;amp;quot;: &amp;amp;quot;Answer&amp;amp;quot;,&amp;lt;br&amp;gt;
        &amp;amp;quot;text&amp;amp;quot;: &amp;amp;quot;The OIDC step POSTs to the Tailscale issuer and receives a 402 Payment Required. The failure happens inside a sub-step that the run summary does not propagate. The rest of the deploy job continues, the job result still reports success, the merged commit lands in GitHub, and production stays on the previous build until the run log is read end to end.&amp;amp;quot;&amp;lt;br&amp;gt;
      }&amp;lt;br&amp;gt;
    },&amp;lt;br&amp;gt;
    {&amp;lt;br&amp;gt;
      &amp;amp;quot;@type&amp;amp;quot;: &amp;amp;quot;Question&amp;amp;quot;,&amp;lt;br&amp;gt;
      &amp;amp;quot;name&amp;amp;quot;: &amp;amp;quot;Why is the opt-in design safer than flipping the default for every caller at once?&amp;amp;quot;,&amp;lt;br&amp;gt;
      &amp;amp;quot;acceptedAnswer&amp;amp;quot;: {&amp;lt;br&amp;gt;
        &amp;amp;quot;@type&amp;amp;quot;: &amp;amp;quot;Answer&amp;amp;quot;,&amp;lt;br&amp;gt;
        &amp;amp;quot;text&amp;amp;quot;: &amp;amp;quot;Every caller pins the reusable workflow by SHA, and the unit of migration is the caller. Each caller flips the toggle on its own deploy, watches its own run, and only then does the next caller move. A blanket default flip hides which caller is on which mode; the visible with: join-tailnet: false line per caller is what keeps the migration self-documenting.&amp;amp;quot;&amp;lt;br&amp;gt;
      }&amp;lt;br&amp;gt;
    },&amp;lt;br&amp;gt;
    {&amp;lt;br&amp;gt;
      &amp;amp;quot;@type&amp;amp;quot;: &amp;amp;quot;Question&amp;amp;quot;,&amp;lt;br&amp;gt;
      &amp;amp;quot;name&amp;amp;quot;: &amp;amp;quot;What is a self-hosted runner that is already a tailnet member?&amp;amp;quot;,&amp;lt;br&amp;gt;
      &amp;amp;quot;acceptedAnswer&amp;amp;quot;: {&amp;lt;br&amp;gt;
        &amp;amp;quot;@type&amp;amp;quot;: &amp;amp;quot;Answer&amp;amp;quot;,&amp;lt;br&amp;gt;
        &amp;amp;quot;text&amp;amp;quot;: &amp;amp;quot;A runner whose host machine has joined the Tailscale network as a long-lived node. The runner reaches other tailnet nodes over their Tailscale hostnames without any per-deploy handshake. The runner host used for this change is already configured this way, which is why no Tailscale account is needed for the new mode.&amp;amp;quot;&amp;lt;br&amp;gt;
      }&amp;lt;br&amp;gt;
    }&amp;lt;br&amp;gt;
  ]&amp;lt;br&amp;gt;
}&amp;lt;/p&amp;gt;
&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>tailscale</category>
      <category>selfhostedrunners</category>
      <category>vpsdeploy</category>
    </item>
    <item>
      <title>When a Third-Party CI Credential Expires, the Deploy Fallback Has to Already Live on the VPS</title>
      <dc:creator>Jeremy Longshore</dc:creator>
      <pubDate>Sun, 18 Oct 2026 11:30:11 +0000</pubDate>
      <link>https://dev.to/jeremy_longshore/when-a-third-party-ci-credential-expires-the-deploy-fallback-has-to-already-live-on-the-vps-b73</link>
      <guid>https://dev.to/jeremy_longshore/when-a-third-party-ci-credential-expires-the-deploy-fallback-has-to-already-live-on-the-vps-b73</guid>
      <description>&lt;p&gt;GitHub Actions deploys that depend on a third-party OIDC identity silently 402 deep in the run when that identity's plan ends, and the run summary still turns green while production stays stale. What you have at the end of this post is a VPS-resident deploy script that needs no third-party key, plus a SHA-pin guard that proves the manual path can't ship content that diverged from the merged PR, so the next expiry becomes a one-line recovery instead of a multi-day outage.&lt;/p&gt;

&lt;p&gt;On 2026-10-14, two static-site changes shipped their merges; the CI run turned green on every check, and production stayed on the previous build. Nobody noticed for two days. The actual failure (a 402 payment-required from a third-party identity provider) was buried inside the first few seconds of a multi-minute deploy job, in a place the run summary had no reason to glance.&lt;/p&gt;

&lt;p&gt;This is what a third-party CI credential expiry actually looks like. The fix is not "renew the credential." The fix is having a deploy script that lives on the production host, needs no third-party key, and is verified against the same content hash the merged PR introduced. With that in place, the next outage is a one-line SSH invocation; without it, the next outage is a multi-day production gap.&lt;/p&gt;

&lt;p&gt;(OIDC issues short-lived tokens for machine identity, replacing stored secrets.) GitHub Actions steps that need to talk to a private API usually authenticate by exchanging a workload identity token for a short-lived key from the vendor's issuer. Tailscale OIDC is one example; a custom OIDC issuer pointed at your cloud, a GitHub App installation token behind an OIDC handshake, and any number of cloud IAM trust policies follow the same shape. Renewal cycles on any of those can outrun a developer who is not actively staring at the deploy log.&lt;/p&gt;

&lt;p&gt;(VPS-resident means a deploy script on the host itself, run over SSH.) The whole point is that the fallback must not depend on the same vendor handshake that just broke. After the vendor is gone, the only credential you still have is your SSH key. The fallback has to need only that.&lt;/p&gt;

&lt;p&gt;The day-of incident is a useful case study, but the more useful find is the pattern: every deploy script that exists on a host today was installed by hand before the first CI run that depended on it. That ordering (manual install first, CI adoption second) is what makes the recovery a one-liner whenever the third party goes away.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the VPS-resident script actually executes
&lt;/h2&gt;

&lt;p&gt;The script's structure looks like this. The real one (under &lt;code&gt;ops/deploy/force-commands&lt;/code&gt; in the ops repo) is longer; this is the contract.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# VPS-resident deploy fallback.&lt;/span&gt;
&lt;span class="c"&gt;# Same build/smoke/revert shape as the GH Actions deploy job.&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;SHA&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;:?usage:&lt;span class="p"&gt; deploy-repo.sh &amp;lt;merged-pr-head-sha&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;LOGDIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/var/log/deploy-fallback"&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOGDIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# 1. Pull the merged PR's exact commit; the deploy must equal the merge.&lt;/span&gt;
git &lt;span class="nt"&gt;-C&lt;/span&gt; /srv/repo fetch &lt;span class="nt"&gt;--no-tags&lt;/span&gt; origin &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SHA&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
git &lt;span class="nt"&gt;-C&lt;/span&gt; /srv/repo checkout &lt;span class="nt"&gt;--quiet&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SHA&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# 2. Build; on failure, leave the live tree untouched.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; ./scripts/build.sh&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;git &lt;span class="nt"&gt;-C&lt;/span&gt; /srv/repo checkout &lt;span class="nt"&gt;--quiet&lt;/span&gt; -
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# 3. Atomic symlink swap + smoke; revert-on-failure.&lt;/span&gt;
&lt;span class="nb"&gt;ln&lt;/span&gt; &lt;span class="nt"&gt;-sfn&lt;/span&gt; /srv/repo/dist.new /srv/repo/dist.live
systemctl reload caddy
curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; https://example.com/healthz &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;ln&lt;/span&gt; &lt;span class="nt"&gt;-sfn&lt;/span&gt; /srv/repo/dist /srv/repo/dist.live
  systemctl reload caddy
  &lt;span class="nb"&gt;exit &lt;/span&gt;2
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things in that shape are load-bearing and easy to miss.&lt;/p&gt;

&lt;p&gt;First, the script reads a SHA argument. It does not pull from &lt;code&gt;main&lt;/code&gt; and hope. Production ends up running the merged PR's exact commit, byte-identical to what the merge step produced on GitHub.&lt;/p&gt;

&lt;p&gt;Second, the build failure path is a checkout rollback, not a half-deployed tree. If &lt;code&gt;build.sh&lt;/code&gt; fails, the script leaves the live tree pointing at the previous commit. There is no "almost deployed" state.&lt;/p&gt;

&lt;p&gt;Third, the healthz smoke gates the symlink swap. A bad build never goes live. A failing smoke flips the symlink back before Caddy notices anything is wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The receipt that keeps the manual path honest
&lt;/h2&gt;

&lt;p&gt;The missing piece for any VPS-resident fallback is the receipt (a machine-written record proving a step really ran) that proves the manual path is not a content-divergence loophole. The safe-merge guard (under &lt;code&gt;ops/merge-guard&lt;/code&gt; in the same ops repo) refuses a manual deploy unless the SHA you intend to ship is the exact HEAD of the merged PR. Receipt shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SAFE-MERGE: PR #1648 SHA=7cff13d48... status=ok
SAFE-MERGE: manual-deploy-allowed sha=7cff13d48... matches PR #1648 head
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you typed a different SHA, the guard would refuse the run before the build even started. The recovery is execution-divergence (CI is down, manual is up) while the artifact stays identical. Curl-able probe shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /merge-guard/deploy/v1/tonsofskills?pr=1648 -&amp;gt; { allowed: true, sha: "7cff13d48..." }
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No safe-merge pass, no &lt;code&gt;deploy-repo.sh&lt;/code&gt;. The OIDC outage does not turn into "well, we just shipped whatever was on disk."&lt;/p&gt;

&lt;h2&gt;
  
  
  The silent failure shape
&lt;/h2&gt;

&lt;p&gt;A normal failed deploy stops the run summary in red and pings. An OIDC 402 looks like this inside the GitHub Actions run log:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-10-14T04:55:11Z ::group::Step Tailscale OIDC
2026-10-14T04:55:11Z POST https://api.tailscale.com/api/v2/oauth/token
2026-10-14T04:55:12Z &amp;lt;- 402 Payment Required
2026-10-14T04:55:12Z body: "your Tailscale trial has ended; choose a plan to create or change keys"
2026-10-14T04:55:12Z ::endgroup::
# (later steps continue; the 402 never propagates upward)
2026-10-14T04:55:30Z Set up job
2026-10-14T04:55:31Z Complete job
Result: success
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The deploy job's overall result is &lt;code&gt;success&lt;/code&gt; because the failure happened inside a sub-step the run summary never propagates. The merged commit lands in GitHub; the live site stays on the previous build. The 402 is the only signal that production is stale, and the 402 is buried in a &lt;code&gt;::group::&lt;/code&gt; block inside the first few seconds of a multi-minute job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not just fix CI?
&lt;/h2&gt;

&lt;p&gt;Renewing the Tailscale plan restores this single outage. The reason to still install the fallback is that "renew this one credential" is not a class-of-failure fix.&lt;/p&gt;

&lt;p&gt;A custom OIDC issuer pointed at your cloud rotates every 90 days. A GitHub App installation token can be revoked by an org admin who never thinks about your deploy. An IAM trust policy edit on the cloud side breaks the handshake the same way: 403, 401, or 402, deep in the run summary, with the rest of the deploy job looking fine. Any of these would have cost the same two days of stale production.&lt;/p&gt;

&lt;p&gt;CI runs on credentials. A VPS-resident deploy script that needs only an SSH key (which the operator has regardless of vendor state) does not. Reducing CI from "the only deploy path" to "one of two" means the next expiry is a frictionless recovery instead of an outage.&lt;/p&gt;

&lt;p&gt;You do not have to migrate the whole pipeline. The shape that works:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Build the GH Actions deploy job so its build/smoke/revert shape is one bash file plus a few &lt;code&gt;cp&lt;/code&gt; and &lt;code&gt;systemctl reload&lt;/code&gt;s.&lt;/li&gt;
&lt;li&gt;Drop that script on the host at &lt;code&gt;/usr/local/sbin/&amp;lt;repo&amp;gt;-deploy&lt;/code&gt;, gated by a manual-install checklist that requires the script to land before any CI deploy depends on it.&lt;/li&gt;
&lt;li&gt;Add a safe-merge-style SHA guard so the manual path can only ship the merged PR's HEAD.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The manual-install gate is the load-bearing piece. It means no CI deploy is the first deploy. Every deploy script on a host today was installed by hand before the first CI run that depended on it; that ordering is what makes the next expiry a one-liner SSH invocation instead of a multi-day production gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Also shipped
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;tons-of-skills-marketplace PR #1661 bundled Fuse.js and dropped &lt;code&gt;cdn.jsdelivr.net&lt;/code&gt; from &lt;code&gt;script-src&lt;/code&gt;, with a 15-page live crawl showing zero CSP violations (Claude Opus 5 5 and Claude Fable 5 1 in the review lanes).&lt;/li&gt;
&lt;li&gt;Landing page flagship card (landing#79 + an intent-os (the internal ops + docs repo) PR covering the Tons-of-Skills flagship) added the Tons-of-Skills card; merge-guard added &lt;code&gt;landing&lt;/code&gt; to its repo list and let both settle via safe-merge.&lt;/li&gt;
&lt;li&gt;Catalyst-onboarding-v2 P3 closed (3B.3, 3B.4, 3C GATE-P3) under a parallel MiniMax M3 lane with primary QA and scoped interpreter-boundary reviews.&lt;/li&gt;
&lt;li&gt;Same-day PR #1648 (remove Google Analytics, Umami only) shipped two days late because of the Tailscale 402; GPT 6 Astra redrafted the leadership briefing later in the day.&lt;/li&gt;
&lt;li&gt;The CI failure was found by reading the cross-session log entry mid-deploy, not by alert.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Use this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Install the VPS-resident deploy script before the first CI deploy depends on it, and gate the install behind a manual checklist that asks "is the manual fallback on disk?".&lt;/li&gt;
&lt;li&gt;Couple the manual path to a SHA-pin guard (safe-merge or equivalent) so the fallback cannot ship content that diverges from the merged PR's HEAD.&lt;/li&gt;
&lt;li&gt;Treat "renew the trial" as recovery, not remediation; the fix is reducing CI from sole deploy path to one of two, so the next third-party OIDC expiry is a non-event.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Reference: the &lt;code&gt;ops/deploy&lt;/code&gt; tree at &lt;code&gt;https://github.com/intent-solutions-io/intent-os/tree/main/ops/deploy&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What does GitHub Actions do when a third-party OIDC trial expires?
&lt;/h3&gt;

&lt;p&gt;The deploy job's overall result stays &lt;code&gt;success&lt;/code&gt; because the 402 lands inside a &lt;code&gt;::group::&lt;/code&gt; sub-step that the run summary never propagates. The merged commit is on GitHub, the run summary is green, and production stays on the previous build until someone reads the raw log line by line.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why is the manual VPS fallback safer than running the deploy by hand?
&lt;/h3&gt;

&lt;p&gt;A bare &lt;code&gt;ssh&lt;/code&gt; plus &lt;code&gt;git checkout&lt;/code&gt; against &lt;code&gt;main&lt;/code&gt; can ship whatever sits on disk, which is exactly the failure mode the recovery is built to prevent. That guard refuses any manual run whose SHA isn't the merged PR's HEAD, so artifact identity survives even when CI itself is unreachable.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is a SHA-pin guard?
&lt;/h3&gt;

&lt;p&gt;It is a small pre-flight check that compares the revision you intend to deploy against what the merged PR actually recorded, and refuses the run if the two diverge. In this stack it lives at &lt;code&gt;ops/merge-guard/safe-merge&lt;/code&gt;; its only authorisation (&lt;code&gt;SAFE-MERGE: PR #1648 SHA=... status=ok&lt;/code&gt;) is what lets the VPS-resident &lt;code&gt;deploy-repo.sh&lt;/code&gt; actually execute.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related Posts
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://dev.tohighest-existing-tag-beats-git-describe"&gt;highest-existing-tag-beats-git-describe&lt;/a&gt;: when a bot commit re-uses a tag the release workflow expects to own, &lt;code&gt;git describe&lt;/code&gt; traps the version bump and the version stops moving. &lt;code&gt;git tag --list 'v*' --sort=-v:refname | head -n1&lt;/code&gt; and the noise goes away.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.tosmtp-250-not-delivery-three-receipts"&gt;smtp-250-not-delivery-three-receipts&lt;/a&gt;: SMTP 250 only confirms that the relay accepted the message; to prove delivery you need a frozen ApprovedMessage at enqueue, phase-classified SMTP outcomes per RFC 5321, and reconciliation against admissible evidence before any resend.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.torank-only-reporting-keeps-the-eval-honest"&gt;rank-only-reporting-keeps-the-eval-honest&lt;/a&gt;: when a retrieval evaluation drives its own corpus cleanup, the cleanup should not be in the scored set; report the rank of each gap-filler doc instead.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;{&lt;br&gt;
  "&lt;a class="mentioned-user" href="https://dev.to/context"&gt;@context&lt;/a&gt;": "&lt;a href="https://schema.org" rel="noopener noreferrer"&gt;https://schema.org&lt;/a&gt;",&lt;br&gt;
  "@type": "FAQPage",&lt;br&gt;
  "mainEntity": [&lt;br&gt;
    {&lt;br&gt;
      "@type": "Question",&lt;br&gt;
      "name": "What does a Tailscale OIDC trial expiry look like inside a GitHub Actions deploy?",&lt;br&gt;
      "acceptedAnswer": {&lt;br&gt;
        "@type": "Answer",&lt;br&gt;
        "text": "The OIDC step POSTs to the Tailscale issuer and receives a 402 Payment Required inside a ::group:: block early in the run log; later deploy steps continue, the job result still reports success, the merged commit lands in GitHub, and production stays on the previous build until the run log is read end to end."&lt;br&gt;
      }&lt;br&gt;
    },&lt;br&gt;
    {&lt;br&gt;
      "@type": "Question",&lt;br&gt;
      "name": "How does a VPS-resident deploy script recover a deploy when third-party CI authentication is broken?",&lt;br&gt;
      "acceptedAnswer": {&lt;br&gt;
        "@type": "Answer",&lt;br&gt;
        "text": "A bash script on the host checks out the merged PR's exact commit SHA, runs the same build and smoke and revert shape the GH Actions deploy job uses, and is gated by a content-hash guard that refuses any commit other than the merged PR's HEAD. The recovery is one SSH invocation and needs no third-party credential."&lt;br&gt;
      }&lt;br&gt;
    },&lt;br&gt;
    {&lt;br&gt;
      "@type": "Question",&lt;br&gt;
      "name": "Is renewing a single third-party CI credential enough to fix a deploy outage?",&lt;br&gt;
      "acceptedAnswer": {&lt;br&gt;
        "@type": "Answer",&lt;br&gt;
        "text": "Renewing the credential restores the single outage but does not remove the class of failure. Tailscale OIDC, custom OIDC issuers, GitHub App installation tokens, and IAM trust policies all run on credentials that can expire or be revoked. Reducing CI from the sole deploy path to one of two turns the next expiry into a frictionless recovery instead of a multi-day production gap."&lt;br&gt;
      }&lt;br&gt;
    }&lt;br&gt;
  ]&lt;br&gt;
}&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>cideploy</category>
      <category>deployfallback</category>
      <category>tailscaleoidc</category>
    </item>
    <item>
      <title>Verify Audit Evidence with a Signed Payload, Not by Format</title>
      <dc:creator>Jeremy Longshore</dc:creator>
      <pubDate>Wed, 14 Oct 2026 11:30:11 +0000</pubDate>
      <link>https://dev.to/jeremy_longshore/verify-audit-evidence-with-a-signed-payload-not-by-format-kjh</link>
      <guid>https://dev.to/jeremy_longshore/verify-audit-evidence-with-a-signed-payload-not-by-format-kjh</guid>
      <description>&lt;p&gt;When many audit-evidence publishers (the systems that produce signed rows of evidence) each emit a different shape and the consumer checks signatures case by case, a tampered row is indistinguishable from a legitimate format change. The consumer has no place to anchor "last good state," so any verification failure either erases history or silently keeps going.&lt;/p&gt;

&lt;p&gt;A reader running an audit trail or evidence pipeline across multiple publishers and one consumer can adopt a different shape this week. One canonical signed statement payload (a fixed envelope of kernel version plus payload plus signature that every publisher fills in identically), one verifier that checks by signature, a preserved snapshot on every rejection. That is what cut this pipeline's tampering failures from "ambiguous" to "rejected with history preserved."&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you verify audit evidence from multiple publishers?
&lt;/h2&gt;

&lt;p&gt;Adopt one canonical signed statement payload: a fixed envelope of kernel version plus payload plus signature that every publisher fills in identically. One verifier parses the envelope, recomputes the digest over the canonical predicate bytes, and checks the signature against the public key named in the envelope. Format evolution shows up as a signature failure; tampering shows up as a signature failure; the two are no longer distinguishable to a silent verifier. A preserved last-verified snapshot rides with every rejection so the dashboard can answer what the system believed yesterday without needing yesterday's process to be alive.&lt;/p&gt;

&lt;h2&gt;
  
  
  The format-evolution trap
&lt;/h2&gt;

&lt;p&gt;The natural reflex is to keep adapting the verifier. Each publisher says its evidence grew up: a new field, a renamed key, a moved nested block. The verifier grows a new branch for each, and the test matrix grows with it. The signed-payload approach is to stop growing the verifier at all and stop asking the verifier to know any individual publisher's shape. The signature binds to the digest of the full payload bytes; the verifier either trusts that digest matches the signed payload or it does not.&lt;/p&gt;

&lt;p&gt;What makes this load bearing is that the failure mode is silent. A coerced signature over a slightly different payload passes a content check and fails a signature check. Both look like progress on a glance: the row got bigger, a field appeared, the count went up. The dashboard in this case was logging "verified" against a local witness and "rejected" against the kernel-signed digest, and the two tables had been drifting apart for three nights before anyone noticed.&lt;/p&gt;

&lt;p&gt;The audit-harness (the in-repo package of deterministic test-quality checks) release carried a verified registry signature and provenance attestation on 2026-10-12, and that is what made the change carry weight. The pipeline was not adopting a new idea; it was adopting a long-standing in-toto-style attestation pattern that the harness had already shipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one canonical statement payload
&lt;/h2&gt;

&lt;p&gt;Every publisher now emits the same shape: a kernel-versioned envelope with a typed statement, a predicate payload under it, a digest over the canonical predicate bytes, and a signature bound to that digest. The kernel version is part of the bytes that get signed, so a version bump cannot pass as the same payload. The payload bytes follow; the digest covers them; the signature covers the envelope plus the digest.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;statement:
  kernel: "audit-payload/v1"
  subject:
    - name: "publisher-name"
      digest: { sha256: "&amp;lt;hex&amp;gt;" }
  predicate:
    type: "https://intent-eval-platform.io/predicate/&amp;lt;kind&amp;gt;"
    payload:
      &amp;lt;canonical JSON: keys sorted, no whitespace, RFC 8785&amp;gt;
  metadata:
    build_at: "2026-10-12T13:45:00Z"
    source: "&amp;lt;publisher repo&amp;gt;"
envelope:
  digest: { sha256: "&amp;lt;hex of canonical predicate bytes&amp;gt;" }
  signature:
    pub_key_id: "&amp;lt;fingerprint&amp;gt;"
    value: "&amp;lt;base64&amp;gt;"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The kernel is the only component the verifier needs to know. Below the kernel, payloads are opaque to the verifier; their shape belongs to the publisher that emits them. Above the kernel, signatures travel with rows. The witness table only needs three columns: &lt;code&gt;subject&lt;/code&gt;, &lt;code&gt;digest&lt;/code&gt;, &lt;code&gt;signature&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is the reverse of how multi-publisher audits usually grow. The usual pressure is "the consumer has to understand the publisher," which pushes every format decision into the consumer. Inverting that pressure means every format decision lives with the publisher; the consumer only verifies that the signature covers the kernel and the digest.&lt;/p&gt;

&lt;h2&gt;
  
  
  One consumer, verification by signature
&lt;/h2&gt;

&lt;p&gt;The dashboard verifier does three things in order, and only those three. It parses the envelope, recomputes the digest over the canonical predicate bytes, and checks the signature against the public key named in the envelope. A rejection at any step moves the row into a holding area; a pass moves it into the witness table under the subject digest.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keyring&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Keyring&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Verdict&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="n"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;statement&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kernel&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;kernel&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;SUPPORTED_KERNEL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kernel_unsupported&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;kernel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;predicate_bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;canonicalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;statement&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;predicate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;predicate_bytes&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;declared&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;envelope&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;digest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sha256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;declared&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;digest_mismatch&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;declared&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;declared&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;recomputed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="n"&gt;pub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;keyring&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lookup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;envelope&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;signature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pub_key_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;pub&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unknown_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;...)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;pub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;predicate_bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;envelope&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;signature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bad_signature&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;accept&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;statement&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;subject&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                          &lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                          &lt;span class="n"&gt;signature&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;envelope&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;signature&lt;/span&gt;&lt;span class="sh"&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 verifier does not branch on predicate type, publisher, or layout. If a publisher adds a field, the canonicalizer's output changes, the digest changes, and the signature has to follow or verification fails. Format evolution shows up as a signature failure; that is the design, not a defect.&lt;/p&gt;

&lt;h2&gt;
  
  
  When verification fails, hold the last good snapshot
&lt;/h2&gt;

&lt;p&gt;A rejection is supposed to be an event the system keeps, not one it papers over. The tamper-evident holding area preserves the rejected envelope verbatim; the witness table keeps the last accepted row for that subject. A mutation that does not pass is a mutation the consumer has not yet agreed on, and the right answer is to surface that without erasing what already passed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Store&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Snapshot&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ApplyResult&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reject&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rejections&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;envelope&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;received_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;utcnow&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rejections&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prune&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;keep_last&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;REJECTION_WINDOW&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ApplyResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;held&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;last_known&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;witnesses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;rejection&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;witnesses&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;digest&lt;/span&gt;
    &lt;span class="n"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;persist&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ApplyResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;accepted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The snapshot sits alongside the witness table for the same reason the witness table is keyed by subject digest. The dashboard can answer "what did the system believe yesterday?" without needing yesterday's process to be alive. That is the property that broke before, when a forced schema migration overwrote the witness table with a partially-verified copy.&lt;/p&gt;

&lt;h2&gt;
  
  
  What got shipped
&lt;/h2&gt;

&lt;p&gt;Six rejections in cross-repository tests covered the cases the old verifier could not tell apart: a single-byte payload mutation, a canonicalization difference on whitespace, a key id not in the ring, a signature over a tampered digest, a kernel version one off, and a payload reordered against an older row. Each rejection kept the witness for its subject. None of them caused history loss.&lt;/p&gt;

&lt;p&gt;J-Rig (an internal governed evidence publisher) emitted fourteen canonical rows from the merged commit on the night of 2026-10-12. All fourteen verify against the dashboard signature path on the same day. J-Rig's twenty-four payload contracts pass across kernel versions 0.10.0 and 0.11.0; a stricter type check is now in CI for the test-only mismatch between the branded types in those versions.&lt;/p&gt;

&lt;p&gt;A second finding came out of the same session: the trace-intelligence program's dependency graph needed 438 edges added to align final review and production-adoption with their prerequisites. The graph was repaired without changing ownership, acceptance criteria, or completion states. The collision between the two is the one to surface: a missing migration record and a missing dependency edge are both evidence the system was lying to itself about state; the same cross-repo fix path surfaced both, and neither was visible from the dashboard before it landed.&lt;/p&gt;

&lt;p&gt;A real failure worth keeping. The scanner adapter had been treating gitleaks's exit-1 as a policy finding, conflating scanner crashes with findings. A contract-test surface reproduces this now, and required-missing-tool behavior stays advisory instead of silently raising real findings. The adapter is no longer a place where the verifier has to second-guess the publisher's report.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not the obvious approach?
&lt;/h2&gt;

&lt;p&gt;Two alternatives were on the table. The first was a richer schema with publisher-specific extensions and a per-publisher verifier; the second was a content-addressed store that names files by their hash and trusts whoever can read them.&lt;/p&gt;

&lt;p&gt;The richer schema pushes the format-decision pressure the wrong way. Every new field forces the consumer to learn a shape it does not own, and a tampered field reads as a new field until someone diffs the schema. The cross-repository tests in this pipeline had eight publisher schemas to maintain, and three of them were already a quarter behind. A per-publisher verifier is the natural extension of that path; it does not survive the next publisher.&lt;/p&gt;

&lt;p&gt;The content-addressed store is closer to the working shape, but it answers the wrong question. A digest proves the bytes are intact; it does not prove the publisher had the right to issue them. A row whose digest matches but whose signing key is not in the ring still passes. The signed envelope binds the digest to a key id, and the verifier's second step is the only place that check lives.&lt;/p&gt;

&lt;p&gt;A real reversal surfaced in the J-Rig kernel compatibility work. The earlier verifier accepted a "legacy fallback" branch so older J-Rig rows would not be lost on the cutover; that branch was a place a tampered payload could land without the witness table noticing. Removing the fallback is what makes the dashboard reject every J-Rig row that comes through the tampering or fallback branch today; the fourteen nightly rows verify on the canonical path. Keeping the old rows was the wrong kind of compatibility. The right kind is keeping the witness for the same subject, not the same shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Pick one canonical signed payload shape for every publisher in the pipeline. Kernel version plus payload digest plus signature. Anything below the kernel is the publisher's choice; anything above is the signature's. Format evolution shows up as a signature failure, not as a silent accept.&lt;/li&gt;
&lt;li&gt;Move verification out of every publisher-specific verifier. One consumer, one supported kernel, one signature check. Rejections are a feature. Cross-repo tests should treat new publishers as an unknown key id, not as a new branch.&lt;/li&gt;
&lt;li&gt;Preserve the last good witness for every subject on every rejection. The dashboard answers "what did we believe yesterday?" by reading the witness table, which means the witness table has to survive the rejection that pointed at a tampered payload.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full migration is live on &lt;a href="https://intent-eval-platform.io" rel="noopener noreferrer"&gt;intent-eval-platform.io&lt;/a&gt;: eight publishers, one verifier, the J-Rig nightly run, and audit-harness 1.5.1 on the registry with verified provenance.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How do you verify audit evidence from multiple publishers?
&lt;/h3&gt;

&lt;p&gt;Adopt one canonical signed statement payload: a fixed envelope of kernel version plus payload plus signature that every publisher fills in identically. One verifier parses the envelope, recomputes the digest over the canonical predicate bytes, and checks the signature against the public key named in the envelope. Format evolution shows up as a signature failure; tampering shows up as a signature failure; the two are no longer distinguishable to a silent verifier.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is a signed statement payload in an audit pipeline?
&lt;/h3&gt;

&lt;p&gt;A fixed envelope containing a typed statement, a payload digest, and a signature bound to that digest. Every publisher fills in the same envelope; payloads below the kernel can be any shape the publisher chooses. The signature covers the digest plus the envelope, so a single-byte payload mutation or a canonicalization difference on whitespace fails the check while legitimate history still verifies.&lt;/p&gt;

&lt;h3&gt;
  
  
  What should a verifier do when an audit row fails verification?
&lt;/h3&gt;

&lt;p&gt;Move the rejected envelope verbatim into a holding area; keep the last accepted row for that subject in the witness table; persist the last-known-good snapshot so the dashboard can answer what the system believed yesterday without needing yesterday's process to be alive. A rejection is an event the system keeps, not one it papers over.&lt;/p&gt;

&lt;p&gt;{&lt;br&gt;
  "&lt;a class="mentioned-user" href="https://dev.to/context"&gt;@context&lt;/a&gt;": "&lt;a href="https://schema.org" rel="noopener noreferrer"&gt;https://schema.org&lt;/a&gt;",&lt;br&gt;
  "@type": "FAQPage",&lt;br&gt;
  "mainEntity": [&lt;br&gt;
    {&lt;br&gt;
      "@type": "Question",&lt;br&gt;
      "name": "How do you verify audit evidence from multiple publishers?",&lt;br&gt;
      "acceptedAnswer": {&lt;br&gt;
        "@type": "Answer",&lt;br&gt;
        "text": "Adopt one canonical signed statement payload: a fixed envelope of kernel version plus payload plus signature that every publisher fills in identically. One verifier parses the envelope, recomputes the digest over the canonical predicate bytes, and checks the signature against the public key named in the envelope. Format evolution shows up as a signature failure; tampering shows up as a signature failure; the two are no longer distinguishable to a silent verifier."&lt;br&gt;
      }&lt;br&gt;
    },&lt;br&gt;
    {&lt;br&gt;
      "@type": "Question",&lt;br&gt;
      "name": "What is a signed statement payload in an audit pipeline?",&lt;br&gt;
      "acceptedAnswer": {&lt;br&gt;
        "@type": "Answer",&lt;br&gt;
        "text": "A fixed envelope containing a typed statement, a payload digest, and a signature bound to that digest. Every publisher fills in the same envelope; payloads below the kernel can be any shape the publisher chooses. The signature covers the digest plus the envelope, so a single-byte payload mutation or a canonicalization difference on whitespace fails the check while legitimate history still verifies."&lt;br&gt;
      }&lt;br&gt;
    },&lt;br&gt;
    {&lt;br&gt;
      "@type": "Question",&lt;br&gt;
      "name": "What should a verifier do when an audit row fails verification?",&lt;br&gt;
      "acceptedAnswer": {&lt;br&gt;
        "@type": "Answer",&lt;br&gt;
        "text": "Move the rejected envelope verbatim into a holding area; keep the last accepted row for that subject in the witness table; persist the last-known-good snapshot so the dashboard can answer what the system believed yesterday without needing yesterday's process to be alive. A rejection is an event the system keeps, not one it papers over."&lt;br&gt;
      }&lt;br&gt;
    }&lt;br&gt;
  ]&lt;br&gt;
}&lt;/p&gt;

&lt;h2&gt;
  
  
  Related Posts
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="//{{&lt;%20ref%20"&gt;}}"&amp;gt;Name the Single Authority in a Daily Pipeline&lt;/a&gt; - Why one named owner per concern beats committee routing in a daily pipeline&lt;/li&gt;
&lt;li&gt;
&lt;a href="//{{&lt;%20ref%20"&gt;}}"&amp;gt;Three-Pass Settlement for a Recurring AI Pipeline Contract&lt;/a&gt; - How three passes (producer, settlement, audit) settle a recurring AI pipeline contract&lt;/li&gt;
&lt;li&gt;
&lt;a href="//{{&lt;%20ref%20"&gt;}}"&amp;gt;Exit Zero Is Not Push OK&lt;/a&gt; - A cron that reports OK after a failed git rebase is a load-bearing lie&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>audit</category>
      <category>evidence</category>
      <category>supplychain</category>
      <category>signedpayload</category>
    </item>
    <item>
      <title>Park a Migration Without Losing the Work</title>
      <dc:creator>Jeremy Longshore</dc:creator>
      <pubDate>Sat, 10 Oct 2026 11:30:13 +0000</pubDate>
      <link>https://dev.to/jeremy_longshore/park-a-migration-without-losing-the-work-5443</link>
      <guid>https://dev.to/jeremy_longshore/park-a-migration-without-losing-the-work-5443</guid>
      <description>&lt;p&gt;A migration that ships by reaching the default branch with a green badge still has a second decision hidden inside it: do we point production at it today, in a week, or ever. When the build and the deploy are welded together, that second decision leaks into rollback theater even when the right answer is to wait months. The pattern this post calls publish-and-park splits them on purpose, so parking an owner can be the cutover's moment instead of its point of no return.&lt;/p&gt;

&lt;p&gt;A Flask-to-Django refactor in &lt;code&gt;catalyst-onboarding&lt;/code&gt; (a public onboarding repo) reached that fork in October. The Django stack was complete, gated, and pushed to two feature branches as atomic fast-forwards. Production stayed on Flask. The work stayed on the feature branches where it had landed. The pattern that made the cutover reversible is what this post is about.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the publish-and-park pattern for software migrations?
&lt;/h2&gt;

&lt;p&gt;The publish-and-park pattern splits a refactor's build from its cutover so parking the work is reversible. The gate suite runs on the merged commit on a feature branch with no production mutation, and a dated "do not cut over" record lands in the repo. Production stays on the old stack until a separate decision points it at the new one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern, in one sentence
&lt;/h2&gt;

&lt;p&gt;Push the refactor to its feature branches as an atomic fast-forward, run the full gate suite on the merged commit, ship nothing to production, write the explicit "do not cut over" record, and open a successor planning repo so the parked work has somewhere forward to go.&lt;/p&gt;

&lt;p&gt;Five invariants make a parked refactor recoverable instead of abandoned:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Atomic push to a feature branch.&lt;/strong&gt; The work lands as one fast-forward commit. No merge commit. No force push. The branch tip is byte-identical to the merged result.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full gate suite on the merged commit.&lt;/strong&gt; Unit, integration, contract, coverage audit, security, and an independent reviewer all run against the tip. Green badge is recorded on the commit, not on a moving branch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No production mutation at ship time.&lt;/strong&gt; The cutover is a separate operation, owned by a separate decision, executed on a separate day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An explicit "do not cut over" document.&lt;/strong&gt; A dated decision record lives at the top of the repo. Future readers find it before they touch the parked work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A successor repo.&lt;/strong&gt; The parked work has somewhere forward to go. It is not a dead branch waiting for a code archaeologist.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When all five hold, parking an owner is reversible. When any one is missing, parking an owner is abandonment in slow motion.&lt;/p&gt;

&lt;h2&gt;
  
  
  The build-up trail
&lt;/h2&gt;

&lt;p&gt;The Flask to Django migration was not a weekend. It was six stacked PRs landed over a single day, each one a thin slice of the new lifecycle. The numbering gap between #113 and #115 is an unrelated CI-infra fix that interleaved between the intake and review slices; it is not part of the migration stack and it would be a mistake to read the migration as seven PRs because of it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PR #113 feat(intake):     add native Django intake and durable Temporal mail
PR #115 feat(review):     run native stages with durable evidence and bounded admission
PR #116 feat(deploy):     promote authenticated immutable image archives
PR #117 feat(profile):    persist native review profiles and durable CRM projection
PR #118 feat(ci):         record complete shard phase and worker timings
PR #119 feat(onboarding): add Select partner footers and repair bounded CI setup
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each PR carried its own gate subset. The full suite ran only when the last commit on the immutable milestone branch was in place. That ordering matters: a partial green badge on a partial stack is not a parking artifact. It is a half-truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  The atomic push
&lt;/h2&gt;

&lt;p&gt;The push that turned the work into a parking artifact looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# After the full makecheck suite passed locally and was re-run by CI on the same SHA:&lt;/span&gt;
git push origin feat/django-agreement-lifecycle:feat/django-agreement-lifecycle &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--ff-only&lt;/span&gt;
git push origin feat/django-agreement-notifications:feat/django-agreement-notifications &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--ff-only&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two pushes, both fast-forward only. The remote tip is byte-identical to the local tip. No merge commit hides what was actually shipped. No force push re-writes history. A reader six months from now can run &lt;code&gt;git log&lt;/code&gt; on either branch and reconstruct the exact build state.&lt;/p&gt;

&lt;p&gt;The atomic property is the load-bearing one. A merge commit would let a future force-push hide reverts. A non-fast-forward push would break anyone who already has the branch checked out. Neither breaks the next person, but both break the parking pattern's audit trail.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gate suite on the merged commit
&lt;/h2&gt;

&lt;p&gt;The full gate suite ran on the immutable milestone commit, not on the moving feature branches:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;makecheck terminal PASS 598.411s
  1746 unit + 1 skip
  2764 SQLite + 160 skip
  436 pilot
  static + security

PG terminal 4659 PASS / 14 skip / 4673 collected
  1262.33s test / 1271.060s whole

v2 coverage audit PASS
Independent reviewer APPROVE &amp;lt;commit&amp;gt; ONLY
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reviewer line is the one most teams skip. A human (or a strictly scoped reviewer agent) confirms the gate results match the code. Without that confirmation, a green badge is a CI vendor's promise. With it, a green badge is a recorded decision by the team that owns the codebase. The parked refactor is recoverable because the gate results are pinned to the commit, not to the workflow run.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cutover stays on Flask
&lt;/h2&gt;

&lt;p&gt;Production stayed on Flask main. The Django stack was pushed to its feature branches, and the PR was converted to draft. A full-ref backup bundle was written to &lt;code&gt;~/backups/catalyst-onboarding-preserve-*&lt;/code&gt; the following morning; the path-embedded date is for the snapshot job that wrote it, not for the work itself. The bundle is belt-and-suspenders: the feature branches are the primary artifact, the backup is the secondary. Neither mutates production.&lt;/p&gt;

&lt;p&gt;This is the second decision, the one the publish-and-park pattern splits out. The decision is not "is the work done." It is "is this the right moment to point production at it." The two questions have different owners, different evidence, and different reversibility costs. Welding them together is what makes migrations feel like a one-way door.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not the obvious approach?
&lt;/h2&gt;

&lt;p&gt;The obvious approach is to merge the Django stack to &lt;code&gt;main&lt;/code&gt;, let CI go green, and cut over. Three reasons that fails:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The badge is on the branch, not the commit.&lt;/strong&gt; A green badge on &lt;code&gt;main&lt;/code&gt; after the merge tells you the gate suite passed against a moving target. Anyone who has reset &lt;code&gt;main&lt;/code&gt; to undo the merge invalidates the badge retroactively. The parked refactor has no such exposure: the gate results are pinned to the commit SHA on the feature branch, and that SHA does not move.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cutting over is a single failure domain.&lt;/strong&gt; If the cutover breaks, you do not know whether the bug is in the new code, in the deploy step, or in the rollback. With the cutover decoupled, the new code can sit on a feature branch for months while the deploy step is rehearsed separately. The two failure domains stay separate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The "do not cut over" decision has nowhere to live.&lt;/strong&gt; A merge to &lt;code&gt;main&lt;/code&gt; is the act of cutting over, by definition. There is no document you can write that says "this is on &lt;code&gt;main&lt;/code&gt; but do not use it" without breaking the social contract that &lt;code&gt;main&lt;/code&gt; is production. The decision record has to land before the merge, which means the merge has to be deferred, which means the work has to live on a feature branch.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The pattern is not a workaround for a missing feature. It is the only shape that makes parking an owner recoverable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "do not cut over" record
&lt;/h2&gt;

&lt;p&gt;The decision record is a PR, not a wiki page and not a numbered-record entry. PR #124 in &lt;code&gt;catalyst-onboarding&lt;/code&gt; carries the dated decision that the Django stack stays parked and Flask stays in production. The PR body names the commit, links the full-ref bundle, lists the gates that passed, and names the successor repo.&lt;/p&gt;

&lt;p&gt;The PR is the artifact a future reader finds before they touch the parked work. A wiki page rots. A numbered-record entry (each repo's dated folder of decision history) can be moved, renamed, or lost when the numbering scheme changes. A PR lives at the top of the repo's pull request list until it is closed, and its merge commit lives in &lt;code&gt;main&lt;/code&gt; history forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  The successor repo
&lt;/h2&gt;

&lt;p&gt;The parked refactor is not abandoned because it has a successor: &lt;code&gt;intent-solutions-io/catalyst-onboarding-v2&lt;/code&gt;, opened the following morning with the beads (a tracked task record in the repo's issue database) prefix &lt;code&gt;catalyst-v2&lt;/code&gt; and no code. The planning-phase foundation exists so the parked work has somewhere forward to go.&lt;/p&gt;

&lt;p&gt;A parked refactor without a successor is a dead branch. A parked refactor with a successor is a deferred plan. The successor repo does not have to contain the parked work; it has to contain the plan for the parked work. The Django stack on &lt;code&gt;feat/django-agreement-lifecycle&lt;/code&gt; is the parking artifact; &lt;code&gt;catalyst-onboarding-v2&lt;/code&gt; is the forward home.&lt;/p&gt;

&lt;h2&gt;
  
  
  What survives a routine branch cleanup
&lt;/h2&gt;

&lt;p&gt;The parked refactor survived a repo sweep that removed 34 temporary worktrees and deleted 25 merged branches. The work lives on the published feature branches and on &lt;code&gt;main&lt;/code&gt; history, not in disposable worktrees or speculative branches.&lt;/p&gt;

&lt;p&gt;Atomic fast-forward pushes land on real branches with real tips. A repo sweep that removes temporary worktrees and merged branches cannot touch those tips without changing the published history. That is a consequence of invariant one: durable artifacts outlive routine cleanup.&lt;/p&gt;

&lt;h2&gt;
  
  
  What did not change the parked work
&lt;/h2&gt;

&lt;p&gt;In the 24 hours after the parking decision, a parallel hardening window merged fifteen fixes in &lt;code&gt;claude-code-plugins&lt;/code&gt;: patched advisories in shipped MCP servers, upgraded &lt;code&gt;actions/cache&lt;/code&gt;, &lt;code&gt;actions/github-script&lt;/code&gt;, &lt;code&gt;codecov-action&lt;/code&gt;, the cosign installer, &lt;code&gt;download-artifact&lt;/code&gt;, &lt;code&gt;pnpm/action-setup&lt;/code&gt;, and Vitest. None of those PRs touched the parked Django work. None of them needed to. The decoupling held under a load that would have broken a single-stack deployment.&lt;/p&gt;

&lt;p&gt;The point is not that the hardening window was easy. The point is that it was possible without a deploy coordination meeting. Two repos, two lifecycles, no shared cutover.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Push the refactor as &lt;code&gt;--ff-only&lt;/code&gt; to a feature branch, not as a merge to &lt;code&gt;main&lt;/code&gt;.&lt;/strong&gt; The atomic property is what makes the gate suite pin to a commit instead of a moving branch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run the full gate suite on the merged commit, including the independent reviewer.&lt;/strong&gt; A CI badge is the vendor's promise; a reviewer-approved badge is the team's promise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the "do not cut over" decision as a PR, and open a successor planning repo.&lt;/strong&gt; The decision has to live where the next reader will find it, and the parked work has to have somewhere forward to go.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Django stack in &lt;code&gt;catalyst-onboarding&lt;/code&gt; is parked. The work is not abandoned. The five invariants held, and parking the cutover became a reversible decision instead of a one-way door.&lt;/p&gt;

&lt;p&gt;The parked Django work and the dated parking decision: &lt;a href="https://github.com/intent-solutions-io/catalyst-onboarding/pull/120" rel="noopener noreferrer"&gt;intentsolutions pull 120&lt;/a&gt; and &lt;a href="https://github.com/intent-solutions-io/catalyst-onboarding/pull/124" rel="noopener noreferrer"&gt;pull 124&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How do you park a refactor without abandoning it?
&lt;/h3&gt;

&lt;p&gt;Five invariants hold: the work lands as an atomic fast-forward push, the gate suite pins to the merged commit (including an independent reviewer), no production mutation happens at ship time, a "do not cut over" decision record lands in the repo, and a successor repo gives the parked work somewhere forward to go. Drop any one and the parked work becomes abandoned in slow motion.&lt;/p&gt;

&lt;h3&gt;
  
  
  When should you delay a production cutover after a green merge?
&lt;/h3&gt;

&lt;p&gt;Delay when a green badge sits on a moving branch, when the cutover and rollback share one failure domain, or when the "do not cut over" decision has nowhere to live. Parking keeps the new code on a feature branch with a pinned badge, so the deploy step can be rehearsed independently before the swap.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why not merge to main and cut over later?
&lt;/h3&gt;

&lt;p&gt;A green badge on main sits on a moving target that any reset invalidates retroactively, a cutover welded to a merge is one failure domain, and a "do not cut over" decision has no place to live when main is the act of cutting over. Parking keeps work, gate results, and decision on separate durable artifacts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related Posts
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://startaitools.com/posts/three-pass-settlement-recurring-ai-pipeline-contract/" rel="noopener noreferrer"&gt;three-pass-settlement-recurring-ai-pipeline-contract&lt;/a&gt;: A recurring AI pipeline contract project settles only after a third evidence pass that closes every named gap with a concrete file and pins every changed gate manifest. Sister pattern: durable artifacts settle the work; here they keep the work parked.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://startaitools.com/posts/name-the-single-authority-in-a-daily-pipeline/" rel="noopener noreferrer"&gt;name-the-single-authority-in-a-daily-pipeline&lt;/a&gt;: When a daily pipeline runs unobserved, every concern accumulates competing authorities; the cure is to name exactly one authority per concern. Decision-22 is the named authority here.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://startaitools.com/posts/link-public-surface-to-deployment-thesis/" rel="noopener noreferrer"&gt;link-public-surface-to-deployment-thesis&lt;/a&gt;: The publish-and-park pattern breaks the public-surface-to-deployment link on purpose; the cataloged post argues the opposite. Worth reading both before committing a migration.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&amp;lt;br&amp;gt;
{&amp;lt;br&amp;gt;
  &amp;amp;quot;&lt;a class="mentioned-user" href="https://dev.to/context"&gt;@context&lt;/a&gt;&amp;amp;quot;: &amp;amp;quot;&amp;lt;a href="https://schema.org"&amp;gt;https://schema.org&amp;lt;/a&amp;gt;&amp;amp;quot;,&amp;lt;br&amp;gt;
  &amp;amp;quot;@type&amp;amp;quot;: &amp;amp;quot;FAQPage&amp;amp;quot;,&amp;lt;br&amp;gt;
  &amp;amp;quot;mainEntity&amp;amp;quot;: [&amp;lt;br&amp;gt;
    {&amp;lt;br&amp;gt;
      &amp;amp;quot;@type&amp;amp;quot;: &amp;amp;quot;Question&amp;amp;quot;,&amp;lt;br&amp;gt;
      &amp;amp;quot;name&amp;amp;quot;: &amp;amp;quot;How do you park a refactor without abandoning it?&amp;amp;quot;,&amp;lt;br&amp;gt;
      &amp;amp;quot;acceptedAnswer&amp;amp;quot;: {&amp;lt;br&amp;gt;
        &amp;amp;quot;@type&amp;amp;quot;: &amp;amp;quot;Answer&amp;amp;quot;,&amp;lt;br&amp;gt;
        &amp;amp;quot;text&amp;amp;quot;: &amp;amp;quot;Five invariants hold: the work lands as an atomic fast-forward push, the gate suite pins to the merged commit (including an independent reviewer), no production mutation happens at ship time, a &amp;amp;#39;do not cut over&amp;amp;#39; decision record lands in the repo, and a successor repo gives the parked work somewhere forward to go. Drop any one and the parked work becomes abandoned in slow motion.&amp;amp;quot;&amp;lt;br&amp;gt;
      }&amp;lt;br&amp;gt;
    },&amp;lt;br&amp;gt;
    {&amp;lt;br&amp;gt;
      &amp;amp;quot;@type&amp;amp;quot;: &amp;amp;quot;Question&amp;amp;quot;,&amp;lt;br&amp;gt;
      &amp;amp;quot;name&amp;amp;quot;: &amp;amp;quot;When should you delay a production cutover after a green merge?&amp;amp;quot;,&amp;lt;br&amp;gt;
      &amp;amp;quot;acceptedAnswer&amp;amp;quot;: {&amp;lt;br&amp;gt;
        &amp;amp;quot;@type&amp;amp;quot;: &amp;amp;quot;Answer&amp;amp;quot;,&amp;lt;br&amp;gt;
        &amp;amp;quot;text&amp;amp;quot;: &amp;amp;quot;Delay when a green badge sits on a moving branch, when the cutover and rollback share one failure domain, or when the &amp;amp;#39;do not cut over&amp;amp;#39; decision has nowhere to live. Parking keeps the new code on a feature branch with a pinned badge, so the deploy step can be rehearsed independently before the swap.&amp;amp;quot;&amp;lt;br&amp;gt;
      }&amp;lt;br&amp;gt;
    },&amp;lt;br&amp;gt;
    {&amp;lt;br&amp;gt;
      &amp;amp;quot;@type&amp;amp;quot;: &amp;amp;quot;Question&amp;amp;quot;,&amp;lt;br&amp;gt;
      &amp;amp;quot;name&amp;amp;quot;: &amp;amp;quot;Why not merge to main and cut over later?&amp;amp;quot;,&amp;lt;br&amp;gt;
      &amp;amp;quot;acceptedAnswer&amp;amp;quot;: {&amp;lt;br&amp;gt;
        &amp;amp;quot;@type&amp;amp;quot;: &amp;amp;quot;Answer&amp;amp;quot;,&amp;lt;br&amp;gt;
        &amp;amp;quot;text&amp;amp;quot;: &amp;amp;quot;A green badge on main sits on a moving target that any reset invalidates retroactively, a cutover welded to a merge is one failure domain, and a &amp;amp;#39;do not cut over&amp;amp;#39; decision has no place to live when main is the act of cutting over. Parking keeps work, gate results, and decision on separate durable artifacts.&amp;amp;quot;&amp;lt;br&amp;gt;
      }&amp;lt;br&amp;gt;
    }&amp;lt;br&amp;gt;
  ]&amp;lt;br&amp;gt;
}&amp;lt;/p&amp;gt;
&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>releaseengineering</category>
      <category>devops</category>
      <category>cicd</category>
    </item>
    <item>
      <title>Team-Page Ordering Is a Release Decision</title>
      <dc:creator>Jeremy Longshore</dc:creator>
      <pubDate>Wed, 30 Sep 2026 11:30:11 +0000</pubDate>
      <link>https://dev.to/jeremy_longshore/team-page-ordering-is-a-release-decision-3el6</link>
      <guid>https://dev.to/jeremy_longshore/team-page-ordering-is-a-release-decision-3el6</guid>
      <description>&lt;p&gt;A new Legal Counsel profile landed at the bottom of the team page yesterday. Heather Johnson was the most consequential reader-facing contact on the page, and the page put her last. The fix shipped thirty-four minutes later as v4.4.1, because team-page ordering is a release decision, not a content decision, and treating it as one is what keeps a canonical team page authoritative across eight linked properties.&lt;/p&gt;

&lt;h2&gt;
  
  
  How should team pages be ordered?
&lt;/h2&gt;

&lt;p&gt;Team-page ordering should put the highest-value reader-facing contact first, after the founder. For a regulated AI practice that is Legal Counsel; for a design studio it might be the creative leads. The rule is short: name who readers are most likely to need, list them in that order, document the rule, and reorder when a new profile lands.&lt;/p&gt;

&lt;h2&gt;
  
  
  What shipped and what did not
&lt;/h2&gt;

&lt;p&gt;The intent-solutions-landing repo (the source of truth for the company team surface at intentsolutions.io/about/#team) shipped v4.4.0 at 18:49 CDT. The PR added seven team photos, a new &lt;code&gt;team.json&lt;/code&gt; roster, a &lt;code&gt;HeatherJohnson.astro&lt;/code&gt; profile page, and an updated footer link. The original reviewer caught the ordering at 19:18, while the v4.4.0 deploy was still propagating through the cache. The reviewer did not flag the order as "wrong." They flagged it as "wrong for the audience the page is about to serve." That distinction is what the rest of this post defends. The roster looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Jeremy Longshore"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Founder"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Opeyemi Adeyemi"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Operations"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Pablo Hernandez"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Engineering"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Max Sheahan"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Education"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Tim Yates"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"AI Engineering"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Tolulope Bakare"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Engineering"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Heather Johnson"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Legal Counsel"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The order is hire chronology: founder first, then each subsequent hire in the order they joined. Heather was the most recent hire, so she landed at the bottom. That is the second-most-reasonable default after alphabetical, and it is also wrong for a regulated AI practice where the counsel is the only person on the page a reader might need to reach for a privacy question, a partnership inquiry, or a compliance ask. Hire-chronology works on small teams where seniority is the only signal a reader has. Once a reader needs to find one specific person, it works against them.&lt;/p&gt;

&lt;p&gt;v4.4.1 reordered the file thirty-four minutes later:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Jeremy Longshore"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Founder"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Heather Johnson"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Legal Counsel"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Opeyemi Adeyemi"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Operations"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Pablo Hernandez"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Engineering"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Max Sheahan"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Education"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Tim Yates"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"AI Engineering"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Tolulope Bakare"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Engineering"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The diff is six lines moved up. The semantic change is that the page now answers the question a reader is most likely to bring to it, before they have to scroll past six other faces.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not the obvious approach?
&lt;/h2&gt;

&lt;p&gt;Two reasonable defaults existed. The first was alphabetical, which we considered and rejected as a draft. The second was the as-hired ordering we actually shipped: it is a content decision masquerading as a release decision. The team page is the canonical contact surface for the whole estate, and a reader does not care who joined first. They care who can answer their question.&lt;/p&gt;

&lt;p&gt;The third option, leaving the order to whoever maintains the page next, is the one that drifts. Within three months, someone adds a contractor. Then a summer intern. Then the intern moves up but stays where they are. The team page stops being authoritative and starts being a snapshot.&lt;/p&gt;

&lt;p&gt;We chose the role-priority ordering with an explicit rule: after the founder, the page lists people in the order a reader is most likely to need them. Legal first, then operations, then the engineering and education roles together, then everything else. The rule is short enough to fit on a sticky note, and short enough that a maintainer three jobs from now will still apply it.&lt;/p&gt;

&lt;p&gt;The role-priority rule is not universal. It is the right rule for a regulated AI practice where the counsel is the highest-value contact. It would be the wrong rule for a design studio where the reader wants to see the creative leads first. The point is not that role-priority is the answer. The point is that the answer is a rule with a name, not a one-off decision the original author made and forgot to document.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 30-minute footer sweep
&lt;/h2&gt;

&lt;p&gt;The same moment v4.4.0 landed, eight repos each picked up a new "Our team" footer link pointing at &lt;code&gt;intentsolutions.io/about/#team&lt;/code&gt;. The sweep window was 18:59 to 19:29 CDT, roughly thirty minutes. Six of those repos are public landing surfaces (&lt;code&gt;intent-demos&lt;/code&gt;, &lt;code&gt;omarchy&lt;/code&gt;, &lt;code&gt;diagnostic-pro&lt;/code&gt;, &lt;code&gt;startaitools&lt;/code&gt;, &lt;code&gt;hustle&lt;/code&gt;, &lt;code&gt;claude-code-plugins&lt;/code&gt;). The seventh is &lt;code&gt;intent-solutions-landing&lt;/code&gt; itself, whose team.json is the source of truth. The eighth is &lt;code&gt;now-lms&lt;/code&gt;, which also renders its own team profile and shipped the CI gate.&lt;/p&gt;

&lt;p&gt;The footer change per repo was mechanical. Each inserted a single line into its existing footer list, between "About" and the next item:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;li&amp;gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://intentsolutions.io/about/#team"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Our team&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&amp;lt;/li&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The insertions are byte-different per repo because each footer is a different framework (&lt;code&gt;site/index.html&lt;/code&gt; for the static HTML sites, &lt;code&gt;Footer.tsx&lt;/code&gt; for diagnostic-pro, &lt;code&gt;layouts/partials/footer.html&lt;/code&gt; for the Hugo build, &lt;code&gt;src/app/(public)/page.tsx&lt;/code&gt; for the Next.js hustle app, &lt;code&gt;marketplace/src/layouts/BaseLayout.astro&lt;/code&gt; for the Astro marketplace). The pattern is identical. Eight repos, one anchor, one canonical URL, one PR opened in each repo on the same minute by the same author.&lt;/p&gt;

&lt;p&gt;The mechanics matter because they explain why the ordering hotfix had to ship in the same hour. When footers sweep together, the page they point at becomes the contract for the whole estate. By 19:00 CDT, every footer in the estate was pointing at a team page that had Heather at the bottom. The hotfix is not a content correction. It is a release of a new public fact, and the new public fact is the ordering on the page those eight footers resolve to. Waiting twenty-four hours would have meant twenty-four hours of every visitor to any of those eight sites seeing a team page with a regulated-AI Legal Counsel buried seventh.&lt;/p&gt;

&lt;p&gt;The rollback path was open if v4.4.1 had broken the build: revert the &lt;code&gt;team.json&lt;/code&gt; diff, cut v4.4.2, redeploy. Eight footers would have re-resolved to the v4.4.0 ordering for the duration of the rollback. That is the cost of treating a team page like a release: you owe it a rollback, and a rollback is cheap when the change is six lines of JSON moved up.&lt;/p&gt;

&lt;p&gt;The alternative, batching the hotfix into the next deploy window, was the original instinct. Batching would have meant a coordinated revert of the eight footers, which is its own release with its own rollback path. The hotfix was cheaper than the batch. That is the math that decides whether something ships same-day or waits.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CI gate: check_estate_bar.py
&lt;/h2&gt;

&lt;p&gt;Once eight repos link to one URL, that URL becomes load-bearing in a way a single-site URL never is. A rename to &lt;code&gt;/team/&lt;/code&gt; or a layout shift that breaks the &lt;code&gt;#team&lt;/code&gt; anchor would silently rot every footer in the estate. The now-lms repo shipped &lt;code&gt;scripts/check_estate_bar.py&lt;/code&gt; in the same hour, a 79-line Python check that hits the canonical URL and verifies the anchor still resolves to a non-empty team roster. The gate is intentionally minimal. It does not parse the ordering. It does not check the photo count. It checks the load-bearing claim: this URL is a roster, and the roster is present.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ESTATE_TEAM_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DEFAULT_URL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;User-Agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;check-estate-bar/1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;soup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BeautifulSoup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;html.parser&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;anchor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;soup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;team&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;anchor&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FAIL: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; has no #team anchor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="n"&gt;members&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;anchor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attrs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data-team-member&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;members&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FAIL: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; #team has no roster&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PASS: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; #team has &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;members&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; members&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The check is a deterministic fact: the URL resolves, the anchor resolves, the roster is non-empty. It does not check ordering, because ordering is a release decision and we already made the release. It checks that the page is still authoritative. That is the gate's job.&lt;/p&gt;

&lt;p&gt;The check is wired into now-lms CI as a required step. The other seven repos do not run it yet; they each link to the same URL and would each fail in the same way if the page rotted. Wiring them all is the next bead.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this looks like at three months
&lt;/h2&gt;

&lt;p&gt;Without the CI gate, the page drifts. A volunteer contributor renames the anchor for SEO. A designer moves the roster into a tabbed component. An image lazy-loads wrong and the team section renders empty on the first paint. None of those changes break the page locally. They all break the eight footers silently, because the footers still resolve to a 200 and the link still works, but the reader who clicks "Our team" lands on a page that does not answer their question.&lt;/p&gt;

&lt;p&gt;We have lived this pattern before on the same estate. A blog post URL moved in March and four linkers rotted quietly until a reader complained in July. An "Our partners" anchor moved under a redesign in April and the cross-link from the partner portal went to a blank section for the better part of a quarter. The pattern is consistent: a small, semantic change to a canonical page is invisible at the page itself and silently broken at every other surface that links to it.&lt;/p&gt;

&lt;p&gt;The CI gate catches two of those three failure modes. The third is a renderer bug, and we have not yet decided whether the gate should expand to cover it. For now, the gate is a presence check, not a quality check. That is the right scope for a 79-line script. Expanding it into a full render check means either a headless browser (slow, flaky, expensive) or a snapshot regression (cheap, brittle, drifts with any visual change). The presence check is the floor that catches the worst class of bug at zero ongoing cost.&lt;/p&gt;

&lt;p&gt;The other thing a CI gate does is give the next maintainer permission to push back. Without the gate, the only signal that "the team page is load-bearing" is the eight footers, and a maintainer three jobs from now will not count the footers. With the gate, the dependency is a line in CI: "this URL must resolve to a roster." That is a contract someone can read, and contracts outlive institutional memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "estate anchor" actually means
&lt;/h2&gt;

&lt;p&gt;An estate anchor is a URL that other properties depend on as a contract rather than as a reference. A reference is a link a reader clicks if they are curious. A contract is a link that is supposed to resolve to a specific thing, and the thing it resolves to is part of what the linking page is asserting.&lt;/p&gt;

&lt;p&gt;The footer sweep turned &lt;code&gt;intentsolutions.io/about/#team&lt;/code&gt; from a reference into a contract. Before the sweep, the page was a page. After the sweep, eight footers were asserting "click here and find our team." The assertion is the contract. If a reader clicks and does not find a team, the footer has lied to them, and the lie is silent because the footer still resolves to a 200.&lt;/p&gt;

&lt;p&gt;Most estates cross the reference-to-contract line without noticing. There is a docs page that points at a pricing surface, a marketing page that references a changelog, and a partner directory that cross-links a contact page. Each link is small. In aggregate, each is a contract. The CI gate is the moment a team acknowledges the contract exists.&lt;/p&gt;

&lt;p&gt;The cost of acknowledging the contract is small. The check is 79 lines. The wiring is one line per repo. The maintenance is one URL to remember. The benefit is that the next push to a wired repo that renames the anchor or moves the roster into a tabbed component gets a red CI run and a one-sentence error explaining why their change broke the estate. The remaining seven footers still depend on the URL, so wiring the gate into every one of them is the next bead; until that ships, a rename on intent-solutions-landing itself will only be caught on the next gated push. That is enough to make the right decision the easy one.&lt;/p&gt;

&lt;p&gt;The next time a similar pattern comes up on the estate (a new partner joins the directory, a new pricing tier appears, a new compliance doc publishes) the discipline is already there. Same-day fix if the canonical surface changes. CI gate before the third link points at it. The team page was the first anchor in the estate to get the full treatment. The pattern is now load-bearing for every anchor that comes after it.&lt;/p&gt;

&lt;p&gt;That is the lesson. A team page is a page. A team page that eight footers depend on is a contract. The cost of treating it like one is one extra script and one extra hour.&lt;/p&gt;

&lt;h2&gt;
  
  
  Also shipped
&lt;/h2&gt;

&lt;p&gt;The rest of the day's work that does not fit the team-page finding:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;now-lms&lt;/code&gt; (#125) shipped the estate-bar check plus the matching team profile rendering for Heather, with the same ordering rule as the canonical surface.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;intent-os&lt;/code&gt; shipped the whiteglove distribution packet (3516 lines, nine print PDFs) and the matching ownership verification records.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;hustle&lt;/code&gt; v3.3.7 and &lt;code&gt;startaitools&lt;/code&gt; v1.21.14 auto-bumped on the same day as the team-link fix landing in each repo.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;omarchy&lt;/code&gt; refreshed its public plugin facts from the GitHub API (generated content only, no semantic change).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The discipline, generalized
&lt;/h2&gt;

&lt;p&gt;The team page is one canonical surface among several on this estate. The partner directory is another. The pricing page is a third. The published-changelog index is a fourth. Every one of them is a page that other repos link to, and every one of them has the same problem: a small, semantic change is invisible at the page and silently broken everywhere else.&lt;/p&gt;

&lt;p&gt;The team-page fix generalizes to a rule: when a single canonical surface becomes load-bearing across the estate, treat every edit to it as a release. The release gets a version bump, a changelog entry, a same-day hotfix window, and a CI gate that proves the URL still resolves to what the linkers expect. None of that is novel. It is the same discipline every API publishes under. The point is that a marketing page deserves the same treatment the moment it becomes an estate anchor, and most pages become estate anchors without anyone noticing.&lt;/p&gt;

&lt;p&gt;The reason this matters for a team page specifically is that the failure mode is invisible to the team and visible only to the reader. The team sees the page render correctly in their browser. The reader sees a page where the person they came for is seventh. The cost of getting the ordering wrong is paid by people who never show up in your bug tracker.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Treat team-page ordering as a release decision. Pick a rule short enough for the next maintainer to apply without asking, document it once, and reorder on the day a new profile lands.&lt;/li&gt;
&lt;li&gt;When you turn a single-site URL into an estate anchor, ship a deterministic check that the URL still resolves to the thing eight footers expect. 79 lines of Python is enough for the first gate.&lt;/li&gt;
&lt;li&gt;Order the canonical team page by the question a reader is most likely to bring, not by hire date and not by alphabet. The page is a contact surface, not a roster. The exact ordering depends on what the reader is most likely to want; the discipline is that the ordering is named, defended, and written down next to the file.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why should team-page ordering be treated as a release decision?
&lt;/h3&gt;

&lt;p&gt;A team page becomes load-bearing the moment multiple footer links point at it. Once eight footers assert "click here and find our team," the ordering is a public fact the estate is publishing, not a content choice. A same-day hotfix is cheaper than batching, because batching would mean a coordinated revert of every footer that points at the page. The page is a contract that other sites depend on, and contracts get release discipline.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is an estate anchor?
&lt;/h3&gt;

&lt;p&gt;An estate anchor is a URL that other properties depend on as a contract rather than as a reference. A reference is a link a reader clicks if curious; a contract is a link that should resolve to a specific thing, and the thing it resolves to is part of what the linking page is asserting. If the anchor breaks, every linking surface silently lies because the link still returns a 200 but the reader does not find what they were promised.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do you keep a team page authoritative across multiple sites?
&lt;/h3&gt;

&lt;p&gt;Ship a deterministic CI gate that hits the canonical URL and verifies the anchor still resolves to a non-empty roster. The check_estate_bar.py gate is 79 lines of Python and runs as a required CI step in &lt;code&gt;now-lms&lt;/code&gt;. Wiring it into every other linking repo is the part that catches silent drift when a designer moves the roster into a tabbed component or someone renames the anchor.&lt;/p&gt;

&lt;p&gt;{&lt;br&gt;
  "&lt;a class="mentioned-user" href="https://dev.to/context"&gt;@context&lt;/a&gt;": "&lt;a href="https://schema.org" rel="noopener noreferrer"&gt;https://schema.org&lt;/a&gt;",&lt;br&gt;
  "@type": "FAQPage",&lt;br&gt;
  "mainEntity": [&lt;br&gt;
    {&lt;br&gt;
      "@type": "Question",&lt;br&gt;
      "name": "Why should team-page ordering be treated as a release decision?",&lt;br&gt;
      "acceptedAnswer": {&lt;br&gt;
        "@type": "Answer",&lt;br&gt;
        "text": "A team page becomes load-bearing the moment multiple footer links point at it. Once eight footers assert 'click here and find our team,' the ordering is a public fact the estate is publishing, not a content choice. A same-day hotfix is cheaper than batching, because batching would mean a coordinated revert of every footer that points at the page. The page is a contract that other sites depend on, and contracts get release discipline."&lt;br&gt;
      }&lt;br&gt;
    },&lt;br&gt;
    {&lt;br&gt;
      "@type": "Question",&lt;br&gt;
      "name": "What is an estate anchor?",&lt;br&gt;
      "acceptedAnswer": {&lt;br&gt;
        "@type": "Answer",&lt;br&gt;
        "text": "An estate anchor is a URL that other properties depend on as a contract rather than as a reference. A reference is a link a reader clicks if curious; a contract is a link that should resolve to a specific thing, and the thing it resolves to is part of what the linking page is asserting. If the anchor breaks, every linking surface silently lies because the link still returns a 200 but the reader does not find what they were promised."&lt;br&gt;
      }&lt;br&gt;
    },&lt;br&gt;
    {&lt;br&gt;
      "@type": "Question",&lt;br&gt;
      "name": "How do you keep a team page authoritative across multiple sites?",&lt;br&gt;
      "acceptedAnswer": {&lt;br&gt;
        "@type": "Answer",&lt;br&gt;
        "text": "Ship a deterministic CI gate that hits the canonical URL and verifies the anchor still resolves to a non-empty roster. The check_estate_bar.py gate is 79 lines of Python and runs as a required CI step in &lt;code&gt;now-lms&lt;/code&gt;. Wiring it into every other linking repo is the part that catches silent drift when a designer moves the roster into a tabbed component or someone renames the anchor."&lt;br&gt;
      }&lt;br&gt;
    }&lt;br&gt;
  ]&lt;br&gt;
}&lt;/p&gt;

&lt;h2&gt;
  
  
  Related Posts
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://startaitools.com/posts/use-the-primitive-not-the-patch-codeql/" rel="noopener noreferrer"&gt;Use the Primitive, Not the Patch&lt;/a&gt; (the same-day-sweep discipline, applied to a security primitive rather than a team page).&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://startaitools.com/posts/scope-federated-tailscale-trust-to-auth-keys/" rel="noopener noreferrer"&gt;Scope Federated Tailscale Trust to Auth Keys&lt;/a&gt; (federating one identity surface across the same eight-repo footprint, with the same anchor-page release posture).&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://startaitools.com/posts/pin-the-installer-and-add-a-renewer/" rel="noopener noreferrer"&gt;Pin the Installer and Add a Renewer&lt;/a&gt; (a named rule plus a CI gate that catches silent drift, the same shape as &lt;code&gt;check_estate_bar.py&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>architecture</category>
      <category>devops</category>
      <category>releaseengineering</category>
    </item>
    <item>
      <title>Tailscale Federated Trust: Scope to Auth Keys, Not All</title>
      <dc:creator>Jeremy Longshore</dc:creator>
      <pubDate>Sun, 27 Sep 2026 11:30:11 +0000</pubDate>
      <link>https://dev.to/jeremy_longshore/tailscale-federated-trust-scope-to-auth-keys-not-all-3gm1</link>
      <guid>https://dev.to/jeremy_longshore/tailscale-federated-trust-scope-to-auth-keys-not-all-3gm1</guid>
      <description>&lt;h1&gt;
  
  
  Tailscale Federated Trust: Scope to Auth Keys, Not All
&lt;/h1&gt;

&lt;p&gt;A Tailscale federated trust hands an external identity (in my case, GitHub Actions OIDC) an API token whose reach is governed by a &lt;code&gt;scope&lt;/code&gt; field on the trust record. The default is &lt;code&gt;['all']&lt;/code&gt;, which gives that external identity every API the underlying token can reach. If the token is account-admin, your CI runner now is too.&lt;/p&gt;

&lt;p&gt;I had 18 deploy trusts wired that way. Every one of them creates a short-lived auth key to land a build on the VPS over Tailscale SSH. None of them need anything else. On 2026-09-25 I narrowed every one to &lt;code&gt;scope:['auth_keys']&lt;/code&gt;, re-encrypted the SOPS secrets bundle, and proved it on a real &lt;code&gt;jeremylongshore.com&lt;/code&gt; production deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a federated trust actually is
&lt;/h2&gt;

&lt;p&gt;Tailscale's term for "let an external identity mint a short-lived Tailscale API token" is a &lt;strong&gt;federated trust&lt;/strong&gt;: a record that maps an OIDC subject (or a SAML subject, or a workload identity federation principal) onto a Tailscale API token. The trust is created once by an admin. The external identity uses it on every CI run to mint a fresh, narrowly-scoped auth key. The fresh auth key is what actually gets used to bring a runner onto the tailnet.&lt;/p&gt;

&lt;p&gt;The trust does not move packets. It only mints keys. The runner uses the key to register a node, the node runs the deploy, the node goes away when the key expires.&lt;/p&gt;

&lt;p&gt;A trust record stored on Tailscale's side looks roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"trust_abc123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"provider"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"github"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"subject"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"repo:intent-solutions-io/jeremylongshore.com:environment:production"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scopes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"all"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"token_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tk_xyz789"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"created_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-04-12T18:22:01Z"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;scopes&lt;/code&gt; field is what the trust is allowed to ask the token to do. It is not what the runner gets to do once the runner is on the tailnet. Those are two different layers, and a &lt;code&gt;['all']&lt;/code&gt; trust means the trust itself is allowed to ask the token to do anything, on every CI run, until someone narrows it.&lt;/p&gt;

&lt;p&gt;The shape of the trust matters because the trust gets a token, and the token has scope. If the trust was created with an account-admin token and &lt;code&gt;scope:['all']&lt;/code&gt;, the OIDC subject inherits that token's full reach every time the trust is exercised. The fact that the runner itself only needs &lt;code&gt;auth_keys&lt;/code&gt; does not constrain what the trust can be tricked into minting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The default that bit me
&lt;/h2&gt;

&lt;p&gt;The Tailscale CLI's &lt;code&gt;tailnet set-trust-credentials&lt;/code&gt; (and the web UI's equivalent) defaults &lt;code&gt;scope&lt;/code&gt; to &lt;code&gt;['all']&lt;/code&gt; when you do not pass it. Most of the docs and most of the tutorials do not tell you that, because most of them are written for the "set it up once and forget it" case. My case was 18 separate repos, each one a deploy, each one created when I was hurrying. The hurrying is why they were all &lt;code&gt;['all']&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The whole change is one line in the trust config.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;   trust:
     provider: github
     subject: repo:intent-solutions-io/&amp;lt;repo&amp;gt;:environment:production
&lt;span class="gd"&gt;-    scopes:
-      - all
&lt;/span&gt;&lt;span class="gi"&gt;+    scopes:
+      - auth_keys
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The only change is &lt;code&gt;scopes: [auth_keys]&lt;/code&gt;. The OIDC subject is unchanged. The token it maps onto is unchanged. The runner still gets to mint an auth key. It no longer gets to mint anything else.&lt;/p&gt;

&lt;p&gt;In SOPS, the edit lands in &lt;code&gt;ops/host/secrets/secrets.prod.sops.yaml&lt;/code&gt;, gets re-encrypted with &lt;code&gt;sops update&lt;/code&gt;, and the next deploy reads the new scope. The trust itself is updated on Tailscale's side by a follow-up &lt;code&gt;tailscale set-trust-credentials --scopes=auth_keys&lt;/code&gt; call, which Tailscale reconciles with the SOPS record. The two stay in sync because both are produced from the same workflow. The &lt;code&gt;secrets.prod.sops.yaml&lt;/code&gt; re-encrypt was the headline change in 62f100aa; the per-trust config edits in &lt;code&gt;intent-os/ops/deploy/&lt;/code&gt; followed in the same commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is not paranoia
&lt;/h2&gt;

&lt;p&gt;Two reasons this matters beyond hygiene.&lt;/p&gt;

&lt;p&gt;First, the trust is the long-lived artifact. The token underneath can be rotated; the trust persists. If the trust says &lt;code&gt;['all']&lt;/code&gt;, every rotation of the token keeps the same reach. The scope is what does not get narrower over time without deliberate action. You set it on day one, then forget, and the day you forget is the failure mode.&lt;/p&gt;

&lt;p&gt;Second, the blast radius of an OIDC mis-substitution is bigger than people assume. GitHub Actions lets you constrain an environment's deployment branch, required reviewers, and protection rules, but it does not let you constrain which subject string the Actions OIDC token actually carries on a particular run if the workflow file is wrong. A workflow that accidentally trusts a fork PR will issue an OIDC token whose subject matches the trust's subject regex, and the trust will mint whatever its scope allows. &lt;code&gt;['all']&lt;/code&gt; is the worst-case scope for that failure mode. &lt;code&gt;['auth_keys']&lt;/code&gt; is the floor: even a misrouted OIDC exchange only ever produces a key, and the key lands on a tailnet ACL that the runner still has to walk through.&lt;/p&gt;

&lt;h2&gt;
  
  
  The proof
&lt;/h2&gt;

&lt;p&gt;The way I prove a trust change actually works is to run a real production deploy after the change. For &lt;code&gt;jeremylongshore.com&lt;/code&gt;, that is run 36106653875 on GitHub Actions. The deploy job:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Exchanges the OIDC token against the trust, asks for an auth key.&lt;/li&gt;
&lt;li&gt;Uses the auth key to bring the runner onto the tailnet as a node tagged &lt;code&gt;tag:ci&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;SSHes to the VPS as the deploy user, lands the build, exits.&lt;/li&gt;
&lt;li&gt;The auth key expires. The runner node drops off the tailnet.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The shape of step 1 looks like this in the deploy step (paraphrased, not the literal workflow):&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;Bring runner onto tailnet&lt;/span&gt;
  &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;ACTIONS_ID_TOKEN_REQUEST_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GH_OIDC_TOKEN }}&lt;/span&gt;
    &lt;span class="na"&gt;TS_AUTHKEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.authkey.outputs.key }}&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;curl -fsSL https://tailscale.com/install.sh | sh&lt;/span&gt;
    &lt;span class="s"&gt;sudo tailscale up --authkey="$TS_AUTHKEY" --hostname="gh-runner-$GITHUB_RUN_ID" --tags=tag:ci&lt;/span&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;SSH to VPS and deploy&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;appleboy/ssh-action@v1&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;intentsolutions&lt;/span&gt;
    &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deploy&lt;/span&gt;
    &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;cd /srv/jeremylongshore &amp;amp;&amp;amp; git pull --ff-only &amp;amp;&amp;amp; ./deploy.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The auth key is fetched first, separately, from Tailscale's federated trust exchange endpoint. The runner then uses that key for &lt;code&gt;tailscale up&lt;/code&gt;. The deploy step SSHes to the VPS as the deploy user, lands the build, exits. The runner node drops off when the auth key expires.&lt;/p&gt;

&lt;p&gt;After the &lt;code&gt;scopes: [auth_keys]&lt;/code&gt; change, run 36106653875 completed end-to-end in 41 seconds. The runner registered, the deploy shipped, the node tore down. The runner could reach nothing beyond an auth key. I verified that by calling &lt;code&gt;tailscale set-acl&lt;/code&gt; from inside the same job and getting this back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;tailscale set-acl &lt;span class="nt"&gt;--acl-file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/tmp/empty.json
&lt;span class="gp"&gt;Error: HTTP 403: scope_mismatch (trust_scopes=auth_keys;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;required&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;acl&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the proof. A deploy that does not try &lt;code&gt;set-acl&lt;/code&gt; does not notice the change; a misrouted OIDC exchange that does try it now fails closed at the trust, before the token is even consulted. The runner gets back a 403 it can log and alert on, which is what you want: a loud refusal, not a silent partial success.&lt;/p&gt;

&lt;p&gt;The full sweep was 18 deploy trusts across &lt;code&gt;intent-os&lt;/code&gt;, &lt;code&gt;braves-booth&lt;/code&gt;, &lt;code&gt;diagnostic-pro&lt;/code&gt;, &lt;code&gt;hustle&lt;/code&gt;, &lt;code&gt;startaitools&lt;/code&gt;, &lt;code&gt;tonsofskills&lt;/code&gt;, and the &lt;code&gt;jeremylongshore.com&lt;/code&gt; site. The Omarchy one needed an extra step: that trust had been deleted and had to be recreated with its immutable OIDC subject read from GitHub, not pasted in (fd2ba8f9). The other 17 were a &lt;code&gt;sops&lt;/code&gt; re-encrypt and a config edit. The deploy pattern is the same; only the OIDC subject string and the tag differ per trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not just leave scope at all and rely on ACL
&lt;/h2&gt;

&lt;p&gt;Because ACL is a different layer and they do different jobs. The trust scope controls what the trust can do at the trust-mint boundary. The tailnet ACL controls what an already-on-the-tailnet node can reach. If you leave &lt;code&gt;scope:['all']&lt;/code&gt; and rely on ACL to keep the runner pinned, you have moved the only thing protecting you into a layer that the runner bypasses before it gets there.&lt;/p&gt;

&lt;p&gt;For my deploys the ACL layer was also locked. The relevant &lt;code&gt;tag:ci&lt;/code&gt; rule from the tailnet ACL is roughly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
  "tagOwners": {"tag:ci": ["autogroup:admin"]},
  "acls": [
    {
      "action": "accept",
      "src": ["tag:ci"],
      "dst": ["tag:deploy-vps:22"]
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That rule pins &lt;code&gt;tag:ci&lt;/code&gt; to SSH-only access to the deploy user on the VPS. The runner cannot reach any other node, cannot reach the admin console, cannot make API calls that go through the tailnet. So even an unscoped &lt;code&gt;['all']&lt;/code&gt; trust would have been pinned down by ACL once the runner registered. The trust scope and the ACL are belt and suspenders, not belt or suspenders. I want both. The narrower trust scope is also the change that survives token rotation, which the ACL does not. Rotate the underlying Tailscale API token every 90 days and the trust scope stays &lt;code&gt;auth_keys&lt;/code&gt; automatically, because the scope is on the trust, not on the token.&lt;/p&gt;

&lt;p&gt;There is also a third layer people forget about: the Tailscale admin console itself. An account-admin API key minted through a &lt;code&gt;['all']&lt;/code&gt; trust can call &lt;code&gt;tailscale set-acl&lt;/code&gt; against the entire tailnet, including deleting or rewriting ACLs. That is not "the runner touches a node." That is "the runner rewrites the rule that decides what nodes can touch." The trust scope is what stops that path before it starts.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the work went
&lt;/h2&gt;

&lt;p&gt;The session digest for the day shows the bulk of the operator work landed with MiniMax M3 (the bead triage, the sweep agents, the evidence filing). The trust scope rewrite itself was a single line repeated 18 times, but the design of "what scope to set, on what trust, with what proof" came out of a back-and-forth with Claude Opus 5.5. The Omarchy trust recreation, which needed the immutable OIDC subject read from GitHub rather than pasted in, was Claude Fable 5.1 because the change was small and well-bounded.&lt;/p&gt;

&lt;p&gt;The estate-graph proof isolation work in PRs 696, 697, and 698 was reviewed by Claude Sonnet 5 and shipped clean. The collaboration score for the day (0.275, 64 failure-to-fix transitions) is high because the estate sweep agent did the bulk of the bead triage, which generated a lot of small fix loops. The trust scope change itself was almost all up-front design and a one-line config edit per trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  Also shipped
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;VPS liveness sweep cleared (intent-os #681); two standing findings closed and a parked row acknowledged.&lt;/li&gt;
&lt;li&gt;Estate-graph proof isolation merged (#696, #697, #698): operator queries are now read-only and the validation path is isolated from operator state.&lt;/li&gt;
&lt;li&gt;Production smoke suite rewritten for the marketplace-site redesign (f80cc59ad): 132/132 chromium+webkit green on the post-deploy e2e.&lt;/li&gt;
&lt;li&gt;CI now fails when plugin verification scores drift from the validator (df3fdae75).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;@intentsolutionsio/ccpi&lt;/code&gt; bumped to 3.0.0 on the Node 22 floor (not yet published to npm latest; publish is tag-triggered).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Use this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;When you create a Tailscale federated trust, set &lt;code&gt;scopes: [auth_keys]&lt;/code&gt; explicitly. The default is &lt;code&gt;['all']&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Treat the trust scope and the tailnet ACL as two separate layers. Lock both. A &lt;code&gt;['all']&lt;/code&gt; trust behind a tight ACL is still worse than a &lt;code&gt;['auth_keys']&lt;/code&gt; trust behind the same ACL, because the trust scope is what survives token rotation.&lt;/li&gt;
&lt;li&gt;After you change a trust's scope, prove the change on a real production run, not just a &lt;code&gt;tailscale set-trust-credentials --dry-run&lt;/code&gt;. The dry run tells you the API accepted the call; only a deploy tells you the OIDC exchange still resolves.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is a Tailscale federated trust?
&lt;/h3&gt;

&lt;p&gt;A Tailscale &lt;strong&gt;federated trust&lt;/strong&gt; is a record that maps an external identity (GitHub Actions OIDC, SAML, or a workload identity federation principal) onto a Tailscale API token. The trust is created once by an admin. The external identity uses it on every CI run to mint a fresh, narrowly-scoped auth key. The fresh auth key is what actually brings a runner onto the tailnet; the trust itself only mints keys.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does scope:['all'] actually grant on a federated trust?
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;scope&lt;/code&gt; field on the trust record governs what the trust can ask its mapped token to do. The default is &lt;code&gt;['all']&lt;/code&gt;, which gives the external identity every API that mapped token can reach. If that token is account-admin, the CI runner inherits that reach on every run, even if the runner itself only needs &lt;code&gt;auth_keys&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why not just rely on the tailnet ACL instead of narrowing the trust scope?
&lt;/h3&gt;

&lt;p&gt;The trust scope controls what the trust can do at the trust-mint boundary. The tailnet ACL controls what an already-on-the-tailnet node can reach. Leaving &lt;code&gt;scope:['all']&lt;/code&gt; and relying on ACL moves the only thing protecting you into a layer the runner bypasses before it gets there. A &lt;code&gt;['all']&lt;/code&gt; trust behind a tight ACL is still worse than a &lt;code&gt;['auth_keys']&lt;/code&gt; trust behind the same ACL, because the trust scope is what survives token rotation.&lt;/p&gt;

&lt;p&gt;{&lt;br&gt;
  "&lt;a class="mentioned-user" href="https://dev.to/context"&gt;@context&lt;/a&gt;": "&lt;a href="https://schema.org" rel="noopener noreferrer"&gt;https://schema.org&lt;/a&gt;",&lt;br&gt;
  "@type": "FAQPage",&lt;br&gt;
  "mainEntity": [&lt;br&gt;
    {&lt;br&gt;
      "@type": "Question",&lt;br&gt;
      "name": "What is a Tailscale federated trust?",&lt;br&gt;
      "acceptedAnswer": {&lt;br&gt;
        "@type": "Answer",&lt;br&gt;
        "text": "A Tailscale federated trust is a record that maps an external identity (GitHub Actions OIDC, SAML, or a workload identity federation principal) onto a Tailscale API token. The trust is created once by an admin. The external identity uses it on every CI run to mint a fresh, narrowly-scoped auth key. The fresh auth key is what actually brings a runner onto the tailnet."&lt;br&gt;
      }&lt;br&gt;
    },&lt;br&gt;
    {&lt;br&gt;
      "@type": "Question",&lt;br&gt;
      "name": "What does scope:['all'] actually grant on a federated trust?",&lt;br&gt;
      "acceptedAnswer": {&lt;br&gt;
        "@type": "Answer",&lt;br&gt;
        "text": "The scope field on the trust record governs what the trust can ask its mapped token to do. The default is ['all'], which gives the external identity every API that mapped token can reach. If that token is account-admin, the CI runner inherits that reach on every run, even if the runner itself only needs auth_keys."&lt;br&gt;
      }&lt;br&gt;
    },&lt;br&gt;
    {&lt;br&gt;
      "@type": "Question",&lt;br&gt;
      "name": "Why not just rely on the tailnet ACL instead of narrowing the trust scope?",&lt;br&gt;
      "acceptedAnswer": {&lt;br&gt;
        "@type": "Answer",&lt;br&gt;
        "text": "The trust scope controls what the trust can do at the trust-mint boundary. The tailnet ACL controls what an already-on-the-tailnet node can reach. Leaving scope:['all'] and relying on ACL moves the only thing protecting you into a layer the runner bypasses before it gets there. A ['all'] trust behind a tight ACL is still worse than a ['auth_keys'] trust behind the same ACL, because the trust scope is what survives token rotation."&lt;br&gt;
      }&lt;br&gt;
    }&lt;br&gt;
  ]&lt;br&gt;
}&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://startaitools.com/posts/pin-the-installer-and-add-a-renewer/" rel="noopener noreferrer"&gt;Pin the Installer and Add a Renewer&lt;/a&gt;: the day's other big thread; pinning a published dependency and adding a cert renewer in the same pass.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://startaitools.com/posts/working-is-not-proven/" rel="noopener noreferrer"&gt;Working Is Not Proven&lt;/a&gt;: "every claim needs a shipped source and an executable proof"; the deploy run 36106653875 is exactly that proof.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://startaitools.com/posts/how-the-same-deploy-pattern-crossed-four-repos-in-one-week/" rel="noopener noreferrer"&gt;How the Same Deploy Pattern Crossed Four Repos in One Week&lt;/a&gt;: the deploy pattern at small scale; this post is what it looks like when the same pattern lands 18 times.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>cicd</category>
      <category>security</category>
      <category>tailscale</category>
      <category>githubactions</category>
    </item>
    <item>
      <title>Three Gates on Standard-User BitLocker State Observation</title>
      <dc:creator>Jeremy Longshore</dc:creator>
      <pubDate>Tue, 15 Sep 2026 11:30:13 +0000</pubDate>
      <link>https://dev.to/jeremy_longshore/three-gates-on-standard-user-bitlocker-state-observation-1h5l</link>
      <guid>https://dev.to/jeremy_longshore/three-gates-on-standard-user-bitlocker-state-observation-1h5l</guid>
      <description>&lt;h2&gt;
  
  
  The day started with a P1 on merge day
&lt;/h2&gt;

&lt;p&gt;Closing intent-blue-gold Epic 1+2 was supposed to be the easy part. The hard parts were the constraints, set in DOC-075/A03: a standard-user account on ARM64 Windows 11 gets PERMISSION_DENIED from the PowerShell Secure Boot and BitLocker surfaces. WMI is out. COM is out. Elevation is out. Anything that requires a privileged token is out. Everything since then has been about carving the safe space inside that envelope. BitLocker state observation, the volume census, the file inventory, the controller topology, the mapping registry, the criticality capture: each one is a new module boundary in the same constrained envelope.&lt;/p&gt;

&lt;p&gt;I ran Codex (GPT-5.6 Sol) read-only against the day's merge candidates on purpose. Eight sessions, 132 turns, 952 minutes on intent-blue-gold over the past week. The audit was not a polish pass. It was a closure gate. It surfaced three P1 blockers. Two of them were exactly the kind of failure the audit exists to catch.&lt;/p&gt;

&lt;p&gt;The three gates that have to hold together are: the observer contract, the bounded native provider, and the fixture-boundary authorization. Gate 3 was the one that slipped. The audit caught it on three failure modes at once, two of them the kind the audit exists to catch, and the post is mostly about what the audit found.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gate 1: the observer contract
&lt;/h2&gt;

&lt;p&gt;The observer contract is the seam between the constrained envelope and any code that wants to read BitLocker state. Anything that talks to &lt;code&gt;GetFveLogicalVolumeInformation&lt;/code&gt;-equivalent surfaces has to enter through this contract. Here is the seam in Go.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// BitlockerObserver is the standard-user entry point. It returns&lt;/span&gt;
&lt;span class="c"&gt;// State with the OBSERVED bit set only when the call ran through&lt;/span&gt;
&lt;span class="c"&gt;// the helper, the helper authenticated, and the fixture boundary&lt;/span&gt;
&lt;span class="c"&gt;// held. Anything else is OBSERVED=false, even if the bytes look&lt;/span&gt;
&lt;span class="c"&gt;// reasonable.&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;BitlockerObserver&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Observe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vol&lt;/span&gt; &lt;span class="n"&gt;VolumeRef&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// State has three meaningful bits:&lt;/span&gt;
&lt;span class="c"&gt;// OBSERVED  = we ran the native probe and got a result&lt;/span&gt;
&lt;span class="c"&gt;// CACHED    = we have a non-stale prior observation for this vol&lt;/span&gt;
&lt;span class="c"&gt;// UNAVAIL  = the probe ran but the OS returned access denied or&lt;/span&gt;
&lt;span class="c"&gt;//            empty; we treat that as no information, not as "off"&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;State&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Volume&lt;/span&gt;    &lt;span class="n"&gt;VolumeRef&lt;/span&gt;
    &lt;span class="n"&gt;Status&lt;/span&gt;    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="c"&gt;// protect_state strings, NOT raw OS bytes&lt;/span&gt;
    &lt;span class="n"&gt;Observed&lt;/span&gt;  &lt;span class="kt"&gt;bool&lt;/span&gt;
    &lt;span class="n"&gt;Cached&lt;/span&gt;    &lt;span class="kt"&gt;bool&lt;/span&gt;
    &lt;span class="n"&gt;Unavail&lt;/span&gt;   &lt;span class="kt"&gt;bool&lt;/span&gt;
    &lt;span class="n"&gt;Source&lt;/span&gt;    &lt;span class="n"&gt;ProbeSource&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The interesting bit is &lt;code&gt;Observed&lt;/code&gt;. A naive port of a privileged tool returns the OS bytes regardless of provenance. That is exactly what gets you a "we have a BitLocker reading" row that came from a non-authorized helper or from an arbitrary temp root. The observer contract refuses to label the result Observed unless every gate cleared. The byte string is still in &lt;code&gt;Status&lt;/code&gt;, but downstream code is supposed to ignore &lt;code&gt;Status&lt;/code&gt; when &lt;code&gt;Observed&lt;/code&gt; is false. That is the contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gate 2: the bounded native provider
&lt;/h2&gt;

&lt;p&gt;Gate 2 is the provider that does the actual syscall. Two constraints matter: it has to run in the standard-user process without UAC, without token impersonation, and without a helper service that takes elevation. It also has to refuse any caller who has not presented the authenticated helper boundary. The provider entry point.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;NativeProvider&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;QueryVolume&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vol&lt;/span&gt; &lt;span class="n"&gt;VolumeRef&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;helperBoundary&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Authorized&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vol&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c"&gt;// Fail closed: an unauthorized caller gets the same answer&lt;/span&gt;
        &lt;span class="c"&gt;// they would get from an unauthenticated probe. No detail,&lt;/span&gt;
        &lt;span class="c"&gt;// no exception type, no "near miss" leak.&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Volume&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;vol&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Unavail&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;ErrBoundaryClosed&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;vol&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsFixtureAuthorized&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c"&gt;// The fixture boundary: even an authenticated helper can&lt;/span&gt;
        &lt;span class="c"&gt;// not cross outside the manifest-verified fixture. This&lt;/span&gt;
        &lt;span class="c"&gt;// is gate 3, but we enforce it at the provider entry to&lt;/span&gt;
        &lt;span class="c"&gt;// avoid a second code path that needs the same guarantee.&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Volume&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;vol&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Unavail&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;ErrOutsideFixture&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;queryViaSyscall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vol&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;queryViaSyscall&lt;/code&gt; is the boring part. &lt;code&gt;FsctlEnumerateVolumeInformation&lt;/code&gt; with a &lt;code&gt;BITLOCKER_INFORMATION&lt;/code&gt; header, &lt;code&gt;IOCTL_VOLUME_GET_VOLUME_DISK_EXTENTS&lt;/code&gt; for layout, fall through to the registry path when the helper IPC returned access denied. The thing worth noticing is what comes BEFORE that: the two early returns. An unauthorized caller and an out-of-fixture caller both get &lt;code&gt;Unavail: true&lt;/code&gt; with &lt;code&gt;Observed: false&lt;/code&gt;. They get the same answer a probe that genuinely failed would have given. There is no signal that says "we are not telling you because of X."&lt;/p&gt;

&lt;p&gt;That is fail-closed by construction. The audit did not flag this gate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gate 3: the fixture-boundary authorization (the failure)
&lt;/h2&gt;

&lt;p&gt;Gate 3 was the failure. The file-inventory CLI is supposed to admit only the manifest-verified fixture. The fixture is a checked-in blob with a hash pinned in &lt;code&gt;fixture.manifest&lt;/code&gt;. Anything else is a synthetic root, and synthetic roots are test-only. The CLI was supposed to refuse a synthetic root at admission. Here is what the gate looks like.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Authorized root check. The CLI calls this before any work.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;FixtureAuth&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Authorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;manifest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;LoadManifest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="c"&gt;// no manifest, no authorization, fail closed&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;pinned&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;manifest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CanonicalRoot&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;pathEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pinned&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c"&gt;// Synthetic roots are valid in tests, but the production&lt;/span&gt;
        &lt;span class="c"&gt;// CLI is not a test. Production admission refuses them.&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ErrSyntheticRootRejected&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;manifest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HashMatches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c"&gt;// The pinned root is the right path but the bytes don't&lt;/span&gt;
        &lt;span class="c"&gt;// match. That is a fixture drift, also a hard refusal.&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ErrFixtureDrift&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is what the code is supposed to do. Codex found that &lt;code&gt;prototypes/blue-windows/cmd/file-inventory/main.go:54-76&lt;/code&gt; accepts any directory containing a user-created marker and then scans its &lt;code&gt;Desktop&lt;/code&gt;, &lt;code&gt;Documents&lt;/code&gt;, &lt;code&gt;Downloads&lt;/code&gt;, and &lt;code&gt;Pictures&lt;/code&gt; children. A caller can add the marker under a live profile and receive filenames, sizes, and paths in the receipt. DOC-076:20-26 says the checked-in fixture is the only root authorized, and the authorization's synthetic-root-only constraint is supposed to bind production admission. The code did not bind it. The CLI was test-friendly and production-broken in the same binary.&lt;/p&gt;

&lt;p&gt;The minimal fix is to call &lt;code&gt;FixtureAuth.Authorize&lt;/code&gt; from &lt;code&gt;main.go:54-76&lt;/code&gt; before any traversal. The CLI keeps its test ergonomics because the test harness can register a synthetic root with the test-only &lt;code&gt;RegisterSynthetic&lt;/code&gt; hook, but the production binary refuses anything except the canonical fixture or a manifest-verified immutable fixture. That is what is shipping today. The audit caught it on merge day, the fix is small, and the constraint is the right one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The TOCTOU reparse replacement (gate 3, the second finding)
&lt;/h2&gt;

&lt;p&gt;The same audit flagged a separate failure in &lt;code&gt;scan.go:185-216&lt;/code&gt;: path-based &lt;code&gt;ReadDir&lt;/code&gt;, &lt;code&gt;Lstat&lt;/code&gt;, then recursive &lt;code&gt;ReadDir&lt;/code&gt;. A validated directory can be swapped for a reparse point mid-traversal. That is a classic TOCTOU path for read-only enumeration to follow attacker-controlled targets. The standard fix on Linux is &lt;code&gt;O_DIRECTORY | O_NOFOLLOW&lt;/code&gt;, then keep the file descriptor and enumerate from it. On Windows the equivalent is to open with &lt;code&gt;FILE_FLAG_OPEN_REPARSE_POINT&lt;/code&gt; and the standard share-mode set, capture the handle, and re-validate the reparse status from the handle before walking into children. The sketch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Linux side: open the directory once, hold the fd, refuse to walk&lt;/span&gt;
&lt;span class="c"&gt;// a reparse change observed post-open.&lt;/span&gt;
&lt;span class="n"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;unix&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unix&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;O_RDONLY&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;unix&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;O_DIRECTORY&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;unix&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;O_NOFOLLOW&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;unix&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c"&gt;// Snapshot the reparse status at open time, then compare against&lt;/span&gt;
&lt;span class="c"&gt;// each entry's d_type before deciding to descend. (The full&lt;/span&gt;
&lt;span class="c"&gt;// re-validation lives in the production helper; the sketch is the&lt;/span&gt;
&lt;span class="c"&gt;// shape, not the implementation.)&lt;/span&gt;
&lt;span class="n"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;unix&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Getdents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Windows side is heavier (handle capture plus &lt;code&gt;DeviceIoControl(FSCTL_GET_REPARSE_POINT)&lt;/code&gt; per candidate), but the principle is the same: validate once, keep the handle, refuse a reparse change observed after open. The PR that landed the fix also added a unit test that creates a reparse point mid-traversal under a debugger breakpoint and asserts the scan refuses to descend. That is the kind of test that earns its place by reproducing the failure it is supposed to prevent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The non-reparse offline file (the stale classifier, gate 3 by association)
&lt;/h2&gt;

&lt;p&gt;The third finding is closer to a stale classifier than a security boundary failure, but it is still P1 because the classifier decides what downstream code does with the result. &lt;code&gt;prototypes/blue-windows/cloudstate/observe_windows.go:41-42&lt;/code&gt; returns &lt;code&gt;StateLocal&lt;/code&gt; whenever &lt;code&gt;FILE_ATTRIBUTE_REPARSE_POINT&lt;/code&gt; is absent. The &lt;code&gt;OFFLINE&lt;/code&gt;, &lt;code&gt;RECALL_ON_OPEN&lt;/code&gt;, and &lt;code&gt;RECALL_ON_DATA_ACCESS&lt;/code&gt; flags are examined later in &lt;code&gt;classifyWindowsState&lt;/code&gt; at lines 73-74, which is unreachable for non-reparse files. A OneDrive file in the demoted state, a storage-tier-pinned file, or anything else marked offline without a reparse point will register as local. That is wrong, and the cost is that downstream criticality capture will under-count those files as low-risk.&lt;/p&gt;

&lt;p&gt;The fix is to evaluate the offline/recall mask before the non-reparse &lt;code&gt;StateLocal&lt;/code&gt; return. A non-reparse file with &lt;code&gt;FILE_ATTRIBUTE_OFFLINE&lt;/code&gt; should classify as &lt;code&gt;CLOUD_ONLY&lt;/code&gt; (or &lt;code&gt;UNKNOWN/unavailable&lt;/code&gt; if we want to be conservative on recall flags we cannot yet test on every Windows SKU). The native probe needs a regression test for a non-reparse offline file. That is also shipping today.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not the obvious approach?
&lt;/h2&gt;

&lt;p&gt;The obvious approach for standard-user state observation is to shell out to &lt;code&gt;manage-bde -status&lt;/code&gt; and parse the text. That is what every PowerShell script in the wild does. It works on a privileged user. It fails open on a standard user: the binary runs, every operation returns "Access is denied," and the parser does not know that is "no data" rather than "BitLocker off." The classifier ends up reporting everything as off.&lt;/p&gt;

&lt;p&gt;The other obvious approach is to elevate. &lt;code&gt;manage-bde&lt;/code&gt; is the right tool for the privileged envelope. Elevation is not available here. DOC-075/A03 is explicit: the standard-user constraint is the envelope, not a temporary compromise. WMI stays out. COM stays out. The shape that fits inside the envelope is the bounded native provider, the observer contract, and the fixture-boundary authorization. They are less convenient than &lt;code&gt;manage-bde&lt;/code&gt;. They are what works inside the envelope.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adjacent finding on the NVMe health-log parser
&lt;/h2&gt;

&lt;p&gt;The audit surfaced a separate bug in the NVMe health-log parser. The parser used wrong offsets: bytes 160-175 instead of 176-191. &lt;code&gt;NVME_HEALTH_INFO_LOG&lt;/code&gt; places UnsafeShutdowns at 160-175 and MediaErrors at 176-191, and the parser was rejecting every byte from 216 onward (which is also where valid sensors 7 and 8 live). Fail-closed against false healthy in some cases but capable of false critical/unknown and missing the documented media-error blocker. Not a P1 for this epic, but it is the next bead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Also shipped
&lt;/h2&gt;

&lt;p&gt;Other repos did work today, briefly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;omarchy-plugins-bluegold-email-fix&lt;/strong&gt;: closed FAQ panels 35 and 36, the adversarial buyer FAQ set. Buyer FAQ 36 is the version that survives a buyer who has already read the documentation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;intent-solutions-landing-bluegold-faq&lt;/strong&gt;: shipped v3.2.0 with a mobile conversion baseline (PR #54). The site finally renders the conversion CTA cleanly on a 360-pixel viewport.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;intent-demos&lt;/strong&gt;: launched the proof-first demos catalog (fd6198e) and fixed site previews on touch screens (6dc710d).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;contributing-clanker&lt;/strong&gt;: closed regression-state isolation and review-synthetic-alert tolerance fixes (#78 and #79).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;jeremylongshore-dot-github&lt;/strong&gt;: bound VPS deploys to the caller revision (#5). Deploys now refuse to land if the action SHA does not match the commit being deployed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;omarchy-listening-post-entry&lt;/strong&gt;: recorded approvals and bound deploy revisions (#19), launched the Perception product foundation (#18).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;braves&lt;/strong&gt;: a beads sync remote update only.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of those are the day's center. The day's center is intent-blue-gold, the three gates, and the audit that caught two of them slipping before they shipped to production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related posts
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="//{{&lt;%20ref%20"&gt;}}"&amp;gt;Auditing Written Claims Against Their Artifacts&lt;/a&gt;: the second-review pattern across two unrelated domains. Today's Codex audit instantiates it on the BitLocker surface.&lt;/li&gt;
&lt;li&gt;
&lt;a href="//{{&lt;%20ref%20"&gt;}}"&amp;gt;Sealing a 168-bead planning graph took three reviews and a seven-seat council&lt;/a&gt;: planning under multi-review pressure, the day before.&lt;/li&gt;
&lt;li&gt;
&lt;a href="//{{&lt;%20ref%20"&gt;}}"&amp;gt;Adversarial review before team rollout&lt;/a&gt;: the adversarial pattern that the Codex audit instantiates on this surface.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>windows</category>
      <category>security</category>
      <category>bitlocker</category>
      <category>go</category>
    </item>
    <item>
      <title>Perception Is On the Way, on Its Own Domain</title>
      <dc:creator>Jeremy Longshore</dc:creator>
      <pubDate>Sun, 13 Sep 2026 11:30:14 +0000</pubDate>
      <link>https://dev.to/jeremy_longshore/perception-is-on-the-way-on-its-own-domain-53g6</link>
      <guid>https://dev.to/jeremy_longshore/perception-is-on-the-way-on-its-own-domain-53g6</guid>
      <description>&lt;p&gt;The day's pattern is the redirection. One Omarchy entry repo launched Perception's&lt;br&gt;
web/API surface and shipped a customer preview page to &lt;code&gt;perception.intentsolutions.io&lt;/code&gt;,&lt;br&gt;
but the live deployment was a coming-soon page rather than the full paid product.&lt;br&gt;
The smaller deployment was what the day's infrastructure could close. The same&lt;br&gt;
shift showed up on a second repo, where a 1,262-line master blueprint commit and&lt;br&gt;
17 documents filed into a flat chronological index paired with a 15-child Beads&lt;br&gt;
decomposition that waits on itself by design. Two products, two repos, one shared&lt;br&gt;
shape: ship the version the constraints can actually carry, and name the gap that&lt;br&gt;
forced the smaller scope.&lt;/p&gt;

&lt;h2&gt;
  
  
  omarchy-listening-post-entry: Perception's web and API surface, then the customer preview
&lt;/h2&gt;

&lt;p&gt;The repo shipped Perception in two commits on 2026-09-11. The first commit&lt;br&gt;
(&lt;code&gt;12ba7df feat: establish Perception web and API product&lt;/code&gt;, 00:17 -0600) was&lt;br&gt;
46 files, 5,894 insertions: a new &lt;code&gt;web/&lt;/code&gt; Vite + React app, a new &lt;code&gt;api/&lt;/code&gt; Fastify&lt;br&gt;
service with Dockerfile, and a &lt;code&gt;packages/perception-contract/&lt;/code&gt; shared schema&lt;br&gt;
package that the web app and the API both depend on. The contract carries the&lt;br&gt;
Perception v1 snapshot schema, with &lt;code&gt;snapshot.id&lt;/code&gt;, &lt;code&gt;snapshot.staleAfter&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;snapshot.account&lt;/code&gt;, &lt;code&gt;snapshot.topics&lt;/code&gt;, and a &lt;code&gt;snapshot.signals&lt;/code&gt; array typed&lt;br&gt;
to &lt;code&gt;lane&lt;/code&gt; ∈ &lt;code&gt;incident | release | pricing | engineering&lt;/code&gt;. The web app imports&lt;br&gt;
the types directly from &lt;code&gt;@listening-post/perception-contract&lt;/code&gt;; the API and the&lt;br&gt;
contract tests share the same module.&lt;/p&gt;

&lt;p&gt;The second commit (&lt;code&gt;d992ce5 feat: prepare Perception customer preview&lt;/code&gt;,&lt;br&gt;
17:15 -0600) was 102 files, 5,077 insertions and added the rest of the&lt;br&gt;
customer-facing surface: a &lt;code&gt;PublicExperience.tsx&lt;/code&gt; that owns the public marketing&lt;br&gt;
site, four policy pages (privacy, terms, acceptable-use, support), a passwordless&lt;br&gt;
auth flow with magic links via &lt;code&gt;consumeMagicLink&lt;/code&gt; and &lt;code&gt;requestMagicLink&lt;/code&gt;,&lt;br&gt;
account views, customer-journey tests, and a &lt;code&gt;mailer.ts&lt;/code&gt; plus &lt;code&gt;customer-messages.ts&lt;/code&gt;&lt;br&gt;
plus &lt;code&gt;entitlements.ts&lt;/code&gt; plus &lt;code&gt;billing-webhook.test.ts&lt;/code&gt; that gate access on Lemon&lt;br&gt;
Squeezy subscription state. The schema gained a stricter signals shape (the&lt;br&gt;
array now requires &lt;code&gt;id, title, url, source, lane, relevance, resolved, quiet,&lt;br&gt;
matchedTopicIds, publishedAt, read&lt;/code&gt; per item with &lt;code&gt;additionalProperties: false&lt;/code&gt;).&lt;br&gt;
The manifest was updated to point &lt;code&gt;perceptionEndpoint&lt;/code&gt; at&lt;br&gt;
&lt;code&gt;https://api.perception.intentsolutions.io&lt;/code&gt; and the web &lt;code&gt;public/CNAME&lt;/code&gt; file&lt;br&gt;
was written with &lt;code&gt;perception.intentsolutions.io&lt;/code&gt;. PRODUCT.md, DESIGN.md,&lt;br&gt;
VERIFICATION.md, SECURITY.md, and CHANGELOG.md were authored together.&lt;/p&gt;

&lt;p&gt;That is the day's product surface. The live deploy is where the day's&lt;br&gt;
reversal starts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the live deploy is a coming-soon page
&lt;/h2&gt;

&lt;p&gt;The original direction arrived as a single line near the end of the day's&lt;br&gt;
session: "merge the prs commit push etc deploy the website i meed it online&lt;br&gt;
i meed it online i give u commercial and legal approval." That authorization&lt;br&gt;
covered the full paid-product release: the API at &lt;code&gt;api.perception.intentsolutions.io&lt;/code&gt;,&lt;br&gt;
the web app, the Lemon Squeezy checkout flow, and the policy pages as binding&lt;br&gt;
production terms.&lt;/p&gt;

&lt;p&gt;The agent surfaced four gaps before changing live traffic. GitHub Pages was&lt;br&gt;
not enabled on the repo. The three &lt;code&gt;PERCEPTION_*&lt;/code&gt; repository variables were&lt;br&gt;
absent. The VPS deploy secrets were absent. The custom domains for both&lt;br&gt;
&lt;code&gt;perception.intentsolutions.io&lt;/code&gt; and &lt;code&gt;api.perception.intentsolutions.io&lt;/code&gt; were&lt;br&gt;
failing DNS resolution. PRODUCT.md was explicit that price, billing interval,&lt;br&gt;
refund window, legal operator, governing law, and final checkout URL were&lt;br&gt;
deployment-owned open decisions. The customer copy labelled itself "not yet&lt;br&gt;
production terms."&lt;/p&gt;

&lt;p&gt;The redirect that followed read, verbatim: "dude i just want perception.intentsolutions.io&lt;br&gt;
to work as a site people click to know its on the way i posted it in discord."&lt;br&gt;
That is the day's reversal. The full release was deferred, not failed: the&lt;br&gt;
agent was told the goal had shrunk to a single page that says the product is&lt;br&gt;
on its way and links to the GitHub repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  GitHub Pages → VPS at intent-solutions
&lt;/h2&gt;

&lt;p&gt;The smaller deployment still needed a live URL. The agent's first attempt was&lt;br&gt;
GitHub Pages: a &lt;code&gt;gh-pages&lt;/code&gt; branch with the standalone coming-soon HTML and a&lt;br&gt;
Porkbun CNAME at 300s TTL. Pages was configured with Actions as its source,&lt;br&gt;
&lt;code&gt;PERCEPTION_DEMO_MODE=true&lt;/code&gt; was the only repo variable set, and the&lt;br&gt;
checkout/API variables were deliberately left unset so the preview could not&lt;br&gt;
masquerade as a paid production launch. DNS propagated. Pages started serving&lt;br&gt;
the page over HTTP. The certificate did not arrive.&lt;/p&gt;

&lt;p&gt;After the certificate stall, the agent switched to the VPS path that was&lt;br&gt;
already under control. Caddy was healthy on the VPS, passwordless sudo was&lt;br&gt;
available, and the same static page was served from there. The first Caddy&lt;br&gt;
reload failed closed because the new access-log file did not yet exist with&lt;br&gt;
Caddy ownership. The reload was isolated and retried after creating only&lt;br&gt;
that log file with the service's established permissions. The second reload&lt;br&gt;
succeeded. Porkbun was then asked to move the &lt;code&gt;perception&lt;/code&gt; CNAME from GitHub&lt;br&gt;
Pages to the VPS A record, with an automatic rollback path armed during the&lt;br&gt;
cutover. Caddy issued the Let's Encrypt certificate on retry. The local DNS&lt;br&gt;
resolver on the workstation had a stale negative cache for the new A record,&lt;br&gt;
which the agent bypassed to confirm the public origin.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;perception.intentsolutions.io&lt;/code&gt; now serves the coming-soon page over HTTPS&lt;br&gt;
from the VPS. The full paid product remains local and un-pushed; the open&lt;br&gt;
beads for price, billing interval, refund window, legal operator, governing&lt;br&gt;
law, and final checkout URL still own those decisions.&lt;/p&gt;

&lt;h2&gt;
  
  
  intent-blue-gold: 17 Stage 0 documents plus an Epic 0 Beads decomposition
&lt;/h2&gt;

&lt;p&gt;The second repo's day was a documentation discipline pass plus a Beads&lt;br&gt;
decomposition. The first commit (&lt;code&gt;d9cd691 docs: replace product summary with&lt;br&gt;
canonical MVP execution blueprint&lt;/code&gt;) added 1,262 insertions across a single&lt;br&gt;
file, the master blueprint that owns the BLUE → GOLD → BLUE → WELCOME&lt;br&gt;
sequence. The second commit (&lt;code&gt;a23517b docs: file Stage 0 repository records&lt;/code&gt;)&lt;br&gt;
filed 17 source documents (&lt;code&gt;PRODUCT.md&lt;/code&gt;, &lt;code&gt;V1-SCOPE.md&lt;/code&gt;, &lt;code&gt;RISK-REGISTER.md&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;THREAT-MODEL.md&lt;/code&gt;, &lt;code&gt;MIGRATION-CONTRACT.md&lt;/code&gt;, &lt;code&gt;COMPATIBILITY.md&lt;/code&gt;, &lt;code&gt;RESEARCH.md&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;AI-STRATEGY.md&lt;/code&gt;, &lt;code&gt;DECISIONS.md&lt;/code&gt;, &lt;code&gt;MASTER-BLUEPRINT.md&lt;/code&gt;, the &lt;code&gt;docs/&lt;/code&gt; subfolder&lt;br&gt;
documents) into a flat chronological &lt;code&gt;000-docs/&lt;/code&gt; tree using a global sequence&lt;br&gt;
plus an &lt;code&gt;AGENTS.md&lt;/code&gt; at root, a &lt;code&gt;CODEX-HANDOFF.md&lt;/code&gt; update, and a&lt;br&gt;
&lt;code&gt;000-docs/000-INDEX.md&lt;/code&gt; that names the order.&lt;/p&gt;

&lt;p&gt;The Beads side was harder. The first Beads init auto-adopted the unrelated&lt;br&gt;
Intent Eval Dolt database (1,499 imported issues inherited from a remote the&lt;br&gt;
agent had not selected). The first reset attempt succeeded technically but the&lt;br&gt;
inherited graph was still present in the local Dolt history. The second reset&lt;br&gt;
moved the &lt;code&gt;.beads/&lt;/code&gt; directory to &lt;code&gt;/tmp/intent-blue-gold-beads-XQ7n2p/imported-beads&lt;/code&gt;&lt;br&gt;
and &lt;code&gt;/tmp/intent-blue-gold-beads-reset-xXV59D/imported-beads&lt;/code&gt; and started&lt;br&gt;
with &lt;code&gt;dolt.local-only: true&lt;/code&gt; and &lt;code&gt;backup.enabled: false&lt;/code&gt; so no remote could be&lt;br&gt;
adopted. The third init came up clean (zero issues, no Dolt remote, no&lt;br&gt;
Intent Eval reference).&lt;/p&gt;

&lt;p&gt;On the clean workspace the master blueprint was decomposed into five epics,&lt;br&gt;
and Epic 0 (engineering foundation, CI/CD, repository quality gates) was&lt;br&gt;
decomposed into 15 bounded Beads children using three Luna-high subagents.&lt;br&gt;
The decomposition surfaced a cross-slice overlap (the CI orchestration bead&lt;br&gt;
and the bootstrap bead both claimed the local command), which was narrowed so&lt;br&gt;
CI consumes the bootstrap-owned gate rather than reimplementing it. The&lt;br&gt;
final DAG has nine dependency waves with maximum parallelism of three, a&lt;br&gt;
single onboarding/exit gate at the apex, and selective Epic 1 integration:&lt;br&gt;
research and safety-analysis work remains runnable, schema/fixture hardening&lt;br&gt;
waits for Epic 0 validators, experiment operationalization waits for workflow&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;destructive-test-isolation controls, and the controlled BLUE → GOLD → BLUE
proof waits for the final Epic 0 exit gate.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Beads config (&lt;code&gt;dolt.local-only: true&lt;/code&gt;, &lt;code&gt;backup.enabled: false&lt;/code&gt;, no remote)&lt;br&gt;
was committed, the issue graph was validated with &lt;code&gt;bd lint&lt;/code&gt; and &lt;code&gt;bd dep cycles&lt;/code&gt;&lt;br&gt;
(zero cycles, zero lint warnings), and the worktree was committed, pushed,&lt;br&gt;
and merged to &lt;code&gt;main&lt;/code&gt; as PR #1 with &lt;code&gt;delete_branch_on_merge&lt;/code&gt; flipped from&lt;br&gt;
&lt;code&gt;false&lt;/code&gt; to &lt;code&gt;true&lt;/code&gt; first via the GitHub repository API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Also shipped
&lt;/h2&gt;

&lt;p&gt;The comehomealabama journal (a separate Astro surface, not startaitools)&lt;br&gt;
published &lt;code&gt;ac9b179 post(journal): Orange Beach is three different decisions.&lt;br&gt;
Know which one you're making.&lt;/code&gt;, automated by &lt;code&gt;scripts/journal/mandy-land.sh&lt;/code&gt;&lt;br&gt;
after the voice and fair-housing lints both passed. That post lives on the&lt;br&gt;
Mandy machine and does not enter the startaitools archive.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it cost
&lt;/h2&gt;

&lt;p&gt;Two redirects, one certificate stall, one Caddy fail-closed reload, two&lt;br&gt;
failed Beads resets, and a 1,262-insertion master blueprint rewrite that&lt;br&gt;
became the canonical reference. The Perception web/API surface and the&lt;br&gt;
Beads Epic 0 graph both landed in their final form on the first attempt&lt;br&gt;
inside their own repositories. The deploy paths and the Beads remote selection&lt;br&gt;
each needed a second try. The customer-facing launch is now bounded by what&lt;br&gt;
HTTPS, Porkbun, and a Caddy file permission can carry, not by what the&lt;br&gt;
product itself can carry.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related Posts
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://startaitools.com/posts/the-same-mission-on-two-surfaces-in-one-day/" rel="noopener noreferrer"&gt;The Same Mission on Two Surfaces in One Day&lt;/a&gt;, 2026-09-10's pair of initial scaffolds and the marketplace claim ledger pattern that precedes the Perception customer preview&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://startaitools.com/posts/a-953-line-skill-entry-fits-the-budget-again/" rel="noopener noreferrer"&gt;A 953-Line Skill Entry Fits the Budget Again&lt;/a&gt;, 2026-09-09's split-and-pull-only refactor and the same-day 17-repo audit-harness refresh&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://startaitools.com/posts/hardening-a-marketplace-in-one-day/" rel="noopener noreferrer"&gt;Hardening a Marketplace in One Day&lt;/a&gt;, 2026-09-08's C44 installable-tree gate and the portable install integrity contract that ship next to the contributor workflow&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>omarchy</category>
      <category>releaseengineering</category>
      <category>deployment</category>
      <category>beads</category>
    </item>
  </channel>
</rss>
