<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Keith Staggers]]></title><description><![CDATA[Keith Staggers]]></description><link>https://keithstaggers.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Keith Staggers</title><link>https://keithstaggers.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Fri, 16 Oct 2026 12:59:36 GMT</lastBuildDate><atom:link href="https://keithstaggers.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Unicode name search in JavaScript without losing the spelling]]></title><description><![CDATA[Two strings can both display as Renée and still fail a strict equality check. The tempting repair is to remove every accent. That solves a different problem: Renée and Renee now share a key, and the o]]></description><link>https://keithstaggers.hashnode.dev/unicode-name-search-javascript-preserve-display-spelling</link><guid isPermaLink="true">https://keithstaggers.hashnode.dev/unicode-name-search-javascript-preserve-display-spelling</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[unicode]]></category><category><![CDATA[Web Development]]></category><dc:creator><![CDATA[Keith Staggers]]></dc:creator><pubDate>Wed, 14 Oct 2026 10:16:05 GMT</pubDate><content:encoded><![CDATA[<p>Two strings can both display as Renée and still fail a strict equality check. The tempting repair is to remove every accent. That solves a different problem: Renée and Renee now share a key, and the original spelling may disappear if you save that key back into the profile.</p>
<p>Let's keep those decisions separate. We'll build a small JavaScript name index that preserves the submitted display string, uses canonical Unicode comparison by default, and makes broader matching an explicit option. Every search returns candidates with their existing record IDs.</p>
<h2>Normalise the comparison</h2>
<p>A precomposed <code>é</code> is one Unicode code point. The sequence <code>e</code> followed by a combining acute accent is another way to represent the same abstract character. <a href="https://www.unicode.org/reports/tr15/#Canonical_Equivalence">Unicode calls that canonical equivalence</a>.</p>
<p>In JavaScript, <code>normalize('NFC')</code> brings canonically equivalent strings into the same form. It preserves the accent in this example. Removing marks takes an additional step. That distinction matters: a matching rule is not permission to rewrite someone's name.</p>
<p>The complete example below makes both operations visible. Save the index as <code>name-index.mjs</code>:</p>
<pre><code class="language-js">const canonicalKey = value =&gt; value.normalize('NFC');
const markInsensitiveKey = value =&gt;
  value.normalize('NFD').replace(/\p{M}/gu, '');

function requireText(value, label) {
  if (typeof value !== 'string' || value.trim() === '') {
    throw new TypeError(`${label} must be a non-empty string`);
  }
}

function addToBucket(index, key, record) {
  const bucket = index.get(key) ?? [];
  bucket.push(record);
  index.set(key, bucket);
}

export function createNameIndex(input) {
  if (!Array.isArray(input)) {
    throw new TypeError('Expected an array of records');
  }

  const byId = new Map();
  const byCanonical = new Map();
  const byMarksRemoved = new Map();

  for (const item of input) {
    requireText(item?.id, 'id');
    requireText(item?.displayName, 'displayName');
    if (byId.has(item.id)) {
      throw new Error(`Duplicate record ID: ${item.id}`);
    }

    const record = Object.freeze({
      id: item.id,
      displayName: item.displayName,
    });
    byId.set(record.id, record);
    addToBucket(byCanonical, canonicalKey(record.displayName), record);
    addToBucket(byMarksRemoved, markInsensitiveKey(record.displayName), record);
  }

  return Object.freeze({
    find(query, mode = 'canonical') {
      if (typeof query !== 'string') {
        throw new TypeError('query must be a string');
      }
      if (mode !== 'canonical' &amp;&amp; mode !== 'marks-removed') {
        throw new RangeError('Unknown search mode');
      }
      if (query.trim() === '') return [];

      const canonical = mode === 'canonical';
      const key = canonical ? canonicalKey(query) : markInsensitiveKey(query);
      if (key === '') return [];
      const index = canonical ? byCanonical : byMarksRemoved;
      return [...(index.get(key) ?? [])];
    },
    list() {
      return [...byId.values()];
    },
  });
}
</code></pre>
<p>The original <code>displayName</code> never goes through either key function before it is stored. Each returned record is frozen, and the index copies the input fields into its own snapshot. Editing the original array later will not quietly change that snapshot. Result arrays are copies, so sorting or clearing one does not alter the index's buckets.</p>
<p>The two maps hold <strong>arrays of records</strong>, not one record per search key. Otherwise, inserting a second name with the same key could overwrite the first. A key collision belongs in the result, where the caller can see it.</p>
<h2>Run three records through it</h2>
<p>Save this beside the index as <code>demo.mjs</code>. The two spellings with an accent use explicit JavaScript escapes so you can see which representation each contains:</p>
<pre><code class="language-js">import { createNameIndex } from './name-index.mjs';

