From a2bb2d345430af8a8b7c907f767c6b42b3ee603e Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Fri, 10 Jul 2026 16:17:12 +0800 Subject: [PATCH] chore(liveness): authorWarn sweep across all governed types + lint coverage to match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 12 new markings so every remaining MISLEADING dead property warns at compile time (security/reliability-shaped ones prioritized: tenancy.strategy/crossTenantAccess, tool.permissions, permission.contextVariables, flow errorHandling.fallbackNodeId). The lint previously only walked objects+fields — markings on other types were silent; it now covers all governed types as flat stack collections and fans container checks over arrays (one finding per item+path). README re-anchored: counts table had drifted badly (field 34/39 listed vs 54/6 actual; action.disabled still described as ignored though live via metadata-admin since 2026-06) — replaced with regenerable numbers + the regeneration script, and added the cross-repo evidence rule (grep ../objectui before classifying dead — the enable.trackHistory lesson). check:liveness green; cli 476 tests green (14 lint contract tests incl. array fan-out and per-type coverage). Co-Authored-By: Claude Fable 5 --- .changeset/liveness-authorwarn-sweep.md | 28 +++++ .../utils/lint-liveness-properties.test.ts | 65 +++++++++++ .../cli/src/utils/lint-liveness-properties.ts | 102 +++++++++++++----- packages/spec/liveness/README.md | 72 +++++++++---- packages/spec/liveness/action.json | 4 +- packages/spec/liveness/agent.json | 4 +- packages/spec/liveness/dataset.json | 4 +- packages/spec/liveness/field.json | 4 +- packages/spec/liveness/flow.json | 12 ++- packages/spec/liveness/object.json | 12 ++- packages/spec/liveness/permission.json | 4 +- packages/spec/liveness/tool.json | 4 +- 12 files changed, 255 insertions(+), 60 deletions(-) create mode 100644 .changeset/liveness-authorwarn-sweep.md diff --git a/.changeset/liveness-authorwarn-sweep.md b/.changeset/liveness-authorwarn-sweep.md new file mode 100644 index 0000000000..ecf8dc238a --- /dev/null +++ b/.changeset/liveness-authorwarn-sweep.md @@ -0,0 +1,28 @@ +--- +'@objectstack/spec': patch +'@objectstack/cli': patch +--- + +chore(liveness): authorWarn sweep across all governed types + lint coverage to match + +Every remaining *misleading* dead property now warns at compile time (12 new +markings): `flow.errorHandling.fallbackNodeId` (engine uses fault edges), +`flow.nodes[].outputSchema` (never validated), `flow.template`, +`action.timeout` (no runtime enforcement), `object.tenancy.strategy` / +`crossTenantAccess` (only enabled+tenantField are read), `object.abstract`, +`field.dependencies`, `agent.tenantId`, `tool.permissions` (invocation not +permission-gated), `permission.contextVariables` (RLS reads current_user.* +only), `dataset.measures[].certified` (governance flag unenforced). + +The compile-time lint previously only checked objects+fields, so markings on +other types were silent — it now covers every governed type (flat stack +collections) and fans container checks out over arrays (one finding per +item+path). Benign display metadata (label/description/tags) stays unmarked +per the README's signal rules. + +Also re-anchors the README: the counts table had drifted badly (field listed +as 34 live/39 dead vs the ledger's actual 54/6; `action.disabled` was still +described as ignored though it went live via metadata-admin) — replaced with +regenerable numbers plus the script to regenerate them, and added the +cross-repo evidence rule (grep ../objectui before classifying dead — the +enable.trackHistory lesson, #2707). diff --git a/packages/cli/src/utils/lint-liveness-properties.test.ts b/packages/cli/src/utils/lint-liveness-properties.test.ts index f9884703fa..6c25d67048 100644 --- a/packages/cli/src/utils/lint-liveness-properties.test.ts +++ b/packages/cli/src/utils/lint-liveness-properties.test.ts @@ -80,4 +80,69 @@ describe('lintLivenessProperties', () => { expect(lintLivenessProperties({})).toEqual([]); expect(lintLivenessProperties({ objects: [] })).toEqual([]); }); + + // ── Coverage beyond object/field: flat stack collections ───────────── + // The 2026-07 authorWarn pass marked misleading dead props on flows, + // actions, agents, tools, datasets, permissions, and the object tenancy + // block. These run against the REAL ledgers, so they double as contract + // tests for those markings. + + it('warns on flow.errorHandling.fallbackNodeId (engine uses fault edges)', () => { + const findings = lintLivenessProperties({ + flows: [{ name: 'f1', errorHandling: { fallbackNodeId: 'n2' } }], + }); + const f = findings.find((x) => x.message.includes('errorHandling.fallbackNodeId')); + expect(f).toBeDefined(); + expect(f!.where).toBe("flow 'f1'"); + }); + + it('fans out array containers: flow.nodes[].outputSchema warns once per flow', () => { + const findings = lintLivenessProperties({ + flows: [{ + name: 'f1', + nodes: [ + { id: 'n1' }, + { id: 'n2', outputSchema: { type: 'object' } }, + { id: 'n3', outputSchema: { type: 'object' } }, + ], + }], + }); + const hits = findings.filter((x) => x.message.includes('nodes.outputSchema')); + expect(hits.length).toBe(1); // one finding per (flow, path), not per node + expect(hits[0].where).toBe("flow 'f1'"); + }); + + it('warns on action.timeout (no runtime enforcement)', () => { + const findings = lintLivenessProperties({ actions: [{ name: 'a1', timeout: 5000 }] }); + expect(paths(findings).some((m) => m.includes('`timeout`'))).toBe(true); + }); + + it('warns on the security-shaped dead props (tenancy.strategy / tool.permissions / permission.contextVariables)', () => { + const tenancy = lintLivenessProperties(objStack({ tenancy: { enabled: true, strategy: 'isolated' } })); + expect(paths(tenancy).some((m) => m.includes('tenancy.strategy'))).toBe(true); + // tenancy.enabled itself is live — must NOT warn. + expect(paths(tenancy).some((m) => m.includes('tenancy.enabled'))).toBe(false); + + const tool = lintLivenessProperties({ tools: [{ name: 't1', permissions: ['crm.admin'] }] }); + expect(paths(tool).some((m) => m.includes('`permissions`'))).toBe(true); + + const perm = lintLivenessProperties({ permissions: [{ name: 'p1', contextVariables: { region: 'emea' } }] }); + expect(paths(perm).some((m) => m.includes('contextVariables'))).toBe(true); + }); + + it('warns on dataset measure certified flag via array fan-out', () => { + const findings = lintLivenessProperties({ + datasets: [{ name: 'd1', measures: [{ name: 'arr', certified: true }] }], + }); + expect(paths(findings).some((m) => m.includes('measures.certified'))).toBe(true); + }); + + it('stays silent on clean flat-collection items', () => { + const findings = lintLivenessProperties({ + flows: [{ name: 'clean', nodes: [{ id: 'n1' }] }], + actions: [{ name: 'clean' }], + tools: [{ name: 'clean' }], + }); + expect(findings).toEqual([]); + }); }); diff --git a/packages/cli/src/utils/lint-liveness-properties.ts b/packages/cli/src/utils/lint-liveness-properties.ts index d078bb018f..3b4520c9ce 100644 --- a/packages/cli/src/utils/lint-liveness-properties.ts +++ b/packages/cli/src/utils/lint-liveness-properties.ts @@ -117,48 +117,88 @@ function checkItem( findings: LivenessLintFinding[], ): void { for (const [path, entry] of warnMap) { - const value = path.includes('.') + const values = path.includes('.') ? getNested(item, path) - : item[path]; - if (!isAuthored(value)) continue; - const { kind, rule } = describe(entry); - const hint = entry.authorHint - ?? entry.note - ?? 'Remove it — it is declared in the spec but not consumed at runtime.'; - findings.push({ - where: whereBase, - message: `sets \`${path}\` but this ${type} property ${kind}.`, - hint, - rule, - }); + : [item[path]]; + for (const value of values instanceof Array ? values : [values]) { + if (!isAuthored(value)) continue; + const { kind, rule } = describe(entry); + const hint = entry.authorHint + ?? entry.note + ?? 'Remove it — it is declared in the spec but not consumed at runtime.'; + findings.push({ + where: whereBase, + message: `sets \`${path}\` but this ${type} property ${kind}.`, + hint, + rule, + }); + break; // one finding per (item, path) even when the container is an array + } } } -/** Resolve a dotted path one or more levels, treating a missing parent as absent. */ -function getNested(obj: AnyRec, path: string): unknown { - let cur: unknown = obj; +/** + * Resolve a dotted path one or more levels, treating a missing parent as + * absent. A container level that is an ARRAY fans out over its elements + * (e.g. `nodes.outputSchema` on a flow checks every node), returning the + * list of resolved values. + */ +function getNested(obj: AnyRec, path: string): unknown[] { + let cur: unknown[] = [obj]; for (const seg of path.split('.')) { - if (cur === null || typeof cur !== 'object') return undefined; - cur = (cur as AnyRec)[seg]; + const next: unknown[] = []; + for (const c of cur) { + if (c === null || typeof c !== 'object') continue; + const v = Array.isArray(c) ? undefined : (c as AnyRec)[seg]; + if (Array.isArray(c)) { + for (const el of c) { + if (el && typeof el === 'object') next.push((el as AnyRec)[seg]); + } + } else { + next.push(v); + } + } + cur = next; } - return cur; + // Final level may itself contain arrays-of-values; flatten one step so a + // trailing array container (e.g. `measures` → each measure) fans out too. + return cur.flatMap((v) => (Array.isArray(v) ? v : [v])); } +/** + * The compiled-stack collection each governed metadata type lives in. + * `object`/`field` keep their bespoke walk (fields nest under objects); + * everything else is a flat top-level array on the stack definition. + */ +const TYPE_COLLECTIONS: Array<{ type: string; key: string }> = [ + { type: 'flow', key: 'flows' }, + { type: 'action', key: 'actions' }, + { type: 'agent', key: 'agents' }, + { type: 'tool', key: 'tools' }, + { type: 'skill', key: 'skills' }, + { type: 'dataset', key: 'datasets' }, + { type: 'permission', key: 'permissions' }, + { type: 'hook', key: 'hooks' }, + { type: 'page', key: 'pages' }, +]; + /** * Lint the compiled stack for authored properties the liveness ledger flags as - * misleading. Advisory only — returns findings, never throws. v1 covers the two - * highest-signal surfaces (objects incl. their `enable.*` flags, and their - * fields); the mechanism is ledger-driven, so coverage grows by marking more - * entries `authorWarn` rather than touching this code. + * misleading. Advisory only — returns findings, never throws. Covers every + * governed metadata type: objects (incl. `enable.*`) and their fields walk + * bespoke nesting; the remaining types are flat stack collections. Container + * properties fan out over arrays (each flow node, each dataset measure). The + * mechanism stays ledger-driven — coverage grows by marking more entries + * `authorWarn` rather than touching this code. */ export function lintLivenessProperties(stack: AnyRec): LivenessLintFinding[] { const dir = resolveLivenessDir(); if (!dir) return []; - const objectWarn = loadWarnMap(dir, 'object'); - const fieldWarn = loadWarnMap(dir, 'field'); - if (objectWarn.size === 0 && fieldWarn.size === 0) return []; const findings: LivenessLintFinding[] = []; + + const objectWarn = loadWarnMap(dir, 'object'); + const fieldWarn = loadWarnMap(dir, 'field'); for (const obj of asArray(stack.objects)) { const objName = typeof obj.name === 'string' ? obj.name : '(unnamed object)'; if (objectWarn.size > 0) checkItem('object', obj, `object '${objName}'`, objectWarn, findings); @@ -169,5 +209,15 @@ export function lintLivenessProperties(stack: AnyRec): LivenessLintFinding[] { } } } + + for (const { type, key } of TYPE_COLLECTIONS) { + const warnMap = loadWarnMap(dir, type); + if (warnMap.size === 0) continue; + for (const item of asArray(stack[key])) { + const name = typeof item.name === 'string' ? item.name : `(unnamed ${type})`; + checkItem(type, item, `${type} '${name}'`, warnMap, findings); + } + } + return findings; } diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index 7b6960783a..aa9ccaeecd 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -115,7 +115,10 @@ Two rules keep it false-positive-free, **both of which the marker author must re Object/string/array props warn when merely present, so this caveat is boolean-only. The lint is ledger-driven: coverage grows by marking more entries `authorWarn`, not by -touching the lint code. Today it covers `object` (incl. `enable.*`) and `field`. +touching the lint code. It covers **every governed type**: objects (incl. `enable.*`) +and their fields walk bespoke nesting; flows/actions/agents/tools/skills/datasets/ +permissions/hooks/pages are checked as flat stack collections, and container +properties fan out over arrays (each flow node, each dataset measure). ## Granularity — drill one level @@ -157,24 +160,49 @@ The governed set is `GOVERNED` at the top of `check-liveness.mts`. To add a type 2. Seed `.json` from that type's liveness audit (file:line evidence) + targeted greps. **Classify only with evidence** — `live` needs a cited consumer; `dead` needs a confirmed absence. -3. Add the type to `GOVERNED`; confirm the gate is green. - -## Current state — 10 governed types (~295 properties) - -| Type | live | exp | dead | Notes | -|---|---|---|---|---| -| object | 35 | – | 13 | versioning/partitioning/cdc tier dead; ObjectCapabilities fully live post-#2707/#2727 (`apiEnabled`/`apiMethods` enforced #1937; `feeds`/`activities` opt-out gates; `trackHistory` UI master switch; `files` opt-in Attachments gate) | -| field | 34 | – | 39 | ~half dead — aspirational enhanced-type + governance config; naming-drift props server-live/client-snake | -| flow | 29 | 1 | 7 | `runAs` experimental (unenforced identity switch); status/active gate nothing; FlowNodeAction enum out of sync | -| action | 26 | – | 5 | `disabled` CEL ignored (renderers read non-spec `enabled`); type:'form'/shortcut/bulkEnabled dead | -| hook | 11 | – | 2 | model-healthy — near-total liveness; only label/description dead | -| permission | 23 | 3 | 2 | CRUD/FLS/RLS live; allow{Transfer,Restore,Purge} experimental; isProfile/contextVariables dead | -| role | 3 | – | 1 | `parent` dead (org hierarchy uses sys_department) | -| agent | 18 | 4 | 5 | access/permissions/visibility dead (chat route hardcodes perms); autonomy experimental | -| tool | 13 | 1 | 5 | write-only metadata; runtime uses a parallel AIToolDefinition | -| skill | 15 | – | 2 | triggerPhrases dead (no matcher); permissions dead | -| dataset | 26 | – | 1 | analytics semantic layer (compiled to a Cube); `measures.certified` dead; `dimensions.dateGranularity` carries the org-tz bucketing proof | - -The `dead` set across types is the enforce-or-remove worklist (ADR-0049). Not yet governed -(rollout): view, page, dashboard, app, report, job, datasource, translation, -email_template, doc, book, validation, seed. +3. **Confirmed absence means BOTH repos.** The renderer layer is a legitimate consumer + (`live` with objectui evidence as prose), so grep `../objectui` before writing `dead`. + Precedent: `enable.trackHistory` was misclassified dead for a month while + RecordDetailView had been gating the History tab on it the whole time (#2707). +4. Add the type to `GOVERNED`; confirm the gate is green. + +## Current state — 12 governed types + +Counts include drilled `children` entries; regenerate with the snippet below rather +than hand-editing (this table drifted badly once — field was listed 34/39 while the +ledger actually said 54/6). + +```bash +python3 - <<'EOF' +import json, glob, os +from collections import Counter +for f in sorted(glob.glob('packages/spec/liveness/*.json')): + d = json.load(open(f)); c = Counter() + def walk(ps): + for v in ps.values(): + if 'status' in v: c[v['status']] += 1 + walk(v.get('children') or {}) + walk(d.get('props', {})) + print(os.path.basename(f)[:-5], dict(c)) +EOF +``` + +| Type | live | exp | dead | planned | Notes | +|---|---|---|---|---|---| +| object | 36 | – | 14 | – | versioning/partitioning/cdc tier dead; ObjectCapabilities fully live post-#2707/#2727; `tenancy.strategy`/`crossTenantAccess` inert (only `enabled`+`tenantField` read) | +| field | 54 | – | 6 | – | near-healthy; dead = referenceFilters/columnName/index/vectorConfig/fileAttachmentConfig/dependencies, all authorWarn'd | +| flow | 27 | – | 4 | – | dead = description/template/nodes.outputSchema/errorHandling.fallbackNodeId (engine uses fault edges) | +| action | 34 | 1 | 1 | – | `disabled` went LIVE via metadata-admin authoring UI (2026-06 audit missed objectui); only `timeout` dead | +| hook | 11 | – | 2 | – | model-healthy; only label/description dead (benign) | +| permission | 32 | – | 1 | – | CRUD/FLS/RLS live; `contextVariables` dead (RLS uses current_user.* built-ins only) | +| position | 3 | – | – | – | (role's ADR-0090 successor) fully live | +| agent | 14 | 5 | 1 | – | `tenantId` dead (tenancy comes from request context); autonomy tier experimental | +| tool | 9 | 1 | 1 | – | `permissions` dead — tool invocation not permission-gated by it | +| skill | 10 | – | – | – | fully live | +| dataset | 19 | – | 1 | – | `measures.certified` declared-but-unenforced governance flag | +| page | 16 | – | – | 1 | fully live + one planned | + +The `dead` set across types is the enforce-or-remove worklist (ADR-0049); every +misleading entry carries `authorWarn` so authors hear about it at compile time. +Not yet governed (rollout): view, dashboard, app, report, job, datasource, +translation, email_template, doc, book, validation, seed. diff --git a/packages/spec/liveness/action.json b/packages/spec/liveness/action.json index 37000ac3c8..38215de868 100644 --- a/packages/spec/liveness/action.json +++ b/packages/spec/liveness/action.json @@ -154,7 +154,9 @@ }, "timeout": { "status": "dead", - "evidence": "action-level timeout DEAD — server uses body.timeoutMs; no UI consumer" + "evidence": "action-level timeout DEAD — server uses body.timeoutMs; no UI consumer", + "authorWarn": true, + "authorHint": "No action-level timeout is enforced at runtime — remove it. Per-action execution time limits are not configurable today." }, "aria": { "status": "live", diff --git a/packages/spec/liveness/agent.json b/packages/spec/liveness/agent.json index 8d63e99313..338f2c0602 100644 --- a/packages/spec/liveness/agent.json +++ b/packages/spec/liveness/agent.json @@ -71,7 +71,9 @@ }, "tenantId": { "status": "dead", - "evidence": "no runtime reader" + "evidence": "no runtime reader", + "authorWarn": true, + "authorHint": "No runtime reader — it does NOT scope the agent to a tenant. Tenancy comes from the request context (resolveAuthzContext), not this field." }, "knowledge": { "status": "live", diff --git a/packages/spec/liveness/dataset.json b/packages/spec/liveness/dataset.json index e59efb8fc3..89effa7992 100644 --- a/packages/spec/liveness/dataset.json +++ b/packages/spec/liveness/dataset.json @@ -101,7 +101,9 @@ "certified": { "status": "dead", "evidence": "no runtime consumer — analytics execution never reads it; not compiled into the Cube", - "note": "measure trust/governance flag, declared but unenforced (ADR-0049 enforce-or-remove candidate). Not authorWarn'd: it may surface as an objectui badge, so it is not necessarily misleading." + "note": "measure trust/governance flag, declared but unenforced (ADR-0049 enforce-or-remove candidate). Not authorWarn'd: it may surface as an objectui badge, so it is not necessarily misleading.", + "authorWarn": true, + "authorHint": "Declared but unenforced — analytics execution never reads it, so nothing badges or restricts \"certified\" measures. Do not present governance guarantees based on this flag." }, "derived": { "status": "live", diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json index 37d7b6ac02..f881daad2f 100644 --- a/packages/spec/liveness/field.json +++ b/packages/spec/liveness/field.json @@ -238,7 +238,9 @@ }, "dependencies": { "status": "dead", - "evidence": "no consumer" + "evidence": "no consumer", + "authorWarn": true, + "authorHint": "No consumer — field dependency/cascade behavior is not implemented. Formula inputs are inferred from the expression itself; visibility rules use their own conditions." }, "inlineTitle": { "status": "live", diff --git a/packages/spec/liveness/flow.json b/packages/spec/liveness/flow.json index be89b5cef4..63095b1153 100644 --- a/packages/spec/liveness/flow.json +++ b/packages/spec/liveness/flow.json @@ -44,7 +44,9 @@ }, "template": { "status": "dead", - "evidence": "no reader either layer" + "evidence": "no reader either layer", + "authorWarn": true, + "authorHint": "No reader in designer or engine — flagging a flow as a template/subflow does nothing. Invoke shared logic via a subflow node referencing the flow by name." }, "type": { "status": "live", @@ -100,7 +102,9 @@ }, "outputSchema": { "status": "dead", - "evidence": "declared, never validated" + "evidence": "declared, never validated", + "authorWarn": true, + "authorHint": "Declared but never validated — node outputs are NOT checked against this schema at runtime. Do not rely on it for contract enforcement." }, "waitEventConfig": { "status": "live", @@ -141,7 +145,9 @@ }, "fallbackNodeId": { "status": "dead", - "evidence": "engine uses per-node fault edges, not this" + "evidence": "engine uses per-node fault edges, not this", + "authorWarn": true, + "authorHint": "The engine routes failures via per-node fault edges, not errorHandling.fallbackNodeId — wire a fault edge from the failing node instead." } } } diff --git a/packages/spec/liveness/object.json b/packages/spec/liveness/object.json index e2c2a3952b..3baa742e86 100644 --- a/packages/spec/liveness/object.json +++ b/packages/spec/liveness/object.json @@ -129,7 +129,9 @@ "strategy": { "status": "dead", "evidence": "inert — only tenancy.enabled is read", - "note": "part of the live tenancy block (sql-driver reads enabled+tenantField); verify before touching." + "note": "part of the live tenancy block (sql-driver reads enabled+tenantField); verify before touching.", + "authorWarn": true, + "authorHint": "Only tenancy.enabled + tenantField are read (sql-driver row scoping) — 'shared'/'isolated'/'hybrid' changes nothing. Do not expect per-tenant databases from this knob." }, "tenantField": { "status": "live", @@ -139,7 +141,9 @@ "crossTenantAccess": { "status": "dead", "evidence": "inert", - "note": "part of the live tenancy block; verify before touching." + "note": "part of the live tenancy block; verify before touching.", + "authorWarn": true, + "authorHint": "Inert — setting it true does NOT grant (or gate) cross-tenant access. Tenant isolation is enforced solely via tenancy.enabled row scoping." } } }, @@ -227,7 +231,9 @@ }, "abstract": { "status": "dead", - "evidence": "no runtime reader" + "evidence": "no runtime reader", + "authorWarn": true, + "authorHint": "No runtime reader — an abstract object still gets a table and is instantiable. Object inheritance/abstraction is not implemented." }, "isSystem": { "status": "dead", diff --git a/packages/spec/liveness/permission.json b/packages/spec/liveness/permission.json index 5f7be807ae..2a827f8d2f 100644 --- a/packages/spec/liveness/permission.json +++ b/packages/spec/liveness/permission.json @@ -162,7 +162,9 @@ "contextVariables": { "status": "dead", "evidence": "rls-compiler.ts never reads it", - "note": "RLS uses only current_user.* built-ins." + "note": "RLS uses only current_user.* built-ins.", + "authorWarn": true, + "authorHint": "The RLS compiler never reads it — row-level conditions can only use the current_user.* built-ins. Custom context variables are not injected." } } } diff --git a/packages/spec/liveness/tool.json b/packages/spec/liveness/tool.json index 0ae5fa676d..8a35c4266a 100644 --- a/packages/spec/liveness/tool.json +++ b/packages/spec/liveness/tool.json @@ -43,7 +43,9 @@ }, "permissions": { "status": "dead", - "evidence": "tool.form.ts only; not on AIToolDefinition, no consumer" + "evidence": "tool.form.ts only; not on AIToolDefinition, no consumer", + "authorWarn": true, + "authorHint": "Not part of AIToolDefinition and no consumer — tool invocation is NOT permission-gated by this list. Gate the underlying action via requiredPermissions / permission sets (ADR-0066)." }, "active": { "status": "live",