A publisher can produce the right final checksum and still expose a broken file between its first and last write. Put an AI-written replacement routine under a reader microscope, make failure happen before commit, and require the public path to contain the complete old artifact or the complete new one—never a truncated mixture.

The Final Hash Misses the Publication Window

Imagine a process that rebuilds a JSON catalog while another process serves that file. The writer opens catalog.json, emits the new bytes, closes the handle, and then verifies the checksum. The final file is perfect. A unit test that reads only after the writer resolves will approve it.

A reader that arrives during the write can see something else. Node documents the w file-system flag as creating a file or truncating an existing one. Opening the public target with that flag changes the reader-visible path before the candidate is complete. A server, indexer, worker, or deployment hook can therefore observe an empty or partial artifact even though the later checksum is exact.

This is not a backpressure test. It does not ask how much data a writable queues or whether a producer pauses. It asks when a new artifact becomes visible at one pathname. The negative control deliberately writes slowly so the fixture can inspect that publication window without depending on lucky timing.

Write the Contract Before Choosing an API

The fixture begins with one complete 18,982-byte JSON document named release-a. A publisher must replace it with a different complete 18,982-byte document named release-b. The contract has five independent parts:

  1. Visibility: every fresh read of the target before commit returns the exact old bytes; a read after commit returns the exact new bytes.
  2. Failure isolation: a write or validation error before commit leaves the old target unchanged.
  3. Candidate identity: the bytes validated in staging are the bytes moved into place.
  4. Private staging: a publisher creates a unique same-directory temp with exclusive-create semantics and does not overwrite somebody else’s temp.
  5. Cleanup: an ordinary caught failure removes the temp that this invocation created.

Those are process-level release properties. They are not yet a promise about sudden power loss, remote filesystems, permissions, open handles, two competing publishers, or a machine crash between cleanup instructions. Naming the boundary keeps a useful fixture from becoming an infrastructure fairy tale.

The Negative Control Publishes Too Early

The intentionally bad function opens the destination itself with w. It then writes the candidate in 257-byte chunks and calls sync() before closing. Awaiting each operation prevents overlapping writes, and syncing requests that file data be flushed, but neither choice repairs the visibility mistake. The public name pointed at the incomplete candidate as soon as the target was truncated.

The test pauses after the first chunk and performs a fresh readFile() on the target. It receives exactly 257 bytes. The snapshot is neither the old artifact nor the new artifact, and JSON parsing fails. After all chunks finish, the same function leaves the exact 18,982-byte new file. This is the negative control the final-hash test lacked: the wrong implementation fails while still producing a clean endpoint.

An injected failure after chunk two makes the consequence harder to dismiss. The function rejects, but the target now contains a 514-byte invalid fragment. Catching the promise or logging the error does not restore the prior release.

Separate Candidate Construction From the Commit Point

The corrected path creates a temp beside the target, writes the entire candidate there, requests a sync, closes it, reads it back for exact-byte and JSON validation, and only then calls rename(tempPath, targetPath). The rename is the commit point. Before it, readers use the old directory entry. After it, new pathname lookups use the replacement.

Keeping the temp in the target directory matters. POSIX permits rename() to fail with EXDEV when the paths are on different file systems. A system temp directory may live on a different volume from the artifact. Building the candidate beside the target avoids designing a commit around a cross-volume move.

The fixture uses a hidden-looking name containing the target basename and a random token, then opens it with wx. Node documents wx as the truncating-write flag plus exclusive creation: it fails if that path already exists. That prevents one invocation from silently truncating a colliding temp. Node also warns that exclusive creation might not work as expected on network file systems, so this mechanism must be tested on the actual deployment storage.

Exclusive temp creation is not a lock on catalog.json. Two writers with different temp names can both validate and rename; the last commit can win. If the application requires generation checks, compare-and-swap behavior, or a single writer, add that coordination as a separate contract. Do not infer it from wx.

Validate What Will Move, Not What You Meant to Write

The corrected function does not validate an in-memory object and assume serialization succeeded. Its default validator reopens the staged file, compares every byte with the candidate buffer, parses the staged UTF-8 as JSON, and returns its SHA-256. Only that validated path is passed to rename().

A real publisher can replace the JSON parse with checks appropriate to its artifact: parse generated HTML, load a manifest, verify referenced assets, enforce a schema, or run a static-site smoke test. The invariant stays the same: validation operates on the closed staged artifact that is about to become visible.