const composed = 'Ren\u00E9e';
const decomposed = 'Rene\u0301e';
console.log('Raw equality:', composed === decomposed);
console.log('NFC equality:', composed.normalize('NFC') === decomposed.normalize('NFC'));

// These IDs come from the application, not from a name transformation.
const records = [
  { id: 'record-101', displayName: composed },
  { id: 'record-202', displayName: 'Renee' },
  { id: 'record-303', displayName: decomposed },
];
const names = createNameIndex(records);
console.log('Canonical:', names.find(decomposed).map(record =&gt; record.id));
console.log('Marks removed:', names.find('Renee', 'marks-removed').map(record =&gt; record.id));
console.log('Original strings retained:', names.list().map(record =&gt; record.displayName));
</code></pre>
<p>The example was tested with Node.js 24.19.0. Run it with:</p>
<pre><code class="language-bash">node demo.mjs
</code></pre>
<p>The important output is:</p>
<pre><code class="language-text">Raw equality: false
NFC equality: true
Canonical: [ 'record-101', 'record-303' ]
Marks removed: [ 'record-101', 'record-202', 'record-303' ]
</code></pre>
<p>The canonical search returns two records because both accented representations have the same NFC key. The unmarked spelling remains different. Choosing <code>marks-removed</code> returns all three.</p>
<p>Neither search combines records, picks a winner, or changes the display names. Even an exact canonical match can produce multiple candidates. The first result is not automatically the right one.</p>
<p>The IDs are supplied by the application. They are not generated from a normalised name. If a display name changes, retain that record's ID and rebuild this snapshot from the updated records. The example rejects duplicate IDs because quietly overwriting a record would hide a data error.</p>
<h2>What the broader mode actually does</h2>
<p><code>normalize('NFD')</code> decomposes the text. <code>/\p{M}/gu</code> then removes Unicode characters in the Mark category. That is a deliberately lossy transformation. It is useful here to expose the collision, rather than to define the right search behaviour for every language.</p>
<p>It also does less than its appearance might suggest. This example stays case-sensitive, preserves spaces, and does not turn every non-English letter into an English one. For example, it will not match <code>Łucja</code> against <code>Lucja</code>. Pick any additional case, whitespace, or language rules explicitly and test them with appropriate examples.</p>
<p>Do not substitute <code>NFKC</code> on the assumption that it means “remove accents”. Compatibility normalisation has a different purpose and can remove other distinctions. <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/normalize#compatibility_normalization">MDN's explanation</a> shows why a transformed value suitable for search may be unsuitable for display.</p>
<p>The default here remains canonical comparison. Blank queries return no results, unknown modes throw, and non-string input is rejected rather than becoming an accidental search for <code>[object Object]</code>. These choices make unintended broad matching harder to introduce through a typo or an empty search box.</p>
<h2>Keep a collision in the test suite</h2>
<p>A test that checks only the composed and decomposed strings misses the main product risk. Include an unmarked spelling and assert that broad search returns every distinct record.</p>
<p>Save this as <code>collision.test.mjs</code>:</p>
<pre><code class="language-js">import assert from 'node:assert/strict';
import test from 'node:test';
import { createNameIndex } from './name-index.mjs';

