<?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</title>
    <description>The most recent home feed on DEV Community.</description>
    <link>https://dev.to</link>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed"/>
    <language>en</language>
    <item>
      <title>Reject Duplicate JSON Keys Before Validating a Record</title>
      <dc:creator>TokenPeak</dc:creator>
      <pubDate>Sun, 01 Nov 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/tokenpeakai/reject-duplicate-json-keys-before-validating-a-record-10ep</link>
      <guid>https://dev.to/tokenpeakai/reject-duplicate-json-keys-before-validating-a-record-10ep</guid>
      <description>&lt;p&gt;&lt;em&gt;AI and affiliation disclosure: An AI agent prepared this standalone technical article for TokenPeak. The inputs are invented teaching fixtures. Its Python examples were checked locally; no remote API, customer payload, or production parser was tested.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Consider a small JSON document containing the same member name twice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{"decision": "review", "decision": "send"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your application validates only the dictionary produced by its decoder, the repeated name may already have disappeared. A check that the final value belongs to an allowed set cannot recover the ambiguity in the original document.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://docs.python.org/3.12/library/json.html#standard-compliance-and-interoperability" rel="noopener noreferrer"&gt;Python 3.12 JSON documentation&lt;/a&gt; describes the default decoder's handling of repeated names and its hooks for customizing object decoding. For a record format that requires unique names, inspect the pairs while decoding instead of inspecting only the resulting dictionary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Define a deliberately narrow input boundary
&lt;/h2&gt;

&lt;p&gt;This teaching parser accepts a supplied string of at most 4,096 UTF-8 bytes and requires a top-level object. It rejects repeated member names, including inside nested objects. It also rejects the non-finite constant spellings accepted by Python's default decoder.&lt;/p&gt;

&lt;p&gt;Decimal fractions become &lt;code&gt;Decimal&lt;/code&gt; values so that a large finite exponent is not silently converted into a floating-point infinity. That is a representation choice, not permission to accept arbitrary numeric ranges in an application schema.&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;decimal&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;unique_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&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;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;pairs&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;key&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;result&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;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Duplicate object member&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;reject_constant&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="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Non-finite JSON constant&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;decode_record&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="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;type&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="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nb"&gt;str&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;TypeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Supply decoded text&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;len&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="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;4096&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;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Teaching input limit exceeded&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&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="n"&gt;object_pairs_hook&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;unique_object&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;parse_constant&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;reject_constant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;parse_float&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Decimal&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="nf"&gt;type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nb"&gt;dict&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;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Top-level object required&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;value&lt;/span&gt;


&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;decode_record&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="s"&gt;decision&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="s"&gt;review&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="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;decision&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;review&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;decode_record&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="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:0.1}&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;amount&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="nc"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.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;bad_inputs&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;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;decision&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="s"&gt;review&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="s"&gt;decision&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="s"&gt;send&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="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nested&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="s"&gt;x&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:1,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:2}}&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;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:NaN}&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;[1,2]&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;bad_inputs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;decode_record&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="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;pass&lt;/span&gt;
        &lt;span class="k"&gt;else&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;AssertionError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Expected rejection&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;6 local cases passed&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;Save this block as &lt;code&gt;decode_record.py&lt;/code&gt; and run it with Python without optimization flags, which can disable assertions. The exact block was run during preparation on Python 3.12.14. Its six cases exercise accepted objects, decimal representation, duplicate detection, non-finite constants, and the root-type rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check ambiguity before business meaning
&lt;/h2&gt;

&lt;p&gt;The object hook receives the member pairs before this function constructs its dictionary. Each nested object goes through the same hook. A repeated &lt;code&gt;x&lt;/code&gt; in a child object is therefore rejected even when the outer object has unique names.&lt;/p&gt;

&lt;p&gt;After this boundary, application validation still has work to do. The parser accepts an object with a single &lt;code&gt;decision&lt;/code&gt; value of &lt;code&gt;"launch spacecraft"&lt;/code&gt;; it does not define the business vocabulary. It also does not establish whether an integer is a valid identifier or whether a decimal lies within an allowed range.&lt;/p&gt;

&lt;p&gt;Keep those stages separate in tests. One test can ask whether the document is unambiguous under your decoding policy. Another can ask whether the decoded record satisfies your domain contract. Their failure messages can then tell the caller which decision failed.&lt;/p&gt;

&lt;h2&gt;
  
  
  State what the size check cannot guarantee
&lt;/h2&gt;

&lt;p&gt;The string already exists in memory before this function checks its encoded length. This is not a bounded network reader. It does not impose a nesting-depth limit, a numeric-digit policy, or a total CPU budget. A production boundary needs limits appropriate to its transport and parser.&lt;/p&gt;

&lt;p&gt;The function deliberately raises an error without echoing the entire input. That keeps this particular exception message small; it is not a comprehensive logging or secret-redaction guarantee.&lt;/p&gt;

&lt;p&gt;The practical lesson is about preserving a validation opportunity. Once decoding has collapsed repeated names into one dictionary entry, a later schema check may be too late to ask whether the original document supplied one answer or several competing answers.&lt;/p&gt;

</description>
      <category>python</category>
      <category>security</category>
      <category>softwareengineering</category>
      <category>programming</category>
    </item>
    <item>
      <title>Security, safety, robustness and privacy are four different problems</title>
      <dc:creator>Ajitkumar Singh</dc:creator>
      <pubDate>Sun, 01 Nov 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/ajitkumar_singh_7a94b3eaa/security-safety-robustness-and-privacy-are-four-different-problems-11cf</link>
      <guid>https://dev.to/ajitkumar_singh_7a94b3eaa/security-safety-robustness-and-privacy-are-four-different-problems-11cf</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foqddrtvgafzfon6www27.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foqddrtvgafzfon6www27.png" alt="Four cards. Security asks whether an untrusted document can trigger an unauthorized tool action, with attack tests and tool traces as evidence. Safety asks whether an allowed answer can cause unacceptable harm: hazard analysis and scenario tests. Robustness asks whether quality holds under blur, accents, long context or missing data: slice metrics and perturbation tests. Privacy asks whether training or inference can expose a person's data: data inventory and access logs. One event can cross all four, and no single guardrail proves them all." width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Teams often file every AI risk under one word, usually "security" or "safety", and then argue about whether a guardrail has dealt with it. The four concerns are related, but they ask different questions, need different evidence, and usually belong to different people.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Security&lt;/strong&gt; asks whether an actor can violate confidentiality, integrity, availability or authorized control.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Safety&lt;/strong&gt; asks whether operating the system can cause unacceptable harm, including harm with no attacker involved.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Robustness&lt;/strong&gt; asks whether behavior stays acceptable under noise, distribution shift, ambiguity or stress.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Privacy&lt;/strong&gt; asks whether personal or sensitive information is collected, inferred, used, retained or disclosed appropriately.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  One summarizer, three different failures
&lt;/h2&gt;

&lt;p&gt;Take a model that summarizes clinical documents.&lt;/p&gt;

&lt;p&gt;A malicious document instructs it to reveal another patient's record. That is a security attack, with privacy impact. A faithful summary leaves out a critical allergy because the scan was poor. That is a robustness failure, with safety impact. And a correct diagnosis, inferred where nobody authorized the inference, can be a privacy and governance failure with no attacker anywhere.&lt;/p&gt;

&lt;p&gt;No single guardrail can prove all three cases are controlled. Treating the four words as synonyms produces controls too vague to test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Different questions need different evidence
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Discipline&lt;/th&gt;
&lt;th&gt;Representative evidence&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Can an untrusted document cause an unauthorized tool action?&lt;/td&gt;
&lt;td&gt;Security&lt;/td&gt;
&lt;td&gt;Taint-aware attack tests, policy decisions, egress and tool traces&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can an allowed answer create unacceptable physical or social harm?&lt;/td&gt;
&lt;td&gt;Safety&lt;/td&gt;
&lt;td&gt;Hazard analysis, scenario tests, review and escalation outcomes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Does quality hold under blur, accent, long context or missing data?&lt;/td&gt;
&lt;td&gt;Robustness&lt;/td&gt;
&lt;td&gt;Slice metrics, perturbation tests, calibrated abstention&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can training or inference expose a person's data?&lt;/td&gt;
&lt;td&gt;Privacy&lt;/td&gt;
&lt;td&gt;Data inventory, minimization, access logs, deletion and privacy attack tests&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Engineering Insight&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Record the overlap, but keep accountability separate. Security may own exploitability and access controls, product safety the hazardous outcomes, privacy the lawful processing and minimization, and ML engineering the robustness. A shared risk register shows where they intersect; each claim in it still needs an owner, a test, a threshold and a response.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;An architecture review that lists one "AI risk" control and calls the job done has usually tested one of these four. Asking which discipline a control serves, and what evidence would show it failing, is the quickest way to find the other three.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://engineertoai.com/articles/security-safety-robustness-privacy?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost" rel="noopener noreferrer"&gt;engineertoai.com&lt;/a&gt;. The full lesson is free, with no sign-up: &lt;a href="https://engineertoai.com/courses/ai-security-and-red-teaming-for-production-systems.html?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crosspost#security-safety" rel="noopener noreferrer"&gt;Separate Security, Safety, Robustness, and Privacy&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>llm</category>
      <category>privacy</category>
    </item>
    <item>
      <title>Why I built a YouTube timeline with no recommendations</title>
      <dc:creator>KH</dc:creator>
      <pubDate>Sun, 01 Nov 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/parkkh/why-i-built-a-youtube-timeline-with-no-recommendations-2fo2</link>
      <guid>https://dev.to/parkkh/why-i-built-a-youtube-timeline-with-no-recommendations-2fo2</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I'd open YouTube to watch one video and lose an hour following recommendations. I never really tried to fight it.&lt;/li&gt;
&lt;li&gt;So I built a page that shows only new uploads from the channels I pick, in upload order, with no recommendations and no Shorts.&lt;/li&gt;
&lt;li&gt;Now I watch what's new and close the tab. That was the whole goal, and it works.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;Plainfeed shows new videos from the YouTube channels you choose, in the order they were posted, without recommendations or Shorts. My last post was about running it on 10,000 API units a day. This one is about why it exists. It's short.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the time went
&lt;/h2&gt;

&lt;p&gt;Nothing dramatic. I'd have a video in mind, open YouTube, watch it, click the one next to it, go back to the home page, find another one. After a while I'd be deep into videos that had nothing to do with why I came. The problem wasn't that the algorithm picked badly. It picked well.&lt;/p&gt;

