Skip to content

Commit c757854

Browse files
authored
feat(spec): verifiedAt re-verification clock; re-verify action.undoable/type against objectui (#3714 follow-up) (#3845)
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. 1. verifiedAt clock. Entries may carry "verifiedAt": "YYYY-MM-DD". Age never fails CI (re-verification is a worklist, not a merge gate) — one summary line per run, full worklist under `check:liveness --stale-verification[=days]`, default 180d. A malformed or future date DOES fail: it would silently exempt the entry from every staleness window, the same silent-no-op shape this ledger exists to catch. New pure module verification.mts + 11 unit tests. 2. Re-verified against objectui 732b1bf: - action.undoable — both readers stand; evidence now closes the call graph end to end. The cited RecordDetailView lines had already drifted 545->573 / 404->432 one day after the issue was filed, hence the pinned sha. - action.type — api -> executeAPI, form -> executeForm, both real. 3. content/docs/ui/actions.mdx told authors `api` and `form` have "no runtime executor / renderer today". Both have executors in objectui's ActionRunner, and the ledger's own action.type entry recorded `form` as live since #2377. Added table rows for both, kept the true half of the callout, and added `undoable` to the UX property list.
1 parent de9af8a commit c757854

7 files changed

Lines changed: 391 additions & 12 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
feat(spec): `verifiedAt` re-verification clock on liveness entries + two entries re-verified against objectui (#3714 follow-up)
6+
7+
A ledger entry is a claim with a timestamp, and **twice** now one has been
8+
falsified by code moving under it — `flow.status` (#3711) and `action.undoable`
9+
(#3714), both *understated*, both found only because a sweep aimed at the
10+
opposite failure walked past them. Nothing in the gate asked how old a claim
11+
was, so a stale entry stayed invisible until someone tripped over it.
12+
13+
**`verifiedAt`.** Ledger entries may now carry `"verifiedAt": "YYYY-MM-DD"`
14+
the date a human last closed the call graph. The asymmetry is the design:
15+
16+
- **Age never fails CI.** Re-verification is a worklist, not a merge gate. Every
17+
run prints one summary line; `pnpm check:liveness --stale-verification[=days]`
18+
prints the worklist (stale oldest-first, then undated). Default 180 days.
19+
- **A malformed or future-dated value DOES fail CI.** A date the parser can't
20+
read would silently exempt that entry from every staleness window — the same
21+
silent-no-op shape this ledger exists to catch. Also rejects calendar-invalid
22+
dates, since `new Date('2026-02-30')` rolls over to March 2 rather than
23+
throwing.
24+
25+
Currently 2 of 401 entries are dated. The rest predate the field and report as
26+
undated; date them as you re-verify rather than back-filling guesses.
27+
28+
**Two entries re-verified against objectui `732b1bf`:**
29+
30+
- `action.undoable` — both readers stand, and the call graph now closes end to
31+
end in the evidence: the two `if (action.undoable …)` gates build
32+
`result.undo`; `ActionRunner.ts:640-643` pushes it onto `globalUndoManager`
33+
and passes `undo` to the toast handler; the toast's Undo button runs
34+
`undoCtl.undo()``useGlobalUndo``UndoManager``dataSource`. The cited
35+
`RecordDetailView` line numbers had already drifted (545→573, 404→432) in the
36+
day between the issue being filed and this pass — hence the pinned sha.
37+
- `action.type``api``executeAPI`, `form``executeForm`, both real.
38+
39+
**Docs correction (`content/docs/ui/actions.mdx`).** That page told authors the
40+
schema's `api` and `form` types have "no runtime executor / renderer today —
41+
stick to the four above." Both have had executors in objectui's `ActionRunner`
42+
for some time, and the ledger's own `action.type` entry recorded `form` as live
43+
since #2377. Same understatement shape as #3714, one page over. Both types now
44+
have table rows; the callout keeps the parts that are true (`shortcut` and
45+
`bulkEnabled` really are unwired) and links the ledger. `undoable` also joins
46+
the UX property list, which is the author-facing payoff of #3714.

content/docs/ui/actions.mdx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,16 @@ The types you'll actually use:
2020
| `flow` | Launch a flow (e.g. a screen-flow wizard) | `target` names the flow |
2121
| `url` | Navigate / open a link | `target` is the URL (`${ctx.record.id}` interpolation supported) |
2222
| `modal` | Open a modal page to collect input, then submit to a handler | `target` names the modal page |
23+
| `api` | Call an HTTP endpoint directly | `target` is the endpoint; `method` / `bodyShape` / `bodyExtra` shape the request |
24+
| `form` | Open a form view, prefilled with the current record | `target` names the FormView; routed to `/forms/:target?recordId=…` |
2325

2426
<Callout type="warn">
25-
The schema also accepts `api` and `form` types, but they have **no runtime
26-
executor / renderer today** — stick to the four above. Likewise prefer
27-
`confirmText`/`params` over schema properties that are not yet wired
28-
(`shortcut`, `bulkEnabled`).
27+
Prefer `confirmText`/`params` over the two action properties that are **not
28+
wired**: `shortcut` (nothing dispatches keydown events to actions) and
29+
`bulkEnabled` (the multi-select toolbar reads the list view's `bulkActions`,
30+
not this flag). The
31+
[liveness ledger](https://github.com/objectstack-ai/objectstack/blob/main/packages/spec/liveness/action.json)
32+
carries the per-property status of everything the action schema accepts.
2933
</Callout>
3034

3135
## Define your first action
@@ -189,6 +193,10 @@ engine still requires the action to declare the matching location (that's why
189193
- **`confirmText`** — confirmation dialog before running.
190194
- **`successMessage` / `errorMessage` / `refreshAfter`** — post-run feedback
191195
and an automatic data refresh.
196+
- **`undoable`** — on a single-record update, the success toast offers an
197+
**Undo** that restores the record's prior field values (`Ctrl+Z` works too).
198+
The runtime only captures the prior values when this flag is set, so an
199+
action that omits it gets no Undo.
192200
- **`resultDialog`** — a one-time reveal dialog for output the user must copy
193201
(generated tokens, export links).
194202
- **`variant` / `icon` / `order`** — presentation and sort position.

packages/spec/liveness/README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,40 @@ Resolution per property: **ledger entry → spec `.describe()` marker → UNCLAS
4646
Framework provenance/lock fields (`_lock*`, `_provenance`, `_packageId/Version`,
4747
`protection` — ADR-0010) are auto-classified `live`.
4848

49+
### `verifiedAt` — the re-verification clock
50+
51+
An entry may carry `"verifiedAt": "YYYY-MM-DD"`: the date a human last closed the
52+
call graph for that property. It exists because **twice** an entry has been
53+
falsified by code moving under it — `flow.status` (#3711) and `action.undoable`
54+
(#3714), both *understated*, both found only because a sweep aimed at the
55+
opposite failure happened to walk past them. Nothing in the gate previously
56+
asked how old a claim was, so a stale entry stayed invisible until someone
57+
tripped over it.
58+
59+
```jsonc
60+
"undoable": {
61+
"status": "live",
62+
"verifiedAt": "2026-07-28",
63+
"evidence": "objectui @732b1bf — CALL GRAPH CLOSED BY HAND: …"
64+
}
65+
```
66+
67+
Two rules, and the asymmetry between them is the point:
68+
69+
- **Age never fails CI.** Re-verification is a worklist, not a merge gate. Every
70+
run prints one summary line; `pnpm check:liveness --stale-verification[=days]`
71+
prints the worklist (stale oldest-first, then the undated ones). Default
72+
threshold 180 days.
73+
- **A malformed or future-dated `verifiedAt` DOES fail CI.** A date the parser
74+
can't read would silently exempt that entry from every staleness window —
75+
which is the same silent-no-op shape this whole ledger exists to catch. Fail
76+
loudly instead.
77+
78+
Most entries predate the field and are simply undated; date them as you
79+
re-verify rather than back-filling guesses. **For objectui-side evidence, pin
80+
the commit** (`objectui @732b1bf`) — `action.undoable`'s reader line numbers had
81+
already drifted by 28 lines one day after the issue citing them was filed.
82+
4983
### ⚠️ An authoring/preview renderer is NOT a runtime consumer
5084

5185
`live` means **authoring the property changes runtime behaviour**. A Studio

packages/spec/liveness/action.json

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,9 @@
2929
},
3030
"type": {
3131
"status": "live",
32-
"evidence": "packages/runtime/src/http-dispatcher.ts",
33-
"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."
32+
"verifiedAt": "2026-07-28",
33+
"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)",
34+
"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."
3435
},
3536
"target": {
3637
"status": "live",
@@ -75,8 +76,9 @@
7576
},
7677
"undoable": {
7778
"status": "live",
78-
"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",
79-
"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."
79+
"verifiedAt": "2026-07-28",
80+
"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",
81+
"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."
8082
},
8183
"refreshAfter": {
8284
"status": "live",

packages/spec/scripts/liveness/check-liveness.mts

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,20 @@
2727
// (see proof-registry.mts), a `live` classification MUST carry a valid proof —
2828
// the file must exist and declare the `@proof: <id>` tag. CI fails otherwise.
2929
//
30+
// RE-VERIFICATION CLOCK (`verifiedAt`): a ledger entry is a claim with a
31+
// timestamp, and code moves under it in BOTH directions — `flow.status` (#3711)
32+
// and `action.undoable` (#3714) were both understated by entries that were
33+
// accurate when written. An optional `"verifiedAt": "YYYY-MM-DD"` records when a
34+
// human last closed the call graph. Age never fails CI (re-verification is a
35+
// worklist); a MALFORMED or future date does, because it silently disables the
36+
// staleness check for that entry.
37+
//
3038
// Usage:
31-
// tsx check-liveness.mts # check all governed types
32-
// tsx check-liveness.mts --dump <type> # inventory a type's properties (seeding aid)
33-
// tsx check-liveness.mts --json # machine-readable report
39+
// tsx check-liveness.mts # check all governed types
40+
// tsx check-liveness.mts --dump <type> # inventory a type's properties (seeding aid)
41+
// tsx check-liveness.mts --json # machine-readable report
42+
// tsx check-liveness.mts --stale-verification # print the re-verification worklist
43+
// tsx check-liveness.mts --stale-verification=90 # ...with a custom staleness threshold
3444

3545
process.env.OS_EAGER_SCHEMAS = '1';
3646

@@ -47,6 +57,12 @@ import {
4757
parseProofRef,
4858
validateProofRef,
4959
} from './proof-registry.mts';
60+
import {
61+
DEFAULT_STALE_DAYS,
62+
buildVerificationReport,
63+
type VerificationEntry,
64+
type VerificationReport,
65+
} from './verification.mts';
5066

5167
const here = dirname(fileURLToPath(import.meta.url));
5268
const specRoot = resolve(here, '../..'); // packages/spec
@@ -185,14 +201,21 @@ const report: any = {
185201
proofErrors: [] as string[], // a `proof` ref that doesn't resolve (missing file / missing tag / malformed)
186202
proofMissing: [] as string[], // a bound high-risk `live` entry with no proof at all
187203
orphanProofs: [] as string[], // a dogfood `@proof:` tag not registered in proof-registry.mts
204+
verification: null as VerificationReport | null, // `verifiedAt` ages — the re-verification worklist
188205
};
189206

207+
// Every classified entry, for the `verifiedAt` fold below. Collected during the
208+
// walk so the age report sees exactly the set the gate itself classified.
209+
const verificationEntries: VerificationEntry[] = [];
210+
190211
const proofFs = { existsSync, readFileSync };
191212

192213
function classify(type: string, path: string, status: string, led: any, cat: any) {
193214
cat.classified++;
194215
cat.byStatus[status] = (cat.byStatus[status] || 0) + 1;
195216
report.totals.byStatus[status] = (report.totals.byStatus[status] || 0) + 1;
217+
// Framework-auto entries (`led === null`) have no ledger row to date-stamp.
218+
if (led !== null) verificationEntries.push({ key: `${type}/${path}`, status, verifiedAt: led?.verifiedAt });
196219
if (status === 'live' && led?.evidence) {
197220
const file = String(led.evidence).split(':')[0];
198221
if (/\//.test(file) && !existsSync(join(repoRoot, file))) report.staleEvidence.push(`${type}/${path}${led.evidence}`);
@@ -271,9 +294,18 @@ for (const type of GOVERNED) {
271294

272295
scanOrphanProofs();
273296

297+
// ── verifiedAt: how old is each claim? ──
298+
// Age never fails the gate — re-verification is a worklist, not a merge gate.
299+
// A MALFORMED value does fail: it silently disables the staleness check for
300+
// that entry, the exact silent-no-op shape this ledger exists to catch.
301+
const staleDaysArg = args.find((a) => a.startsWith('--stale-verification'));
302+
const staleDays = Number(staleDaysArg?.split('=')[1]) || DEFAULT_STALE_DAYS;
303+
const showWorklist = staleDaysArg !== undefined;
304+
report.verification = buildVerificationReport(verificationEntries, { staleDays });
305+
274306
const totalUnclassified = report.unclassified.length;
275307
const totalProofFailures = report.proofErrors.length + report.proofMissing.length;
276-
const failed = totalUnclassified > 0 || totalProofFailures > 0;
308+
const failed = totalUnclassified > 0 || totalProofFailures > 0 || report.verification.errors.length > 0;
277309
if (asJson) {
278310
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
279311
} else {
@@ -304,6 +336,29 @@ if (asJson) {
304336
console.log(`\n✗ ${totalUnclassified} UNCLASSIFIED — classify in packages/spec/liveness/<type>.json:`);
305337
report.unclassified.forEach((s: string) => console.log(` ${s}`));
306338
}
339+
// ── re-verification clock ──
340+
const v = report.verification!;
341+
if (v.errors.length) {
342+
console.log(`\n✗ ${v.errors.length} malformed \`verifiedAt\` value(s) — a bad date silently disables the staleness check:`);
343+
v.errors.forEach((s: string) => console.log(` ${s}`));
344+
}
345+
const dated = v.fresh + v.stale.length;
346+
console.log(
347+
`\nre-verification clock: ${dated} entr(ies) carry \`verifiedAt\` (${v.stale.length} older than ${staleDays}d), ` +
348+
`${v.unverified.length} undated.`,
349+
);
350+
if (showWorklist) {
351+
if (v.stale.length) {
352+
console.log(`\n stale (verified > ${staleDays}d ago) — re-verify oldest first:`);
353+
v.stale.forEach((s) => console.log(` ${s.key.padEnd(40)} ${s.verifiedAt} (${s.ageDays}d)`));
354+
}
355+
if (v.unverified.length) {
356+
console.log(`\n never dated (${v.unverified.length}) — predate the field; date them as you re-verify:`);
357+
v.unverified.forEach((k: string) => console.log(` ${k}`));
358+
}
359+
} else if (v.stale.length || v.unverified.length) {
360+
console.log(' run with --stale-verification[=days] for the worklist.');
361+
}
307362
if (!failed) {
308363
console.log('\n✓ all governed-type properties are classified; all bound high-risk proofs resolve.');
309364
}

0 commit comments

Comments
 (0)