test('broad search preserves collisions and display spelling', () =&gt; {
  const input = [
    { id: 'a', displayName: 'Ren\u00E9e' },
    { id: 'b', displayName: 'Renee' },
    { id: 'c', displayName: 'Rene\u0301e' },
  ];
  const names = createNameIndex(input);
  assert.deepEqual(names.find('Renee', 'marks-removed'), input);
  assert.deepEqual(names.find('Rene\u0301e').map(record =&gt; record.id), ['a', 'c']);
  assert.deepEqual(JSON.parse(JSON.stringify(names.list())), input);
});
</code></pre>
<p>Run <code>node --test collision.test.mjs</code>. This checks both the wider result set and a JSON export/import round trip. The stored strings survive that round trip exactly, including their different internal representations.</p>
<p>Before using the pattern in an application, follow the display name into exports, messages, and reimported records too. Keeping it intact inside this index will not help if another layer replaces it with the search key.</p>
<p>A name result is a set of text matches. Account access and decisions about a person's identity need their own authorised controls. When you show results in a UI, render the names as text rather than treating them as HTML. This index also does not decide how to rank results, handle every language, or coordinate database updates.</p>
<p>Keep the value you display separate from the values you compare. Let search be forgiving where you have chosen that behaviour, while keeping the original spelling and each record's existing ID intact.</p>
<hr />
<p>Keith Staggers is an AI trainer and automation builder in Tampa, Florida. He creates practical training materials and tools at <a href="https://www.keithstaggers.com/">Keith Staggers Studio</a>.</p>
]]></content:encoded></item><item><title><![CDATA[The form is full. Why is the payload empty?]]></title><description><![CDATA[The workshop title is still on the screen. The captions box is still checked. You click the button, the form becomes unavailable while it saves, and the payload is empty.
The tempting place to investi]]></description><link>https://keithstaggers.hashnode.dev/the-form-is-full-why-is-the-payload-empty</link><guid isPermaLink="true">https://keithstaggers.hashnode.dev/the-form-is-full-why-is-the-payload-empty</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[HTML]]></category><category><![CDATA[Web Development]]></category><dc:creator><![CDATA[Keith Staggers]]></dc:creator><pubDate>Mon, 12 Oct 2026 13:54:33 GMT</pubDate><content:encoded><![CDATA[<p>The workshop title is still on the screen. The captions box is still checked. You click the button, the form becomes unavailable while it saves, and the payload is empty.</p>
<p>The tempting place to investigate is the request: the endpoint, the serializer, the server parser. But the values may have disappeared before a request was made.</p>
<p>This order is enough to cause it:</p>
<pre><code class="language-js">fields.disabled = true;
const data = new FormData(form);
</code></pre>
<p>Here, <code>fields</code> is the fieldset containing the inputs. Disabling it is meant to prevent another edit while an operation is in progress. It also changes which controls the browser collects. The interface has changed the meaning of the form before taking its snapshot.</p>
<h2>Watch one value disappear</h2>
<p>In <a href="https://codepen.io/editor/Keith-Staggers/pen/01a1302e-d295-7956-a86f-a49045b81407">this interactive FormData demo</a>, the workshop title starts read-only. Inspect the entries, change that field to Disabled, and inspect again. The words stay visible. The <code>workshop</code> entry disappears.</p>
<p>An enabled, named read-only text input contributes its value. A disabled input does not. Unchecking the captions checkbox removes its entry too; it does not produce <code>false</code>. Those are the browser's collection rules, not a decision made by the demo. The <a href="https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#constructing-the-entry-list">HTML entry-list algorithm</a> defines those exclusions.</p>
<p>Now put that behavior inside a submit handler. If the first step disables the fieldset, the following <code>new FormData(form)</code> sees the disabled form. It cannot recover the earlier state from the words still visible inside it.</p>
<h2>Capture, then lock</h2>
<p>For an operation that should use the values present at submission, collect them first. Disable the intended region afterward. Restore its previous state when the operation finishes, including when it rejects.</p>
<p>Save the following as <code>formdata-saving-state.html</code> and open it in a browser. Both buttons inspect sample data locally. There is no request or storage operation in this example.</p>
<pre><code class="language-html">&lt;!doctype html&gt;
&lt;html lang="en"&gt;
&lt;meta charset="utf-8"&gt;
&lt;meta name="viewport" content="width=device-width, initial-scale=1"&gt;
&lt;title&gt;FormData and the saving state&lt;/title&gt;