&lt;p&gt;The subscriptions tab didn't fix it either. Watching new uploads from the top, one by one, worked fine, but once I got to the end, autoplay would queue up videos from channels I didn't follow. Right when I thought I was done, the recommendations came in through the back door. Shorts were mixed in along the way, too.&lt;/p&gt;

&lt;p&gt;A lot of people seem to have the same problem. A post I read recently on a community forum described opening YouTube for one long video and forgetting about it because of Shorts and thumbnails. The question was whether any habit or software could help.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I didn't try
&lt;/h2&gt;

&lt;p&gt;Honestly, I didn't try the usual fixes. Deleting the app, turning off notifications, installing a screen-time extension. They all felt like a fight I'd have to win every single time. The temptation stays on the screen; only my willpower changes.&lt;/p&gt;

&lt;p&gt;I figured if the temptation wasn't on the screen, there'd be nothing to fight. YouTube without recommendations. That was the whole idea.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built instead
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;In short: I put time where the recommendations used to be.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Only new uploads from channels I pick, stacked in upload order. Nobody chose the order; the clock did.&lt;/li&gt;
&lt;li&gt;Shorts are filtered out. Watched videos change their marker and can be hidden, so the list empties as you go. When it's empty, you're done.&lt;/li&gt;
&lt;li&gt;No "up next" beside the player. When a video ends, there's nothing to do. That's on purpose.&lt;/li&gt;
&lt;li&gt;No sign-in. Paste a channel URL and it works.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The key was a list with an end. A recommendation feed has no end, so there's no natural point to stop. New uploads from the channels you follow are a handful a day, and once you've seen them, closing the tab is the obvious next move.&lt;/p&gt;

&lt;h2&gt;
  
  
  What surprised me
&lt;/h2&gt;

&lt;p&gt;I didn't expect so many Shorts. Of the 590 most recent videos across my 40 channels, 153 were Shorts, about 26% (October 2026, measured for my last post). Channels I thought of as long-form were posting Shorts in between. Filtering them out made the list noticeably shorter, which means I reach "all caught up" sooner.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I didn't build
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No screen-time limits or timers.&lt;/strong&gt; Watch as much as you want; just don't get dragged along.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No attempt at "better recommendations."&lt;/strong&gt; The goal was to remove them, not improve them.&lt;/li&gt;
&lt;li&gt;It's not a YouTube replacement. If I want to search or browse, I go to YouTube. This is one window for new uploads from channels I follow.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;I'm happy with it. I open it, watch what's new from the channels I care about, and close it. Not a big life change, but I no longer forget what I came for.&lt;/p&gt;

&lt;p&gt;Next post goes back to the technical side: running this on a single Cloudflare free plan while making sure one person can't burn through D1's daily read and write limits.&lt;/p&gt;

</description>
      <category>youtube</category>
      <category>productivity</category>
      <category>sideprojects</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Eight entries in one content array, 1,041 to 2,034 words each, and the optional field that has exactly one user</title>
      <dc:creator>Daniel Pertu</dc:creator>
      <pubDate>Sun, 01 Nov 2026 12:59:38 +0000</pubDate>
      <link>https://dev.to/daniel_pertu/eight-entries-in-one-content-array-1041-to-2034-words-each-and-the-optional-field-that-has-239l</link>
      <guid>https://dev.to/daniel_pertu/eight-entries-in-one-content-array-1041-to-2034-words-each-and-the-optional-field-that-has-239l</guid>
      <description>&lt;p&gt;Munchable covers eight digestive conditions, and each one has a guide page. All eight are entries in a single array of a single interface, server-rendered at build time, so &lt;a href="https://munchable.app/conditions" rel="noopener noreferrer"&gt;the index&lt;/a&gt; and every guide under it come out of one file.&lt;/p&gt;

&lt;p&gt;That makes them comparable, which is the only reason this post exists. Content you can measure against itself tells you things that content in a CMS never will.&lt;/p&gt;

&lt;h2&gt;
  
  
  The inventory
&lt;/h2&gt;

&lt;p&gt;I counted the source entries and then counted the live pages. Sorted by the live word count:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;guide                      src lines  sections  FAQs   words   links in HTML   answers linking in
coeliac disease                   87         4     5   2,034         125                88
bile acid malabsorption           54         3     3   1,543         103                67
low FODMAP and IBS                55         3     3   1,338         100                64
lactose intolerance               47         2     3   1,257          88                52
SIBO                              41         2     2   1,168         100                64
IBD                               49         3     2   1,126          84                48
GERD and reflux                   51         3     2   1,075          80                44
gastroparesis                     44         2     2   1,041          70                34
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The links column counts every internal link in the page's HTML, so about 30 of each row is the header and footer that all 516 of our pages share. The last column is the number of ingredient answer pages that link back to that guide.&lt;/p&gt;

&lt;p&gt;The top row is 95 per cent longer than the bottom row. It has twice as many source lines, twice as many sections, and more than twice the FAQ entries. It was also the last one written, which is the part that matters, because nothing in the type or the tests asked it to be bigger. It is bigger because it was written on a day when we knew more about writing these than we did for the first one.&lt;/p&gt;

&lt;p&gt;The other seven do not form any pattern at all. They are not ordered by how common the condition is, or by how old the page is, or by anything else I can find. They are each as long as the afternoon they were written, which is what a content set with no floor looks like when you finally line it up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The optional field
&lt;/h2&gt;

&lt;p&gt;Here is the part of the interface that interests me:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;ConditionPage&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ConditionId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;metaTitle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;metaDescription&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;lede&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;sections&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ConditionSection&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;checks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;faqs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;q&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}[];&lt;/span&gt;
  &lt;span class="nl"&gt;furtherReading&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;inline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;blurb&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}[];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eleven required fields and one optional one. The optional one has exactly one user out of eight entries, and it was introduced by the entry that needed it: coeliac disease has head terms that are a vocabulary rather than a question, so it got &lt;a href="https://munchable.app/hidden-gluten-ingredients" rel="noopener noreferrer"&gt;a reference page&lt;/a&gt; of its own, and the guide needed somewhere to point at it from.&lt;/p&gt;

&lt;p&gt;That is a perfectly reasonable thing to do, and it is also the exact mechanism by which content types rot. A required field is a question every entry has to answer. An optional field is a note that one entry had something extra to say. Add enough of them and the type describes the union of everything anybody ever wanted to publish, no single entry is ever incomplete, and nothing fails when a page is thin.&lt;/p&gt;

&lt;p&gt;The alternative shape would be a separate registry keyed by condition, which makes the asymmetry visible in a file of its own rather than invisible as an absent property. I do not think that is better here, because further reading genuinely belongs next to the guide it extends. But it is worth naming the trade, because "make it optional" is the default move and it has a cost that arrives later, in a column of numbers like the one above.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thinnest page is also the least linked
&lt;/h2&gt;

&lt;p&gt;Look at the last two columns together. Each of the 461 ingredient answer pages links back to the guide for its condition, so the inbound count is a property of how many ingredient questions that condition has pages for. The spread is 88 down to 34.&lt;/p&gt;

&lt;p&gt;Those two columns are nearly the same ranking. The condition with the most to say got the longest guide and 88 internal links pointing at it. The one with the least got the shortest guide and 34. Nobody decided that, and it compounds in the wrong direction: the page most in need of help getting found has the least help, and the page that already reads best is the one the rest of the site keeps recommending.&lt;/p&gt;

&lt;p&gt;This is the thing I would not have noticed from inside the editor. Both numbers look fine on their own. It is only when you print them in the same table that you see the site has a quiet preference.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a floor would actually look like
&lt;/h2&gt;

&lt;p&gt;The temptation is a word count minimum, and it is a trap. A test that fails under 1,200 words produces 1,200 words, and the last 160 of them will be padding that makes the page worse.&lt;/p&gt;

&lt;p&gt;A structural floor is defensible: every guide has at least three sections and at least three FAQ entries. Those are not quality, but they are shape, and shape is the thing that drifted. We already enforce an upper bound on the other end, a title and description budget read out of the source files by a test, so the machinery for this exists and is one assertion long.&lt;/p&gt;

&lt;p&gt;The real remedy is not a test, though. It is going back to the first seven pages with what we learned writing the eighth. The useful output of an audit like this is not a CI rule. It is a list, in order, of which page to fix first, and that list is just the table above read from the bottom up.&lt;/p&gt;

&lt;p&gt;If you want to see the two ends of it, the longest is &lt;a href="https://munchable.app/conditions/coeliac-disease" rel="noopener noreferrer"&gt;the coeliac guide&lt;/a&gt; and the shortest is &lt;a href="https://munchable.app/conditions/gastroparesis" rel="noopener noreferrer"&gt;the gastroparesis one&lt;/a&gt;. Same template, same type, same build, 993 words apart.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>webdev</category>
      <category>seo</category>
      <category>contentstrategy</category>
    </item>
    <item>
      <title>I Didn't Hack This Box, I Just Pressed Ctrl+U — CyberHeroes (TryHackMe)</title>
      <dc:creator>Farhan Hussain</dc:creator>
      <pubDate>Sun, 01 Nov 2026 12:58:14 +0000</pubDate>
      <link>https://dev.to/cyberfarhan/i-didnt-hack-this-box-i-just-pressed-ctrlu-cyberheroes-tryhackme-1n93</link>
      <guid>https://dev.to/cyberfarhan/i-didnt-hack-this-box-i-just-pressed-ctrlu-cyberheroes-tryhackme-1n93</guid>
      <description>&lt;p&gt;&lt;strong&gt;Platform:&lt;/strong&gt; TryHackMe · &lt;strong&gt;Difficulty:&lt;/strong&gt; Easy · &lt;strong&gt;Category:&lt;/strong&gt; Client-Side Authentication Anti-Pattern&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;No exploit. No payload. No shell. The entire "challenge" lives in a browser's View Source and it took longer to write this sentence than it took to solve the box. That sounds like a joke, but it's actually the most important lesson in this whole series: if your authentication logic runs in the browser, it's not authentication, it's a suggestion the attacker is free to ignore.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CyberHeroes&lt;/strong&gt; has the audacity to call itself a challenge while leaving its front door not just unlocked, but labelled "FRONT DOOR" in neon letters. It's a single-page portfolio site with a login form that genuinely believes security is a server-side problem. Spoiler: they didn't check with the server. If this box were a bank, the vault combination would be written on the outside of the vault in permanent marker. It's that kind of day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recon
