NEO.K / MSSP FIELD LAB003-record-migration
編號003-record-migration
語言javascript
版本v1.0
日期2026-08-03
行數631
執行node src/main.js

003 — Record migration: what "500 migrated, 0 errors" actually says

What this program does

It takes a batch of records, runs each one through a set of field transforms — split a name into given and family, rewrite a phone into a canonical form — and reports what happened.

node src/main.js            # the ledger
node src/main.js --summary  # the sentence a normal migration tool prints
node src/island-test.js     # each transform alone, plus four attempts to make the ledger lie

No dependencies, no source database, no destination. The records are a literal.

The structural decision

DMS is not the run log. Its job is to make a successful run checkable — and a successful run is the hard case, because failure announces itself and success does not.

Here is what a migration normally tells you:

$ node src/main.js --summary

  5 records migrated, 0 errors

Every word is true. It is also word-for-word what you would get from:

The sentence cannot distinguish them, and it is the sentence almost every batch tool prints. So the ledger answers three questions it cannot.

1. Does the arithmetic balance? Every record must appear exactly once across the four outcome kinds. Zero failures is a claim about one bucket; it says nothing about whether the loop visited everything it was handed. A shortfall means records were lost somewhere the failure counter never reached, and that makes the run untrustworthy regardless of how clean the error column looks.

This lives in SMS, not DMS. A report that computed its own correctness would be marking its own work; the pipeline cannot honestly return a result without reconciling, so reconciliation is part of closing the loop.

2. Can I see one? Two witnesses per outcome kind, printed before and after — including for records nothing changed, with the reason. An unchanged count on its own is an assertion; an unchanged count next to one token only; splitting it would be a guess is a decision a reader can disagree with.

3. What did not happen?

  capabilities
    transforms/split-name      invoked   4, changed   3, declined   1
    transforms/normalise-phone NEVER INVOKED
                                 declined 5 record(s); reads phone
                                 this run says nothing about whether it works

Not one record in the fixture carries a phone number. The phone transform is loaded, correct, and never reached. It is not an error and it is not a success — it is the absence of evidence, and the report has to be able to say which of the three it is.

This is the decision the example exists for. A capability that was never invoked is reported as loudly as one that failed, because a green run over an input that never reaches a transform is the commonest way to believe something works that has never once executed.

Set by set

FMSmanifest.json. What the program is, what each capability does, and the two decisions above with their reasons. No procedure.

SCLpolicy.json and policy.js. One permission: may_drop. Rewriting a field and removing a record are the same shape in code — a function returning an outcome — and only one of them loses data, so the difference is stated as data a runtime reads rather than a rule a transform is trusted to respect. A drop from a transform that may not drop is recorded as that transform's failure, and the record stays accounted for.

SMSmodel.js (the four outcome kinds), reconcile.js (the arithmetic), pipeline.js (the loop). The pipeline imports no transform; it is handed a list, which is what lets the island test hand it exactly one.

TMStransforms/split-name and transforms/normalise-phone. Two units in one category directory, so an import between them is a violation. Neither knows the other exists.

DMSledger.js. Renders; decides nothing. assessment() is separate from render() because the report is for a person and the assessment is for a caller that has to act — and the two answers differ: this run is trustworthy (it balances) and only partly demonstrative (one capability never ran).

The island test

$ node src/island-test.js

== 1. transforms/split-name alone
  PASS  runs with no sibling loaded - 2 of 2 accounted for
  PASS  split-name was actually invoked - invoked 2
  PASS  phone fields survived untouched - the absent transform left its fields alone rather than nulling them

== 2. transforms/normalise-phone alone
  PASS  runs with no sibling loaded
  PASS  normalise-phone was actually invoked
  PASS  it changed the one that needed changing - 1 applied; the already-normalised number came back unchanged
  PASS  assessment reports nothing unexercised here

== 3. reconciliation rejects a lost record
  PASS  a shortfall is unbalanced - missing 2
  PASS  and the shortfall is reported, not just flagged
  PASS  an exact match still balances

== 4. reconciliation rejects a duplicated record
  PASS  counts alone would have passed
  PASS  the duplicate is caught anyway - duplicated [d-1]