&lt;form id="workshop-form"&gt;
  &lt;fieldset id="fields" disabled&gt;
    &lt;legend&gt;Workshop request&lt;/legend&gt;
    &lt;p&gt;
      &lt;label for="workshop"&gt;Workshop&lt;/label&gt;
      &lt;input id="workshop" name="workshop"
             value="Camera basics" readonly&gt;
    &lt;/p&gt;
    &lt;label&gt;
      &lt;input name="captions" type="checkbox" value="yes" checked&gt;
      Include captions
    &lt;/label&gt;
    &lt;p&gt;
      &lt;button type="submit" name="action" value="preview"&gt;
        Capture first
      &lt;/button&gt;
      &lt;button id="broken" type="button"&gt;Disable first&lt;/button&gt;
    &lt;/p&gt;
  &lt;/fieldset&gt;
&lt;/form&gt;
&lt;noscript&gt;This example needs JavaScript to inspect the entries.&lt;/noscript&gt;
&lt;pre id="result" aria-live="polite"&gt;Choose an order.&lt;/pre&gt;

&lt;script&gt;
  const form = document.getElementById("workshop-form");
  const fields = document.getElementById("fields");
  const result = document.getElementById("result");
  let pending = false;

  async function preview(data) {
    result.textContent = JSON.stringify([...data.entries()], null, 2);
  }

  form.addEventListener("submit", async (event) =&gt; {
    event.preventDefault();
    if (pending) return;
    pending = true;
    const wasDisabled = fields.disabled;

    try {
      const data = event.submitter
        ? new FormData(form, event.submitter)
        : new FormData(form);
      fields.disabled = true;
      await preview(data);
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);
      result.textContent = "Preview failed: " + message;
    } finally {
      fields.disabled = wasDisabled;
      pending = false;
    }
  });

  document.getElementById("broken").addEventListener("click", () =&gt; {
    if (pending) return;
    const wasDisabled = fields.disabled;
    try {
      fields.disabled = true;
      const data = new FormData(form);
      result.textContent = JSON.stringify([...data.entries()], null, 2);
    } finally {
      fields.disabled = wasDisabled;
    }
  });

  form.addEventListener("input", () =&gt; {
    if (!pending) result.textContent = "Form changed. Inspect again.";
  });
  fields.disabled = false;