&lt;/h2&gt;

&lt;p&gt;Same opening move as always — RustScan for speed, Nmap for detail.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rustscan &lt;span class="nt"&gt;-a&lt;/span&gt; 10.49.154.83 &lt;span class="nt"&gt;-r&lt;/span&gt; 1-65535 &lt;span class="nt"&gt;--ulimit&lt;/span&gt; 5000 &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nt"&gt;-Pn&lt;/span&gt; &lt;span class="nt"&gt;-sC&lt;/span&gt; &lt;span class="nt"&gt;-sV&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Port&lt;/th&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;22/tcp&lt;/td&gt;
&lt;td&gt;SSH&lt;/td&gt;
&lt;td&gt;OpenSSH 8.2p1 Ubuntu 4ubuntu0.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;80/tcp&lt;/td&gt;
&lt;td&gt;HTTP&lt;/td&gt;
&lt;td&gt;Apache httpd 2.4.48 (Ubuntu)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two ports, nothing exotic. Apache 2.4.48 is not ancient like some of the others in this series — this one isn't about outdated services. The HTTP title comes back as &lt;code&gt;CyberHeros : Index&lt;/code&gt;. Typo in the page title is always a great sign of quality software engineering.&lt;/p&gt;

&lt;p&gt;Feroxbuster with PHP, TXT, and HTML extensions across the medium wordlist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;feroxbuster &lt;span class="nt"&gt;-u&lt;/span&gt; http://10.49.154.83/ &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-w&lt;/span&gt; /usr/share/wordlists/seclists/Discovery/Web-Content/DirBuster-2007_directory-list-2.3-medium.txt &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-x&lt;/span&gt; php,txt,html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# output (relevant hits only)
200  http://10.49.154.83/index.html
200  http://10.49.154.83/login.html     # ^^^ this is where the fun is
200  http://10.49.154.83/changelog.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;(truncated — the rest was asset files: images, vendor JS/CSS, nothing actionable)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;login.html&lt;/code&gt; is the obvious next stop. &lt;code&gt;changelog.txt&lt;/code&gt; is worth a read — &lt;em&gt;it appeared to be a generic template changelog with no sensitive information&lt;/em&gt; — so the login page it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Foothold
&lt;/h2&gt;

&lt;p&gt;Authentication is supposed to be validated on the &lt;em&gt;server&lt;/em&gt; — a system the attacker cannot directly inspect or manipulate. When a developer moves that logic into &lt;em&gt;client-side JavaScript&lt;/em&gt;, they've handed the attacker the padlock, the key, the combination, and a handwritten note explaining how the lock works. The browser must download and execute all JavaScript to render the page — which means the attacker can read every line of it before touching a single input field.&lt;/p&gt;

&lt;p&gt;Navigating to &lt;code&gt;login.html&lt;/code&gt; and hitting &lt;code&gt;Ctrl+U&lt;/code&gt; (View Source) immediately reveals the &lt;code&gt;authenticate()&lt;/code&gt; function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;authenticate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;uname&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pass&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RevereString&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;reverse&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="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;h3ck3rBoi&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;&lt;span class="nc"&gt;RevereString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;54321@terceSrepuS&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;xhttp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;XMLHttpRequest&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;xhttp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onreadystatechange&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;function&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="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;readyState&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;flag&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;responseText&lt;/span&gt; &lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;todel&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rm&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="nx"&gt;xhttp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;GET&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;RandomLo0o0o0o0o0o0o0o0o0o0gpath12345_Flag_&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;_&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.txt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;xhttp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;();&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="nf"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Incorrect Password, try again.. you got this hacker !&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Breaking this down:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Username&lt;/strong&gt; is hardcoded in the comparison: &lt;code&gt;h3ck3rBoi&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Password&lt;/strong&gt; is the string &lt;code&gt;54321@terceSrepuS&lt;/code&gt; passed through &lt;code&gt;RevereString()&lt;/code&gt;, which reverses it character by character — yielding &lt;code&gt;SuperSecret@12345&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;On success, the script constructs a GET request to a &lt;code&gt;.txt&lt;/code&gt; file at an "obscure" path that's... right there in the source, assembled from the username and password values&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The credentials, the reversal algorithm, and the flag's file path are all completely readable before submitting a single form field.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Username&lt;/th&gt;
&lt;th&gt;Password&lt;/th&gt;
&lt;th&gt;Where Found&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;h3ck3rBoi&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SuperSecret@12345&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;login.html&lt;/code&gt; JavaScript source&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Substituting the known credential values into the path template from the JS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RandomLo0o0o0o0o0o0o0o0o0o0gpath12345_Flag_h3ck3rBoi_SuperSecret@12345.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This file can be fetched directly without ever touching the login form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"http://10.49.154.83/RandomLo0o0o0o0o0o0o0o0o0o0gpath12345_Flag_h3ck3rBoi_SuperSecret@12345.txt"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or simply by entering the credentials into the login form, which triggers the XHR fetch and injects the response into the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shell / Access
&lt;/h2&gt;

&lt;p&gt;No shell required for this challenge. Access to the flag was achieved entirely via browser interaction with the login page after reading the client-side source.&lt;/p&gt;

&lt;h2&gt;
  
  
  Flags
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Flag&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Location&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Challenge Flag&lt;/td&gt;
&lt;td&gt;&lt;code&gt;flag{edb0be532c540b1a150c3a7e85d2466e}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fetched via XHR after login — or direct URL access&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Mitigations
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Vulnerability&lt;/th&gt;
&lt;th&gt;Severity&lt;/th&gt;
&lt;th&gt;Mitigation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hardcoded credentials in client-side JavaScript&lt;/td&gt;
&lt;td&gt;Critical&lt;/td&gt;
&lt;td&gt;Move all authentication logic server-side; never compare credentials in the browser&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plaintext (reversed) password obfuscation&lt;/td&gt;
&lt;td&gt;Critical&lt;/td&gt;
&lt;td&gt;String reversal is not encryption — use proper server-side password hashing (bcrypt, Argon2)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Predictable flag file path assembled client-side&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Flag delivery should be gated by a server-side session check, not a client-constructed URL&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Key Takeaway
&lt;/h2&gt;

&lt;p&gt;Anything that runs in the browser is readable by the user — no amount of string reversal, obfuscation, or "obscure" path names changes that. Authentication logic belongs on the server, full stop.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Room:&lt;/strong&gt; TryHackMe — CyberHeroes&lt;/p&gt;

</description>
      <category>security</category>
      <category>ctf</category>
      <category>tryhackme</category>
      <category>writeup</category>
    </item>
    <item>
      <title>56 pages that answer the same question, 12 to 21 percent shared text, and three of them collect every related link</title>
      <dc:creator>Daniel Pertu</dc:creator>
      <pubDate>Sun, 01 Nov 2026 12:57:17 +0000</pubDate>
      <link>https://dev.to/daniel_pertu/56-pages-that-answer-the-same-question-12-to-21-percent-shared-text-and-three-of-them-collect-36g1</link>
      <guid>https://dev.to/daniel_pertu/56-pages-that-answer-the-same-question-12-to-21-percent-shared-text-and-three-of-them-collect-36g1</guid>
      <description>&lt;p&gt;CogniPrep has a page per assessment provider answering one question: can you cheat this test? &lt;a href="https://cogniprep.app/cheating" rel="noopener noreferrer"&gt;56 of them&lt;/a&gt;, and the answer on every single one is no.&lt;/p&gt;

&lt;p&gt;That is the worst possible brief for a content cluster. 56 pages, one verdict, one call to action, and a vendor name as the only obvious variable. Written carelessly it is a doorway-page farm. Here is what we did instead, and the one thing we got wrong, which I only found by counting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule that makes the pages different
&lt;/h2&gt;

&lt;p&gt;The registry's own header comment states the decision the cluster is built on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Whether an assessment can be cheated is a property of the instrument, not of
the company that bought it. Arctic Shores defeats a shortcut in exactly the
same way at Thales as it does at Capita, so a page per employer would be
dozens of restatements of one argument. Keying the cluster to the provider
means each page can explain a mechanism that is genuinely its own.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole trick. Key the cluster to the thing that actually varies. A page per employer would have produced 132 pages with nothing to say; a page per instrument produces 56 pages where the reason a shortcut fails is different every time, because a behavioural profile with no marks, a timed adaptive paper and a forced-choice personality inventory fail a cheater for genuinely unrelated reasons.&lt;/p&gt;

&lt;p&gt;Each entry in the registry carries seven strings: the meta title, the description, the standfirst, the verdict line, the one-line mechanism, the hub card blurb, and the name candidates actually type into a search box. I checked all seven across all 56 entries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;field              values   distinct
metaTitle              56         56
metaDescription        56         56
intro                  56         56
verdict                56         56
defence                56         56
cardBlurb              56         56
searchName             56         56
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero repeats in 392 strings. Meta titles run from 30 to 47 characters and not one is over 60, so none of them is truncated in a result. The page bodies are 56 separate &lt;code&gt;page.tsx&lt;/code&gt; files, 8,911 lines in total, from 117 lines for the shortest to 271 for the longest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring it from the outside
&lt;/h2&gt;

&lt;p&gt;Counting distinct strings in your own registry proves you did not copy and paste. It does not prove the published pages read as different documents, because the bodies could still be one template with the nouns swapped.&lt;/p&gt;

