← Blog
How it works TableRune

A missing date is not a date

“Our invoices sometimes print a date nobody entered. The screen for the same invoice shows a placeholder, so the data is clearly missing, and I cannot work out which of the two is lying.”

Here are four renderings of one field. The field is an invoice's issue date, the invoice is one record, and all four functions live in the same frontend:

screen (formatInvoiceDate):   an em dash placeholder
JSON   (toISODate):           null
CSV    (toISODate(...) || ""):  ""
PDF    (new Date + toLocaleDateString): "Jan 1, 1970"

Three of those say the date is missing. One says it is the first of January 1970. The one that says 1970 is the PDF, which is the only one of the four that gets emailed to the person who owes the money.

This is not a story about a forgotten null check, because the codebase has not forgotten nulls at all. It has a representation for a date it does not have, a helper that renders it, a schema rule that permits it, and a comment explaining why it exists.

The dates in the commit log are the whole plot. The PDF generator's two date lines were written on 2026-01-03. 219 days later, on 2026-08-10, one commit started producing null dates, and in the same commit it wrote the tolerant helper that renders them and taught the screens to use it. It did not touch the PDF file. A refactor on 2026-10-07 moved the null-producing code into one function and did not touch the PDF file either. Nobody broke the PDF. The PDF stayed exactly as it was while the meaning of the field it reads changed underneath it, which is the version of this bug that no diff review catches, because every line in the diff that introduced it is correct.

Everything below was measured today, from the repository at its current default branch tip. What I could not check is listed at the end.

Where the missing date comes from

The app is an on-chain invoicing frontend. The contract stores the parties, the amount, the token, the paid and cancelled flags, and a hash of an invoice payload that is delivered separately. It does not store dates. For the sender, that payload lives only on the device that created the invoice. Open the app on another device, or after clearing site data, and the page has the invoice but not its dates. The same happens when a stored payload cannot be trusted. Either way the code says so explicitly:

  const base = trusted
    ? { ...payload }
    : {
        user: { address: raw[1].toLowerCase() },
        client: { address: raw[2].toLowerCase() },
        issueDate: null,
        dueDate: null,
      };

That is buildChainInvoice, and the null is a decision, not an oversight. Its own docstring says a stored payload that is not the one the sender committed to is ignored, because "anyone could have put it there", and the invoice is still payable from the chain's values. A missing payload gets the same treatment. The dates are the fields that have no on-chain counterpart, so they are the fields that become null.

The helper that renders them is just as deliberate. Its docstring:

/**
 * Format a date for display, tolerating the nulls that on-chain-only invoices
 * carry - the chain does not store invoice dates, so a stub has none.
 *
 * @param {string|number|Date|null|undefined} value
 * @returns {string} a localised date-time, or an em dash placeholder
 */