== 5. the ledger cannot hide an unexercised capability
  PASS  the report names it
  PASS  and says what it would have needed
  PASS  assessment agrees
  PASS  and stops saying it when the capability does run - otherwise the warning is decoration that is always present

== 6. SCL refuses a drop from a transform that may not drop
  PASS  the drop did not take effect - outcomes: failed
  PASS  it was recorded as a failure, not silently ignored
  PASS  the record is still accounted for
  PASS  assertMayDrop itself throws

  island test passed

Sections 1–2 are the island test proper. Sections 3–6 are there because of 改良點 6: every claim this example makes rests on the ledger being able to say no, and a check nobody has watched fail is not yet a check.

Two of them are worth reading closely.

Section 4 builds a ledger where the totals agree — two records in, two outcomes out — and one id appears twice. Counting alone passes it. Reconciliation only catches it because it checks identity as well as arithmetic, which is a distinction that would never have come up if the check had been written as accounted === input and left there.

Section 5 checks both directions. The report must say NEVER INVOKED when the capability is unreached, and must stop saying it when the same code runs with phone numbers present. A warning that is always there is not a warning.

What this example does not solve

Source

FMS

FMS/manifest.json
{
  "name": "record-migration",
  "what_it_is": "Runs a batch of records through a set of field transforms and reports what happened in a form somebody can check.",
  "why_it_exists": "Because \"500 records migrated, 0 errors\" is true of a correct run, of a run that touched nothing, and of a run that lost half its input to an early return.",
  "core_task_loop": "records -> one outcome per record -> reconciliation -> ledger",

  "capabilities": {
    "SMS": {
      "model": "Record and Outcome. Four outcome kinds, with `unchanged` distinct from `applied` on purpose.",
      "reconcile": "The arithmetic that must balance: every record accounted for exactly once. Core, not reporting — the pipeline cannot honestly return a result without it.",
      "pipeline": "The loop. Imports no transform; it is handed a list."
    },
    "SCL": {
      "policy": "Which transform may drop a record. Rewriting a field and removing a record look identical in code and only one of them loses data."
    },
    "TMS": {
      "transforms/split-name": "Splits `name` into given and family. Declines a single token rather than guessing.",
      "transforms/normalise-phone": "Rewrites `phone` to E.164-ish. Never invoked by the shipped fixture, deliberately."
    },
    "DMS": {
      "ledger": "The report. Reconciliation, witnesses before and after, and any capability that was never invoked."
    }
  },

  "terminology": {
    "reconciliation": "input == applied + unchanged + dropped + failed, with no id appearing twice.",
    "witness": "A concrete before/after pair, printed so a reader can check the transform rather than take the count on faith.",
    "unexercised capability": "One that was loaded, was correct, and was never reached by this input."
  },

  "decisions": [
    {
      "id": "D-001",
      "date": "2026-08-03",
      "decision": "reconciliation lives in SMS, not in DMS",
      "because": "A report that computed its own correctness would be marking its own work. DMS renders the answer; it does not decide it."
    },
    {
      "id": "D-002",
      "date": "2026-08-03",
      "decision": "an unexercised capability is reported as loudly as a failure",
      "because": "It is not an error, and a run that never reached it supports no claim about it. Those are different things and both need saying."
    }
  ],

  "non_goals": [
    "Being a migration tool. There is no source, no destination and no transaction — the records are a literal in main.js.",
    "Deciding whether the transforms are correct. It reports what they did; correctness is the reader's call, which is the point."
  ]
}

SCL

SCL/policy.js
// What each transform is permitted to do.
//
// The interesting entry is `may_drop`. Rewriting a field and removing a record
// look identical in code — both are a function returning an outcome — and only
// one of them loses data. Permission is the axis that tells them apart, so it
// is data a runtime reads rather than a rule a transform agrees to follow.

import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

const here = path.dirname(fileURLToPath(import.meta.url));
const policy = JSON.parse(fs.readFileSync(path.join(here, "policy.json"), "utf8"));

export const TRANSFORMS = policy.transforms;

export class PermissionError extends Error {}

export function mayDrop(name) {
  return Boolean(TRANSFORMS[name]?.may_drop);
}

export function assertMayDrop(name) {
  if (!mayDrop(name)) {
    throw new PermissionError(`${name} returned a drop, and its policy entry says may_drop: false`);
  }
}