&lt;p&gt;So measure the deployed HTML. Strip the tags, cut the text into overlapping eight-word windows, and intersect them pairwise:&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;shingles&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="n"&gt;n&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;words&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&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;w&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[A-Za-z&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="n"&gt;text&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;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;words&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&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;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&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;words&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;n&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Across five pages in the cluster (&lt;a href="https://cogniprep.app/cheating/shl" rel="noopener noreferrer"&gt;shl&lt;/a&gt;, &lt;a href="https://cogniprep.app/cheating/aon" rel="noopener noreferrer"&gt;aon&lt;/a&gt;, &lt;a href="https://cogniprep.app/cheating/hogan" rel="noopener noreferrer"&gt;hogan&lt;/a&gt;, &lt;a href="https://cogniprep.app/cheating/usps" rel="noopener noreferrer"&gt;usps&lt;/a&gt; and &lt;a href="https://cogniprep.app/cheating/train-driver" rel="noopener noreferrer"&gt;train-driver&lt;/a&gt;), each 1,361 to 1,806 words:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;aon vs hogan            15.0% of the first page's shingles
aon vs shl              20.4%
aon vs train-driver     21.3%
hogan vs usps           19.3%
train-driver vs usps    12.0%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;12 to 21 percent, and the floor is the interesting number. No pair in the cluster shares fewer than 210 shingles. To find out what that floor is made of, I ran the same comparison between a cheating page and &lt;a href="https://cogniprep.app/tests" rel="noopener noreferrer"&gt;an unrelated page on the site&lt;/a&gt;: 162 shared shingles, with no subject matter in common at all. So 162 of the floor is the header, navigation and footer, and the remaining 48 or so is the cluster's own shared furniture, the verdict banner and the call to action. Subtract both and the bodies overlap by a few percent.&lt;/p&gt;

&lt;p&gt;Eight words is the right window size for this. Four-word windows match on ordinary English and tell you nothing; sixteen only catch verbatim paragraphs. Eight catches a reused sentence shape, which is the thing a template produces and a writer does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part we got wrong
&lt;/h2&gt;

&lt;p&gt;Every page ends with three related pages, chosen by a function with a &lt;code&gt;limit = 3&lt;/code&gt; default:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;family&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&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="nx"&gt;FAMILIES&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="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;provider&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ordered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;family&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="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;CHEATING_GUIDES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;)&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="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;family&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;ordered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;BY_PROVIDER&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;p&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="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Family first, then everything else in canonical order. The families themselves are carefully argued, with comments explaining why a given provider sits with the reasoning group rather than the speeded one. The sizes are the problem:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;reasoning      30 members
dutch           9
speeded         8
gamified        4
no family       5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 30-member family and three slots means the three slots are always the same three members. Taken across all 56 pages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;distinct related-link trios            17
pages showing shl / faa-atsa / sova    27
inbound related links to shl           29
providers never offered as related     37
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two thirds of the family sees the same trio, and 37 of the 56 pages are never recommended by any page in their own cluster. You can watch it happen:&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="k"&gt;for &lt;/span&gt;p &lt;span class="k"&gt;in &lt;/span&gt;train-driver usa-hire usps hogan&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"--- &lt;/span&gt;&lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://cogniprep.app/cheating/&lt;/span&gt;&lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'href="/cheating/[a-z0-9-]*"'&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;train-driver&lt;/code&gt; offers shl, faa-atsa and sova. So does almost every other page in the reasoning family. &lt;code&gt;usps&lt;/code&gt; and &lt;code&gt;hogan&lt;/code&gt;, which belong to no family, get the first three entries in registry order instead, which is an even more arbitrary answer dressed up as a recommendation.&lt;/p&gt;

&lt;p&gt;Nothing is broken. Every link is valid, every target is a real page, the typecheck is clean and the pages render. The function does exactly what it says. It just turns a 56-node graph into a star with three centres, and no test asserts anything about the shape of the output, so it went unnoticed for as long as the families stayed small.&lt;/p&gt;

&lt;p&gt;The fix is to deal from the family rather than slicing it: order the candidates by something page-specific, rotate so each page appears in a bounded number of other pages' slots, and then assert the distribution in a test rather than the validity of the links. Checking that the links resolve was never the hard part.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two smaller things the count turned up
&lt;/h2&gt;

&lt;p&gt;The word &lt;code&gt;fourteen&lt;/code&gt; appears three times in that file's comments, describing the cluster's size. There are 56 pages. The comments were written when 14 providers were live and they were accurate then, which is the problem with counts in prose: they are facts with no test attached. The comments that survived are the ones stating a rule rather than a number.&lt;/p&gt;

&lt;p&gt;And the derived list is built like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CHEATING_GUIDES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ALL_PROVIDERS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;GUIDES&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="nx"&gt;guide&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;guide&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;)&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="nx"&gt;guide&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;guide&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;CheatingGuide&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;guide&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adding a provider without writing its guide does not fail a build. It produces one fewer page, silently, and the &lt;code&gt;.filter&lt;/code&gt; makes the types work out perfectly. What saves us is a per-provider test asserting that the provider appears everywhere it should, which is the only reason that line is safe to keep.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you have a cluster like this
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Key it to whatever genuinely varies, and if nothing does, write one page.&lt;/li&gt;
&lt;li&gt;Generate the metadata from one place so a canonical cannot drift, and hand-write every sentence a reader sees.&lt;/li&gt;
&lt;li&gt;Measure the published pages, not the source: eight-word shingles against the live HTML, and expect your chrome to be the floor.&lt;/li&gt;
&lt;li&gt;Test the shape of your internal links, not just their validity. Valid links pointing at three pages is the failure mode that looks like success.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The hub is at &lt;a href="https://cogniprep.app/cheating" rel="noopener noreferrer"&gt;cogniprep.app/cheating&lt;/a&gt;, and the shingle script above runs against it unchanged.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>typescript</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Stop Hardcoding Color Variants. Relative Color Syntax Does It.</title>
      <dc:creator>Parsa J</dc:creator>
      <pubDate>Sun, 01 Nov 2026 12:57:09 +0000</pubDate>
      <link>https://dev.to/parsajiravand/stop-hardcoding-color-variants-relative-color-syntax-does-it-1f8k</link>
      <guid>https://dev.to/parsajiravand/stop-hardcoding-color-variants-relative-color-syntax-does-it-1f8k</guid>
      <description>&lt;p&gt;The PR comment was two words: "wrong blue." Not a bug report, not a reproduction — a designer scrolling through a staging build, stopping on the "Upgrade" button's hover state, and flagging it because something about it felt off against the rest of the rebrand that had shipped two weeks earlier.&lt;/p&gt;

&lt;p&gt;They were right, and it took longer than it should have to find out why. The button's resting color matched the new brand blue exactly — that token had been updated the day the rebrand landed. Its hover color was still the &lt;em&gt;old&lt;/em&gt; brand blue, darkened. Nobody had touched it, because nobody remembered it existed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five variables, one of which actually gets updated
&lt;/h2&gt;

&lt;p&gt;Open almost any design system's color file and you'll find the same shape: one brand color, and a handful of hand-picked variants sitting next to it that all &lt;em&gt;look&lt;/em&gt; derived but are actually just... more hex codes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nd"&gt;:root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--brand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;#3b6fd6&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--brand-hover&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;#2f58ab&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c"&gt;/* "a bit darker" — picked by eye, once */&lt;/span&gt;
  &lt;span class="py"&gt;--brand-active&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;#244289&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c"&gt;/* "darker still" — same deal */&lt;/span&gt;
  &lt;span class="py"&gt;--brand-border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;#6e92e1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c"&gt;/* "a bit lighter" */&lt;/span&gt;
  &lt;span class="py"&gt;--brand-tint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;59&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;111&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;214&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.08&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c"&gt;/* "faint background wash" */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every one of those four extra values is somebody's good-faith attempt, at some point in the past, to eyeball a lighter or darker or more-transparent version of &lt;code&gt;--brand&lt;/code&gt;. They're not wrong on day one. The problem shows up the day &lt;code&gt;--brand&lt;/code&gt; changes and the other four don't — because CSS has no idea they were ever related. To the engine, &lt;code&gt;--brand-hover&lt;/code&gt; isn't "darker &lt;code&gt;--brand&lt;/code&gt;." It's just a string that starts with &lt;code&gt;#2f58ab&lt;/code&gt;, full stop.&lt;/p&gt;

&lt;p&gt;The "obvious" fix is discipline: a comment reminding whoever edits &lt;code&gt;--brand&lt;/code&gt; to also update the other four, or a design-tokens build step that regenerates them from a single source before CSS ever sees them. Both work, right up until the person making a one-line brand tweak six months from now doesn't know the build step exists, or skims past the comment. Discipline that depends on nobody being in a hurry isn't a fix — it's a bet, and the rebrand PR is the bet losing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The variables were never the problem — storing the &lt;em&gt;relationship&lt;/em&gt; was
&lt;/h2&gt;

&lt;p&gt;Here's the reframe that matters: &lt;code&gt;--brand-hover&lt;/code&gt; was never really an independent color. It was always a &lt;em&gt;function of&lt;/em&gt; &lt;code&gt;--brand&lt;/code&gt; — "darker, same hue." &lt;code&gt;--brand-tint&lt;/code&gt; was always "the same color, 8% opaque." The relationship was real and well understood by whoever first picked those hex codes. What got lost was that the relationship lived in a person's head for thirty seconds and then evaporated, leaving only its one-time output behind, frozen in a stylesheet forever.&lt;/p&gt;

&lt;p&gt;What you actually want is for CSS to store the &lt;em&gt;relationship&lt;/em&gt;, not just a snapshot of what it once produced — and that's a genuinely new category of thing for CSS to express. For most of CSS's life, a color value was a dead end: &lt;code&gt;#3b6fd6&lt;/code&gt; tells the renderer what to paint, but nothing about where it came from or how to get a cousin of it. &lt;code&gt;color-mix()&lt;/code&gt; loosened that slightly — you can blend two colors — but blending two colors isn't the shape of this problem. You don't have two brand colors to mix; you have &lt;strong&gt;one&lt;/strong&gt; color, and you want its lightness turned down a notch while everything else about it stays put.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before you keep reading: what would you even call the CSS feature that lets you say "same color, just darker"?&lt;/strong&gt; It's a fair question, because until relatively recently there wasn't a clean answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Relative color syntax: read a color's own channels back out
&lt;/h2&gt;

