Skip to content

Commit ec084a0

Browse files
os-zhuangclaude
andcommitted
chore(spec): re-root liveness gate on the metadata-type registry (drill-down)
The json-schema/-based gate was structurally blind to the core authorable types: z.toJSONSchema does not emit object/field/flow/action/view/page/hook, so those — the highest-frequency surface — could never be governed. The 3 categories that worked (security/identity/ai) did so by luck. Re-root on BUILTIN_METADATA_TYPE_SCHEMAS (packages/spec/src/kernel/ metadata-type-schemas.ts) — the same registry the runtime /api/v1/meta/types/:type endpoint and Studio metadata-admin forms use, i.e. exactly the authorable types. The gate walks each Zod schema directly (version-tolerant unwrap of lazy/optional/default/pipe), classifies top-level props, and drills one level into container props (object/record/array) via a ledger `children` block so permission.objects.allowCreate stays distinguishable from a blanket objects. - New gate: packages/spec/scripts/liveness/check-liveness.mts (tsx; imports the registry — no gen:schema needed). Framework provenance/lock fields auto-live. - Ledgers re-keyed category → type. Onboards 10 types, ~295 props, all classified: object (31 live/17 dead), field (34/39), flow (29/7/1exp), action (26/5), hook (11/2), permission (23/2/3exp), role (3/1), agent (18/5/4exp), tool (13/5/1exp), skill (15/2). Seeded from the per-type liveness audits. - Supersedes the category-keyed security/identity/ai ledgers; check:liveness now runs tsx; CI no longer runs gen:schema. Not yet governed (rollout): view/page/dashboard/app/report/dataset/job/datasource/ translation/email_template/doc/book/validation/seed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 417b6ac commit ec084a0

19 files changed

Lines changed: 621 additions & 605 deletions

.changeset/liveness-gate-reroot.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
---
3+
4+
chore(spec): re-root the spec-liveness gate on the metadata-type registry. The gate now reads `BUILTIN_METADATA_TYPE_SCHEMAS` (the authorable types the runtime/Studio use) by walking each Zod schema directly, instead of the generated `json-schema/` directory which omits most top-level types (object/field/flow/action/...). Ledgers are re-keyed by metadata type with one-level drill-down for container properties. Onboards 10 types (object, field, flow, action, hook, permission, role, agent, tool, skill — ~295 properties), superseding the category-keyed security/identity/ai ledgers. Repo-internal tooling; no package version impact.

.github/workflows/spec-liveness-check.yml

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ name: Spec Liveness Check
22

33
# For a metadata-driven platform the spec is the product surface — a parsed-but-
44
# unenforced property is a silent no-op (and, for security props, false compliance).
5-
# This gate requires every authorable property in a GOVERNED category to declare a
6-
# liveness status with evidence in packages/spec/liveness/<category>.json. A new
7-
# unclassified property fails the check (the ratchet: no new undeclared surface).
8-
# Governed categories are rolled out highest-risk-first; see packages/spec/liveness/README.md.
5+
# This gate reads the metadata-type registry (the authorable types) and requires every
6+
# property of a GOVERNED type to declare a liveness status with evidence in
7+
# packages/spec/liveness/<type>.json. A new unclassified property fails the check (the
8+
# ratchet: no new undeclared surface). See packages/spec/liveness/README.md.
99

1010
on:
1111
pull_request:
@@ -35,8 +35,5 @@ jobs:
3535
- name: Install dependencies
3636
run: pnpm install --frozen-lockfile
3737

38-
- name: Generate JSON schemas
39-
run: pnpm --filter @objectstack/spec gen:schema
40-
4138
- name: Check spec liveness
42-
run: node packages/spec/scripts/liveness/check-liveness.mjs
39+
run: pnpm --filter @objectstack/spec check:liveness

packages/spec/liveness/README.md

Lines changed: 82 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -1,101 +1,97 @@
11
# Spec liveness ledger
22

33
For a metadata-driven platform, **the spec is the product surface**: authors write
4-
metadata against these Zod schemas. A property that is parsed but has no runtime
5-
consumer is a silent no-op — and for a *security* property, a silent no-op is
6-
**false compliance** (e.g. `forceMfa: true` accepted and ignored). The
7-
metadata-liveness audits (`docs/audits/2026-06-*-property-liveness.md`) found that
8-
large swaths of the declared surface are DEAD.
4+
metadata against these schemas. A property that is parsed but has no runtime consumer
5+
is a silent no-op — and for a *security* property, a silent no-op is **false
6+
compliance** (e.g. `forceMfa: true` accepted and ignored). The metadata-liveness audits
7+
(`docs/audits/2026-06-*-property-liveness.md`) found that large swaths of the declared
8+
surface are DEAD.
99

