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
28 changes: 28 additions & 0 deletions .changeset/liveness-authorwarn-sweep.md
Original file line number Diff line number Diff line change
@@ -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).
65 changes: 65 additions & 0 deletions packages/cli/src/utils/lint-liveness-properties.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});
});
102 changes: 76 additions & 26 deletions packages/cli/src/utils/lint-liveness-properties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
}
72 changes: 50 additions & 22 deletions packages/spec/liveness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -157,24 +160,49 @@ The governed set is `GOVERNED` at the top of `check-liveness.mts`. To add a type
2. Seed `<type>.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.
4 changes: 3 additions & 1 deletion packages/spec/liveness/action.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion packages/spec/liveness/agent.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion packages/spec/liveness/dataset.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion packages/spec/liveness/field.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 9 additions & 3 deletions packages/spec/liveness/flow.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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."
}
}
}
Expand Down
Loading