&lt;p&gt;Relative color syntax lets a color function take another color as input and expose &lt;em&gt;that color's own channels&lt;/em&gt; as variables you can do math on, right there in the function call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nd"&gt;:root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--brand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;#3b6fd6&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c"&gt;/* "same hue and chroma as --brand, just darker" */&lt;/span&gt;
  &lt;span class="py"&gt;--brand-hover&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="nf"&gt;oklch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="l"&gt;--brand&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="m"&gt;0.12&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--brand-active&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;oklch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="l"&gt;--brand&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="m"&gt;0.20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c"&gt;/* "same color, lighter, for a border" */&lt;/span&gt;
  &lt;span class="py"&gt;--brand-border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;oklch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="l"&gt;--brand&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="m"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c"&gt;/* "same color, 8% opaque" */&lt;/span&gt;
  &lt;span class="py"&gt;--brand-tint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;oklch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="l"&gt;--brand&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="m"&gt;8%&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;Read the &lt;code&gt;oklch(from var(--brand) ...)&lt;/code&gt; part literally: "take &lt;code&gt;--brand&lt;/code&gt;, convert it to OKLCH, and give me three variables back — &lt;code&gt;l&lt;/code&gt; for lightness, &lt;code&gt;c&lt;/code&gt; for chroma, &lt;code&gt;h&lt;/code&gt; for hue." Whatever you write after that is just CSS math on those three numbers (plus an optional alpha after the slash). &lt;code&gt;calc(l - 0.12)&lt;/code&gt; means "this color's own lightness, minus 0.12" — not a hardcoded lightness value, a relationship to whatever &lt;code&gt;--brand&lt;/code&gt; happens to be &lt;em&gt;right now&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Change &lt;code&gt;--brand&lt;/code&gt; to a rebrand's new blue, or swap it entirely for a dark-mode theme, and every line below it recomputes. There's no second hex code to forget, because there is no second hex code — &lt;code&gt;--brand-hover&lt;/code&gt; isn't stored anywhere; it's derived, every time the browser needs it, from the one variable a human actually edits.&lt;/p&gt;

&lt;p&gt;This isn't OKLCH-only, either. &lt;code&gt;rgb()&lt;/code&gt;, &lt;code&gt;hsl()&lt;/code&gt;, and &lt;code&gt;color()&lt;/code&gt; all support the same &lt;code&gt;from&lt;/code&gt; syntax, exposing whichever channels that color space naturally has:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* rgb()'s own channels: r, g, b (0–255), alpha */&lt;/span&gt;
&lt;span class="nc"&gt;.badge&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;rgb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="l"&gt;--brand&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="m"&gt;15%&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;/* hsl()'s own channels: h, s, l, alpha — s and l come back as plain
   numbers (0-100), not percentages, so the math below uses 10, not 10% */&lt;/span&gt;
&lt;span class="nc"&gt;.link&lt;/span&gt;&lt;span class="nd"&gt;:hover&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;hsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="l"&gt;--brand&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="nf"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="m"&gt;10&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;OKLCH is usually the better pick for lightness math specifically, because it's designed so that "lightness" tracks &lt;em&gt;perceived&lt;/em&gt; brightness consistently across hues — nudging &lt;code&gt;l&lt;/code&gt; down by the same amount darkens a yellow and a blue by roughly the same perceived amount. Nudge &lt;code&gt;hsl()&lt;/code&gt;'s &lt;code&gt;l&lt;/code&gt; channel the same way and the result can feel inconsistent hue to hue, because HSL's lightness was never built to model human perception in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  🎮 Try it yourself
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://bestpractic.org/blog/css-relative-color-syntax/playground" rel="noopener noreferrer"&gt;▶️ Open the interactive playground →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Runs right in your browser — poke at it and watch the concept react live.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The one sharp edge: clamp your math
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;calc(l - 0.12)&lt;/code&gt; is honest math, which means it'll happily produce a number outside the range that makes sense. Pick a brand color that's already quite dark, subtract 0.20 from its lightness, and &lt;code&gt;l&lt;/code&gt; can go negative — which isn't a color error so much as a "this doesn't mean anything" result, rendered as whatever the browser decides black looks like.&lt;/p&gt;

&lt;p&gt;The fix is the same &lt;code&gt;clamp()&lt;/code&gt; you'd reach for anywhere else in CSS, wrapped around the channel math:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;--brand-hover&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nt"&gt;oklch&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;from&lt;/span&gt; &lt;span class="nt"&gt;var&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;--brand&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nt"&gt;clamp&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;calc&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;l&lt;/span&gt; &lt;span class="nt"&gt;-&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="err"&gt;12&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt; &lt;span class="err"&gt;1&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nt"&gt;c&lt;/span&gt; &lt;span class="nt"&gt;h&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one change makes the derivation safe for &lt;em&gt;any&lt;/em&gt; input color, including ones your design system doesn't control yet — a user-customizable accent color, say, or a brand color read from a CMS. Skip the clamp and the formula only works for colors in roughly the lightness range you tested it against, which defeats a good chunk of the point: a derivation that only works for some inputs is just a more elaborate hardcoded value.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is this safe to ship, or is it another flag-gated preview?
&lt;/h2&gt;

&lt;p&gt;Safe to ship — but the rollout order isn't what you'd guess. Safari 16.4 (March 2023) was the earliest browser with &lt;em&gt;something&lt;/em&gt;, but it was a partial pass at an older draft of the spec with real parsing bugs; that implementation was later replaced, not extended. Chrome and Firefox did the work of actually matching the final spec through 2024 (Firefox in Firefox 128, July 2024), and Safari didn't catch up — bug fixes included — until Safari 18 in September 2024. So for the exact forms used above, Safari was last to ship the behavior this post describes, not first. Either way, that was over two years ago: by now every major evergreen browser has unprefixed, unflagged, spec-correct support, and there's no realistic "but what about X" objection left to raise for a typical modern browser matrix. If your project still has to support browsers from before that window, the fallback is simple and honest: keep writing literal hex variants for those browsers, and layer the relative syntax on top for everyone else — it degrades to "the hardcoded value you already had," not to a broken page.&lt;/p&gt;

&lt;h2&gt;
  
  
  🧠 Test yourself
&lt;/h2&gt;

&lt;p&gt;Think it clicked? &lt;strong&gt;&lt;a href="https://bestpractic.org/blog/css-relative-color-syntax/quiz" rel="noopener noreferrer"&gt;Take the 7-question quiz →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The bug wasn't really "someone forgot to update a hex code." It was that the stylesheet never had anywhere to &lt;em&gt;put&lt;/em&gt; the fact that &lt;code&gt;--brand-hover&lt;/code&gt; meant "darker &lt;code&gt;--brand&lt;/code&gt;" — so that knowledge lived in a person's memory instead, with exactly the shelf life you'd expect. Relative color syntax isn't a shorter way to write the same five hex codes. It's a way to stop writing four of them at all.&lt;/p&gt;

&lt;p&gt;Where does your own color system still keep that kind of relationship in a comment instead of in the CSS itself?&lt;/p&gt;

&lt;h2&gt;
  
  
  📚 Read next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/the-css-property-that-fixes-dark-modes-ugly-white-boxes-58hc"&gt;The CSS Property That Fixes Dark Mode's Ugly White Boxes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/one-css-property-replaces-your-checkbox-hack-4jhk"&gt;One CSS Property Replaces Your Checkbox Hack&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/you-reach-for-sass-to-mix-colors-color-mix-does-it-natively-17p1"&gt;You reach for Sass to mix colors. &lt;code&gt;color-mix()&lt;/code&gt; does it natively.&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;🚀 &lt;strong&gt;Want more like this?&lt;/strong&gt; Every guide, playground, and quiz lives on &lt;strong&gt;&lt;a href="https://bestpractic.org/" rel="noopener noreferrer"&gt;bestpractic.org&lt;/a&gt;&lt;/strong&gt; — open it and &lt;strong&gt;&lt;a href="https://bestpractic.org/" rel="noopener noreferrer"&gt;sign up free&lt;/a&gt;&lt;/strong&gt; so the next one finds you.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Thanks for reading! Let's stay connected:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;⭐ &lt;strong&gt;GitHub&lt;/strong&gt; — follow me and star the projects: &lt;a href="https://github.com/parsajiravand" rel="noopener noreferrer"&gt;github.com/parsajiravand&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;💬 &lt;strong&gt;Discord&lt;/strong&gt; — join the frontend best-practices community: &lt;a href="https://discord.gg/d9KRhuAwQ" rel="noopener noreferrer"&gt;discord.gg/d9KRhuAwQ&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;📸 &lt;strong&gt;Instagram&lt;/strong&gt; — frontend best practices, daily: &lt;a href="https://www.instagram.com/bestpractice___/" rel="noopener noreferrer"&gt;@bestpractice___&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>css</category>
      <category>webdev</category>
      <category>frontend</category>
      <category>programming</category>
    </item>
    <item>
      <title>Two of our pages are 1,065 and 1,083 words long, and the reading times under their titles say 6 minutes and 8 minutes</title>
      <dc:creator>Daniel Pertu</dc:creator>
      <pubDate>Sun, 01 Nov 2026 12:56:36 +0000</pubDate>
      <link>https://dev.to/daniel_pertu/two-of-our-pages-are-1065-and-1083-words-long-and-the-reading-times-under-their-titles-say-6-34p9</link>
      <guid>https://dev.to/daniel_pertu/two-of-our-pages-are-1065-and-1083-words-long-and-the-reading-times-under-their-titles-say-6-34p9</guid>
      <description>&lt;p&gt;&lt;a href="https://notifio.app" rel="noopener noreferrer"&gt;Notifio&lt;/a&gt; is a desktop app that watches rental search pages and alerts you when a new listing appears. Around it is a Next.js site, and eight of that site's 38 URLs are long-form pages: five &lt;a href="https://notifio.app/guides" rel="noopener noreferrer"&gt;guides&lt;/a&gt; and three pages for a kind of person rather than a task.&lt;/p&gt;

&lt;p&gt;They are all one type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Article&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="cm"&gt;/** Which route this belongs to. */&lt;/span&gt;
  &lt;span class="nl"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;audience&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;guide&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;eyebrow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;standfirst&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="cm"&gt;/** Rough minutes, shown under the title. Keep it honest. */&lt;/span&gt;
  &lt;span class="nl"&gt;readingMinutes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nl"&gt;sections&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ArticleSection&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;faqs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;q&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}[];&lt;/span&gt;
  &lt;span class="nl"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ArticleSource&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;relatedSites&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;relatedArticles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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;readingMinutes&lt;/code&gt; is a number a person types. There is no word count behind it, no division, nothing derived. The comment says "Keep it honest", which is the sort of instruction that deserves to be checked, so I checked it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the pages actually contain
&lt;/h2&gt;