File metadata needs its own plan. A temp created under the process umask can have different permissions, ownership, ACLs, extended attributes, or labels from the file it replaces. This fixture tests bytes and pathname visibility only. A production release gate should assert the metadata its runtime depends on before treating rename as ready.

A Three-File Publication Lab for Node 24.18

Keep the following files together in a new directory; every dependency comes from Node core. First ask Node to parse both modules with npm run check, then execute the seven claims with npm test. Validation below used precisely Node 24.18.0, as declared in the manifest.

package.json

{
  "name": "atomic-file-publisher-fixture",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "check": "node --check artifact-publisher.mjs && node --check artifact-publisher.test.mjs",
    "test": "node --test artifact-publisher.test.mjs"
  },
  "engines": {
    "node": "24.18.0"
  }
}

artifact-publisher.mjs

import { createHash, randomUUID } from 'node:crypto';
import { open, readFile, rename, rm } from 'node:fs/promises';
import { basename, dirname, join } from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';

export const DEFAULTS = Object.freeze({
  chunkSize: 257,
  delayMs: 1,
  recordCount: 96,
});

export function buildArtifact(release, recordCount = DEFAULTS.recordCount) {
  if (!/^[a-z0-9.-]+$/i.test(release)) {
    throw new TypeError('release must be a simple non-empty identifier');
  }
  if (!Number.isInteger(recordCount) || recordCount < 1) {
    throw new RangeError('recordCount must be a positive integer');
  }

  const records = Array.from({ length: recordCount }, (_, index) => ({
    id: `item-${String(index).padStart(3, '0')}`,
    release,
    payload: `${release}:${String(index).padStart(3, '0')}:${'x'.repeat(96)}`,
  }));

  return Buffer.from(`${JSON.stringify({ schemaVersion: 1, release, records }, null, 2)}\n`);
}

export function sha256(bytes) {
  return createHash('sha256').update(bytes).digest('hex');
}

async function writeCompletely(handle, bytes) {
  let offset = 0;
  while (offset < bytes.length) {
    const { bytesWritten } = await handle.write(bytes, offset, bytes.length - offset, null);
    if (bytesWritten === 0) throw new Error('write made no progress');
    offset += bytesWritten;
  }
}

async function writeInChunks(handle, bytes, options) {
  const {
    chunkSize = DEFAULTS.chunkSize,
    delayMs = DEFAULTS.delayMs,
    failAfterChunks = null,
    afterChunk = async () => {},
  } = options;

  if (!Number.isInteger(chunkSize) || chunkSize < 1) {
    throw new RangeError('chunkSize must be a positive integer');
  }

  let chunksWritten = 0;
  for (let start = 0; start < bytes.length; start += chunkSize) {
    const chunk = bytes.subarray(start, Math.min(start + chunkSize, bytes.length));
    await writeCompletely(handle, chunk);
    chunksWritten += 1;
    await afterChunk({
      chunksWritten,
      bytesWritten: Math.min(start + chunk.length, bytes.length),
      totalBytes: bytes.length,
    });

    if (chunksWritten === failAfterChunks) {
      throw new Error(`injected failure after chunk ${chunksWritten}`);
    }
    if (delayMs > 0) await delay(delayMs);
  }

  return chunksWritten;
}

export async function validateExactJson(candidatePath, expectedBytes) {
  const actual = await readFile(candidatePath);
  if (!actual.equals(expectedBytes)) {
    throw new Error('staged bytes do not match the candidate');
  }
  JSON.parse(actual.toString('utf8'));
  return sha256(actual);
}

// Negative control: opening with "w" truncates the reader-visible target first.
export async function publishInPlace(targetPath, candidate, options = {}) {
  const bytes = Buffer.from(candidate);
  const trace = [];
  let handle;

  try {
    handle = await open(targetPath, 'w');
    trace.push('opened-target');
    await writeInChunks(handle, bytes, options);
    trace.push('wrote-target');
    await handle.sync();
    trace.push('synced-target');
  } finally {
    if (handle) {
      await handle.close();
      trace.push('closed-target');
    }
  }

  return { targetPath, trace, sha256: sha256(bytes) };
}