/** Fields a transform declares it reads. Used by DMS to explain a zero. */
export function declaredReads(name) {
  return TRANSFORMS[name]?.reads ?? [];
}
SCL/policy.json
{
  "policy_version": "1.0",

  "_comment": [
    "Dropping a record is a permission, not a behaviour. A transform that can",
    "decide a record should not survive the migration is doing something",
    "categorically different from one that can only rewrite fields, and the",
    "difference is invisible in the code — both are a function that returns an",
    "outcome.",
    "",
    "So it is stated here as data, and SMS/pipeline refuses a drop from a",
    "transform whose entry does not allow it. The transform is not asked to",
    "behave; it is not able to."
  ],

  "transforms": {
    "transforms/split-name": {
      "may_drop": false,
      "reads": ["name"],
      "writes": ["given_name", "family_name"]
    },
    "transforms/normalise-phone": {
      "may_drop": false,
      "reads": ["phone"],
      "writes": ["phone"]
    }
  },

  "rules": [
    {
      "id": "drop-requires-permission",
      "statement": "A transform whose entry says may_drop:false cannot remove a record from the migration.",
      "enforced_by": "SCL/policy.js::assertMayDrop, called by SMS/pipeline.js before the outcome is recorded"
    }
  ]
}

SMS

SMS/model.js
// The shapes every set agrees on.
//
// `Outcome` is the one worth looking at. A transform returns one of four
// things, and `unchanged` is a distinct answer from `applied` on purpose: a
// migration where every transform declines every record is a legitimate,
// successful, completely useless run, and the report has to be able to say so.

export const APPLIED = "applied";
export const UNCHANGED = "unchanged";
export const DROPPED = "dropped";
export const FAILED = "failed";

export const OUTCOMES = [APPLIED, UNCHANGED, DROPPED, FAILED];

/** One record, before anything touches it. */
export function record(id, fields) {
  return { id, fields: { ...fields } };
}

/**
 * What one transform did to one record.
 *
 * `before` and `after` are carried even when nothing changed, because the
 * witness sample in DMS needs a reader to be able to see that nothing changed
 * — "unchanged" asserted without the pair is the same shape of claim this
 * example exists to argue against.
 */
export function outcome(kind, { by, id, before, after = before, reason = "" }) {
  if (!OUTCOMES.includes(kind)) throw new Error(`unknown outcome: ${kind}`);
  return { kind, by, id, before, after, reason };
}
SMS/pipeline.js
// The loop. Records in, one outcome per record per transform out.
//
// It imports no transform. It is handed a list, which is what lets the island
// test hand it exactly one.

import { assertMayDrop, PermissionError } from "../SCL/policy.js";
import { DROPPED, FAILED, outcome, UNCHANGED } from "./model.js";
import { reconcile } from "./reconcile.js";

/**
 * @param records   the input, in full
 * @param transforms  each publishing { name, applies(record), apply(record) }
 */
export function migrate(records, transforms) {
  const outcomes = [];
  // Counted here rather than derived from outcomes later: a transform that is
  // never invoked produces no outcome to count, and "never invoked" is the
  // fact this example exists to surface.
  const invocations = Object.fromEntries(transforms.map((t) => [t.name, 0]));
  const declines = Object.fromEntries(transforms.map((t) => [t.name, 0]));

  for (const record of records) {
    let current = record;
    let touched = false;

    for (const transform of transforms) {
      if (!transform.applies(current)) {
        declines[transform.name] += 1;
        continue;
      }
      invocations[transform.name] += 1;

      let result;
      try {
        result = transform.apply(current);
      } catch (error) {
        outcomes.push(outcome(FAILED, {
          by: transform.name, id: current.id, before: current, reason: String(error.message),
        }));
        touched = true;
        break;
      }

      if (result.kind === DROPPED) {
        try {
          assertMayDrop(transform.name);
        } catch (error) {
          if (!(error instanceof PermissionError)) throw error;
          // A refused drop is a failure of the transform, not of the record.
          outcomes.push(outcome(FAILED, {
            by: transform.name, id: current.id, before: current, reason: error.message,
          }));
          touched = true;
          break;
        }
      }

      outcomes.push(result);
      touched = true;
      if (result.kind === DROPPED) break;
      current = result.after;
    }

    // A record no transform claimed still has to appear in the ledger, or the
    // arithmetic will not balance and the run cannot be trusted.
    if (!touched) {
      outcomes.push(outcome(UNCHANGED, {
        by: "(none)", id: record.id, before: record, reason: "no transform applied to it",
      }));
    }
  }

  return { outcomes, invocations, declines, reconciliation: reconcile(records.length, outcomes) };
}
SMS/reconcile.js
// The arithmetic that has to balance.
//
// This is core, not reporting. "0 errors" is a claim about one bucket; whether
// the buckets add up to the input is a claim about whether the loop visited
// everything it was given. A run can have zero failures and still have lost
// records — by an early return, a filter applied before counting, a transform
// that threw somewhere the counter did not reach.
//
// Reconciliation belongs in SMS because the pipeline cannot honestly report a
// result without it. DMS renders the answer; it does not decide it.