&lt;p&gt;Counted from the rendered &lt;code&gt;&amp;lt;main&amp;gt;&lt;/code&gt; of each live page, which includes the body, the headings, the FAQ block and the related links, because that is what a reader scrolls through:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Page&lt;/th&gt;
&lt;th&gt;Claimed&lt;/th&gt;
&lt;th&gt;Words&lt;/th&gt;
&lt;th&gt;Implied words per minute&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://notifio.app/guides/how-fast-do-rental-listings-go" rel="noopener noreferrer"&gt;/guides/how-fast-do-rental-listings-go&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;1,065&lt;/td&gt;
&lt;td&gt;178&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://notifio.app/guides/how-to-be-first-to-a-rental-listing" rel="noopener noreferrer"&gt;/guides/how-to-be-first-to-a-rental-listing&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;1,085&lt;/td&gt;
&lt;td&gt;155&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://notifio.app/guides/first-message-to-a-landlord" rel="noopener noreferrer"&gt;/guides/first-message-to-a-landlord&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;966&lt;/td&gt;
&lt;td&gt;161&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://notifio.app/guides/rental-documents-checklist" rel="noopener noreferrer"&gt;/guides/rental-documents-checklist&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;845&lt;/td&gt;
&lt;td&gt;169&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://notifio.app/guides/rental-scams-netherlands" rel="noopener noreferrer"&gt;/guides/rental-scams-netherlands&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;1,172&lt;/td&gt;
&lt;td&gt;195&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://notifio.app/for/students" rel="noopener noreferrer"&gt;/for/students&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;1,228&lt;/td&gt;
&lt;td&gt;175&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://notifio.app/for/expats" rel="noopener noreferrer"&gt;/for/expats&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;1,219&lt;/td&gt;
&lt;td&gt;152&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://notifio.app/for/amsterdam" rel="noopener noreferrer"&gt;/for/amsterdam&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;1,083&lt;/td&gt;
&lt;td&gt;135&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The usual convention for a read-time badge is 200 to 250 words a minute. Against 200, every single one of these eight claims is longer than the page. That on its own I can live with: generous is the right direction to be wrong in, because a reader who finishes early is pleased and a reader who runs over feels misled.&lt;/p&gt;

&lt;p&gt;The part I cannot defend is the spread. The first row and the last row are 18 words apart, 1,065 against 1,083, and their badges differ by two minutes. There is no reading speed at which both of those numbers are right. Whatever the typed figure measures, it is not length, and it is not consistent between two pages written weeks apart by the same person.&lt;/p&gt;

&lt;p&gt;It is also not very useful information. The honest version of the field, for a page of this size, has two possible values: "about five minutes" or "about eight minutes", and the difference between the two is one scroll.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two ways to get this wrong
&lt;/h2&gt;

&lt;p&gt;A sibling codebase of mine has &lt;a href="https://dev.to/daniel_pertu/every-read-time-on-our-blog-is-a-word-count-divided-by-200-and-the-word-count-is-a-number-somebody-52l8"&gt;read times computed as a word count divided by 200&lt;/a&gt;, where the division is real and the word count is a number somebody typed. This one skips the arithmetic and types the answer.&lt;/p&gt;

&lt;p&gt;They are the same bug wearing different clothes: a derived number stored as an input. Both look fine forever, because nothing can fail. A page gets two new sections, nobody updates the badge, and the badge was never checked against anything in the first place.&lt;/p&gt;

&lt;p&gt;The fix is three lines, and it is the same three lines in both codebases:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;readingMinutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;words&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sections&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flatMap&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;heading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;points&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;flatMap&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
  &lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;words&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&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="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+/&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The content is already structured data in a TypeScript file, so the word count is available at build time for free. Deleting a field from a type is a nicer change than adding a test that asserts a human typed a defensible number.&lt;/p&gt;

&lt;h2&gt;
  
  
  While I was in there
&lt;/h2&gt;

&lt;p&gt;Three other things the same eight objects told me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Thirty-two FAQs, exactly four per page.&lt;/strong&gt; Not approximately four. Four, eight times. Nobody decided that a page must have four questions, and yet nobody has ever shipped one with three or five, which is the kind of pattern that forms when you copy the previous entry and fill it in. The questions are good and the uniformity is a tell: a page with only two real questions should ship two.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Five citations across eight pages.&lt;/strong&gt; &lt;code&gt;sources&lt;/code&gt; is part of the trust model, because the rule these pages are written against, in the type's own comment, is that "the advice has to be worth reading by somebody who never buys anything". Two pages carry two sources, one carries one, and five carry none. The layout renders the block conditionally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="c1"&gt;// ...the Sources heading and the list&lt;/span&gt;
&lt;span class="p"&gt;)}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So five of the eight do not show an empty Sources heading, they show no heading, and nothing in the build complains. The pages with nothing to cite are the softer, advice-shaped ones, which is exactly where a citation does the most work. A test that fails a published article with zero sources is the cheap version of a standard, and it would currently fail five pages, which is the point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One URL space, no root.&lt;/strong&gt; Guides live at &lt;code&gt;/guides/&amp;lt;slug&amp;gt;&lt;/code&gt; and audience pages at &lt;code&gt;/for/&amp;lt;slug&amp;gt;&lt;/code&gt;, from one type, split by one field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;articleHref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;audience&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`/for/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`/guides/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That split is deliberate and I would do it again: &lt;code&gt;/guides/&lt;/code&gt; is for somebody describing a task, &lt;code&gt;/for/&lt;/code&gt; is for somebody describing themselves, and the two read differently even when they say similar things. The consequence is that &lt;code&gt;/for&lt;/code&gt; by itself is a 404. The hub at &lt;a href="https://notifio.app/guides" rel="noopener noreferrer"&gt;/guides&lt;/a&gt; lists both kinds and its &lt;code&gt;ItemList&lt;/code&gt; structured data carries all eight, so nothing is unreachable, but the parent of three live URLs answering 404 is the kind of thing an SEO audit flags and a human never notices.&lt;/p&gt;

&lt;h2&gt;
  
  
  The link graph, which I was not expecting to be the best part
&lt;/h2&gt;

&lt;p&gt;Each article links three others and three or four of the per-site &lt;a href="https://notifio.app/alerts" rel="noopener noreferrer"&gt;alert pages&lt;/a&gt;. Counting inbound links across the eight:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://notifio.app/guides/first-message-to-a-landlord" rel="noopener noreferrer"&gt;first-message-to-a-landlord&lt;/a&gt;: 5&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://notifio.app/guides/rental-scams-netherlands" rel="noopener noreferrer"&gt;rental-scams-netherlands&lt;/a&gt;: 4&lt;/li&gt;
&lt;li&gt;rental-documents-checklist, expats, amsterdam: 3 each&lt;/li&gt;
&lt;li&gt;how-fast-do-rental-listings-go, how-to-be-first-to-a-rental-listing, students: 2 each&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Twenty-four links, hand-written, no orphans, and the most-linked page is the one about what to write to a landlord, which is also the page a person actually needs at the moment they are panicking. The structure came out right because each article's &lt;code&gt;relatedArticles&lt;/code&gt; was filled in by somebody thinking about that reader rather than about the graph.&lt;/p&gt;

&lt;p&gt;The alert pages did less well. Twelve of the fifteen get at least one link from an article. Three get none: &lt;a href="https://notifio.app/alerts/huurwoningen" rel="noopener noreferrer"&gt;Huurwoningen&lt;/a&gt;, &lt;a href="https://notifio.app/alerts/immobilienscout24" rel="noopener noreferrer"&gt;ImmobilienScout24&lt;/a&gt; and &lt;a href="https://notifio.app/alerts/zoopla" rel="noopener noreferrer"&gt;Zoopla&lt;/a&gt;. Those three are reachable from the hub and the footer, so this is not the &lt;a href="https://dev.to/daniel_pertu/fifteen-pages-45-sibling-links-and-the-one-page-no-sibling-links-to-290d"&gt;page with no sibling links&lt;/a&gt; problem I wrote about before, it is a weaker version of it: the pages with the fewest editorial links in are the ones whose countries the guides happen not to be about. Two of the three are the UK and Germany, and the guides lean Dutch. The fix is a guide, not a link.&lt;/p&gt;

&lt;p&gt;If you want to read the pages rather than the measurements, they are at &lt;a href="https://notifio.app/guides" rel="noopener noreferrer"&gt;notifio.app/guides&lt;/a&gt;, the per-site setup pages are at &lt;a href="https://notifio.app/alerts" rel="noopener noreferrer"&gt;notifio.app/alerts&lt;/a&gt;, and the app the whole thing is in front of is on the &lt;a href="https://notifio.app/download" rel="noopener noreferrer"&gt;download page&lt;/a&gt; with its price on the &lt;a href="https://notifio.app/pricing" rel="noopener noreferrer"&gt;pricing page&lt;/a&gt;. The reading times are under the titles. They are too high by about a minute each, and the next change to that file takes the field out.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>seo</category>
      <category>typescript</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>You Have Joined a Team That Writes Code for a Device on Your Desk</title>
      <dc:creator>Asael Shinder</dc:creator>
      <pubDate>Sun, 01 Nov 2026 12:56:21 +0000</pubDate>
      <link>https://dev.to/asael_shinder_9f53bdca840/you-have-joined-a-team-that-writes-code-for-a-device-on-your-desk-595a</link>
      <guid>https://dev.to/asael_shinder_9f53bdca840/you-have-joined-a-team-that-writes-code-for-a-device-on-your-desk-595a</guid>
      <description>&lt;p&gt;For years your code has run on servers you never saw. Now you have moved to a team that builds the software inside a small device, a sensor that sits on factory machines and reports their temperature. On your first morning someone hands you a circuit board, a cable and a datasheet of two hundred pages. There is no debugger you recognise, no log viewer and no way to refresh the page.&lt;/p&gt;

&lt;p&gt;It is tempting to start by reading everything. The datasheet, the codebase, the team's wiki, the notes from the last hardware revision. You will learn some of it, and it will all feel abstract, because you have no way yet to watch any of it happen.&lt;/p&gt;

&lt;p&gt;Here is a better first goal. Before you try to learn how the device works, build yourself a way to see what it is doing.&lt;/p&gt;

&lt;p&gt;In web work that comes for free. You change a line, reload, and the result is in front of you within seconds. Here you may have to make it yourself. Find out how the team gets messages out of the board, often through a serial connection to your laptop. Learn how to flash a new build and how long it takes. Ask whether there is a logic analyser or an oscilloscope in the office, and ask someone to show you how they use it. Get a light to blink when your code reaches a certain line.&lt;/p&gt;

&lt;p&gt;That sounds trivial. It is the most important thing you will do in the first week. Once you can change something and see the effect quickly, every other kind of learning speeds up. The datasheet stops being a wall of tables and becomes a reference you check against what you can observe. Questions you would have had to ask a colleague, you can now answer by trying.&lt;/p&gt;