10-
This ledger makes that classification **explicit and regression-proof**: in a
11-
*governed* category, every authorable property must declare a liveness status with
12-
evidence, or CI fails (the ratchet — you can't add new undeclared surface).
10+
This ledger makes that classification **explicit and regression-proof**: every property
11+
of a governed metadata type must declare a liveness status with evidence, or CI fails
12+
(the ratchet — you can't add new undeclared surface).
1313

14-
## Status vocabulary
15-
16-
| Status | Meaning |
17-
|---|---|
18-
| `live` | Has a runtime consumer. Cite it in `evidence` (`file:line`, or a test). |
19-
| `experimental` / `planned` | Declared, intentionally not enforced yet. Also recognised from a spec `.describe()` marker like `[EXPERIMENTAL — not enforced]`. |
20-
| `dead` | Parsed, no consumer. Tracked for **enforce-or-remove** (cite the audit/grep). |
21-
| `internal` *(schema-level)* | Not authorable metadata (runtime result/DTO, context, enum). Exempt. |
22-
23-
Resolution order per property: **ledger entry → spec `.describe()` marker → UNCLASSIFIED**.
24-
A schema-level `"_schema": "<status>"` applies to all its properties (used for
25-
wholesale-dead subtrees like `PasswordPolicy`, or `internal` runtime types).
26-
Caveat: a `_schema`-classified schema also absorbs *new* properties at that status,
27-
so the ratchet does not flag additions to a wholesale-dead/internal subtree — only
28-
additions to per-property schemas (the mixed ones like `ObjectPermission`,
29-
`PermissionSet`). Use `_schema` only for subtrees that are genuinely all-one-status.
30-
31-
## Two governance modes
14+
## Source of truth = the metadata-type registry
3215

33-
A category's ledger picks how the gate scopes it:
16+
The gate reads `BUILTIN_METADATA_TYPE_SCHEMAS` (`packages/spec/src/kernel/metadata-type-schemas.ts`)
17+
via `listMetadataTypeSchemaTypes()` / `getMetadataTypeSchema()`**the same registry the
18+
runtime `/api/v1/meta/types/:type` endpoint and the Studio metadata-admin forms use**,
19+
i.e. exactly the set of *authorable* metadata types. It walks each type's Zod schema
20+
directly (not `z.toJSONSchema`, which throws on `object`/`action`).
3421

35-
- **default***every* authorable object schema in the category must be classified
36-
(`"_schema": "internal"` exempts non-authorable ones). Right for clean,
37-
fully-authorable categories: `security`, `identity`.
38-
- **allowlist** (`"mode": "allowlist"` + `"governed": ["Agent","Tool","Skill"]`) —
39-
only the named schemas are checked; the rest of the category is out of scope.
40-
Right for categories dominated by protocol/engine/runtime DTOs where the
41-
authorable types are a small subset: `ai` (Agent/Tool/Skill among embedder/
42-
knowledge/model DTOs). A `governed` name that no longer resolves to a schema is
43-
reported (catches renames that would silently drop coverage).
22+
This matters: the older gate read the generated `json-schema/` directory, which omits
23+
most top-level authorable types (object/field/flow/action/...) — so it was blind to the
24+
core surface. The registry is complete.
4425

45-
## Framework fields (auto-classified)
46-
47-
The ADR-0010 provenance/lock overlay fields — `_lock`, `_lockReason`, `_lockSource`,
48-
`_lockDocsUrl`, `_provenance`, `_packageId`, `_packageVersion`, `protection` — appear
49-
on every authorable type and are system-stamped, not type-specific surface. The gate
50-
auto-classifies them `live`, so ledgers don't repeat them.
26+
## Status vocabulary
5127

52-
## Files
28+
| Status | Meaning |
29+
|---|---|
30+
| `live` | Has a runtime consumer. Cite it in `evidence` (`file:line`; objectui-repo paths as prose to avoid false stale-flags). |
31+
| `experimental` / `planned` | Declared, intentionally not enforced yet. Also read from a spec `.describe()` marker like `[EXPERIMENTAL — not enforced]`. |
32+
| `dead` | Parsed, no consumer. Tracked for **enforce-or-remove** (ADR-0049). |
33+
34+
Resolution per property: **ledger entry → spec `.describe()` marker → UNCLASSIFIED**.
35+
Framework provenance/lock fields (`_lock*`, `_provenance`, `_packageId/Version`,
36+
`protection` — ADR-0010) are auto-classified `live`.
37+
38+
## Granularity — drill one level
39+
40+
A property is classified at the top level by default. A **container** property (object /
41+
record / array-of-object) may be drilled one level via `"children"` to keep sub-properties
42+
distinguishable — e.g. `permission.objects.allowCreate` (live) vs `allowTransfer` (experimental),
43+
or `flow.errorHandling.fallbackNodeId` (dead) vs the rest (live). Drill only where the
44+
audit gives divergent sub-statuses; otherwise the top-level entry covers the whole subtree.
45+
46+
```jsonc
47+
// packages/spec/liveness/permission.json
48+
{ "type": "permission", "props": {
49+
"name": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts" },
50+
"objects": { "children": {
51+
"allowCreate": { "status": "live", "evidence": "permission-evaluator.ts:8" }
52+
// allowTransfer/Restore/Purge omitted → resolved 'experimental' via spec marker
53+
} }
54+
} }
55+
```
5356

54-
- `<category>.json` — the ledger for a governed category (`security`, `identity`, `ai`).
55-
- `../scripts/liveness/check-liveness.mjs` — the gate. Reads the generated
56-
`packages/spec/json-schema/<category>/*.json`, resolves each authorable
57-
property's status, and exits non-zero on any UNCLASSIFIED property.
57+
## Files & usage
5858

59-
## Usage
59+
- `<type>.json` — the ledger for a governed metadata type.
60+
- `../scripts/liveness/check-liveness.mts` — the gate (tsx; imports the registry).
6061

6162
```bash
62-
pnpm --filter @objectstack/spec gen:schema # produce json-schema/ (the source of truth)
63-
pnpm --filter @objectstack/spec check:liveness # run the gate
64-
node packages/spec/scripts/liveness/check-liveness.mjs --dump security # inventory a category (seeding aid)
63+
pnpm --filter @objectstack/spec check:liveness # run the gate
64+
tsx packages/spec/scripts/liveness/check-liveness.mts --dump field # inventory a type (seeding aid)
6565
```
6666

67-
CI: `.github/workflows/spec-liveness-check.yml` runs the gate on PRs touching
68-
`packages/spec/**`.
69-
70-
## Rolling out the next category
71-
72-
Governed categories are listed in `GOVERNED` at the top of `check-liveness.mjs`,
73-
rolled out **highest-risk-first**. To add one (e.g. `automation`, `ui`, `data`):
74-
75-
1. `--dump <category>` to inventory its authorable properties. **The json-schema
76-
categories do NOT map to "authorable types"** — most (`data`, `automation`, `ui`,
77-
`kernel`) are dominated by ObjectQL/engine/protocol DTOs, and some authorable
78-
types live elsewhere (Agent/Tool/Skill in `ai`, Dataset in `ui`). Decide the
79-
handful of authorable schemas and use **allowlist mode** unless the whole
80-
category is authorable.
81-
2. Seed `<category>.json` from that category's liveness audit (file:line evidence)
82-
and targeted greps for anything the audit didn't cover. **Classify only with
83-
evidence**`live` needs a cited consumer; `dead` needs a confirmed absence.
84-
3. Add the category to `GOVERNED` and confirm the gate is green.
85-
86-
## Current state
87-
88-
| Category | Mode | Properties | Notes |
89-
|---|---|---|---|
90-
| `security` | default | 93 (26 live / 1 exp / 66 dead)* | ~71% parsed-but-unenforced; enforce-or-remove worklist |
91-
| `identity` | default | 4 (3 live / 1 dead) | `Role` authorable; rest internal (SCIM/auth runtime) |
92-
| `ai` | allowlist | 63 (46 live / 5 exp / 12 dead) | Agent/Tool/Skill; `Tool` is write-only, agent access-control dead |
93-
94-
\* security numbers shift to 26 / 35 / 32 once the PolicySchema experimental
95-
markers (ADR-0049 #1882) land.
96-
97-
The `dead` entries are the cross-category enforce-or-remove worklist (ADR-0049).
98-
Highest-signal: the destructive `ObjectPermission.allow{Transfer,Restore,Purge}`
99-
(ungated), the entirely-dead `Policy` tree, and **agent `access`/`permissions`/
100-
`visibility`** — "who can chat with this agent" is a no-op (the chat route hardcodes
101-
`['ai:chat','ai:agents']`).
67+
CI: `.github/workflows/spec-liveness-check.yml` runs on PRs touching `packages/spec/**`.
68+
69+
## Adding a type
70+
71+
The governed set is `GOVERNED` at the top of `check-liveness.mts`. To add a type:
72+
73+
1. `--dump <type>` to inventory its properties (containers auto-expand so you can see
74+
drill-down candidates).
75+
2. Seed `<type>.json` from that type's liveness audit (file:line evidence) + targeted
76+
greps. **Classify only with evidence**`live` needs a cited consumer; `dead` needs a
77+
confirmed absence.
78+
3. Add the type to `GOVERNED`; confirm the gate is green.
79+
80+
## Current state — 10 governed types (~295 properties)
81+
82+
| Type | live | exp | dead | Notes |
83+
|---|---|---|---|---|
84+
| object | 31 || 17 | `enable`/ObjectCapabilities + versioning/partitioning/cdc tier dead; `apiEnabled` unenforced |
85+
| field | 34 || 39 | ~half dead — aspirational enhanced-type + governance config; naming-drift props server-live/client-snake |
86+
| flow | 29 | 1 | 7 | `runAs` experimental (unenforced identity switch); status/active gate nothing; FlowNodeAction enum out of sync |
87+
| action | 26 || 5 | `disabled` CEL ignored (renderers read non-spec `enabled`); type:'form'/shortcut/bulkEnabled dead |
88+
| hook | 11 || 2 | model-healthy — near-total liveness; only label/description dead |
89+
| permission | 23 | 3 | 2 | CRUD/FLS/RLS live; allow{Transfer,Restore,Purge} experimental; isProfile/contextVariables dead |
90+
| role | 3 || 1 | `parent` dead (org hierarchy uses sys_department) |
91+
| agent | 18 | 4 | 5 | access/permissions/visibility dead (chat route hardcodes perms); autonomy experimental |
92+
| tool | 13 | 1 | 5 | write-only metadata; runtime uses a parallel AIToolDefinition |
93+
| skill | 15 || 2 | triggerPhrases dead (no matcher); permissions dead |
94+
95+
The `dead` set across types is the enforce-or-remove worklist (ADR-0049). Not yet governed
96+
(rollout): view, page, dashboard, app, report, dataset, job, datasource, translation,
97+
email_template, doc, book, validation, seed.

packages/spec/liveness/action.json

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"type": "action",
3+
"_note": "ActionSchema. Seeded from docs/audits/2026-06-actionschema-property-liveness.md. Renderers live in objectui (evidence cited as prose, not framework paths); framework-side: service-ai action-tools, runtime body-runner/http-dispatcher. Containers (params/resultDialog/ai/aria) classified at top level — no divergent sub-statuses in the audit.",
4+
"props": {
5+
"name": { "status": "live", "evidence": "objectui action renderers + runtime dispatcher" },
6+
"label": { "status": "live", "note": "display." },
7+
"objectName": { "status": "live", "evidence": "packages/services/service-ai/src/tools/action-tools.ts:535", "note": "action dispatch + AI bridge." },
8+
"icon": { "status": "live", "note": "objectui renderers." },
9+
"locations": { "status": "live", "note": "objectui — where the action is surfaced." },
10+
"component": { "status": "live", "note": "objectui visual component override." },
11+
"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 DEAD (no consumer)." },
12+
"target": { "status": "live", "evidence": "packages/runtime/src/http-dispatcher.ts", "note": "URL/script/flow/endpoint + ${param}/${ctx} interpolation." },
13+
"body": { "status": "live", "evidence": "packages/runtime/src/http-dispatcher.ts", "note": "server script (L1/L2) via engine.executeAction→body-runner." },
14+
"execute": { "status": "dead", "evidence": "@deprecated — auto-migrated to target" },
15+
"params": { "status": "live", "evidence": "packages/services/service-ai/src/tools/action-tools.ts", "note": "field/objectOverride/defaultFromRow/options/placeholder/helpText/defaultValue/required all consumed." },
16+
"variant": { "status": "live", "note": "objectui button styling." },
17+
"confirmText": { "status": "live", "note": "objectui confirm dialog." },
18+
"successMessage": { "status": "live", "note": "objectui toast." },
19+
"refreshAfter": { "status": "live", "note": "objectui post-action refresh." },
20+
"resultDialog": { "status": "live", "note": "objectui one-shot result reveal {title,description,acknowledge,format,fields}." },
21+
"visible": { "status": "live", "note": "objectui — CEL, fail-closed." },
22+
"disabled": { "status": "dead", "evidence": "primary renderers read a non-spec `enabled` (action-button.tsx:56,116) and invert it; CEL-predicate form has zero consumers; only boolean honored in detail/quick-action toolbars", "note": "ADR-0049-style naming drift — wire the CEL form or rename." },
23+
"shortcut": { "status": "dead", "evidence": "ActionEngine registers it but no keydown listener pumps events — never fires" },
24+
"bulkEnabled": { "status": "dead", "evidence": "engine has executeBulk but no spec-driven view path calls it" },
25+
"ai": { "status": "live", "evidence": "packages/services/service-ai/src/tools/action-tools.ts", "note": "full AI bridge (exposed/description/category/paramHints/outputSchema/requiresConfirmation)." },
26+
"recordIdParam": { "status": "live", "note": "objectui + dispatcher." },
27+
"recordIdField": { "status": "live", "note": "objectui + dispatcher." },
28+
"bodyShape": { "status": "live", "evidence": "packages/runtime/src/http-dispatcher.ts", "note": "{wrap} request shaping." },
29+
"method": { "status": "live", "evidence": "packages/runtime/src/http-dispatcher.ts" },
30+
"bodyExtra": { "status": "live", "evidence": "packages/runtime/src/http-dispatcher.ts" },
31+
"mode": { "status": "live", "note": "PARTIAL — read only by the AI HITL heuristic (action-tools.ts), never by the UI." },
32+
"opensInNewTab": { "status": "live", "note": "objectui." },
33+
"newTabUrl": { "status": "live", "note": "objectui." },
34+
"timeout": { "status": "dead", "evidence": "action-level timeout DEAD — server uses body.timeoutMs; no UI consumer" },
35+
"aria": { "status": "live", "note": "PARTIAL — honored by a few objectui renderers, not the core action buttons/menus." }
36+
}
37+
}

packages/spec/liveness/agent.json

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"type": "agent",
3+
"_note": "AgentSchema. Seeded from docs/audits/2026-06-agentschema-property-liveness.md. agent-runtime.ts is the runtime consumer; AgentPreview is display-only.",
4+
"props": {
5+
"name": { "status": "live", "evidence": "packages/services/service-ai/src/agent-runtime.ts" },
6+
"label": { "status": "live", "note": "display/core." },
7+
"avatar": { "status": "live", "note": "display." },
8+
"role": { "status": "live", "evidence": "packages/services/service-ai/src/agent-runtime.ts", "note": "persona → system prompt." },
9+
"instructions": { "status": "live", "evidence": "packages/services/service-ai/src/agent-runtime.ts", "note": "system prompt." },
10+
"model": { "status": "live", "evidence": "packages/services/service-ai/src/agent-runtime.ts:264", "note": "PARTIAL — model/temperature/maxTokens applied; model.provider + topP DEAD." },
11+
"skills": { "status": "live", "evidence": "packages/services/service-ai/src/agent-runtime.ts", "note": "Agent→Skill→Tool." },
12+
"tools": { "status": "live", "evidence": "packages/services/service-ai/src/agent-runtime.ts", "note": "legacy direct-tool fallback." },
13+
"active": { "status": "live", "evidence": "packages/services/service-ai/src/agent-runtime.ts", "note": "gates listing + 403 on chat." },
14+
"planning": { "status": "live", "evidence": "packages/services/service-ai/src/agent-runtime.ts", "note": "PARTIAL — only planning.maxIterations live; strategy/allowReplan DEAD." },
15+
"access": { "status": "dead", "evidence": "packages/services/service-ai/src/routes/agent-routes.ts:109", "note": "chat route hardcodes ['ai:chat','ai:agents'] — 'who can chat' is a no-op. ADR-0049 #1884." },
16+
"permissions": { "status": "dead", "evidence": "packages/services/service-ai/src/routes/agent-routes.ts:109", "note": "display-only; not enforced." },
17+
"visibility": { "status": "dead", "evidence": "no runtime reader", "note": "defaults 'organization' but gates nothing." },
18+
"tenantId": { "status": "dead", "evidence": "no runtime reader" },
19+
"knowledge": { "status": "dead", "evidence": "no runtime reader; AgentPreview reads knowledge.sources but spec defines {topics,indexes}", "note": "shape drift; RAG via service-knowledge." },
20+
"lifecycle": { "status": "experimental", "evidence": "no runtime reader (StateMachine)", "note": "aspirational autonomy." },
21+
"memory": { "status": "experimental", "evidence": "no runtime reader", "note": "aspirational autonomy." },
22+
"guardrails": { "status": "experimental", "evidence": "no runtime reader", "note": "aspirational — real limits via quota service." },
23+
"structuredOutput": { "status": "experimental", "evidence": "no runtime reader", "note": "aspirational." }
24+
}
25+
}

0 commit comments

Comments
 (0)