&lt;/script&gt;
&lt;/html&gt;
</code></pre>
<p><strong>Capture first</strong> produces three pairs:</p>
<pre><code class="language-json">[
  ["workshop", "Camera basics"],
  ["captions", "yes"],
  ["action", "preview"]
]
</code></pre>
<p><strong>Disable first</strong> produces <code>[]</code>. All the controls that could contribute an entry are inside the disabled fieldset. Its legend contains text only, so the exception for controls inside a fieldset's first legend does not affect this example.</p>
<p>The extra <code>action</code> pair belongs to the clicked submit button. The <a href="https://developer.mozilla.org/en-US/docs/Web/API/FormData/FormData">second argument to <code>FormData</code></a> supplies the submitter so its name and value can be included. Without that argument, the button's entry is absent. If your form has Save and Delete buttons with different values, losing that distinction can be a separate bug. Both buttons in this example only inspect data.</p>
<p>The <code>pending</code> guard prevents overlapping submissions through this handler. The fieldset controls the temporary interaction state. They have different jobs.</p>
<h2>Restore the state you actually changed</h2>
<p>It is easy to end a handler by setting every input's <code>disabled</code> property to <code>false</code>. That can enable a control which was deliberately unavailable before the operation started.</p>
<p>This example changes only the fieldset, so it saves and restores only that fieldset's previous <code>disabled</code> value. Individual controls keep their own attributes. A checkbox disabled before submission remains disabled afterward. If your interface changes several controls individually, save each one's previous state and restore each one, rather than applying one blanket value.</p>
<p>The <code>finally</code> block also runs if collecting the entries or the preview operation throws. Move the same cleanup around your real operation. A rejected request should not leave an otherwise usable form stuck in its saving state.</p>
<p>That assumes this handler owns the temporary lock. If another part of the application can change the same controls while the request is pending, coordinate those changes in the application's state model. Restoring an old value must not undo a newer permission decision.</p>
<h2>An absent entry needs a meaning</h2>
<p>Uncheck Include captions and click Capture first. The result contains <code>workshop</code> and <code>action</code>; there is no <code>captions</code> pair. Disable that checkbox instead, and its entry is also absent. The resulting data does not tell the server which of those situations occurred. <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/checkbox">MDN's checkbox reference</a> explains why an unchecked checkbox contributes no name or value.</p>
<p>Decide what omission means in the request contract. For a complete preferences form with an available checkbox, omission may mean false. For a partial update, it may mean leave the existing preference alone. Those operations need different handling.</p>
<p>The example prints the entry list directly. It does not turn it into an object first. A form can legitimately contain several values with the same name. Inspecting <code>data.getAll("name")</code> preserves them; converting the list with <code>Object.fromEntries()</code> keeps only the last value for each repeated key. Do not let a debugging shortcut hide part of the evidence.</p>
<h2>A correct snapshot is still untrusted input</h2>
<p>Using <code>readonly</code> can be appropriate when a text value should remain selectable and be included, but should not be edited through the control. It is not a general replacement for <code>disabled</code>: <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/readonly">read-only is not supported on checkboxes or select elements</a>.</p>
<p>Neither attribute makes the submitted value trustworthy. A browser user can change the markup or send a different request. The server must validate the values and authorize the requested action. If the workshop title is determined by a trusted booking record, resolve it there; do not treat the read-only text as authoritative. <a href="https://developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Forms/Form_validation">Client-side validation cannot replace server validation</a>.</p>
<p>The local <code>preview</code> function only displays the captured pairs. In a real application, collecting data, sending it and accepting the operation are separate steps. A successful collection does not establish any of the later outcomes.</p>
<p>Before wiring up the request, try three changes: disable the region before collection, uncheck an option, and make the operation reject. For the last check, replace the body of <code>preview</code> with <code>throw new Error("Try again")</code>. Compare the entries and the restored control state in each case. My free <a href="https://www.keithstaggers.com/workflow-testing-template/">workflow testing worksheet</a> has space for the prediction, the observed result and the evidence.</p>
<p>Start the debugging session one line earlier: inspect what the browser collected before investigating what the server received.</p>
]]></content:encoded></item><item><title><![CDATA[The JSON is valid. The deadline is still made up.]]></title><description><![CDATA[The source note says “Possible launch Friday.” The update says “Launch confirmed for Friday.”
Both sentences fit inside perfectly valid JSON. A schema can tell you that date contains a date. It cannot]]></description><link>https://keithstaggers.hashnode.dev/the-json-is-valid-the-deadline-is-still-made-up</link><guid isPermaLink="true">https://keithstaggers.hashnode.dev/the-json-is-valid-the-deadline-is-still-made-up</guid><dc:creator><![CDATA[Keith Staggers]]></dc:creator><pubDate>Sun, 11 Oct 2026 15:54:58 GMT</pubDate><content:encoded><![CDATA[<p>The source note says “Possible launch Friday.” The update says “Launch confirmed for Friday.”</p>
<p>Both sentences fit inside perfectly valid JSON. A schema can tell you that <code>date</code> contains a date. It cannot tell you who confirmed it.</p>
<p>Here is a small way to make that change visible: keep a reviewed table of source facts beside the draft, then compare the draft's labeled claims with that table. We will catch a changed deadline status, a borrowed task owner and a successful export that gets mistaken for a successful import. No model call, API key or package installation is needed.</p>
<h2>Start with the notes, not the polished update</h2>
<p>These five notes describe the project:</p>
<ul>
<li><strong>S1:</strong> Landing-page draft done. No client approval yet.</li>
<li><strong>S2:</strong> Ana to send draft to client Tuesday, October 27, 2026.</li>
<li><strong>S3:</strong> Spreadsheet export passed three sample checks. Import into the next system still errors.</li>
<li><strong>S4:</strong> Possible launch Friday, October 30. Proposed, not confirmed.</li>
<li><strong>S5:</strong> Help page needs an owner. Nobody assigned.</li>
</ul>
<p>Three details deserve their own fields. The launch has a date <strong>and a degree of certainty</strong>. The help page's owner is explicitly unassigned. Export and import are different steps, even though they use the same file.</p>
<p>A person first checks the fact table against these notes. Each fact gets an ID, its source, a subject, a field, a value and a certainty label. <code>recorded</code> means the value was recorded in the note; it does not mean the work is complete. The launch uses <code>proposed</code>. An unassigned owner uses <code>null</code>.</p>
<p>Each draft claim carries the same fields and a reference to one fact. A person must check that the labels describe what the sentence actually says. If another model supplies those labels, its work needs the same review. Otherwise we would be asking an unreviewed model to certify another unreviewed model.</p>
<h2>Give the code a job it can actually do</h2>
<p>The comparison is deliberately literal. It checks the reference and compares subject, field, value and certainty. It does not rewrite the update, infer a missing owner or decide which source is newest. A malformed record throws an error instead of becoming an empty success.</p>
<p>Save the complete example below as <code>demo.mjs</code> and run it with Node.js 20 or newer:</p>
<pre><code class="language-sh">node demo.mjs
</code></pre>
<p>The source text, fact table, claims, checks and readable output are all included.</p>
<pre><code class="language-javascript">// Compare tagged claims with a source table a person has checked.
// This does not interpret prose or decide whether an update is safe to send.
const certainties = new Set(["recorded", "proposed", "confirmed"]);
const nonempty = (value) =&gt; typeof value === "string" &amp;&amp; value.trim() !== "";
const scalar = (value) =&gt; value === null || typeof value === "string" ||
  typeof value === "boolean" || (typeof value === "number" &amp;&amp; Number.isFinite(value));
