Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .changeset/liveness-verified-at-clock.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 12 additions & 4 deletions content/docs/ui/actions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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=…` |

<Callout type="warn">
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.
</Callout>

## Define your first action
Expand Down Expand Up @@ -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.
Expand Down
34 changes: 34 additions & 0 deletions packages/spec/liveness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions packages/spec/liveness/action.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
63 changes: 59 additions & 4 deletions packages/spec/scripts/liveness/check-liveness.mts
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,20 @@
// (see proof-registry.mts), a `live` classification MUST carry a valid proof —
// the file must exist and declare the `@proof: <id>` 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 <type> # 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 <type> # 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';

Expand All @@ -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
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -304,6 +336,29 @@ if (asJson) {
console.log(`\n✗ ${totalUnclassified} UNCLASSIFIED — classify in packages/spec/liveness/<type>.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.');
}
Expand Down
Loading
Loading