import { OUTCOMES } from "./model.js";

export function tally(outcomes) {
  const counts = Object.fromEntries(OUTCOMES.map((kind) => [kind, 0]));
  for (const item of outcomes) counts[item.kind] += 1;
  return counts;
}

/**
 * Does the ledger account for every record that went in?
 *
 * Returns the discrepancy rather than a boolean: a caller that only learns
 * "unbalanced" has to go and find out by how much, and the difference is the
 * first thing anyone asks.
 */
export function reconcile(inputCount, outcomes) {
  const counts = tally(outcomes);
  const accounted = OUTCOMES.reduce((sum, kind) => sum + counts[kind], 0);

  // Every record must appear exactly once, so a duplicated id is as wrong as a
  // missing one even when the totals happen to agree.
  const seen = new Map();
  for (const item of outcomes) seen.set(item.id, (seen.get(item.id) ?? 0) + 1);
  const duplicated = [...seen.entries()].filter(([, n]) => n > 1).map(([id]) => id);

  return {
    input: inputCount,
    accounted,
    counts,
    missing: inputCount - accounted,
    duplicated,
    balanced: accounted === inputCount && duplicated.length === 0,
  };
}

TMS

TMS/transforms/normalise-phone.js
// Rewrites a `phone` field into E.164-ish form.
//
// In the shipped fixture this transform is never invoked, because none of the
// records carries a phone number. That is deliberate: it is the capability the
// ledger has to be able to talk about, and a report that cannot distinguish
// "ran and changed nothing" from "never ran" is the thing this example argues
// against.

import { APPLIED, outcome, UNCHANGED } from "../../SMS/model.js";

export const name = "transforms/normalise-phone";

export const normalisePhone = {
  name,

  applies(record) {
    return typeof record.fields.phone === "string" && record.fields.phone.trim() !== "";
  },

  apply(record) {
    const digits = record.fields.phone.replace(/[^\d+]/g, "");
    const normalised = digits.startsWith("+") ? digits : `+886${digits.replace(/^0/, "")}`;
    if (normalised === record.fields.phone) {
      return outcome(UNCHANGED, {
        by: name, id: record.id, before: record, reason: "already normalised",
      });
    }
    return outcome(APPLIED, {
      by: name, id: record.id, before: record,
      after: { id: record.id, fields: { ...record.fields, phone: normalised } },
    });
  },
};
TMS/transforms/split-name.js
// Splits a single `name` field into `given_name` and `family_name`.
//
// Declines anything without a `name`, and declines a name it cannot split
// rather than guessing — a decline is a recorded answer, so declining is not
// the same as doing nothing.

import { APPLIED, outcome, UNCHANGED } from "../../SMS/model.js";

export const name = "transforms/split-name";

export const splitName = {
  name,

  applies(record) {
    return typeof record.fields.name === "string" && record.fields.name.trim() !== "";
  },

  apply(record) {
    const parts = record.fields.name.trim().split(/\s+/);
    if (parts.length < 2) {
      return outcome(UNCHANGED, {
        by: name, id: record.id, before: record,
        reason: "one token only; splitting it would be a guess",
      });
    }
    const family = parts.pop();
    const after = {
      id: record.id,
      fields: { ...record.fields, given_name: parts.join(" "), family_name: family },
    };
    delete after.fields.name;
    return outcome(APPLIED, { by: name, id: record.id, before: record, after });
  },
};