const record = (value) =&gt; value !== null &amp;&amp; typeof value === "object" &amp;&amp; !Array.isArray(value);

function validatePacket(packet) {
  if (!record(packet) || !record(packet.sources) ||
      !Array.isArray(packet.facts) || !Array.isArray(packet.claims)) {
    throw new TypeError("Expected sources, facts and claims.");
  }
  if (!Object.entries(packet.sources).every(([id, text]) =&gt; nonempty(id) &amp;&amp; nonempty(text))) {
    throw new TypeError("Every source needs an ID and source text.");
  }
  for (const [kind, rows] of [["fact", packet.facts], ["claim", packet.claims]]) {
    const seen = new Set();
    for (const row of rows) {
      if (!record(row) || !["id", "subject", "field"].every((key) =&gt; nonempty(row[key])) ||
          !Object.hasOwn(row, "value") || !scalar(row.value) ||
          !certainties.has(row.certainty) || seen.has(row.id)) {
        throw new TypeError(`Invalid or duplicate ${kind} record.`);
      }
      seen.add(row.id);
      if (kind === "fact" &amp;&amp; (!nonempty(row.sourceId) ||
          !Object.hasOwn(packet.sources, row.sourceId))) {
        throw new TypeError(`Unknown source for fact ${row.id}.`);
      }
      if (kind === "claim" &amp;&amp; (!nonempty(row.text) ||
          !(row.evidenceId === null || nonempty(row.evidenceId)))) {
        throw new TypeError(`Claim ${row.id} needs text and an evidence ID or null.`);
      }
    }
  }
}

function compareClaimFields(packet) {
  validatePacket(packet);
  const facts = new Map(packet.facts.map((fact) =&gt; [fact.id, fact]));
  return packet.claims.map((claim) =&gt; {
    const fact = facts.get(claim.evidenceId);
    const issues = [];
    if (claim.evidenceId === null) {
      issues.push("MISSING_EVIDENCE");
    } else if (!fact) {
      issues.push("UNKNOWN_EVIDENCE");
    } else {
      if (claim.subject !== fact.subject) issues.push("SUBJECT_MISMATCH");
      if (claim.field !== fact.field) issues.push("FIELD_MISMATCH");
      if (claim.value !== fact.value) issues.push("VALUE_MISMATCH");
      if (claim.certainty !== fact.certainty) issues.push("CERTAINTY_MISMATCH");
    }
    return {
      id: claim.id,
      status: issues.length ? "needs_review" : "fields_match",
      issues,
      sourceId: fact?.sourceId ?? null,
      proseReviewRequired: true,
    };
  });
}