(The dash in that docstring is a real em dash in the source. This site's copy gate bars the character, so it is a hyphen above and described in words elsewhere.)

The JSON and CSV exporters go through a different helper, toISODate, which opens with if (!value) return null;. The CSV exporter then writes toISODate(...) || "", so an absent date becomes an empty cell.

And then the PDF generator. This is the issue date; the due date line under it is identical:

  const issueDate = new Date(invoice.issueDate).toLocaleDateString("en-US", {
    year: "numeric",
    month: "short",
    day: "numeric",
  });

No guard. The value goes straight into new Date().

Why null becomes 1970 and undefined does not

new Date(null) is not an error and it is not an Invalid Date. It is the epoch, and the specification gets there in three steps.

The Date constructor with one argument, when that argument is not a Date and not a String, does this:

1. Let _primitive_ be ? ToPrimitive(_value_).
   ...
1. Else,
   1. Let _tv_ be ? ToNumber(_primitive_).
1. Let _dv_ be TimeClip(_tv_).

ToNumber is where the two spellings of "missing" part company:

1. If _arg_ is *undefined*, return *NaN*.
1. If _arg_ is either *null* or *false*, return *+0*.

And +0 is not an arbitrary number. The clause on time values says: "The exact moment of midnight at the beginning of 1 January 1970 UTC is represented by the time value +0."

So undefined produces NaN, which TimeClip passes through, which gives an Invalid Date and the string "Invalid Date". null produces +0, which is a perfectly valid moment in time, and the formatter has no way to know that nobody chose it. The empty string takes a third route, through Date.parse, and also ends up an Invalid Date.

Three ways of spelling an absent value, two of which fail loudly and one of which fails by inventing a plausible answer. Measured in two independent engines, Node 22.22.0 (V8) and Bun 1.4.4 (JavaScriptCore), with TZ=UTC:

null                getTime()=0     toLocaleDateString="Jan 1, 1970"
undefined           getTime()=NaN   toLocaleDateString="Invalid Date"
"" (empty string)   getTime()=NaN   toLocaleDateString="Invalid Date"
0 (number)          getTime()=0     toLocaleDateString="Jan 1, 1970"

Both engines agree on every line.

Note the last row. A real timestamp of 0 is indistinguishable from null here, which is also why the if (!value) guard in the working helpers is not quite right either: it treats a genuine midnight-1970 timestamp as missing. That direction of the confusion is harmless in an invoicing app and worth knowing in one that handles older data.

The date depends on where the reader is

toLocaleDateString with no timeZone option formats in the runtime's local zone. The stored moment is midnight UTC, so for anyone behind UTC the calendar day is the one before. Same script, same engines, TZ=America/New_York:

null                getTime()=0     toLocaleDateString="Dec 31, 1969"

So the printed date for a missing value is 1 January 1970 in London and Mumbai and 31 December 1969 in New York. It is not even a stable wrong answer. Two invoices exported from the same record on two laptops disagree, and the field that disagrees is a date on a document about money.

It is reachable, and the button next to it knows

A bug in a code path nobody takes is a curiosity. This one needs nothing unusual: a sender opening the app on a second laptop, then three clicks.

An on-chain-only invoice appears in the Sent and Received lists like any other, and its date cell shows the placeholder, because the list uses the tolerant helper. Opening the row opens a drawer: toggleDrawer has no condition on the stub flag. The drawer shows both dates, again through the tolerant helper, again as placeholders.

At the bottom of that same drawer are two buttons, and this is the part worth looking at:

                  {!drawerState.selectedInvoice._onChainOnly && (
                    <button
                      type="button"
                      onClick={() =>
                        setShareInvoiceId(

The Share button is hidden for exactly the invoices whose dates are null. The author knew which rows were stubs and gated a feature on it. That condition closes on line 1463. The Export Invoice button opens on line 1465, outside it, and its menu offers PDF, CSV and JSON. Select the invoice in the list instead and use the bulk export and you get a merged PDF, one page per invoice, with no filter on the flag anywhere in that path.

So the screen shows a placeholder, and the document generated from that screen, by a button two lines past the brace that closes a test of the exact flag responsible, shows a date.

Nothing can check these dates

It is worth being precise about why no validation catches this.

The payload validator permits an absent date by name:

const isAbsent = (value) => value === undefined || value === null;
...
  if (!isDateOrAbsent(payload.issueDate)) return "issueDate";

That function exists to stop a hostile sender crashing the pages with wrong types, and it was written knowing the dates are optional. The on-chain comparison, which is the part that actually proves a payload honest, compares the sender, the recipient, the token address and the amount. It cannot compare dates, because the contract never stored any. A payload with issueDate: null therefore passes the hash check, passes the shape check, passes the field comparison, and arrives at the PDF generator as a fully verified invoice with no issue date.

The useful way to say this: the contract vouches for the fields it holds, and the dates are outside that set. Any date on one of these PDFs is the sender's claim and nothing more, which is fine, as long as the document does not manufacture one when the sender made no claim at all.

Where TableRune fits

TableRune does not generate invoices, but it formats stored timestamps for display, and looking at our own three call sites is the clearest version of the lesson.

Two are guarded the same way the working helpers above are. The license page in both TableRune and AegisPDF:

  function fmtDate(unix: number | undefined): string {
    if (!unix) return '-';
    return new Date(unix * 1000).toLocaleString();
  }

(That placeholder is an em dash in the source too.)

The data grid is better than guarded, and it is the pattern I would hold up. It never hands an arbitrary value to the constructor at all:

  function asDate(v: unknown): Date | null {
    if (v instanceof Date) return v;
    if (typeof v === 'number') return new Date(v);
    if (typeof v === 'bigint') return new Date(Number(v));
    return null;
  }

unknown in, and only the three types that have a defined meaning are converted. Everything else is null, and the caller then checks Number.isNaN(d.getTime()) before formatting. There is no route from a missing value to a date.

The third is the saved-workflows panel, and it is unguarded:

  function fmt(ts: number): string {
    return new Date(ts).toLocaleDateString();
  }

That looks safe, and in a sense it is: ts is typed number, so the compiler will not let a null through. But the value comes from localStorage, and the module that reads it does this:

    const parsed = JSON.parse(raw);
    return Array.isArray(parsed) ? (parsed as SavedWorkflow[]) : [];

JSON.parse returns any and the cast asserts a shape that nothing verified. The annotation on fmt is a claim about what the compiler saw, not about what is in the browser. JSON can express null. Running TableRune's own read, sort and fmt against three stored records:

"written by today's build"     fmt() = "10/26/2026"
"savedAt is null"              fmt() = "1/1/1970"
"savedAt is absent"            fmt() = "Invalid Date"

Same two engines, and in TZ=America/New_York the middle line reads 12/31/1969.

Today the only code that writes those records is the panel's save button, which always passes savedAt: Date.now(), so the broken rows come only from storage someone has edited or a schema change we have not made yet. The blast radius is also much smaller than an invoice: it is a label on your own saved pipeline, on your own machine, not a figure on a document you send to someone who owes you money. The reason it is worth fixing anyway is that it is the same shape, and the shape is the lesson. A type annotation on the formatter is a promise the parser cannot keep. The guard has to be in the formatter, where the value actually arrives.

The general version

The two working helpers in that invoice app, which between them cover three of its four display paths, and the two working ones in ours all do the same thing: they decide what "missing" renders as before converting anything. The two broken ones convert first and render whatever comes out. That is the whole difference.

What makes the date case nastier than most missing-value bugs is that the conversion succeeds. A missing name renders as an empty space and a reader sees a gap. A missing number renders as NaN and a reader sees a bug. A missing date renders as a date, in the right format, in the right place on the page, and the only thing wrong with it is that it is not true. The failure has been laundered into something that looks like data, and the document carrying it is the one artifact in the system that outlives the session, gets filed, gets attached to an email, and gets read by someone with no access to the screen that said the field was empty.

If you have one of these generators, the check is quick: find every new Date( whose argument comes from storage or a network payload, and ask what that expression returns when the field is absent. If the answer is a date, you have a document that can state something nobody ever entered.

What was verified, and what was not

Measured on 2026-10-26, on Linux, with Node 22.22.0 (V8 12.4.254.21) and Bun 1.4.4 (JavaScriptCore), in TZ=UTC, TZ=America/New_York and TZ=Asia/Kolkata, then measured again on a second machine with Node 24.19.0 and Bun 1.3.1, adding TZ=Europe/London. Every output in this post was identical across all of them.

The invoice app is StabilityNexus/Chainvoice, cloned anonymously over git today. Its default-branch tip at clone time was a44ba2c1, dated 2026-10-25. Every line quoted from it is verbatim at that commit, and the behaviour described is present there: no commit has touched those two date lines since f15ae793 wrote them on 2026-01-03. The commit that introduced the null-date stub and the tolerant helper, d04bc732 of 2026-08-10, does not touch that file, and neither does 5b022f1c of 2026-10-07, which moved the stub into buildChainInvoice.

The specification quotations are from the ECMA-262 editor's draft source, cloned today at 40ce6f20, from the clauses sec-date (the Date constructor), sec-tonumber and sec-time-values-and-time-range. The algorithm steps are quoted with the source's own markup for values removed.

Named limits:

  • I did not run the app. The four comparison lines at the top of this post come from running the four functions standalone, copied verbatim out of the checkout, against a stub holding issueDate: null. All four are pure. Installing that project's dependencies was not permitted here, so no PDF was generated and nothing was rendered in a browser. The claim about what the PDF contains is a claim about the string the generator passes to the PDF library, which is the line quoted above, not about a file I opened.
  • buildChainInvoice was not executed, because it needs the project's dependencies. The null it assigns is read from the source.
  • Reachability is read from the source, not clicked through. The chain of list, drawer, ungated export button and unfiltered bulk export is traced through the JSX; I did not run the UI.
  • There is an upstream bug report about this, Chainvoice issue 265, opened on 2026-10-12 and still open. It describes the same mechanism, the null from chainInvoice.js reaching new Date() in the PDF generator, and asks for a placeholder instead. It does not mention the time zone variant. A maintainer has asked for a reproduction in the running app before anyone works on it, and this post does not supply one either: the reachability above is read from the source.
  • The TableRune claim is about a path nothing currently writes. saveWorkflow always sets a real timestamp. The measurement shows what the display does with a record that lacks one; it does not show that such a record exists in anyone's browser today. I did not run svelte-check, so the statement that the compiler accepts fmt is a reading of the cast, not the output of a type checker.

No counts appear anywhere in this post: not of reports, not of affected projects, not of anything. I had no way to check any, so there are none to check.