DMS

DMS/ledger.js
// The run report.
//
// The whole example is here. A migration that says
//
//     500 records migrated, 0 errors
//
// is compatible with all of these: it migrated 500 correctly; it skipped 500
// silently; it migrated three and returned early; the input was empty. The
// sentence is true in every case and useless in every case.
//
// So this report answers three questions the summary cannot:
//
//   1. Does the arithmetic balance?  Every record must appear exactly once.
//      Zero failures says nothing about whether the loop visited everything.
//   2. Can I see one?  A witness sample, before and after, including for
//      records nothing changed — an unchanged claim without the pair is the
//      same shape of assertion this example argues against.
//   3. What did NOT happen?  A capability that was never invoked is reported
//      as loudly as one that failed, because a green run over a fixture that
//      never reaches a transform is the commonest way to believe something
//      works that has never executed.
//
// It renders; it decides nothing. Reconciliation is SMS — a report that
// computed its own correctness would be marking its own work.

import { declaredReads } from "../SCL/policy.js";
import { APPLIED, DROPPED, FAILED, UNCHANGED } from "../SMS/model.js";

const pad = (value, width) => String(value).padStart(width);

function fields(record) {
  return Object.entries(record.fields)
    .map(([k, v]) => `${k}=${JSON.stringify(v)}`)
    .join(" ");
}

export function render({ outcomes, invocations, declines, reconciliation }, { witnesses = 2 } = {}) {
  const lines = [];
  const r = reconciliation;

  lines.push("  reconciliation");
  lines.push(`    input                ${pad(r.input, 4)}`);
  for (const kind of [APPLIED, UNCHANGED, DROPPED, FAILED]) {
    lines.push(`    ${kind.padEnd(20)} ${pad(r.counts[kind], 4)}`);
  }
  lines.push(`    accounted for        ${pad(r.accounted, 4)}`);
  if (!r.balanced) {
    lines.push(`    UNBALANCED           missing ${r.missing}, duplicated [${r.duplicated.join(", ")}]`);
    lines.push("    the run is not trustworthy regardless of the failure count");
  } else {
    lines.push("    balanced: every record appears exactly once");
  }

  lines.push("");
  lines.push("  capabilities");
  for (const [name, count] of Object.entries(invocations)) {
    const changed = outcomes.filter((o) => o.by === name && o.kind === APPLIED).length;
    if (count === 0) {
      // The loud case. Not an error — but a claim about this capability is
      // unsupported by this run, and the report says which fields would have
      // been needed to support one.
      const reads = declaredReads(name);
      lines.push(`    ${name.padEnd(26)} NEVER INVOKED`);
      lines.push(`    ${" ".repeat(26)}   declined ${declines[name]} record(s); reads ${reads.join(", ") || "(undeclared)"}`);
      lines.push(`    ${" ".repeat(26)}   this run says nothing about whether it works`);
    } else {
      lines.push(`    ${name.padEnd(26)} invoked ${pad(count, 3)}, changed ${pad(changed, 3)}, declined ${pad(declines[name], 3)}`);
    }
  }

  lines.push("");
  lines.push(`  witnesses (${witnesses} per outcome kind, before -> after)`);
  for (const kind of [APPLIED, UNCHANGED, DROPPED, FAILED]) {
    const sample = outcomes.filter((o) => o.kind === kind).slice(0, witnesses);
    if (sample.length === 0) {
      lines.push(`    ${kind}: none in this run`);
      continue;
    }
    for (const item of sample) {
      lines.push(`    ${kind} ${item.id} by ${item.by}`);
      lines.push(`      before  ${fields(item.before)}`);
      if (item.kind === APPLIED) lines.push(`      after   ${fields(item.after)}`);
      else lines.push(`      reason  ${item.reason || "(none given)"}`);
    }
  }

  return lines.join("\n");
}

/**
 * Whether this run is evidence of anything.
 *
 * Separate from `render` on purpose: the report is for a human, and this is for
 * a caller that has to decide. A balanced run in which every capability was
 * never invoked is a successful run that demonstrates nothing.
 */