&lt;p&gt;Pay attention to how the experienced people on the team watch the device. Their habits will show you which signals matter. They may have a script that decodes the raw output or a test rig that fakes a sensor reading. Ask for a copy.&lt;/p&gt;

&lt;p&gt;The same lesson works far beyond hardware. Whenever you land somewhere unfamiliar, a new platform, a new data pipeline, a system that runs overnight, your first job is to shorten the distance between doing something and seeing what happened. Learning follows much faster after that.&lt;/p&gt;

&lt;p&gt;– Asael Shinder&lt;/p&gt;

</description>
      <category>asaelshinder</category>
      <category>learning</category>
      <category>embedded</category>
      <category>hardware</category>
    </item>
    <item>
      <title>We passed null to say use the default and Terraform switched off deletion protection</title>
      <dc:creator>Sergey Shinder</dc:creator>
      <pubDate>Sun, 01 Nov 2026 12:56:12 +0000</pubDate>
      <link>https://dev.to/sergey_shinder_ab2d943365/we-passed-null-to-say-use-the-default-and-terraform-switched-off-deletion-protection-312i</link>
      <guid>https://dev.to/sergey_shinder_ab2d943365/we-passed-null-to-say-use-the-default-and-terraform-switched-off-deletion-protection-312i</guid>
      <description>&lt;p&gt;In August an engineer ran terraform destroy in what he thought was his own sandbox workspace. It was the shared QA environment, and among the things it removed was the QA database. Nobody worried at first, because our database module sets deletion_protection to true, and AWS refuses to delete an RDS instance with that flag set. The instance was gone anyway.&lt;/p&gt;

&lt;p&gt;The platform team owns the database module, and its variable for the flag has a default of true. Most teams do not call it directly. They call a wrapper module that adds their naming and tags, and the wrapper exposes the same setting with a default of null. The intention, written in a comment in the wrapper, was simple: if a team says nothing, let the platform module decide.&lt;/p&gt;

&lt;p&gt;That is not what null does. A variable in Terraform is nullable unless it says otherwise, and passing null into a nullable variable does not mean the caller left it out. It means the caller set it to null, and the default is never consulted. The platform module received null and handed it straight to the aws_db_instance resource, where a null argument is treated as not set at all. The provider then used its own default, which for deletion protection is false.&lt;/p&gt;

&lt;p&gt;So every database built through the wrapper had been created without protection. Each create plan had said deletion_protection = false, one line among sixty in a module everyone trusted. AWS Config has a managed rule, rds-instance-deletion-protection-enabled, and when we finally switched it on it reported fourteen of our twenty two instances, all built through the wrapper since its first version. We restored QA from the nightly snapshot copy and lost an afternoon of test data.&lt;/p&gt;

&lt;p&gt;Every variable in the platform module now sets nullable to false. With that set, a null coming in is replaced by the module's own default, which is the behaviour the wrapper's author had expected all along. The wrapper drops its own defaults and passes values only when a team sets them. The Config rule stays on in every account, and the database resource has a precondition that fails the plan unless the resolved value is true.&lt;/p&gt;

&lt;p&gt;Null in Terraform means unset, and an unset value goes to whoever is next in line. Ours was a provider that had never heard of our standards.&lt;/p&gt;

&lt;p&gt;– Sergey Shinder&lt;/p&gt;

</description>
      <category>sergeyshinder</category>
      <category>terraform</category>
      <category>aws</category>
      <category>iac</category>
    </item>
    <item>
      <title>Our only patched dependency spells its version in four places by hand, and the install checks three of them</title>
      <dc:creator>Daniel Pertu</dc:creator>
      <pubDate>Sun, 01 Nov 2026 12:56:02 +0000</pubDate>
      <link>https://dev.to/daniel_pertu/our-only-patched-dependency-spells-its-version-in-four-places-by-hand-and-the-install-checks-three-4p5m</link>
      <guid>https://dev.to/daniel_pertu/our-only-patched-dependency-spells-its-version-in-four-places-by-hand-and-the-install-checks-three-4p5m</guid>
      <description>&lt;p&gt;Munchable's label camera has tap to focus. You point the phone at an ingredients panel, the autofocus hunts, you tap the words and it locks on them for a few seconds before handing back to continuous focus.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;expo-camera&lt;/code&gt; does not ship that. So the repo carries a patch, and the whole of it is two lines of config:&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="c1"&gt;# pnpm-workspace.yaml&lt;/span&gt;
&lt;span class="na"&gt;patchedDependencies&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;expo-camera@57.0.5&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;patches/expo-camera@57.0.5.patch&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a post about those two lines rather than about the camera, because today I fixed a bug in them that no tool in the repository could have found.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is in a 221-line patch
&lt;/h2&gt;

&lt;p&gt;Ten files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;android/src/main/java/expo/modules/camera/CameraViewModule.kt
android/src/main/java/expo/modules/camera/ExpoCameraView.kt
ios/CameraViewModule.swift
ios/Current/CameraSessionManager.swift
ios/Current/CameraView.swift
src/Camera.types.ts
src/CameraView.tsx
build/Camera.types.d.ts
build/CameraView.d.ts
build/CameraView.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;121 added lines. The Kotlin side builds a metering action from the touch point and lets the preview's own point factory undo whatever the cover-fit crop did to the coordinates. The Swift side does the equivalent. Then the same method has to exist in JavaScript, and that is where patching a published package gets interesting, because an Expo module ships compiled output:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;src/CameraView.tsx&lt;/code&gt; is what a person reads&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;build/CameraView.js&lt;/code&gt; is what Metro actually loads&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;build/CameraView.d.ts&lt;/code&gt; is what TypeScript believes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All three have to be in the patch. Edit only the source and the method does not exist at runtime. Edit only the build output and your own call site is a type error. This is the part people do not expect from "just patch the package": you are editing a build artefact, by hand, in a diff.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a patch and not a fork
&lt;/h2&gt;

&lt;p&gt;A fork means publishing a package and owning its release cadence against an SDK that moves every few weeks. A config plugin cannot add a method to a view manager. A separate native module means re-implementing enough of the camera to own a preview.&lt;/p&gt;

&lt;p&gt;The patch, by contrast, is reproducible from the lockfile, which records it by content:&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="c1"&gt;# pnpm-lock.yaml&lt;/span&gt;
&lt;span class="na"&gt;patchedDependencies&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;expo-camera@57.0.5&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;d3f519a89e5ff8270e755eb5497f063d085496b31906fa63a44704e6aa67275b&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And that hash is not just a note. It is part of the package's identity in the resolution graph:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;expo-camera@57.0.5(patch_hash=d3f519a8...)(expo@57.0.24)(react-native@0.86.3)...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Change one line of the patch and the dependency becomes a different dependency, so every machine either has the patched copy or re-fetches and re-patches. That property is the whole argument for this approach. A patch that can silently not apply would be worse than a fork.&lt;/p&gt;

&lt;h2&gt;
  
  
  The version number lives in four hand-written places
&lt;/h2&gt;

&lt;p&gt;Count them:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;apps/mobile/package.json&lt;/code&gt;, as the dependency range: &lt;code&gt;"expo-camera": "~57.0.5"&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pnpm-workspace.yaml&lt;/code&gt;, as the &lt;code&gt;patchedDependencies&lt;/code&gt; key: &lt;code&gt;expo-camera@57.0.5&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The patch filename itself: &lt;code&gt;patches/expo-camera@57.0.5.patch&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;A comment in &lt;code&gt;apps/mobile/app/capture.tsx&lt;/code&gt; explaining where &lt;code&gt;focusAtAsync&lt;/code&gt; comes from&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Plus two more that pnpm generates in the lockfile, which do not count because nobody types them.&lt;/p&gt;

&lt;p&gt;Three of those four are load-bearing, and the install is loud about all three. I tested the second one in a throwaway project rather than taking pnpm's documentation on faith: a key naming a version that is not installed, with everything else correct.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ERR_PNPM_UNUSED_PATCH] The following patches were not used: is-odd@3.0.0

Either remove them from "patchedDependencies" or update them to match
packages in your dependencies.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Install fails. Not a warning, not a quiet skip, a non-zero exit, which is exactly what you want from a patch that adds a native method the app calls unconditionally. The filename is checked the same way: name a file that is not there and the install stops.&lt;/p&gt;

&lt;p&gt;The fourth place is the comment, and nothing on earth checks a comment. When the dependency moved from 57.0.4 to 57.0.5, items one, two and three had to change together or the install would not complete. Item four did not have to change, so it did not, and for a while the file explaining the patch pointed at &lt;code&gt;patches/expo-camera@57.0.4.patch&lt;/code&gt;, a path that does not exist, in the one place a new reader would go looking.&lt;/p&gt;

&lt;p&gt;That was today's fix. One character.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix I did not make
&lt;/h2&gt;

&lt;p&gt;The obvious response is to stop writing the version in the key. pnpm accepts a semver range, or the bare package name, and I checked both work:&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;patchedDependencies&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;is-odd@^3.0.0&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;patches/is-odd@3.0.0.patch&lt;/span&gt;   &lt;span class="c1"&gt;# applies to 3.0.1&lt;/span&gt;
  &lt;span class="na"&gt;is-odd&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;patches/is-odd@3.0.0.patch&lt;/span&gt;          &lt;span class="c1"&gt;# also applies&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tempting, and wrong for this patch. An exact pin means the next &lt;code&gt;expo-camera&lt;/code&gt; release stops the install until a human re-reads 221 lines of native diff against the new upstream code. A range means a patch written for 57.0.5 applies itself to 57.1.0 as long as the context lines still match, and "the context lines still match" is not the same statement as "the camera still focuses". For a patch made of Kotlin, Swift and a compiled JavaScript file, I want the interruption.&lt;/p&gt;

&lt;p&gt;So the pin stays, and the conclusion goes the other way: the version number should not be in the comment at all. The comment should say that a patch exists and that &lt;code&gt;pnpm-workspace.yaml&lt;/code&gt; names it, because that file cannot be stale. A fact with no checker, written in prose, is not documentation. It is a claim about the repository that only decays.&lt;/p&gt;

&lt;p&gt;If you want to see what those 121 lines bought, you need the app rather than the website: &lt;a href="https://munchable.app" rel="noopener noreferrer"&gt;munchable.app&lt;/a&gt; has the install link, and the tap to focus is on the label camera, the screen you reach by scanning a barcode we do not hold yet. If you want the words instead, &lt;a href="https://munchable.app/about" rel="noopener noreferrer"&gt;the about page&lt;/a&gt; says what the camera is for.&lt;/p&gt;