export async function publishStaged(targetPath, candidate, options = {}) {
  const bytes = Buffer.from(candidate);
  const {
    token = randomUUID(),
    validate = validateExactJson,
  } = options;

  if (!/^[a-z0-9_-]+$/i.test(token)) {
    throw new TypeError('token must contain only letters, digits, underscores, or hyphens');
  }

  const tempPath = join(dirname(targetPath), `.${basename(targetPath)}.${token}.tmp`);
  const trace = [];
  let handle;
  let created = false;
  let renamed = false;

  try {
    handle = await open(tempPath, 'wx');
    created = true;
    trace.push('created-temp');
    await writeInChunks(handle, bytes, options);
    trace.push('wrote-temp');
    await handle.sync();
    trace.push('synced-temp');
    await handle.close();
    handle = undefined;
    trace.push('closed-temp');

    const stagedHash = await validate(tempPath, bytes);
    trace.push('validated-temp');
    await rename(tempPath, targetPath);
    renamed = true;
    trace.push('renamed-temp');

    return { targetPath, tempPath, trace, sha256: stagedHash };
  } finally {
    if (handle) {
      await handle.close();
      trace.push('closed-temp');
    }
    if (created && !renamed) {
      await rm(tempPath, { force: true });
      trace.push('removed-temp');
    }
  }
}

artifact-publisher.test.mjs

