diff --git a/.changeset/liveness-verified-at-clock.md b/.changeset/liveness-verified-at-clock.md
new file mode 100644
index 0000000000..638a73197f
--- /dev/null
+++ b/.changeset/liveness-verified-at-clock.md
@@ -0,0 +1,46 @@
+---
+"@objectstack/spec": patch
+---
+
+feat(spec): `verifiedAt` re-verification clock on liveness entries + two entries re-verified against objectui (#3714 follow-up)
+
+A ledger entry is a claim with a timestamp, and **twice** now one has been
+falsified by code moving under it — `flow.status` (#3711) and `action.undoable`
+(#3714), both *understated*, both found only because a sweep aimed at the
+opposite failure walked past them. Nothing in the gate asked how old a claim
+was, so a stale entry stayed invisible until someone tripped over it.
+
+**`verifiedAt`.** Ledger entries may now carry `"verifiedAt": "YYYY-MM-DD"` —
+the date a human last closed the call graph. The asymmetry is the design:
+
+- **Age never fails CI.** Re-verification is a worklist, not a merge gate. Every
+ run prints one summary line; `pnpm check:liveness --stale-verification[=days]`
+ prints the worklist (stale oldest-first, then undated). Default 180 days.
+- **A malformed or future-dated value DOES fail CI.** A date the parser can't
+ read would silently exempt that entry from every staleness window — the same
+ silent-no-op shape this ledger exists to catch. Also rejects calendar-invalid
+ dates, since `new Date('2026-02-30')` rolls over to March 2 rather than
+ throwing.
+
+Currently 2 of 401 entries are dated. The rest predate the field and report as
+undated; date them as you re-verify rather than back-filling guesses.
+
+**Two entries re-verified against objectui `732b1bf`:**
+
+- `action.undoable` — both readers stand, and the call graph now closes end to
+ end in the evidence: the two `if (action.undoable …)` gates build
+ `result.undo`; `ActionRunner.ts:640-643` pushes it onto `globalUndoManager`
+ and passes `undo` to the toast handler; the toast's Undo button runs
+ `undoCtl.undo()` → `useGlobalUndo` → `UndoManager` → `dataSource`. The cited
+ `RecordDetailView` line numbers had already drifted (545→573, 404→432) in the
+ day between the issue being filed and this pass — hence the pinned sha.
+- `action.type` — `api` → `executeAPI`, `form` → `executeForm`, both real.
+
+**Docs correction (`content/docs/ui/actions.mdx`).** That page told authors the
+schema's `api` and `form` types have "no runtime executor / renderer today —
+stick to the four above." Both have had executors in objectui's `ActionRunner`
+for some time, and the ledger's own `action.type` entry recorded `form` as live
+since #2377. Same understatement shape as #3714, one page over. Both types now
+have table rows; the callout keeps the parts that are true (`shortcut` and
+`bulkEnabled` really are unwired) and links the ledger. `undoable` also joins
+the UX property list, which is the author-facing payoff of #3714.
diff --git a/content/docs/ui/actions.mdx b/content/docs/ui/actions.mdx
index 6633b769c6..3836bd9298 100644
--- a/content/docs/ui/actions.mdx
+++ b/content/docs/ui/actions.mdx
@@ -20,12 +20,16 @@ The types you'll actually use:
| `flow` | Launch a flow (e.g. a screen-flow wizard) | `target` names the flow |
| `url` | Navigate / open a link | `target` is the URL (`${ctx.record.id}` interpolation supported) |
| `modal` | Open a modal page to collect input, then submit to a handler | `target` names the modal page |
+| `api` | Call an HTTP endpoint directly | `target` is the endpoint; `method` / `bodyShape` / `bodyExtra` shape the request |
+| `form` | Open a form view, prefilled with the current record | `target` names the FormView; routed to `/forms/:target?recordId=…` |
-The schema also accepts `api` and `form` types, but they have **no runtime
-executor / renderer today** — stick to the four above. Likewise prefer
-`confirmText`/`params` over schema properties that are not yet wired
-(`shortcut`, `bulkEnabled`).
+Prefer `confirmText`/`params` over the two action properties that are **not
+wired**: `shortcut` (nothing dispatches keydown events to actions) and
+`bulkEnabled` (the multi-select toolbar reads the list view's `bulkActions`,
+not this flag). The
+[liveness ledger](https://github.com/objectstack-ai/objectstack/blob/main/packages/spec/liveness/action.json)
+carries the per-property status of everything the action schema accepts.
## Define your first action
@@ -189,6 +193,10 @@ engine still requires the action to declare the matching location (that's why
- **`confirmText`** — confirmation dialog before running.
- **`successMessage` / `errorMessage` / `refreshAfter`** — post-run feedback
and an automatic data refresh.
+- **`undoable`** — on a single-record update, the success toast offers an
+ **Undo** that restores the record's prior field values (`Ctrl+Z` works too).
+ The runtime only captures the prior values when this flag is set, so an
+ action that omits it gets no Undo.
- **`resultDialog`** — a one-time reveal dialog for output the user must copy
(generated tokens, export links).
- **`variant` / `icon` / `order`** — presentation and sort position.
diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md
index 85c93ff9ef..7969140ecd 100644
--- a/packages/spec/liveness/README.md
+++ b/packages/spec/liveness/README.md
@@ -46,6 +46,40 @@ Resolution per property: **ledger entry → spec `.describe()` marker → UNCLAS
Framework provenance/lock fields (`_lock*`, `_provenance`, `_packageId/Version`,
`protection` — ADR-0010) are auto-classified `live`.
+### `verifiedAt` — the re-verification clock
+
+An entry may carry `"verifiedAt": "YYYY-MM-DD"`: the date a human last closed the
+call graph for that property. It exists because **twice** an entry has been
+falsified by code moving under it — `flow.status` (#3711) and `action.undoable`
+(#3714), both *understated*, both found only because a sweep aimed at the
+opposite failure happened to walk past them. Nothing in the gate previously
+asked how old a claim was, so a stale entry stayed invisible until someone
+tripped over it.
+
+```jsonc
+"undoable": {
+ "status": "live",
+ "verifiedAt": "2026-07-28",
+ "evidence": "objectui @732b1bf — CALL GRAPH CLOSED BY HAND: …"
+}
+```
+
+Two rules, and the asymmetry between them is the point:
+
+- **Age never fails CI.** Re-verification is a worklist, not a merge gate. Every
+ run prints one summary line; `pnpm check:liveness --stale-verification[=days]`
+ prints the worklist (stale oldest-first, then the undated ones). Default
+ threshold 180 days.
+- **A malformed or future-dated `verifiedAt` DOES fail CI.** A date the parser
+ can't read would silently exempt that entry from every staleness window —
+ which is the same silent-no-op shape this whole ledger exists to catch. Fail
+ loudly instead.
+
+Most entries predate the field and are simply undated; date them as you
+re-verify rather than back-filling guesses. **For objectui-side evidence, pin
+the commit** (`objectui @732b1bf`) — `action.undoable`'s reader line numbers had
+already drifted by 28 lines one day after the issue citing them was filed.
+
### ⚠️ An authoring/preview renderer is NOT a runtime consumer
`live` means **authoring the property changes runtime behaviour**. A Studio
diff --git a/packages/spec/liveness/action.json b/packages/spec/liveness/action.json
index 896f90c35a..4a11fb4375 100644
--- a/packages/spec/liveness/action.json
+++ b/packages/spec/liveness/action.json
@@ -29,8 +29,9 @@
},
"type": {
"status": "live",
- "evidence": "packages/runtime/src/http-dispatcher.ts",
- "note": "api/script/flow wired; url thinner; modal PARTIAL (maps to serverActionHandler, not a real modal); form LIVE via objectui ActionRunner.executeForm (routes a type:'form' action to the FormView at /forms/:target, forwarding the current record id) — the 2026-06 audit mis-classified as dead (objectui renderer not re-verified; fixed the 'Log Time does nothing' report). Build-time lint-view-refs.ts validates the form target resolves to a form view."
+ "verifiedAt": "2026-07-28",
+ "evidence": "packages/runtime/src/http-dispatcher.ts; objectui @732b1bf core ActionRunner.ts:513-541 switches every variant to its own executor (executeScript/Url/Modal/Flow/API/Form/Navigation)",
+ "note": "api/script/flow wired; url thinner; modal PARTIAL (maps to serverActionHandler, not a real modal); form LIVE via objectui ActionRunner.executeForm (routes a type:'form' action to the FormView at /forms/:target, forwarding the current record id) — the 2026-06 audit mis-classified as dead (objectui renderer not re-verified; fixed the 'Log Time does nothing' report). Build-time lint-view-refs.ts validates the form target resolves to a form view. RE-VERIFIED 2026-07 (#3714 follow-up): `api` -> executeAPI (:974, string-or-ApiConfig endpoint, method/headers/queryParams/responseType) and `form` -> executeForm (:920) both resolve; content/docs/ui/actions.mdx had been telling authors the opposite (\"no runtime executor / renderer today\") and was corrected in the same pass."
},
"target": {
"status": "live",
@@ -75,8 +76,9 @@
},
"undoable": {
"status": "live",
- "evidence": "objectui: app-shell useConsoleActionRuntime.tsx:409 gates building the undo operation the success toast's Undo button invokes (:147); app-shell RecordDetailView.tsx:545 gates restoring the record's prior values (:404); plumbed through by components action/action-button.tsx:113",
- "note": "STATUS CORRECTED 2026-07 (#3714) — this entry UNDERSTATED liveness, the mirror image of the preview-renderer over-claims #3686/#3711 fixed. The #1992-era note (\"no runtime reader yet ... objectui has an UndoManager but does not key off this field\") was true when written and has since been falsified: objectui wired two readers, both gating real behaviour, and action-button.tsx:113 forwards the flag precisely because — per its own comment — \"without this the flag is dropped and the handler never builds the undo operation\". Authoring side: ActionDefaultInspector.tsx \"Offer undo\" checkbox. Consequence while the entry stood: the CLI liveness lint warns on `experimental` too, so authoring a WORKING property produced a \"declared but NOT enforced\" warning."
+ "verifiedAt": "2026-07-28",
+ "evidence": "objectui @732b1bf — CALL GRAPH CLOSED BY HAND: app-shell useConsoleActionRuntime.tsx:409 and RecordDetailView.tsx:573 each gate `if (action.undoable ...)` on building `result.undo`; core ActionRunner.ts:640-643 pushes it onto globalUndoManager AND passes `undo` to the toast handler; the toast's Undo button calls undoCtl.undo() (useConsoleActionRuntime.tsx:147, RecordDetailView.tsx:432) -> useGlobalUndo -> UndoManager -> dataSource. Plumbed through by components action/action-button.tsx:113",
+ "note": "STATUS CORRECTED 2026-07 (#3714) — this entry UNDERSTATED liveness, the mirror image of the preview-renderer over-claims #3686/#3711 fixed. The #1992-era note (\"no runtime reader yet ... objectui has an UndoManager but does not key off this field\") was true when written and has since been falsified: objectui wired two readers, both gating real behaviour, and action-button.tsx:113 forwards the flag precisely because — per its own comment — \"without this the flag is dropped and the handler never builds the undo operation\". Authoring side: ActionDefaultInspector.tsx \"Offer undo\" checkbox. Consequence while the entry stood: the CLI liveness lint warns on `experimental` too, so authoring a WORKING property produced a \"declared but NOT enforced\" warning. RE-VERIFIED against objectui HEAD in #3714 follow-up: both readers stand, but RecordDetailView had drifted 545->573 / 404->432 in the ~1 day since the issue was filed — which is why this entry now carries `verifiedAt` and an objectui sha."
},
"refreshAfter": {
"status": "live",
diff --git a/packages/spec/scripts/liveness/check-liveness.mts b/packages/spec/scripts/liveness/check-liveness.mts
index 4bca6b2a92..75563766ae 100644
--- a/packages/spec/scripts/liveness/check-liveness.mts
+++ b/packages/spec/scripts/liveness/check-liveness.mts
@@ -27,10 +27,20 @@
// (see proof-registry.mts), a `live` classification MUST carry a valid proof —
// the file must exist and declare the `@proof: ` tag. CI fails otherwise.
//
+// RE-VERIFICATION CLOCK (`verifiedAt`): a ledger entry is a claim with a
+// timestamp, and code moves under it in BOTH directions — `flow.status` (#3711)
+// and `action.undoable` (#3714) were both understated by entries that were
+// accurate when written. An optional `"verifiedAt": "YYYY-MM-DD"` records when a
+// human last closed the call graph. Age never fails CI (re-verification is a
+// worklist); a MALFORMED or future date does, because it silently disables the
+// staleness check for that entry.
+//
// Usage:
-// tsx check-liveness.mts # check all governed types
-// tsx check-liveness.mts --dump # inventory a type's properties (seeding aid)
-// tsx check-liveness.mts --json # machine-readable report
+// tsx check-liveness.mts # check all governed types
+// tsx check-liveness.mts --dump # inventory a type's properties (seeding aid)
+// tsx check-liveness.mts --json # machine-readable report
+// tsx check-liveness.mts --stale-verification # print the re-verification worklist
+// tsx check-liveness.mts --stale-verification=90 # ...with a custom staleness threshold
process.env.OS_EAGER_SCHEMAS = '1';
@@ -47,6 +57,12 @@ import {
parseProofRef,
validateProofRef,
} from './proof-registry.mts';
+import {
+ DEFAULT_STALE_DAYS,
+ buildVerificationReport,
+ type VerificationEntry,
+ type VerificationReport,
+} from './verification.mts';
const here = dirname(fileURLToPath(import.meta.url));
const specRoot = resolve(here, '../..'); // packages/spec
@@ -185,14 +201,21 @@ const report: any = {
proofErrors: [] as string[], // a `proof` ref that doesn't resolve (missing file / missing tag / malformed)
proofMissing: [] as string[], // a bound high-risk `live` entry with no proof at all
orphanProofs: [] as string[], // a dogfood `@proof:` tag not registered in proof-registry.mts
+ verification: null as VerificationReport | null, // `verifiedAt` ages — the re-verification worklist
};
+// Every classified entry, for the `verifiedAt` fold below. Collected during the
+// walk so the age report sees exactly the set the gate itself classified.
+const verificationEntries: VerificationEntry[] = [];
+
const proofFs = { existsSync, readFileSync };
function classify(type: string, path: string, status: string, led: any, cat: any) {
cat.classified++;
cat.byStatus[status] = (cat.byStatus[status] || 0) + 1;
report.totals.byStatus[status] = (report.totals.byStatus[status] || 0) + 1;
+ // Framework-auto entries (`led === null`) have no ledger row to date-stamp.
+ if (led !== null) verificationEntries.push({ key: `${type}/${path}`, status, verifiedAt: led?.verifiedAt });
if (status === 'live' && led?.evidence) {
const file = String(led.evidence).split(':')[0];
if (/\//.test(file) && !existsSync(join(repoRoot, file))) report.staleEvidence.push(`${type}/${path} → ${led.evidence}`);
@@ -271,9 +294,18 @@ for (const type of GOVERNED) {
scanOrphanProofs();
+// ── verifiedAt: how old is each claim? ──
+// Age never fails the gate — re-verification is a worklist, not a merge gate.
+// A MALFORMED value does fail: it silently disables the staleness check for
+// that entry, the exact silent-no-op shape this ledger exists to catch.
+const staleDaysArg = args.find((a) => a.startsWith('--stale-verification'));
+const staleDays = Number(staleDaysArg?.split('=')[1]) || DEFAULT_STALE_DAYS;
+const showWorklist = staleDaysArg !== undefined;
+report.verification = buildVerificationReport(verificationEntries, { staleDays });
+
const totalUnclassified = report.unclassified.length;
const totalProofFailures = report.proofErrors.length + report.proofMissing.length;
-const failed = totalUnclassified > 0 || totalProofFailures > 0;
+const failed = totalUnclassified > 0 || totalProofFailures > 0 || report.verification.errors.length > 0;
if (asJson) {
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
} else {
@@ -304,6 +336,29 @@ if (asJson) {
console.log(`\n✗ ${totalUnclassified} UNCLASSIFIED — classify in packages/spec/liveness/.json:`);
report.unclassified.forEach((s: string) => console.log(` ${s}`));
}
+ // ── re-verification clock ──
+ const v = report.verification!;
+ if (v.errors.length) {
+ console.log(`\n✗ ${v.errors.length} malformed \`verifiedAt\` value(s) — a bad date silently disables the staleness check:`);
+ v.errors.forEach((s: string) => console.log(` ${s}`));
+ }
+ const dated = v.fresh + v.stale.length;
+ console.log(
+ `\nre-verification clock: ${dated} entr(ies) carry \`verifiedAt\` (${v.stale.length} older than ${staleDays}d), ` +
+ `${v.unverified.length} undated.`,
+ );
+ if (showWorklist) {
+ if (v.stale.length) {
+ console.log(`\n stale (verified > ${staleDays}d ago) — re-verify oldest first:`);
+ v.stale.forEach((s) => console.log(` ${s.key.padEnd(40)} ${s.verifiedAt} (${s.ageDays}d)`));
+ }
+ if (v.unverified.length) {
+ console.log(`\n never dated (${v.unverified.length}) — predate the field; date them as you re-verify:`);
+ v.unverified.forEach((k: string) => console.log(` ${k}`));
+ }
+ } else if (v.stale.length || v.unverified.length) {
+ console.log(' run with --stale-verification[=days] for the worklist.');
+ }
if (!failed) {
console.log('\n✓ all governed-type properties are classified; all bound high-risk proofs resolve.');
}
diff --git a/packages/spec/scripts/liveness/verification.mts b/packages/spec/scripts/liveness/verification.mts
new file mode 100644
index 0000000000..b0c1d97914
--- /dev/null
+++ b/packages/spec/scripts/liveness/verification.mts
@@ -0,0 +1,115 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+//
+// `verifiedAt` — the re-verification clock on a liveness ledger entry.
+//
+// WHY THIS EXISTS. Twice now a ledger entry has been falsified by code moving
+// under it, in both directions:
+//
+// - `flow.status` (#3711) — the file note said "status/active gate nothing";
+// true when written, falsified a month later by 497bda853 (UNDERSTATED).
+// - `action.undoable` (#3714) — marked `experimental` on a #1992-era "no
+// runtime reader yet" note; objectui had since wired two (UNDERSTATED).
+//
+// Both were found by a sweep aimed at the OPPOSITE failure (the #3686
+// preview-renderer over-claims). Nothing in the gate ever asks "how old is this
+// claim?", so a stale entry is invisible until someone happens to trip over it.
+//
+// `verifiedAt` makes the age of a claim a first-class, machine-readable fact.
+// It deliberately does NOT fail CI on age: re-verification is a judgement call
+// on a worklist, not a merge blocker. What DOES fail is a *malformed* value —
+// an unparseable or future-dated `verifiedAt` silently disables the staleness
+// check for that entry, which is precisely the silent-no-op failure mode this
+// whole ledger exists to prevent.
+
+/** Entries older than this are surfaced on the re-verification worklist. */
+export const DEFAULT_STALE_DAYS = 180;
+
+const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
+const MS_PER_DAY = 86_400_000;
+
+export type VerifiedAtResult =
+ | { ok: true; date: Date }
+ | { ok: false; error: string };
+
+/**
+ * Parse a ledger `verifiedAt` value. Strict `YYYY-MM-DD` — no timestamps, no
+ * `YYYY-MM`, no slop. A future date is rejected: it would park the entry
+ * outside every staleness window forever, which is worse than no value at all
+ * (an absent `verifiedAt` at least reports as unverified).
+ */
+export function parseVerifiedAt(value: unknown, now: Date = new Date()): VerifiedAtResult {
+ if (typeof value !== 'string') return { ok: false, error: `verifiedAt must be a "YYYY-MM-DD" string, got ${typeof value}` };
+ const m = ISO_DATE.exec(value);
+ if (!m) return { ok: false, error: `verifiedAt "${value}" is not a "YYYY-MM-DD" date` };
+
+ const [, y, mo, d] = m;
+ const date = new Date(`${value}T00:00:00Z`);
+ if (Number.isNaN(date.getTime())) return { ok: false, error: `verifiedAt "${value}" is not a real date` };
+ // `new Date('2026-02-30')` rolls over to March 2 rather than throwing, so
+ // round-trip the parts to reject calendar-invalid dates.
+ const roundTrip =
+ date.getUTCFullYear() === Number(y) &&
+ date.getUTCMonth() + 1 === Number(mo) &&
+ date.getUTCDate() === Number(d);
+ if (!roundTrip) return { ok: false, error: `verifiedAt "${value}" is not a real calendar date` };
+
+ if (date.getTime() > now.getTime()) return { ok: false, error: `verifiedAt "${value}" is in the future` };
+ return { ok: true, date };
+}
+
+/** Whole days between a verification date and `now` (never negative). */
+export function verificationAgeDays(date: Date, now: Date = new Date()): number {
+ return Math.max(0, Math.floor((now.getTime() - date.getTime()) / MS_PER_DAY));
+}
+
+export interface VerificationEntry {
+ /** `/` — the ledger coordinate. */
+ key: string;
+ status: string;
+ verifiedAt?: unknown;
+}
+
+export interface VerificationReport {
+ /** Malformed `verifiedAt` values — these FAIL the gate. */
+ errors: string[];
+ /** Parsed and older than the threshold — the re-verification worklist. */
+ stale: Array<{ key: string; verifiedAt: string; ageDays: number }>;
+ /** Parsed and inside the threshold. */
+ fresh: number;
+ /**
+ * No `verifiedAt` at all. Counted, not listed: most of the ledger predates
+ * the field, so listing every one would drown the signal. They join the
+ * worklist only under `--stale-verification`.
+ */
+ unverified: string[];
+}
+
+/**
+ * Fold a flat list of ledger entries into a verification report. Pure — `now`
+ * and the threshold are injected so the gate, the tests, and any future sweep
+ * script all see the same arithmetic.
+ */
+export function buildVerificationReport(
+ entries: VerificationEntry[],
+ { now = new Date(), staleDays = DEFAULT_STALE_DAYS }: { now?: Date; staleDays?: number } = {},
+): VerificationReport {
+ const report: VerificationReport = { errors: [], stale: [], fresh: 0, unverified: [] };
+
+ for (const entry of entries) {
+ if (entry.verifiedAt === undefined) {
+ report.unverified.push(entry.key);
+ continue;
+ }
+ const parsed = parseVerifiedAt(entry.verifiedAt, now);
+ if (!parsed.ok) {
+ report.errors.push(`${entry.key} → ${parsed.error}`);
+ continue;
+ }
+ const ageDays = verificationAgeDays(parsed.date, now);
+ if (ageDays > staleDays) report.stale.push({ key: entry.key, verifiedAt: String(entry.verifiedAt), ageDays });
+ else report.fresh++;
+ }
+
+ report.stale.sort((a, b) => b.ageDays - a.ageDays);
+ return report;
+}
diff --git a/packages/spec/scripts/liveness/verification.test.ts b/packages/spec/scripts/liveness/verification.test.ts
new file mode 100644
index 0000000000..245c60c009
--- /dev/null
+++ b/packages/spec/scripts/liveness/verification.test.ts
@@ -0,0 +1,119 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+//
+// Unit tests for the `verifiedAt` re-verification clock (#3714 follow-up).
+
+import { describe, it, expect } from 'vitest';
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join, resolve } from 'node:path';
+import {
+ DEFAULT_STALE_DAYS,
+ buildVerificationReport,
+ parseVerifiedAt,
+ verificationAgeDays,
+} from './verification.mts';
+
+const here = dirname(fileURLToPath(import.meta.url));
+const ledgerRoot = resolve(here, '../../liveness');
+const NOW = new Date('2026-07-28T12:00:00Z');
+
+describe('parseVerifiedAt', () => {
+ it('accepts a strict YYYY-MM-DD date in the past', () => {
+ const r = parseVerifiedAt('2026-07-28', NOW);
+ expect(r.ok).toBe(true);
+ expect(r.ok && r.date.toISOString()).toBe('2026-07-28T00:00:00.000Z');
+ });
+
+ it('rejects loose or non-string forms', () => {
+ for (const bad of ['2026-07', '2026/07/28', '28-07-2026', '2026-07-28T00:00:00Z', '', 20260728, null, true]) {
+ expect(parseVerifiedAt(bad as unknown, NOW).ok).toBe(false);
+ }
+ });
+
+ it('rejects a calendar-invalid date instead of silently rolling it over', () => {
+ // `new Date('2026-02-30')` yields March 2 rather than throwing.
+ const r = parseVerifiedAt('2026-02-30', NOW);
+ expect(r.ok).toBe(false);
+ expect(!r.ok && r.error).toMatch(/not a real calendar date/);
+ });
+
+ it('rejects a FUTURE date — it would park the entry outside every window forever', () => {
+ const r = parseVerifiedAt('2027-01-01', NOW);
+ expect(r.ok).toBe(false);
+ expect(!r.ok && r.error).toMatch(/in the future/);
+ });
+});
+
+describe('verificationAgeDays', () => {
+ it('counts whole days elapsed', () => {
+ expect(verificationAgeDays(new Date('2026-07-28T00:00:00Z'), NOW)).toBe(0);
+ expect(verificationAgeDays(new Date('2026-07-18T00:00:00Z'), NOW)).toBe(10);
+ });
+
+ it('never returns a negative age', () => {
+ expect(verificationAgeDays(new Date('2026-08-28T00:00:00Z'), NOW)).toBe(0);
+ });
+});
+
+describe('buildVerificationReport', () => {
+ const entries = [
+ { key: 'action/undoable', status: 'live', verifiedAt: '2026-07-28' }, // fresh
+ { key: 'action/shortcut', status: 'dead', verifiedAt: '2025-01-01' }, // stale
+ { key: 'flow/status', status: 'live', verifiedAt: '2024-06-01' }, // staler
+ { key: 'field/type', status: 'live' }, // undated
+ { key: 'tool/active', status: 'dead', verifiedAt: 'yesterday' }, // malformed
+ ];
+
+ it('sorts stale entries oldest-first and buckets the rest', () => {
+ const r = buildVerificationReport(entries, { now: NOW, staleDays: 180 });
+ expect(r.fresh).toBe(1);
+ expect(r.stale.map((s) => s.key)).toEqual(['flow/status', 'action/shortcut']);
+ expect(r.unverified).toEqual(['field/type']);
+ expect(r.errors).toHaveLength(1);
+ expect(r.errors[0]).toMatch(/tool\/active/);
+ });
+
+ it('honours a custom threshold', () => {
+ const r = buildVerificationReport(entries, { now: NOW, staleDays: 100_000 });
+ expect(r.stale).toHaveLength(0);
+ expect(r.fresh).toBe(3);
+ });
+
+ it('treats a malformed value as an ERROR, never as fresh', () => {
+ // The whole point: a bad date must not silently buy an entry a clean bill
+ // of health — that is the silent-no-op shape this ledger exists to catch.
+ const r = buildVerificationReport([{ key: 'x/y', status: 'live', verifiedAt: '2099-13-45' }], { now: NOW });
+ expect(r.fresh).toBe(0);
+ expect(r.stale).toHaveLength(0);
+ expect(r.errors).toHaveLength(1);
+ });
+
+ it('defaults to DEFAULT_STALE_DAYS', () => {
+ const justInside = new Date(NOW.getTime() - (DEFAULT_STALE_DAYS - 1) * 86_400_000).toISOString().slice(0, 10);
+ const justOutside = new Date(NOW.getTime() - (DEFAULT_STALE_DAYS + 1) * 86_400_000).toISOString().slice(0, 10);
+ const r = buildVerificationReport(
+ [{ key: 'a/b', status: 'live', verifiedAt: justInside }, { key: 'c/d', status: 'live', verifiedAt: justOutside }],
+ { now: NOW },
+ );
+ expect(r.fresh).toBe(1);
+ expect(r.stale.map((s) => s.key)).toEqual(['c/d']);
+ });
+});
+
+// Contract test against the REAL ledgers — the gate fails on a malformed
+// `verifiedAt`, so a typo committed to a ledger must surface here too.
+describe('shipped ledgers', () => {
+ it('every `verifiedAt` in packages/spec/liveness/*.json parses', () => {
+ const entries: Array<{ key: string; status: string; verifiedAt?: unknown }> = [];
+ for (const type of ['action', 'field', 'flow', 'object', 'view', 'agent', 'tool', 'skill']) {
+ const ledger = JSON.parse(readFileSync(join(ledgerRoot, `${type}.json`), 'utf8'));
+ for (const [key, entry] of Object.entries(ledger.props || {})) {
+ if (entry?.verifiedAt !== undefined) entries.push({ key: `${type}/${key}`, status: entry.status, verifiedAt: entry.verifiedAt });
+ for (const [ck, centry] of Object.entries(entry?.children || {})) {
+ if (centry?.verifiedAt !== undefined) entries.push({ key: `${type}/${key}.${ck}`, status: centry.status, verifiedAt: centry.verifiedAt });
+ }
+ }
+ }
+ expect(buildVerificationReport(entries).errors).toEqual([]);
+ });
+});