</description>
      <category>node</category>
      <category>expo</category>
      <category>devops</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Half of our revalidatePath calls name a layout, and a streaming campaign header is the reason</title>
      <dc:creator>Daniel Pertu</dc:creator>
      <pubDate>Sun, 01 Nov 2026 12:55:30 +0000</pubDate>
      <link>https://dev.to/daniel_pertu/half-of-our-revalidatepath-calls-name-a-layout-and-a-streaming-campaign-header-is-the-reason-5fig</link>
      <guid>https://dev.to/daniel_pertu/half-of-our-revalidatepath-calls-name-a-layout-and-a-streaming-campaign-header-is-the-reason-5fig</guid>
      <description>&lt;p&gt;Next.js gives you server rendering with a cache in front of it and then makes invalidation your problem. In &lt;a href="https://nakodo.app/" rel="noopener noreferrer"&gt;Nakodo&lt;/a&gt; that problem is 46 lines long, and I had never looked at all 46 of them together until this week.&lt;/p&gt;

&lt;p&gt;Here is the whole census.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;Ten files carry &lt;code&gt;"use server"&lt;/code&gt;, holding 62 exported actions. Six of those files call &lt;code&gt;revalidatePath&lt;/code&gt;, 46 times in total:&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="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rl&lt;/span&gt; &lt;span class="s1"&gt;'"use server"'&lt;/span&gt; src | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
10
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rc&lt;/span&gt; &lt;span class="s2"&gt;"revalidatePath("&lt;/span&gt; src | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s1"&gt;':0'&lt;/span&gt;
src/app/&lt;span class="o"&gt;(&lt;/span&gt;app&lt;span class="o"&gt;)&lt;/span&gt;/settings/actions.ts:3
src/app/&lt;span class="o"&gt;(&lt;/span&gt;app&lt;span class="o"&gt;)&lt;/span&gt;/support/actions.ts:6
src/app/&lt;span class="o"&gt;(&lt;/span&gt;app&lt;span class="o"&gt;)&lt;/span&gt;/campaigns/actions.ts:27
src/app/admin/users/actions.ts:4
src/app/admin/support/actions.ts:2
src/app/admin/mailing/actions.ts:4
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rno&lt;/span&gt; &lt;span class="s1"&gt;'revalidatePath([^)]*)'&lt;/span&gt; src | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'"layout"'&lt;/span&gt;
24
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"revalidateTag&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;'use cache'"&lt;/span&gt; src | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So: 38 of the 62 actions invalidate something, 24 invalidate nothing at all, half of the calls pass &lt;code&gt;"layout"&lt;/code&gt; as the second argument, and tags are not used anywhere in the codebase.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why half of them say "layout"
&lt;/h2&gt;

&lt;p&gt;Every one of the 24 &lt;code&gt;"layout"&lt;/code&gt; calls names a campaign path, and the reason is where the campaign's header lives.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;CampaignLayout&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;LayoutProps&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/campaigns/[id]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;params&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"space-y-6"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"space-y-3"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Suspense&lt;/span&gt; &lt;span class="na"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;HeaderSkeleton&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;CampaignHeader&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Suspense&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Suspense&lt;/span&gt; &lt;span class="na"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;CampaignTabs&lt;/span&gt; &lt;span class="na"&gt;campaignId&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Tabs&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Suspense&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only the URL is awaited in the layout itself; the header and the tab bar stream in separately, so switching tabs shows the new page's skeleton immediately instead of waiting for a header that has not changed. That is a performance decision, and it has a cache consequence: the header is not part of any page, it is part of the layout, and it holds the three things most likely to be out of date after an action.&lt;/p&gt;

&lt;p&gt;The header renders the campaign's name and logo, its catalogue number, a live status dot, and a progress line that is either "All N keywords searched" or "M of N keywords searched". Pause a campaign and all of that changes. Approve a batch of first emails and the status word changes. Save a brief and the name can change.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;revalidatePath("/campaigns/" + id)&lt;/code&gt; on its own invalidates the page segment and leaves the layout above it cached, so the body of the screen would update while the status beside the title still said the old thing. The second argument is not a precaution, it is the difference between a correct screen and a screen that contradicts itself.&lt;/p&gt;

&lt;p&gt;The cost is real and I accept it: &lt;code&gt;"layout"&lt;/code&gt; invalidates the whole subtree, so a reply to one conversation re-renders the results tab, the outreach tab and the brief tab the next time any of them is visited. Those are all dynamic, per-user pages whose data is one round trip to Postgres, so the thing I would be optimising is a few database queries nobody is waiting on. A finer-grained version of this would need tags, and tags would need a cache key per panel, and that is a lot of machinery to avoid re-running &lt;code&gt;select count(*)&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 24 that invalidate nothing
&lt;/h2&gt;

&lt;p&gt;Three different reasons, and all three are deliberate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;They redirect.&lt;/strong&gt; &lt;code&gt;deleteCampaignAction&lt;/code&gt;, &lt;code&gt;changePlanAction&lt;/code&gt;, &lt;code&gt;deleteAccountAction&lt;/code&gt;, sign-in, sign-up, password reset, the opt-out page. A redirect from a Server Action navigates, and every destination here is a dynamic page that renders per user on arrival, so there is nothing to invalidate. This is the one group where I would add a comment next time: the correctness depends on the destination being dynamic, and if we ever set &lt;code&gt;staleTimes&lt;/code&gt; to keep dynamic segments in the client router cache, a deleted campaign could appear in the list it redirects to. We set no &lt;code&gt;staleTimes&lt;/code&gt; today, so the default behaviour is on our side.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;They return a suggestion, not a fact.&lt;/strong&gt; &lt;code&gt;suggestProposalAction&lt;/code&gt;, &lt;code&gt;suggestCampaignDetailsAction&lt;/code&gt;, &lt;code&gt;findLogoAction&lt;/code&gt;, &lt;code&gt;checkedGuessesAction&lt;/code&gt;, &lt;code&gt;previewUpgradeAction&lt;/code&gt;. These hand an answer back to an open form, which puts it in React state next to whatever the person has already typed. Invalidating the page would be worse than useless: it would re-render the server's version of a form whose client state is the one the user is editing. The write happens later, in the save action, and the save action does invalidate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The UI already knows.&lt;/strong&gt; &lt;code&gt;acceptTermsAction&lt;/code&gt; is the clearest case. The terms notice records acceptance on mount and the dismiss control only hides it, so there is no server render anybody is waiting for. &lt;code&gt;askSupportAction&lt;/code&gt; is the same shape: the answer is appended to a transcript the client holds.&lt;/p&gt;

&lt;p&gt;The pattern across all three is that &lt;code&gt;revalidatePath&lt;/code&gt; is for changes to what the server would say if it were asked again. If the browser is already holding the new truth, invalidating is a request to go and fetch the old screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  The action that invalidates in both branches
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;try&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;campaign&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;queueRescore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;campaignId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;revalidatePath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/campaigns/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;campaignId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;layout&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;revalidatePath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/campaigns/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;campaignId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;layout&lt;/span&gt;&lt;span class="dl"&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="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;failure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;briefSaved&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the save-and-launch action, and the duplicate is the point. The brief is saved before the launch is attempted, so a failed launch still changed the campaign. Invalidating only on success would leave the screen showing the old brief next to an error message about launching it, which is the single most confusing thing a form can do. The &lt;code&gt;briefSaved: true&lt;/code&gt; in the failure result is the other half of the same thought: the error message has to be able to say "your brief is saved, the launch is not".&lt;/p&gt;

&lt;p&gt;The admin notice sender does the same thing four times, once at each of its four exits, because every exit writes to the progress row the page is showing. One path per exit, one call per path, no attempt at a &lt;code&gt;finally&lt;/code&gt;, because two of those exits return early and a &lt;code&gt;finally&lt;/code&gt; would hide which ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Paths that are not the page you are on
&lt;/h2&gt;

&lt;p&gt;Operator actions invalidate the record they edited:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;revalidatePath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/admin/users/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;except the one that deletes the account, which invalidates the list instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;revalidatePath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/admin/users&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Invalidating &lt;code&gt;/admin/users/&amp;lt;id&amp;gt;&lt;/code&gt; after deleting &lt;code&gt;&amp;lt;id&amp;gt;&lt;/code&gt; would be asking the cache to refresh a page that will now 404. Obvious once written down, easy to get wrong when you are copying the line above.&lt;/p&gt;

&lt;p&gt;The support actions invalidate two paths each, a computed thread path and the list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;revalidatePath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;supportPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reference&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nf"&gt;revalidatePath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/settings/support&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;because a reply changes both the conversation and its row in the list, and the list shows the last message time and an unread marker.&lt;/p&gt;

&lt;h2&gt;
  
  
  What revalidatePath cannot do here
&lt;/h2&gt;

&lt;p&gt;Nakodo's screens change while nobody is pressing anything. Creators turn up, replies arrive, emails go out on a schedule. No Server Action ran, so no invalidation can be the mechanism. That half of the problem is solved by polling a 16-character fingerprint of what the page shows and calling &lt;code&gt;router.refresh()&lt;/code&gt; only when it changes, which is a separate route and the subject of its own post. There are exactly four &lt;code&gt;router.refresh()&lt;/code&gt; calls in the app, and that poll is one of them.&lt;/p&gt;

&lt;p&gt;Which leaves one honest gap in the census. Twenty-three calls name a single campaign's subtree (18 of them by campaign id, five by the campaign a conversation belongs to), and exactly one call in the whole codebase names the campaigns list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;revalidatePath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/campaigns&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;layout&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is in the action that splits the daily email allowance between campaigns, because that one visibly changes every row at once. But pausing a campaign also changes its row in that list, and that action invalidates only the campaign's own subtree. The list is dynamic, so a fresh navigation re-renders it; a back-navigation inside the same session is the case I want to go and watch in a browser before claiming it is fine.&lt;/p&gt;

&lt;p&gt;Forty-six calls, no tags, no &lt;code&gt;"use cache"&lt;/code&gt;, and the only thing in this census I would change is a comment on the 24 actions that deliberately do nothing. Code that is correct because of a framework default is code that needs a sentence saying so.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>react</category>
      <category>webdev</category>
      <category>performance</category>
    </item>
  </channel>
</rss>