function summaryRows(packet, report, language = "en") {
  const pt = language === "pt-BR";
  const messages = pt ? {
    MISSING_EVIDENCE: "falta uma referência",
    UNKNOWN_EVIDENCE: "referência desconhecida",
    SUBJECT_MISMATCH: "o assunto é outro",
    FIELD_MISMATCH: "o campo é outro",
    VALUE_MISMATCH: "o valor é diferente",
    CERTAINTY_MISMATCH: "o grau de certeza mudou",
  } : {
    MISSING_EVIDENCE: "missing evidence",
    UNKNOWN_EVIDENCE: "unknown evidence",
    SUBJECT_MISMATCH: "different subject",
    FIELD_MISMATCH: "different field",
    VALUE_MISMATCH: "different value",
    CERTAINTY_MISMATCH: "certainty changed",
  };
  return report.map((result, index) =&gt; ({
    id: result.id,
    text: packet.claims[index].text,
    result: result.status === "fields_match"
      ? (pt ? "CAMPOS CONFEREM" : "FIELDS MATCH")
      : (pt ? "REVISAR" : "REVIEW"),
    reason: result.issues.length
      ? result.issues.map((code) =&gt; messages[code]).join("; ")
      : (pt ? "a redação ainda precisa de revisão" : "prose still needs review"),
  }));
}