export function assessment({ invocations, reconciliation }) {
  const unexercised = Object.entries(invocations).filter(([, n]) => n === 0).map(([name]) => name);
  return {
    trustworthy: reconciliation.balanced,
    unexercised,
    demonstrates: reconciliation.balanced && unexercised.length < Object.keys(invocations).length,
  };
}

root

island-test.js
// The island test, and four attempts to make the ledger lie.
//
//   node src/island-test.js
//
// Sections 1–2 are the ordinary island test: each transform alone, no sibling
// loaded. Sections 3–5 exist because of 改良點 6 — a check nobody has watched
// fail is not yet a check, and every claim this example makes rests on the
// ledger being able to say no.

import { assessment, render } from "./DMS/ledger.js";
import { assertMayDrop, PermissionError } from "./SCL/policy.js";
import { DROPPED, outcome, record, UNCHANGED } from "./SMS/model.js";
import { migrate } from "./SMS/pipeline.js";
import { reconcile } from "./SMS/reconcile.js";
import { normalisePhone } from "./TMS/transforms/normalise-phone.js";
import { splitName } from "./TMS/transforms/split-name.js";

const failures = [];
const report = (label, ok, detail = "") => {
  console.log(`  ${ok ? "PASS" : "FAIL"}  ${label}${detail ? ` - ${detail}` : ""}`);
  if (!ok) failures.push(label);
};

const PEOPLE = [
  record("p-1", { name: "Ada Lovelace", phone: "0912-345-678" }),
  record("p-2", { name: "Prince", phone: "+886912345678" }),
];

console.log("\n== 1. transforms/split-name alone");
{
  const result = migrate(PEOPLE, [splitName]);
  report("runs with no sibling loaded", result.reconciliation.balanced,
    `${result.reconciliation.accounted} of ${result.reconciliation.input} accounted for`);
  report("split-name was actually invoked", result.invocations["transforms/split-name"] === 2,
    `invoked ${result.invocations["transforms/split-name"]}`);
  report("phone fields survived untouched", PEOPLE.every((p) => p.fields.phone !== undefined),
    "the absent transform left its fields alone rather than nulling them");
}

console.log("\n== 2. transforms/normalise-phone alone");
{
  const result = migrate(PEOPLE, [normalisePhone]);
  const applied = result.outcomes.filter((o) => o.kind === "applied");
  report("runs with no sibling loaded", result.reconciliation.balanced);
  report("normalise-phone was actually invoked", result.invocations["transforms/normalise-phone"] === 2);
  report("it changed the one that needed changing", applied.length === 1,
    `${applied.length} applied; the already-normalised number came back unchanged`);
  report("assessment reports nothing unexercised here", assessment(result).unexercised.length === 0);
}

console.log("\n== 3. reconciliation rejects a lost record");
{
  // The failure this exists for: a loop that returns early, or a filter applied
  // before counting. Four records in, three outcomes out, zero failures.
  const outcomes = PEOPLE.map((p) => outcome(UNCHANGED, { by: "x", id: p.id, before: p }));
  const bad = reconcile(3, outcomes.slice(0, 2));
  const worse = reconcile(4, outcomes.slice(0, 2));
  report("a shortfall is unbalanced", !worse.balanced, `missing ${worse.missing}`);
  report("and the shortfall is reported, not just flagged", worse.missing === 2);
  report("an exact match still balances", reconcile(2, outcomes).balanced);
  void bad;
}

console.log("\n== 4. reconciliation rejects a duplicated record");
{
  const one = record("d-1", { name: "A B" });
  const twice = [
    outcome(UNCHANGED, { by: "x", id: "d-1", before: one }),
    outcome(UNCHANGED, { by: "y", id: "d-1", before: one }),
  ];
  const r = reconcile(2, twice);
  // The totals agree — 2 in, 2 accounted — and it is still wrong.
  report("counts alone would have passed", r.accounted === r.input);
  report("the duplicate is caught anyway", !r.balanced, `duplicated [${r.duplicated.join(", ")}]`);
}