import assert from 'node:assert/strict';
import { readdir, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { test } from 'node:test';

import {
  DEFAULTS,
  buildArtifact,
  publishInPlace,
  publishStaged,
  sha256,
} from './artifact-publisher.mjs';

const OLD_SHA256 = 'b41fd7656b46cb02f0fbca0403b7c869cc428ecfd5d43d33bbb18e0bda2da834';
const NEW_SHA256 = '8b78cf592b22b2be35e6bc54e2724590e86a7258cbb43e2089db31dfe80ca20b';

async function withFixture(t) {
  const directory = await import('node:fs/promises').then(({ mkdtemp }) =>
    mkdtemp(join(tmpdir(), 'atomic-publish-')),
  );
  t.after(() => rm(directory, { recursive: true, force: true }));
  const targetPath = join(directory, 'catalog.json');
  const oldBytes = buildArtifact('release-a');
  const newBytes = buildArtifact('release-b');
  await writeFile(targetPath, oldBytes);
  return { directory, targetPath, oldBytes, newBytes };
}

async function tempNames(directory) {
  return (await readdir(directory)).filter((name) => name.endsWith('.tmp')).sort();
}

test('atomic file publication evidence matrix', async (t) => {
  await t.test('fixtures have stable, distinct bytes and hashes', () => {
    const oldBytes = buildArtifact('release-a');
    const newBytes = buildArtifact('release-b');
    assert.equal(oldBytes.length, newBytes.length);
    assert.equal(sha256(oldBytes), OLD_SHA256);
    assert.equal(sha256(newBytes), NEW_SHA256);
    assert.notEqual(sha256(oldBytes), sha256(newBytes));
    assert.equal(JSON.parse(oldBytes).records.length, DEFAULTS.recordCount);
  });

  await t.test('negative control exposes a partial target despite an exact final file', async (t) => {
    const { targetPath, newBytes } = await withFixture(t);
    let firstSnapshot;

    await publishInPlace(targetPath, newBytes, {
      afterChunk: async ({ chunksWritten }) => {
        if (chunksWritten === 1) firstSnapshot = await readFile(targetPath);
      },
    });

    assert.equal(firstSnapshot.length, DEFAULTS.chunkSize);
    assert.notDeepEqual(firstSnapshot, newBytes);
    assert.throws(() => JSON.parse(firstSnapshot.toString('utf8')));
    assert.deepEqual(await readFile(targetPath), newBytes);
  });

  await t.test('staged writes leave every sampled target old until rename, then new', async (t) => {
    const { targetPath, oldBytes, newBytes } = await withFixture(t);
    const observedHashes = [];

    const result = await publishStaged(targetPath, newBytes, {
      token: 'successful-run',
      afterChunk: async () => observedHashes.push(sha256(await readFile(targetPath))),
    });

    assert.ok(observedHashes.length > 1);
    assert.deepEqual(new Set(observedHashes), new Set([sha256(oldBytes)]));
    assert.deepEqual(await readFile(targetPath), newBytes);
    assert.equal(dirname(result.tempPath), dirname(targetPath));
    assert.deepEqual(result.trace, [
      'created-temp',
      'wrote-temp',
      'synced-temp',
      'closed-temp',
      'validated-temp',
      'renamed-temp',
    ]);
  });

  await t.test('an in-place failure damages the reader-visible target', async (t) => {
    const { targetPath, oldBytes, newBytes } = await withFixture(t);

    await assert.rejects(
      publishInPlace(targetPath, newBytes, { failAfterChunks: 2 }),
      /injected failure after chunk 2/,
    );

    const damaged = await readFile(targetPath);
    assert.notDeepEqual(damaged, oldBytes);
    assert.notDeepEqual(damaged, newBytes);
    assert.throws(() => JSON.parse(damaged.toString('utf8')));
  });

  await t.test('a staged failure preserves the old target and removes its temp', async (t) => {
    const { directory, targetPath, oldBytes, newBytes } = await withFixture(t);

    await assert.rejects(
      publishStaged(targetPath, newBytes, {
        token: 'failed-run',
        failAfterChunks: 2,
      }),
      /injected failure after chunk 2/,
    );

    assert.deepEqual(await readFile(targetPath), oldBytes);
    assert.deepEqual(await tempNames(directory), []);
  });

  await t.test('validation failure also preserves the old target and cleans up', async (t) => {
    const { directory, targetPath, oldBytes, newBytes } = await withFixture(t);

    await assert.rejects(
      publishStaged(targetPath, newBytes, {
        token: 'invalid-run',
        validate: async () => { throw new Error('candidate rejected'); },
      }),
      /candidate rejected/,
    );

    assert.deepEqual(await readFile(targetPath), oldBytes);
    assert.deepEqual(await tempNames(directory), []);
  });

  await t.test('exclusive temp creation refuses a collision without deleting it', async (t) => {
    const { directory, targetPath, oldBytes, newBytes } = await withFixture(t);
    const collision = join(directory, '.catalog.json.collision.tmp');
    const sentinel = Buffer.from('belongs to another publisher');
    await writeFile(collision, sentinel, { flag: 'wx' });

    await assert.rejects(
      publishStaged(targetPath, newBytes, { token: 'collision' }),
      (error) => error?.code === 'EEXIST',
    );

    assert.deepEqual(await readFile(targetPath), oldBytes);
    assert.deepEqual(await readFile(collision), sentinel);
  });
});

Read the Seven Cases as Separate Evidence

The Node test runner reports eight passing tests because the evidence matrix is one parent with seven subtests. Each subtest protects a different claim:

  1. Stable fixtures: both releases contain 18,982 bytes. release-a hashes to b41fd7656b46cb02f0fbca0403b7c869cc428ecfd5d43d33bbb18e0bda2da834; release-b hashes to 8b78cf592b22b2be35e6bc54e2724590e86a7258cbb43e2089db31dfe80ca20b.
  2. Bad-path contrast: the first fresh target read during an in-place update contains 257 bytes of invalid JSON, even though the final file is exact.
  3. Staged success: all 74 reads taken between staged chunks return the old hash. The final target then byte-matches the new release, and the trace orders create, write, sync, close, validate, and rename.
  4. In-place write failure: rejection after two chunks leaves a reader-visible 514-byte invalid fragment.
  5. Staged write failure: the same injected failure preserves every old byte and leaves no invocation-owned temp behind.
  6. Validation failure: a rejected candidate never reaches rename; the old target remains exact and staging is cleaned.
  7. Name collision: wx produces EEXIST, preserves the target, and does not delete or change the pre-existing temp.

The after-chunk callback is a deterministic observation point, not a mock filesystem. Both implementations call real Node file APIs in a real temporary directory. The callback simply gives the reader a turn after a known write and before the next one. This makes the failure repeatable without pretending that a one-millisecond timer proves a scheduling rule.

Atomic Visibility and Durable Storage Are Different Claims

Node’s Node 24.18 documentation for filehandle.sync() says the call requests that file data be flushed to the storage device and notes that the implementation is operating-system- and device-specific. That is why the code syncs the staged file before rename. It reduces a known gap; it does not authorize the sentence “this update survives every crash.”

The POSIX.1-2024 rename() specification gives a strong directory-entry visibility rule on conforming systems: when replacing an existing non-directory, the destination name remains visible and refers to either the old or new file during the operation. The POSIX rationale on directory durability separately explains that atomic directory operations are not necessarily durable and discusses syncing the directory when the new entry itself must survive a crash.

Node’s promise API documents fsPromises.rename() as renaming one path to another; it does not turn every filesystem and deployment arrangement into POSIX. This fixture passed on its Windows test volume under Node 24.18.0. Before release, repeat it on the actual local, container, mounted, or network storage where the publisher runs and record the platform and filesystem.

A stronger crash-durability implementation may need a directory-handle sync after rename, a journaled datastore, a versioned-object pointer, or a deployment primitive provided by the storage system. That extension is intentionally outside this cross-platform fixture. The test would need controlled crash or power-loss fault injection; a normal thrown exception is not equivalent.

Fresh Path Reads Are Not Already-Open Handles

Every observation in the matrix opens the target path afresh. A reader that already holds the old file open can continue reading that old file after the pathname changes, depending on platform and sharing behavior. That can be desirable: one request finishes consistently on the old version while a later request opens the new one. It is a different observable from repeated path lookups.

If a consumer memory-maps the file, caches its descriptor, watches directory events, or tails by inode, design a fixture for that behavior. “The path is atomically replaced” does not mean every pre-existing handle instantly changes identity.

Cleanup Handles Exceptions, Not Every Interruption

The finally block closes the handle and removes the temp when ordinary JavaScript control returns through an error. It deliberately tracks whether this invocation created the temp; when exclusive open fails on a collision, it leaves the other file untouched.

A killed process cannot run finally. Production systems often need a conservative scavenger for abandoned staging files. Use a publisher-specific name pattern, minimum age, ownership or generation metadata, and a rule that cannot delete an active candidate. Scavenging is maintenance, not part of the commit’s visibility proof.

The same caution applies to cleanup failures. Logging a failed temp removal may be sufficient when the target is still intact; silently converting it into a successful release is not. Preserve the original publication error and report the orphan separately.

Report Whether the Commit Point Was Crossed

Failure injection should name a phase, not merely flip one generic error switch. Useful checkpoints include before temp creation, midway through writing, after sync, after validation, during rename, and after rename while recording metrics or sending a notification. The first five are pre-commit failures in this design: the target should remain old. An error after a successful rename is post-commit. The new artifact is already visible, even if the publisher’s surrounding job eventually rejects.

That distinction affects retries. Retrying a pre-commit failure is an attempt to publish something that never became current. Blindly retrying a post-commit failure may run follow-up effects twice or replace a newer writer’s result. A production API can return or persist a phase, candidate hash, generation identifier, and committed flag so recovery code does not have to infer the outcome from one exception message.

The compact fixture injects mid-write and validation failures because both can be verified portably before rename. Add a controllable rename adapter if the real publisher must prove its behavior when the storage layer rejects the commit. Add a post-rename hook if downstream notifications, cache invalidation, or manifests participate in the release. Keep those assertions separate from file visibility; a failed notification does not turn an already-renamed file back into the old version.

Classify Snapshots by Identity, Not Parseability

JSON parsing is a useful negative assertion—the 257-byte and 514-byte fragments are invalid—but it is not the primary oracle. A partial or mixed artifact can sometimes remain valid JSON. Conversely, a legitimate artifact format may not be parseable by this test at all. The stronger classifier compares each target snapshot with the known old and new hashes. Anything else is an unauthorized third state.

For very large artifacts, a release test can compare a signed manifest, content-addressed filename, Merkle root, or trusted digest rather than retaining two full buffers. The principle is unchanged: define the allowed identities before the run. Do not inspect an unexpected snapshot afterward and invent a reason to accept it.

Turn the Contract Into a Release Gate

Release this publisher only when

  • the extracted article files byte-match the fixture files that passed;
  • syntax checks and all seven evidence cases pass under the declared Node version;
  • a fresh reader sees only the exact old hash during staging and the exact new hash after commit;
  • write and validation failures preserve the old artifact;
  • invocation-owned temps are removed after caught failures, while colliding temps are untouched;
  • the temp and target resolve to the same intended directory and storage boundary;
  • required file metadata is checked independently; and
  • multiwriter coordination and crash durability are either tested or explicitly out of scope.

Hold the release when

  • a test reads only after the publisher resolves;
  • the public target is truncated before candidate validation finishes;
  • a system temp directory is assumed to share the target filesystem;
  • wx is described as a target lock;
  • a final checksum is used as evidence that no partial version was visible; or
  • atomic rename is advertised as a universal power-loss guarantee.

The General Pattern: Stage, Inspect, Commit

The artifact could be a generated page, search index, configuration snapshot, model manifest, or export. The useful design move is the same: keep construction off the reader-visible path, inspect the exact closed candidate, and make one narrow operation the commit point.

AI-written code often compresses that sequence into “write the file” because the happy path looks equivalent at the end. A publication-window test restores the missing dimension. It asks not only whether the destination became correct, but what every reader was allowed to see on the way there.

Primary References

Review Your Draft in One Workspace

Check AI-likelihood signals, revise structure and tone, and review the result before you publish.

Open AI Humanizer