const packet = {
  sources: {
    "S1": "Landing-page draft done. No client approval yet.",
    "S2": "Ana to send draft to client Tuesday, October 27, 2026.",
    "S3": "Spreadsheet export passed three sample checks. Import into the next system still errors.",
    "S4": "Possible launch Friday, October 30. Proposed, not confirmed.",
    "S5": "Help page needs an owner. Nobody assigned.",
  },
  facts: [
    {"id": "F1", "sourceId": "S1", "subject": "landing-page", "field": "client_approval", "value": "not_received", "certainty": "recorded"},
    {"id": "F2", "sourceId": "S2", "subject": "send-draft", "field": "owner", "value": "Ana", "certainty": "recorded"},
    {"id": "F3", "sourceId": "S3", "subject": "spreadsheet-export", "field": "sample_checks_passed", "value": 3, "certainty": "recorded"},
    {"id": "F4", "sourceId": "S3", "subject": "next-system-import", "field": "status", "value": "error", "certainty": "recorded"},
    {"id": "F5", "sourceId": "S4", "subject": "launch", "field": "date", "value": "2026-10-30", "certainty": "proposed"},
    {"id": "F6", "sourceId": "S5", "subject": "help-page", "field": "owner", "value": null, "certainty": "recorded"},
  ],
  claims: [
    {"id": "C1", "text": "Launch is confirmed for October 30.", "subject": "launch", "field": "date", "value": "2026-10-30", "certainty": "confirmed", "evidenceId": "F5"},
    {"id": "C2", "text": "Ana owns the help page.", "subject": "help-page", "field": "owner", "value": "Ana", "certainty": "recorded", "evidenceId": "F6"},
    {"id": "C3", "text": "The import passed three sample checks.", "subject": "next-system-import", "field": "sample_checks_passed", "value": 3, "certainty": "recorded", "evidenceId": "F3"},
    {"id": "C4", "text": "The client approved the landing page.", "subject": "landing-page", "field": "client_approval", "value": "received", "certainty": "recorded", "evidenceId": "F1"},
    {"id": "C5", "text": "October 30 is a proposed launch date.", "subject": "launch", "field": "date", "value": "2026-10-30", "certainty": "proposed", "evidenceId": "F5"},
    {"id": "C6", "text": "The help page has no assigned owner.", "subject": "help-page", "field": "owner", "value": null, "certainty": "recorded", "evidenceId": "F6"},
    {"id": "C7", "text": "The export passed three sample checks.", "subject": "spreadsheet-export", "field": "sample_checks_passed", "value": 3, "certainty": "recorded", "evidenceId": "F3"},
    {"id": "C8", "text": "The next-system import still errors.", "subject": "next-system-import", "field": "status", "value": "error", "certainty": "recorded", "evidenceId": "F4"},
    {"id": "C9", "text": "Ana is assigned to send the draft.", "subject": "send-draft", "field": "owner", "value": "Ana", "certainty": "recorded", "evidenceId": "F2"},
  ],
};
console.table(summaryRows(packet, compareClaimFields(packet), "en"));
</code></pre>
<h2>Read the report as a comparison, not permission to send</h2>
<p>The first four claims need review. The following five match the fields in the fact table.</p>
<table>
<thead>
<tr>
<th>Claim</th>
<th>Result</th>
<th>What changed or stayed intact</th>
</tr>
</thead>
<tbody><tr>
<td>C1: launch confirmed</td>
<td>REVIEW</td>
<td><code>proposed</code> became <code>confirmed</code></td>
</tr>
<tr>
<td>C2: Ana owns the help page</td>
<td>REVIEW</td>
<td><code>null</code> became <code>Ana</code></td>
</tr>
<tr>
<td>C3: import passed three checks</td>
<td>REVIEW</td>
<td>The evidence describes the export</td>
</tr>
<tr>
<td>C4: client approved the page</td>
<td>REVIEW</td>
<td>Approval changed from absent to received</td>
</tr>
<tr>
<td>C5: proposed launch date</td>
<td>FIELDS MATCH</td>
<td>Date and uncertainty are preserved</td>
</tr>
<tr>
<td>C6: no help-page owner</td>
<td>FIELDS MATCH</td>
<td>Nobody has been assigned</td>
</tr>
<tr>
<td>C7: export passed three checks</td>
<td>FIELDS MATCH</td>
<td>The count belongs to the export</td>
</tr>
<tr>
<td>C8: import still errors</td>
<td>FIELDS MATCH</td>
<td>The unresolved step remains visible</td>
</tr>
<tr>
<td>C9: Ana is assigned to send the draft</td>
<td>FIELDS MATCH</td>
<td>Her assignment stays attached to that task</td>
</tr>
</tbody></table>
<p>Notice what is missing: there is no <code>approved</code> result. Every record keeps <code>proseReviewRequired: true</code>.</p>
<p>When a comparison fails, fix the draft or investigate the source. Do not edit the fact table merely to make the draft pass. If the real plan changes, review the new note and update the table deliberately. A fact table also needs a date or version in a working system; otherwise an old, accurately copied decision can become today's wrong answer.</p>
<h2>Now break it on purpose</h2>
<p>In C5, change only the sentence to “Launch is confirmed for October 30.” Leave its <code>certainty</code> as <code>proposed</code>.</p>
<p>It still returns <code>FIELDS MATCH</code>.</p>
<p>That is the boundary of this checker. The tags agree with the source table, but the sentence disagrees with its tags. Reading <code>text</code> for truth is not part of the comparison. A person must check that connection, along with whether the source table is current and whether an important fact has been omitted. One record should describe one claim. Split a sentence containing several claims before checking it.</p>
<p>Try a second change: give C2 the evidence ID <code>F2</code>, the fact about Ana sending the draft. The name now matches, but the subject does not. Ana's assignment to send a document still does not make her responsible for the help page.</p>
<p>You can add this kind of check beside an AI drafting step, a form or a spreadsheet export. Its useful contribution is a small, inspectable list of disagreements. The person reviewing the update gets to spend attention on the decision instead of hunting for where one changed word came from.</p>
<p>For another round, the <a href="https://www.keithstaggers.com/notes/weekly-project-status-update-example/">English five-claim exercise and editable worksheet</a> use the same notes. Start with the sentence a teammate is most likely to act on. Make its date, owner and uncertainty earn their place.</p>
]]></content:encoded></item></channel></rss>