console.log("\n== 5. the ledger cannot hide an unexercised capability");
{
  const noPhones = [record("n-1", { name: "Ada Lovelace" })];
  const result = migrate(noPhones, [splitName, normalisePhone]);
  const text = render(result);
  report("the report names it", text.includes("NEVER INVOKED"));
  report("and says what it would have needed", text.includes("reads phone"));
  report("assessment agrees", assessment(result).unexercised.includes("transforms/normalise-phone"));

  // and the inverse: given phones, the same code must stop saying it
  const withPhones = [record("n-2", { name: "Ada Lovelace", phone: "0912345678" })];
  const covered = render(migrate(withPhones, [splitName, normalisePhone]));
  report("and stops saying it when the capability does run", !covered.includes("NEVER INVOKED"),
    "otherwise the warning is decoration that is always present");
}

console.log("\n== 6. SCL refuses a drop from a transform that may not drop");
{
  const rogue = {
    name: "transforms/split-name",           // policy says may_drop: false
    applies: () => true,
    apply: (r) => outcome(DROPPED, { by: "transforms/split-name", id: r.id, before: r, reason: "because I felt like it" }),
  };
  const result = migrate([record("s-1", { name: "A B" })], [rogue]);
  const kinds = result.outcomes.map((o) => o.kind);
  report("the drop did not take effect", !kinds.includes(DROPPED), `outcomes: ${kinds.join(", ")}`);
  report("it was recorded as a failure, not silently ignored", kinds.includes("failed"));
  report("the record is still accounted for", result.reconciliation.balanced);

  let threw = false;
  try { assertMayDrop("transforms/split-name"); } catch (e) { threw = e instanceof PermissionError; }
  report("assertMayDrop itself throws", threw);
}

console.log("");
if (failures.length) {
  console.log(`  ${failures.length} check(s) failed: ${failures.join(", ")}`);
  process.exit(1);
}
console.log("  island test passed");
main.js
// Run the migration.
//
//   node src/main.js            the shipped fixture
//   node src/main.js --summary  what a conventional report would have said
//
// The second one is the argument. It prints the sentence a normal migration
// tool prints, and the sentence is true.

import { render, assessment } from "./DMS/ledger.js";
import { record } from "./SMS/model.js";
import { migrate } from "./SMS/pipeline.js";
import { normalisePhone } from "./TMS/transforms/normalise-phone.js";
import { splitName } from "./TMS/transforms/split-name.js";

// Note what is absent: not one record carries a phone number. That is the
// fixture doing its job — the phone transform is loaded, correct, and never
// reached, which is the state a summary line cannot distinguish from working.
const RECORDS = [
  record("r-001", { name: "Neo K", email: "a@example.com" }),
  record("r-002", { name: "Ada Lovelace", email: "b@example.com" }),
  record("r-003", { name: "Prince", email: "c@example.com" }),
  record("r-004", { email: "d@example.com" }),
  record("r-005", { name: "Grace Brewster Hopper", email: "e@example.com" }),
];

function main(argv) {
  const result = migrate(RECORDS, [splitName, normalisePhone]);
  const verdict = assessment(result);

  if (argv.includes("--summary")) {
    const failed = result.reconciliation.counts.failed;
    console.log(`\n== what a conventional migration reports`);
    console.log(`\n  ${RECORDS.length} records migrated, ${failed} errors\n`);
    console.log("  Every word of that is true. It is also true of a run that");
    console.log("  touched nothing, and of a run that lost two records to an");
    console.log("  early return. Run without --summary for the difference.\n");
    return 0;
  }

  console.log("\n== record migration");
  console.log(render(result));

  console.log("");
  console.log(`  trustworthy   ${verdict.trustworthy}   (the arithmetic balances)`);
  console.log(`  demonstrates  ${verdict.demonstrates}   (at least one capability actually ran)`);
  if (verdict.unexercised.length) {
    console.log(`  unexercised   ${verdict.unexercised.join(", ")}`);
  }

  // This program's exit code reports whether the *ledger* did its job, not
  // whether the fixture was clean. The fixture is deliberately unclean.
  const problems = [];
  if (!verdict.trustworthy) problems.push("the run did not reconcile");
  if (verdict.unexercised.length === 0) {
    problems.push("every capability ran, so this fixture no longer demonstrates the unexercised case");
  }
  if (problems.length) {
    console.log(`\n  LEDGER FAILED: ${problems.join("; ")}`);
    return 1;
  }
  console.log("\n  the ledger reported an unexercised capability, which a summary line cannot.");
  return 0;
}

process.exit(main(process.argv.slice(2)));