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([]); + }); +});