diff --git a/.changeset/liveness-gate-reroot.md b/.changeset/liveness-gate-reroot.md new file mode 100644 index 0000000000..a410e7f28c --- /dev/null +++ b/.changeset/liveness-gate-reroot.md @@ -0,0 +1,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. diff --git a/.github/workflows/spec-liveness-check.yml b/.github/workflows/spec-liveness-check.yml index 6575900c46..2d4c233841 100644 --- a/.github/workflows/spec-liveness-check.yml +++ b/.github/workflows/spec-liveness-check.yml @@ -2,10 +2,10 @@ name: Spec Liveness Check # For a metadata-driven platform the spec is the product surface — a parsed-but- # unenforced property is a silent no-op (and, for security props, false compliance). -# This gate requires every authorable property in a GOVERNED category to declare a -# liveness status with evidence in packages/spec/liveness/.json. A new -# unclassified property fails the check (the ratchet: no new undeclared surface). -# Governed categories are rolled out highest-risk-first; see packages/spec/liveness/README.md. +# This gate reads the metadata-type registry (the authorable types) and requires every +# property of a GOVERNED type to declare a liveness status with evidence in +# packages/spec/liveness/.json. A new unclassified property fails the check (the +# ratchet: no new undeclared surface). See packages/spec/liveness/README.md. on: pull_request: @@ -35,8 +35,5 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Generate JSON schemas - run: pnpm --filter @objectstack/spec gen:schema - - name: Check spec liveness - run: node packages/spec/scripts/liveness/check-liveness.mjs + run: pnpm --filter @objectstack/spec check:liveness diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index 25c9f17c6a..3ca182ad05 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -1,101 +1,97 @@ # Spec liveness ledger For a metadata-driven platform, **the spec is the product surface**: authors write -metadata against these Zod schemas. A property that is parsed but has no runtime -consumer is a silent no-op — and for a *security* property, a silent no-op is -**false compliance** (e.g. `forceMfa: true` accepted and ignored). The -metadata-liveness audits (`docs/audits/2026-06-*-property-liveness.md`) found that -large swaths of the declared surface are DEAD. +metadata against these schemas. A property that is parsed but has no runtime consumer +is a silent no-op — and for a *security* property, a silent no-op is **false +compliance** (e.g. `forceMfa: true` accepted and ignored). The metadata-liveness audits +(`docs/audits/2026-06-*-property-liveness.md`) found that large swaths of the declared +surface are DEAD. -This ledger makes that classification **explicit and regression-proof**: in a -*governed* category, every authorable property must declare a liveness status with -evidence, or CI fails (the ratchet — you can't add new undeclared surface). +This ledger makes that classification **explicit and regression-proof**: every property +of a governed metadata type must declare a liveness status with evidence, or CI fails +(the ratchet — you can't add new undeclared surface). -## Status vocabulary - -| Status | Meaning | -|---|---| -| `live` | Has a runtime consumer. Cite it in `evidence` (`file:line`, or a test). | -| `experimental` / `planned` | Declared, intentionally not enforced yet. Also recognised from a spec `.describe()` marker like `[EXPERIMENTAL — not enforced]`. | -| `dead` | Parsed, no consumer. Tracked for **enforce-or-remove** (cite the audit/grep). | -| `internal` *(schema-level)* | Not authorable metadata (runtime result/DTO, context, enum). Exempt. | - -Resolution order per property: **ledger entry → spec `.describe()` marker → UNCLASSIFIED**. -A schema-level `"_schema": ""` applies to all its properties (used for -wholesale-dead subtrees like `PasswordPolicy`, or `internal` runtime types). -Caveat: a `_schema`-classified schema also absorbs *new* properties at that status, -so the ratchet does not flag additions to a wholesale-dead/internal subtree — only -additions to per-property schemas (the mixed ones like `ObjectPermission`, -`PermissionSet`). Use `_schema` only for subtrees that are genuinely all-one-status. - -## Two governance modes +## Source of truth = the metadata-type registry -A category's ledger picks how the gate scopes it: +The gate reads `BUILTIN_METADATA_TYPE_SCHEMAS` (`packages/spec/src/kernel/metadata-type-schemas.ts`) +via `listMetadataTypeSchemaTypes()` / `getMetadataTypeSchema()` — **the same registry the +runtime `/api/v1/meta/types/:type` endpoint and the Studio metadata-admin forms use**, +i.e. exactly the set of *authorable* metadata types. It walks each type's Zod schema +directly (not `z.toJSONSchema`, which throws on `object`/`action`). -- **default** — *every* authorable object schema in the category must be classified - (`"_schema": "internal"` exempts non-authorable ones). Right for clean, - fully-authorable categories: `security`, `identity`. -- **allowlist** (`"mode": "allowlist"` + `"governed": ["Agent","Tool","Skill"]`) — - only the named schemas are checked; the rest of the category is out of scope. - Right for categories dominated by protocol/engine/runtime DTOs where the - authorable types are a small subset: `ai` (Agent/Tool/Skill among embedder/ - knowledge/model DTOs). A `governed` name that no longer resolves to a schema is - reported (catches renames that would silently drop coverage). +This matters: the older gate read the generated `json-schema/` directory, which omits +most top-level authorable types (object/field/flow/action/...) — so it was blind to the +core surface. The registry is complete. -## Framework fields (auto-classified) - -The ADR-0010 provenance/lock overlay fields — `_lock`, `_lockReason`, `_lockSource`, -`_lockDocsUrl`, `_provenance`, `_packageId`, `_packageVersion`, `protection` — appear -on every authorable type and are system-stamped, not type-specific surface. The gate -auto-classifies them `live`, so ledgers don't repeat them. +## Status vocabulary -## Files +| Status | Meaning | +|---|---| +| `live` | Has a runtime consumer. Cite it in `evidence` (`file:line`; objectui-repo paths as prose to avoid false stale-flags). | +| `experimental` / `planned` | Declared, intentionally not enforced yet. Also read from a spec `.describe()` marker like `[EXPERIMENTAL — not enforced]`. | +| `dead` | Parsed, no consumer. Tracked for **enforce-or-remove** (ADR-0049). | + +Resolution per property: **ledger entry → spec `.describe()` marker → UNCLASSIFIED**. +Framework provenance/lock fields (`_lock*`, `_provenance`, `_packageId/Version`, +`protection` — ADR-0010) are auto-classified `live`. + +## Granularity — drill one level + +A property is classified at the top level by default. A **container** property (object / +record / array-of-object) may be drilled one level via `"children"` to keep sub-properties +distinguishable — e.g. `permission.objects.allowCreate` (live) vs `allowTransfer` (experimental), +or `flow.errorHandling.fallbackNodeId` (dead) vs the rest (live). Drill only where the +audit gives divergent sub-statuses; otherwise the top-level entry covers the whole subtree. + +```jsonc +// packages/spec/liveness/permission.json +{ "type": "permission", "props": { + "name": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts" }, + "objects": { "children": { + "allowCreate": { "status": "live", "evidence": "permission-evaluator.ts:8" } + // allowTransfer/Restore/Purge omitted → resolved 'experimental' via spec marker + } } +} } +``` -- `.json` — the ledger for a governed category (`security`, `identity`, `ai`). -- `../scripts/liveness/check-liveness.mjs` — the gate. Reads the generated - `packages/spec/json-schema//*.json`, resolves each authorable - property's status, and exits non-zero on any UNCLASSIFIED property. +## Files & usage -## Usage +- `.json` — the ledger for a governed metadata type. +- `../scripts/liveness/check-liveness.mts` — the gate (tsx; imports the registry). ```bash -pnpm --filter @objectstack/spec gen:schema # produce json-schema/ (the source of truth) -pnpm --filter @objectstack/spec check:liveness # run the gate -node packages/spec/scripts/liveness/check-liveness.mjs --dump security # inventory a category (seeding aid) +pnpm --filter @objectstack/spec check:liveness # run the gate +tsx packages/spec/scripts/liveness/check-liveness.mts --dump field # inventory a type (seeding aid) ``` -CI: `.github/workflows/spec-liveness-check.yml` runs the gate on PRs touching -`packages/spec/**`. - -## Rolling out the next category - -Governed categories are listed in `GOVERNED` at the top of `check-liveness.mjs`, -rolled out **highest-risk-first**. To add one (e.g. `automation`, `ui`, `data`): - -1. `--dump ` to inventory its authorable properties. **The json-schema - categories do NOT map to "authorable types"** — most (`data`, `automation`, `ui`, - `kernel`) are dominated by ObjectQL/engine/protocol DTOs, and some authorable - types live elsewhere (Agent/Tool/Skill in `ai`, Dataset in `ui`). Decide the - handful of authorable schemas and use **allowlist mode** unless the whole - category is authorable. -2. Seed `.json` from that category's liveness audit (file:line evidence) - and targeted greps for anything the audit didn't cover. **Classify only with - evidence** — `live` needs a cited consumer; `dead` needs a confirmed absence. -3. Add the category to `GOVERNED` and confirm the gate is green. - -## Current state - -| Category | Mode | Properties | Notes | -|---|---|---|---| -| `security` | default | 93 (26 live / 1 exp / 66 dead)* | ~71% parsed-but-unenforced; enforce-or-remove worklist | -| `identity` | default | 4 (3 live / 1 dead) | `Role` authorable; rest internal (SCIM/auth runtime) | -| `ai` | allowlist | 63 (46 live / 5 exp / 12 dead) | Agent/Tool/Skill; `Tool` is write-only, agent access-control dead | - -\* security numbers shift to 26 / 35 / 32 once the PolicySchema experimental -markers (ADR-0049 #1882) land. - -The `dead` entries are the cross-category enforce-or-remove worklist (ADR-0049). -Highest-signal: the destructive `ObjectPermission.allow{Transfer,Restore,Purge}` -(ungated), the entirely-dead `Policy` tree, and **agent `access`/`permissions`/ -`visibility`** — "who can chat with this agent" is a no-op (the chat route hardcodes -`['ai:chat','ai:agents']`). +CI: `.github/workflows/spec-liveness-check.yml` runs on PRs touching `packages/spec/**`. + +## Adding a type + +The governed set is `GOVERNED` at the top of `check-liveness.mts`. To add a type: + +1. `--dump ` to inventory its properties (containers auto-expand so you can see + drill-down candidates). +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 | 31 | – | 17 | `enable`/ObjectCapabilities + versioning/partitioning/cdc tier dead; `apiEnabled` unenforced | +| 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 | + +The `dead` set across types is the enforce-or-remove worklist (ADR-0049). Not yet governed +(rollout): view, page, dashboard, app, report, dataset, job, datasource, translation, +email_template, doc, book, validation, seed. diff --git a/packages/spec/liveness/action.json b/packages/spec/liveness/action.json new file mode 100644 index 0000000000..61d18af860 --- /dev/null +++ b/packages/spec/liveness/action.json @@ -0,0 +1,37 @@ +{ + "type": "action", + "_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.", + "props": { + "name": { "status": "live", "evidence": "objectui action renderers + runtime dispatcher" }, + "label": { "status": "live", "note": "display." }, + "objectName": { "status": "live", "evidence": "packages/services/service-ai/src/tools/action-tools.ts:535", "note": "action dispatch + AI bridge." }, + "icon": { "status": "live", "note": "objectui renderers." }, + "locations": { "status": "live", "note": "objectui — where the action is surfaced." }, + "component": { "status": "live", "note": "objectui visual component override." }, + "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)." }, + "target": { "status": "live", "evidence": "packages/runtime/src/http-dispatcher.ts", "note": "URL/script/flow/endpoint + ${param}/${ctx} interpolation." }, + "body": { "status": "live", "evidence": "packages/runtime/src/http-dispatcher.ts", "note": "server script (L1/L2) via engine.executeAction→body-runner." }, + "execute": { "status": "dead", "evidence": "@deprecated — auto-migrated to target" }, + "params": { "status": "live", "evidence": "packages/services/service-ai/src/tools/action-tools.ts", "note": "field/objectOverride/defaultFromRow/options/placeholder/helpText/defaultValue/required all consumed." }, + "variant": { "status": "live", "note": "objectui button styling." }, + "confirmText": { "status": "live", "note": "objectui confirm dialog." }, + "successMessage": { "status": "live", "note": "objectui toast." }, + "refreshAfter": { "status": "live", "note": "objectui post-action refresh." }, + "resultDialog": { "status": "live", "note": "objectui one-shot result reveal {title,description,acknowledge,format,fields}." }, + "visible": { "status": "live", "note": "objectui — CEL, fail-closed." }, + "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." }, + "shortcut": { "status": "dead", "evidence": "ActionEngine registers it but no keydown listener pumps events — never fires" }, + "bulkEnabled": { "status": "dead", "evidence": "engine has executeBulk but no spec-driven view path calls it" }, + "ai": { "status": "live", "evidence": "packages/services/service-ai/src/tools/action-tools.ts", "note": "full AI bridge (exposed/description/category/paramHints/outputSchema/requiresConfirmation)." }, + "recordIdParam": { "status": "live", "note": "objectui + dispatcher." }, + "recordIdField": { "status": "live", "note": "objectui + dispatcher." }, + "bodyShape": { "status": "live", "evidence": "packages/runtime/src/http-dispatcher.ts", "note": "{wrap} request shaping." }, + "method": { "status": "live", "evidence": "packages/runtime/src/http-dispatcher.ts" }, + "bodyExtra": { "status": "live", "evidence": "packages/runtime/src/http-dispatcher.ts" }, + "mode": { "status": "live", "note": "PARTIAL — read only by the AI HITL heuristic (action-tools.ts), never by the UI." }, + "opensInNewTab": { "status": "live", "note": "objectui." }, + "newTabUrl": { "status": "live", "note": "objectui." }, + "timeout": { "status": "dead", "evidence": "action-level timeout DEAD — server uses body.timeoutMs; no UI consumer" }, + "aria": { "status": "live", "note": "PARTIAL — honored by a few objectui renderers, not the core action buttons/menus." } + } +} diff --git a/packages/spec/liveness/agent.json b/packages/spec/liveness/agent.json new file mode 100644 index 0000000000..0b537a562f --- /dev/null +++ b/packages/spec/liveness/agent.json @@ -0,0 +1,25 @@ +{ + "type": "agent", + "_note": "AgentSchema. Seeded from docs/audits/2026-06-agentschema-property-liveness.md. agent-runtime.ts is the runtime consumer; AgentPreview is display-only.", + "props": { + "name": { "status": "live", "evidence": "packages/services/service-ai/src/agent-runtime.ts" }, + "label": { "status": "live", "note": "display/core." }, + "avatar": { "status": "live", "note": "display." }, + "role": { "status": "live", "evidence": "packages/services/service-ai/src/agent-runtime.ts", "note": "persona → system prompt." }, + "instructions": { "status": "live", "evidence": "packages/services/service-ai/src/agent-runtime.ts", "note": "system prompt." }, + "model": { "status": "live", "evidence": "packages/services/service-ai/src/agent-runtime.ts:264", "note": "PARTIAL — model/temperature/maxTokens applied; model.provider + topP DEAD." }, + "skills": { "status": "live", "evidence": "packages/services/service-ai/src/agent-runtime.ts", "note": "Agent→Skill→Tool." }, + "tools": { "status": "live", "evidence": "packages/services/service-ai/src/agent-runtime.ts", "note": "legacy direct-tool fallback." }, + "active": { "status": "live", "evidence": "packages/services/service-ai/src/agent-runtime.ts", "note": "gates listing + 403 on chat." }, + "planning": { "status": "live", "evidence": "packages/services/service-ai/src/agent-runtime.ts", "note": "PARTIAL — only planning.maxIterations live; strategy/allowReplan DEAD." }, + "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." }, + "permissions": { "status": "dead", "evidence": "packages/services/service-ai/src/routes/agent-routes.ts:109", "note": "display-only; not enforced." }, + "visibility": { "status": "dead", "evidence": "no runtime reader", "note": "defaults 'organization' but gates nothing." }, + "tenantId": { "status": "dead", "evidence": "no runtime reader" }, + "knowledge": { "status": "dead", "evidence": "no runtime reader; AgentPreview reads knowledge.sources but spec defines {topics,indexes}", "note": "shape drift; RAG via service-knowledge." }, + "lifecycle": { "status": "experimental", "evidence": "no runtime reader (StateMachine)", "note": "aspirational autonomy." }, + "memory": { "status": "experimental", "evidence": "no runtime reader", "note": "aspirational autonomy." }, + "guardrails": { "status": "experimental", "evidence": "no runtime reader", "note": "aspirational — real limits via quota service." }, + "structuredOutput": { "status": "experimental", "evidence": "no runtime reader", "note": "aspirational." } + } +} diff --git a/packages/spec/liveness/ai.json b/packages/spec/liveness/ai.json deleted file mode 100644 index 7271711eae..0000000000 --- a/packages/spec/liveness/ai.json +++ /dev/null @@ -1,215 +0,0 @@ -{ - "category": "ai", - "mode": "allowlist", - "governed": [ - "Agent", - "Tool", - "Skill" - ], - "_note": "The `ai` category is dominated by embedder/knowledge/model/runtime DTOs; the authorable metadata types are Agent, Tool, Skill (allowlist). Seeded from docs/audits/2026-06-{agent,tool,skill}schema-property-liveness.md (file:line evidence). Framework provenance/lock fields (_lock*, _provenance, _packageId, protection) are auto-classified by the gate (ADR-0010).", - "schemas": { - "Agent": { - "_note": "agent-runtime.ts is the runtime consumer; AgentPreview is display-only (not a runtime reader).", - "props": { - "name": { - "status": "live", - "evidence": "packages/services/service-ai/src/agent-runtime.ts", - "note": "core identity." - }, - "label": { - "status": "live", - "note": "display/core." - }, - "avatar": { - "status": "live", - "note": "display." - }, - "role": { - "status": "live", - "evidence": "agent-runtime.ts", - "note": "persona → system prompt; also derives chatbot picker description." - }, - "instructions": { - "status": "live", - "evidence": "agent-runtime.ts", - "note": "system prompt." - }, - "model": { - "status": "live", - "evidence": "packages/services/service-ai/src/agent-runtime.ts:264", - "note": "PARTIAL — model/temperature/maxTokens applied; model.provider + model.topP are DEAD (provider comes from the configured adapter)." - }, - "skills": { - "status": "live", - "evidence": "agent-runtime.ts", - "note": "Agent→Skill→Tool; contributes tools + prompt." - }, - "tools": { - "status": "live", - "evidence": "agent-runtime.ts", - "note": "legacy direct-tool fallback." - }, - "active": { - "status": "live", - "evidence": "agent-runtime.ts", - "note": "gates listing + 403 on chat." - }, - "planning": { - "status": "live", - "evidence": "packages/services/service-ai/src/agent-runtime.ts", - "note": "PARTIAL — only planning.maxIterations is live (3 call sites); planning.strategy/allowReplan are DEAD." - }, - "access": { - "status": "dead", - "evidence": "packages/services/service-ai/src/routes/agent-routes.ts:109", - "note": "'who can chat' is a no-op — the chat route hardcodes ['ai:chat','ai:agents'] regardless. Latent access-control gap (ADR-0049 #1884: enforce or remove)." - }, - "permissions": { - "status": "dead", - "evidence": "agent-routes.ts:109 (hardcoded perms)", - "note": "display-only; not enforced. Security gap." - }, - "visibility": { - "status": "dead", - "evidence": "no runtime reader", - "note": "defaults 'organization' but gates nothing." - }, - "tenantId": { - "status": "dead", - "evidence": "no runtime reader", - "note": "not read at runtime." - }, - "knowledge": { - "status": "dead", - "evidence": "no runtime reader; AgentPreview.tsx:213 reads knowledge.sources but spec defines {topics,indexes}", - "note": "shape drift; RAG is wired via service-knowledge, not agent.knowledge." - }, - "lifecycle": { - "status": "experimental", - "evidence": "no runtime reader (StateMachine)", - "note": "aspirational autonomy — prune or keep as roadmap (ADR-0049 enforce-or-remove)." - }, - "memory": { - "status": "experimental", - "evidence": "no runtime reader", - "note": "aspirational autonomy (shortTerm/longTerm/reflectionInterval)." - }, - "guardrails": { - "status": "experimental", - "evidence": "no runtime reader", - "note": "aspirational — real limits enforced by an unrelated quota service, not these." - }, - "structuredOutput": { - "status": "experimental", - "evidence": "no runtime reader", - "note": "aspirational." - } - } - }, - "Tool": { - "_note": "Tool metadata is WRITE-ONLY — projected one-way from the code-built ToolRegistry into metadata for Studio display (plugin.ts:813). No code reads `tool` metadata back; the runtime contract is a separate AIToolDefinition. The LIVE props are live via that same-named AIToolDefinition surface, not via metadata read-back.", - "props": { - "name": { - "status": "live", - "evidence": "packages/services/service-ai/src/adapters/vercel-adapter.ts:46", - "note": "selection + LLM function key." - }, - "label": { - "status": "live", - "note": "display." - }, - "description": { - "status": "live", - "evidence": "packages/services/service-ai/src/adapters/vercel-adapter.ts:46", - "note": "sent to the LLM." - }, - "parameters": { - "status": "live", - "evidence": "packages/services/service-ai/src/adapters/vercel-adapter.ts:46", - "note": "LLM function schema." - }, - "objectName": { - "status": "live", - "evidence": "packages/services/service-ai/src/tools/action-tools.ts:535", - "note": "action-tool dispatch." - }, - "outputSchema": { - "status": "experimental", - "evidence": "tools/action-tools.ts:437 (keys folded into description only)", - "note": "docstring claims output validation + chaining, but no validation exists." - }, - "category": { - "status": "dead", - "evidence": "listing/preview tag only; enum drift vs AIToolDefinition free-string", - "note": "not sent to the model." - }, - "requiresConfirmation": { - "status": "dead", - "evidence": "tools/action-tools.ts:239 (HITL re-derives from action.ai.requiresConfirmation)", - "note": "never read off the def — redundant mirror." - }, - "permissions": { - "status": "dead", - "evidence": "tool.form.ts only; not on AIToolDefinition, no consumer" - }, - "active": { - "status": "dead", - "evidence": "never set by any registration path" - }, - "builtIn": { - "status": "dead", - "evidence": "never set by any registration path" - } - } - }, - "Skill": { - "_note": "skill-registry.ts + agent-runtime.ts are the runtime consumers.", - "props": { - "name": { - "status": "live", - "evidence": "packages/services/service-ai/src/skill-registry.ts" - }, - "label": { - "status": "live", - "evidence": "packages/services/service-ai/src/skill-registry.ts:247", - "note": "injected into the agent system prompt." - }, - "description": { - "status": "live", - "evidence": "packages/services/service-ai/src/skill-registry.ts:247", - "note": "injected into prompt." - }, - "instructions": { - "status": "live", - "evidence": "packages/services/service-ai/src/skill-registry.ts:247", - "note": "injected into prompt." - }, - "tools": { - "status": "live", - "evidence": "packages/services/service-ai/src/skill-registry.ts:206", - "note": "tool-contribution path incl. action_* wildcard expand." - }, - "triggerConditions": { - "status": "live", - "evidence": "packages/services/service-ai/src/skill-registry.ts:153", - "note": "THE activation gate — AND of {field,operator,value}." - }, - "active": { - "status": "live", - "evidence": "packages/services/service-ai/src/skill-registry.ts:93", - "note": "inactive skills dropped." - }, - "triggerPhrases": { - "status": "dead", - "evidence": "no intent/phrase matcher; only populates the slash-command palette summary (toSummary)", - "note": "schema frames them as activating the skill, but nothing matches user text — intent routing unimplemented." - }, - "permissions": { - "status": "dead", - "evidence": "no code restricts a skill by skill.permissions; naming drift (preview calls it requiredPermissions)", - "note": "enforce-or-remove + fix naming (ADR-0049)." - } - } - } - } -} diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json new file mode 100644 index 0000000000..cad5b1eac1 --- /dev/null +++ b/packages/spec/liveness/field.json @@ -0,0 +1,79 @@ +{ + "type": "field", + "_note": "FieldSchema (flat — all field-type configs are top-level optional props). Seeded from docs/audits/2026-06-fieldschema-property-liveness.md. ~half dead. Nested config objects (currencyConfig/vectorConfig/fileAttachmentConfig/encryptionConfig/maskingRule/cached/dataQuality) are wholly dead → classified at top level. Naming-drift props are server-live but client-snake. Framework evidence cited with paths; objectui-renderer evidence as prose.", + "props": { + "name": { "status": "live", "evidence": "packages/objectql/src/engine.ts" }, + "label": { "status": "live", "note": "renderers." }, + "type": { "status": "live", "evidence": "packages/objectql/src/engine.ts" }, + "description": { "status": "live", "note": "display." }, + "format": { "status": "live", "evidence": "packages/objectql/src/engine.ts" }, + "required": { "status": "live", "evidence": "packages/objectql/src/validation/record-validator.ts" }, + "multiple": { "status": "live", "evidence": "packages/objectql/src/engine.ts" }, + "defaultValue": { "status": "live", "evidence": "packages/objectql/src/engine.ts" }, + "min": { "status": "live", "evidence": "packages/objectql/src/validation/record-validator.ts" }, + "max": { "status": "live", "evidence": "packages/objectql/src/validation/record-validator.ts" }, + "options": { "status": "live", "note": "select options {label,value,color,default} — renderers + validation." }, + "deleteBehavior": { "status": "live", "evidence": "packages/objectql/src/engine.ts", "note": "reference cascade/restrict." }, + "expression": { "status": "live", "evidence": "packages/objectql/src/engine.ts", "note": "formula." }, + "summaryOperations": { "status": "live", "evidence": "packages/objectql/src/engine.ts", "note": "rollup {object,field,function,relationshipField}." }, + "requiredWhen": { "status": "live", "evidence": "packages/objectql/src/validation/record-validator.ts", "note": "CEL." }, + "readonlyWhen": { "status": "live", "note": "renderer CEL." }, + "visibleWhen": { "status": "live", "note": "renderer CEL." }, + "conditionalRequired": { "status": "live", "note": "deprecated alias of requiredWhen — plan removal." }, + "readonly": { "status": "live", "note": "renderer." }, + "hidden": { "status": "live", "note": "renderer." }, + "system": { "status": "live", "evidence": "packages/objectql/src/engine.ts" }, + "sortable": { "status": "live", "note": "grid." }, + "inlineEdit": { "status": "live", "note": "grid inline editing." }, + "language": { "status": "live", "note": "code field — CodeField.tsx:13 (only language is live; theme/lineNumbers dead)." }, + "step": { "status": "live", "note": "slider — SliderField.tsx (min/max/step live)." }, + "group": { "status": "live", "note": "field group/layout (renderer)." }, + "inlineHelpText": { "status": "live", "note": "display help (renderer)." }, + "unique": { "status": "live", "evidence": "packages/plugins/driver-sql/src/sql-driver.ts:1853", "note": "CAVEAT — DDL-only; NOT validated on the write path (violations surface as raw driver errors)." }, + "precision": { "status": "live", "note": "CAVEAT — UI display formatting only; DDL never sizes (maps to float). (NumberField.tsx:16)." }, + "scale": { "status": "live", "note": "CAVEAT — grid formatting only; DDL never sizes." }, + "reference": { "status": "live", "evidence": "packages/objectql/src/engine.ts:1672", "note": "CAVEAT — $expand/cascade/seed live; FK DDL reads reference_to (unmapped from reference)." }, + "autonumberFormat": { "status": "live", "evidence": "packages/objectql/src/engine.ts:765", "note": "CAVEAT — runtime sequence formatting live; UI AutoNumberField ignores it." }, + "maxLength": { "status": "live", "evidence": "packages/objectql/src/validation/record-validator.ts:127", "note": "CAVEAT — server validation honors camelCase, but the client form reads snake_case max_length." }, + "minLength": { "status": "live", "evidence": "packages/objectql/src/validation/record-validator.ts:130", "note": "CAVEAT — server camel; client form reads min_length." }, + "referenceFilters": { "status": "dead", "evidence": "lookup dialog reads lookup_filters (LookupField.tsx:171) — entirely dead as authored (naming drift)" }, + "maxRating": { "status": "dead", "evidence": "RatingField reads `max` (RatingField.tsx:13) — dead + redundant with max" }, + "columnName": { "status": "dead", "evidence": "resolveColumnName (spec system-names.ts:182) has ZERO call sites; SQL driver hardcodes column = field key", "note": "DANGEROUS — advertises custom physical columns the driver never honors." }, + "searchable": { "status": "dead", "evidence": "field-level — no DDL/query consumer; search is object-level / view searchableFields" }, + "index": { "status": "dead", "evidence": "field-level — driver reads object indexes[] (sql-driver.ts:1252); field bool unused" }, + "externalId": { "status": "dead", "evidence": "field-level — upsert keys off dataset-level externalId (seed-loader.ts:175)" }, + "currencyConfig": { "status": "dead", "evidence": "nested config — renderers read flat currency/precision (CurrencyField.tsx:40); no currencyConfig consumer" }, + "vectorConfig": { "status": "dead", "evidence": "nested config — renderers read flat dimensions; no consumer, no vector-index DDL" }, + "fileAttachmentConfig": { "status": "dead", "evidence": "nested config — renderers read flat multiple/accept/maxSize (FileField.tsx:16); no size/type/virus enforcement in write path" }, + "encryptionConfig": { "status": "dead", "evidence": "no consumer; the only at-rest protection is the separate type:'secret' channel (engine.ts encryptSecretFields)" }, + "maskingRule": { "status": "dead", "evidence": "no consumer" }, + "auditTrail": { "status": "dead", "evidence": "no consumer (aspirational governance)" }, + "dataQuality": { "status": "dead", "evidence": "no consumer (aspirational governance)" }, + "cached": { "status": "dead", "evidence": "no consumer" }, + "dependencies": { "status": "dead", "evidence": "no consumer" }, + "trackFeedHistory": { "status": "dead", "evidence": "no consumer" }, + "caseSensitive": { "status": "dead", "evidence": "no consumer" }, + "writeRequiresMasterRead": { "status": "dead", "evidence": "no consumer (governance)" }, + "theme": { "status": "dead", "evidence": "code field — only language is live" }, + "lineNumbers": { "status": "dead", "evidence": "code field — only language is live" }, + "allowHalf": { "status": "dead", "evidence": "rating — unread" }, + "displayMap": { "status": "dead", "evidence": "location — unread" }, + "allowGeocoding": { "status": "dead", "evidence": "location — unread" }, + "addressFormat": { "status": "dead", "evidence": "address — unread" }, + "colorFormat": { "status": "dead", "evidence": "color — ColorField uses a fixed hex input" }, + "allowAlpha": { "status": "dead", "evidence": "color — unread" }, + "presetColors": { "status": "dead", "evidence": "color — unread" }, + "showValue": { "status": "dead", "evidence": "slider — unread" }, + "marks": { "status": "dead", "evidence": "slider — unread" }, + "barcodeFormat": { "status": "dead", "evidence": "barcode/qr — unread" }, + "qrErrorCorrection": { "status": "dead", "evidence": "barcode/qr — unread" }, + "displayValue": { "status": "dead", "evidence": "barcode/qr — unread" }, + "allowScanning": { "status": "dead", "evidence": "barcode/qr — unread" }, + "inlineTitle": { "status": "dead", "evidence": "master-detail explicit override — auto-derivation works (deriveMasterDetail.ts); overrides unread" }, + "inlineColumns": { "status": "dead", "evidence": "master-detail explicit override — unread" }, + "inlineAmountField": { "status": "dead", "evidence": "master-detail explicit override — unread" }, + "relatedList": { "status": "dead", "evidence": "detail-page related lists come from view metadata, not FieldSchema" }, + "relatedListTitle": { "status": "dead", "evidence": "master-detail explicit override — unread" }, + "relatedListColumns": { "status": "dead", "evidence": "master-detail explicit override — unread" } + } +} diff --git a/packages/spec/liveness/flow.json b/packages/spec/liveness/flow.json new file mode 100644 index 0000000000..211b485850 --- /dev/null +++ b/packages/spec/liveness/flow.json @@ -0,0 +1,42 @@ +{ + "type": "flow", + "_note": "FlowSchema. Seeded from docs/audits/2026-06-flowschema-property-liveness.md. Consumers: service-automation engine + node executors. runAs is marker-driven experimental (spec). status/active gate nothing (engine uses an in-memory flowEnabled map via toggleFlow).", + "props": { + "name": { "status": "live", "evidence": "packages/services/service-automation/src/engine.ts" }, + "label": { "status": "live", "note": "display." }, + "description": { "status": "dead", "evidence": "no reader either layer" }, + "version": { "status": "live", "evidence": "packages/services/service-automation/src/engine.ts" }, + "status": { "status": "dead", "evidence": "engine gates on its in-memory flowEnabled map (toggleFlow), not on status", "note": "collapse with active (ADR-0049 recommendation)." }, + "active": { "status": "dead", "evidence": "spec-flagged Deprecated; redundant with status; engine uses flowEnabled" }, + "template": { "status": "dead", "evidence": "no reader either layer" }, + "type": { "status": "live", "evidence": "packages/services/service-automation/src/engine.ts:637", "note": "flow.type==='schedule'/'api' drives trigger dispatch." }, + "variables": { "status": "live", "evidence": "packages/services/service-automation/src/engine.ts", "note": "{name,isInput,isOutput} wired." }, + "edges": { "status": "live", "evidence": "packages/services/service-automation/src/engine.ts", "note": "{source,target,condition(CEL),label} wired." }, + "nodes": { + "children": { + "id": { "status": "live", "evidence": "packages/services/service-automation/src/engine.ts" }, + "type": { "status": "live", "evidence": "packages/services/service-automation/src/engine.ts", "note": "open string vs live executor registry (ADR-0018); FlowNodeAction enum is out of sync." }, + "label": { "status": "live", "note": "designer/display." }, + "config": { "status": "live", "evidence": "packages/services/service-automation/src/engine.ts" }, + "connectorConfig": { "status": "live", "evidence": "packages/services/service-automation/src/engine.ts" }, + "position": { "status": "live", "note": "designer canvas layout (no engine consumer)." }, + "timeoutMs": { "status": "live", "evidence": "packages/services/service-automation/src/engine.ts" }, + "inputSchema": { "status": "live", "evidence": "packages/services/service-automation/src/engine.ts", "note": "runtime-validated." }, + "outputSchema": { "status": "dead", "evidence": "declared, never validated" }, + "waitEventConfig": { "status": "live", "evidence": "packages/services/service-automation/src/engine.ts" }, + "boundaryConfig": { "status": "dead", "evidence": "drives the dead boundary_event node (BPMN import/export-only, no executor)" } + } + }, + "errorHandling": { + "children": { + "strategy": { "status": "live", "evidence": "packages/services/service-automation/src/engine.ts" }, + "maxRetries": { "status": "live", "evidence": "packages/services/service-automation/src/engine.ts" }, + "retryDelayMs": { "status": "live", "evidence": "packages/services/service-automation/src/engine.ts" }, + "backoffMultiplier": { "status": "live", "evidence": "packages/services/service-automation/src/engine.ts" }, + "maxRetryDelayMs": { "status": "live", "evidence": "packages/services/service-automation/src/engine.ts" }, + "jitter": { "status": "live", "evidence": "packages/services/service-automation/src/engine.ts" }, + "fallbackNodeId": { "status": "dead", "evidence": "engine uses per-node fault edges, not this" } + } + } + } +} diff --git a/packages/spec/liveness/hook.json b/packages/spec/liveness/hook.json new file mode 100644 index 0000000000..c59e707427 --- /dev/null +++ b/packages/spec/liveness/hook.json @@ -0,0 +1,19 @@ +{ + "type": "hook", + "_note": "HookSchema. Seeded from docs/audits/2026-06-hookschema-property-liveness.md — a model-healthy schema (near-total liveness). Consumers: objectql hook-binder/engine + runtime sandbox.", + "props": { + "name": { "status": "live", "evidence": "packages/objectql/src/hook-binder.ts:182", "note": "registration key + log identity." }, + "object": { "status": "live", "evidence": "packages/objectql/src/hook-binder.ts:171", "note": "single/array/'*' wildcard honored." }, + "events": { "status": "live", "evidence": "packages/objectql/src/hook-binder.ts:171", "note": "per-event registration." }, + "body": { "status": "live", "evidence": "packages/objectql/src/hook-wrappers.ts", "note": "body.{language,source,capabilities,timeoutMs} wired (L1/L2); body.memoryMb is advisory/soft under QuickJS." }, + "handler": { "status": "live", "evidence": "packages/objectql/src/hook-binder.ts:237", "note": "deprecated but fully wired; body takes precedence." }, + "priority": { "status": "live", "evidence": "packages/objectql/src/hook-binder.ts:177", "note": "genuinely orders hooks (lower first)." }, + "async": { "status": "live", "evidence": "packages/objectql/src/hook-wrappers.ts", "note": "fire-and-forget, after* only." }, + "condition": { "status": "live", "evidence": "packages/objectql/src/hook-wrappers.ts", "note": "CEL gate; falsy/error → skip." }, + "retryPolicy": { "status": "live", "evidence": "packages/objectql/src/hook-wrappers.ts", "note": "{maxRetries,backoffMs} linear backoff." }, + "timeout": { "status": "live", "evidence": "packages/objectql/src/hook-wrappers.ts", "note": "wall-clock abort, independent of body.timeoutMs." }, + "onError": { "status": "live", "evidence": "packages/objectql/src/hook-wrappers.ts", "note": "'log' suppresses+continues; 'abort' rethrows." }, + "label": { "status": "dead", "evidence": "pure docs, zero runtime readers (runtime uses only name)" }, + "description": { "status": "dead", "evidence": "pure docs, zero runtime readers; redundant with label" } + } +} diff --git a/packages/spec/liveness/identity.json b/packages/spec/liveness/identity.json deleted file mode 100644 index c26c883d6d..0000000000 --- a/packages/spec/liveness/identity.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "category": "identity", - "_note": "Liveness for authorable identity metadata. Same audit as security (docs/audits/2026-06-security-identity-property-liveness.md), which scoped identity to RoleSchema. The only author-written metadata type here is Role (RBAC). The rest are NOT authorable metadata: SCIM 2.0 provisioning protocol DTOs, better-auth runtime tables (User/Session/Account/VerificationToken/ApiKey), and org/membership primitives (Member/Organization/Invitation) managed by plugin-auth / better-auth / the org plugins — classified `internal`.", - "schemas": { - "Role": { - "props": { - "name": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:113", "note": "sys_role.name reused as a permission-set name for RBAC resolution." }, - "label": { "status": "live", "note": "display metadata (admin nav/forms, security-plugin.ts:153)." }, - "description": { "status": "live", "note": "display metadata." }, - "parent": { "status": "dead", "evidence": "no consumer (grep); org hierarchy walks sys_department.parent_department_id via department-graph.ts, not sys_role.parent", "note": "Role 'Reports To' rollup unimplemented — removal candidate (ADR-0049). label/description display-only." } - } - }, - "User": { "_schema": "internal", "_note": "better-auth runtime user table — not authorable metadata." }, - "Session": { "_schema": "internal", "_note": "better-auth runtime session table." }, - "Account": { "_schema": "internal", "_note": "better-auth runtime account/credential table." }, - "VerificationToken": { "_schema": "internal", "_note": "better-auth runtime verification token." }, - "ApiKey": { "_schema": "internal", "_note": "better-auth API key table." }, - "Member": { "_schema": "internal", "_note": "org membership primitive (org plugin), not author-written metadata." }, - "Organization": { "_schema": "internal", "_note": "org/tenant primitive (org plugin)." }, - "Invitation": { "_schema": "internal", "_note": "org invitation primitive (org plugin)." }, - "SCIMUser": { "_schema": "internal", "_note": "SCIM 2.0 provisioning protocol DTO." }, - "SCIMGroup": { "_schema": "internal", "_note": "SCIM 2.0 protocol DTO." }, - "SCIMEnterpriseUser": { "_schema": "internal", "_note": "SCIM 2.0 protocol DTO." }, - "SCIMName": { "_schema": "internal", "_note": "SCIM 2.0 protocol DTO." }, - "SCIMEmail": { "_schema": "internal", "_note": "SCIM 2.0 protocol DTO." }, - "SCIMPhoneNumber": { "_schema": "internal", "_note": "SCIM 2.0 protocol DTO." }, - "SCIMAddress": { "_schema": "internal", "_note": "SCIM 2.0 protocol DTO." }, - "SCIMMeta": { "_schema": "internal", "_note": "SCIM 2.0 protocol DTO." }, - "SCIMGroupReference": { "_schema": "internal", "_note": "SCIM 2.0 protocol DTO." }, - "SCIMMemberReference": { "_schema": "internal", "_note": "SCIM 2.0 protocol DTO." }, - "SCIMListResponse": { "_schema": "internal", "_note": "SCIM 2.0 protocol response DTO." }, - "SCIMError": { "_schema": "internal", "_note": "SCIM 2.0 protocol error DTO." }, - "SCIMPatchOperation": { "_schema": "internal", "_note": "SCIM 2.0 protocol DTO." }, - "SCIMPatchRequest": { "_schema": "internal", "_note": "SCIM 2.0 protocol DTO." }, - "SCIMBulkOperation": { "_schema": "internal", "_note": "SCIM 2.0 protocol DTO." }, - "SCIMBulkRequest": { "_schema": "internal", "_note": "SCIM 2.0 protocol DTO." }, - "SCIMBulkResponse": { "_schema": "internal", "_note": "SCIM 2.0 protocol DTO." }, - "SCIMBulkResponseOperation": { "_schema": "internal", "_note": "SCIM 2.0 protocol DTO." } - } -} diff --git a/packages/spec/liveness/object.json b/packages/spec/liveness/object.json new file mode 100644 index 0000000000..9ca49c99ce --- /dev/null +++ b/packages/spec/liveness/object.json @@ -0,0 +1,50 @@ +{ + "type": "object", + "_note": "ObjectSchema (top-level). Seeded from docs/audits/2026-06-objectschema-property-liveness.md. Roughly half dead — a large aspirational enterprise-data-management tier with zero runtime. Framework evidence cited with paths; objectui-renderer evidence as prose.", + "props": { + "name": { "status": "live", "evidence": "packages/plugins/driver-sql/src/sql-driver.ts", "note": "table name; objectql registry key." }, + "label": { "status": "live", "note": "objectui app-shell/grid." }, + "pluralLabel": { "status": "live", "note": "objectui." }, + "description": { "status": "live", "note": "display metadata." }, + "icon": { "status": "live", "note": "objectui." }, + "displayNameField": { "status": "live", "note": "objectui RecordDetailView." }, + "titleFormat": { "status": "live", "note": "objectui ({{record.field}} interpolation)." }, + "compactLayout": { "status": "live", "note": "objectui hover/cards/lookups." }, + "fieldGroups": { "status": "live", "note": "objectui form layout order." }, + "listViews": { "status": "live", "note": "objectui segmented tabs." }, + "detail": { "status": "live", "note": "objectui plugin-detail (detail.renderViaSchema)." }, + "fields": { "status": "live", "evidence": "packages/objectql/src/engine.ts", "note": "engine/DDL + forms." }, + "datasource": { "status": "live", "evidence": "packages/objectql/src/engine.ts:1147", "note": "driver routing." }, + "external": { "status": "live", "evidence": "packages/runtime/src/external-validation-plugin.ts", "note": "remote table + write gate." }, + "indexes": { "status": "live", "evidence": "packages/plugins/driver-sql/src/sql-driver.ts:1181", "note": "DDL." }, + "validations": { "status": "live", "evidence": "packages/objectql/src/validation/rule-validator.ts:154", "note": "incl. state_machine." }, + "actions": { "status": "live", "evidence": "packages/runtime/src/app-plugin.ts:929", "note": "served on /meta/objects/:name." }, + "managedBy": { "status": "live", "evidence": "packages/objectql/src/registry.ts:208", "note": "default perms; ui crudAffordances." }, + "userActions": { "status": "live", "note": "objectui ObjectGrid per-object CRUD." }, + "systemFields": { "status": "live", "evidence": "packages/objectql/src/registry.ts", "note": "organization_id auto-inject gate." }, + "sharingModel": { "status": "live", "evidence": "packages/plugins/plugin-sharing/src/sharing-service.ts:54" }, + "publicSharing": { "status": "live", "evidence": "packages/plugins/plugin-sharing/src/share-link-service.ts:56" }, + "tenancy": { + "children": { + "enabled": { "status": "live", "evidence": "packages/plugins/driver-sql/src/sql-driver.ts:1081" }, + "strategy": { "status": "dead", "evidence": "inert — only tenancy.enabled is read" }, + "tenantField": { "status": "dead", "evidence": "inert" }, + "crossTenantAccess": { "status": "dead", "evidence": "inert" } + } + }, + "enable": { "status": "dead", "evidence": "ObjectCapabilities block (trackHistory/searchable/apiEnabled/apiMethods/files/feeds/activities/trash/mru/clone) — all 10 flags have zero behavior-changing readers", "note": "apiEnabled/apiMethods NOT enforced by REST — false sense of security (ADR-0049-class)." }, + "versioning": { "status": "dead", "evidence": "aspirational; no runtime" }, + "partitioning": { "status": "dead", "evidence": "aspirational; no runtime" }, + "cdc": { "status": "dead", "evidence": "aspirational; no runtime" }, + "softDelete": { "status": "dead", "evidence": "no runtime; duplicates the (also dead) enable.trash" }, + "search": { "status": "dead", "evidence": "SearchConfigSchema — no runtime; duplicates enable.searchable" }, + "recordTypes": { "status": "dead", "evidence": "elaborate docstring, unimplemented" }, + "defaultDetailForm": { "status": "dead", "evidence": "documented fallback chain unimplemented" }, + "recordName": { "status": "dead", "evidence": "superseded by a field of type:'autonumber' + autonumberFormat (engine.ts:757)" }, + "keyPrefix": { "status": "dead", "evidence": "no runtime" }, + "tags": { "status": "dead", "evidence": "no runtime reader" }, + "abstract": { "status": "dead", "evidence": "no runtime reader" }, + "isSystem": { "status": "dead", "evidence": "object-level — no runtime reader" }, + "active": { "status": "dead", "evidence": "object-level — no runtime reader" } + } +} diff --git a/packages/spec/liveness/permission.json b/packages/spec/liveness/permission.json new file mode 100644 index 0000000000..266ebbf08b --- /dev/null +++ b/packages/spec/liveness/permission.json @@ -0,0 +1,43 @@ +{ + "type": "permission", + "_note": "PermissionSetSchema (also aliased as `profile`). Seeded from docs/audits/2026-06-security-identity-property-liveness.md. CRUD/FLS/RLS enforced via plugin-security; destructive ops + isProfile/contextVariables dead. allowTransfer/Restore/Purge are marker-driven experimental (spec `[EXPERIMENTAL — not enforced]`).", + "props": { + "name": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts", "note": "registration/assignment key." }, + "label": { "status": "live", "note": "display metadata." }, + "isProfile": { "status": "dead", "evidence": "audit: profile-vs-permset never gates anything" }, + "objects": { + "children": { + "allowCreate": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:8" }, + "allowRead": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:8" }, + "allowEdit": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:8" }, + "allowDelete": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:8" }, + "viewAllRecords": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:64" }, + "modifyAllRecords": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:60" } + } + }, + "fields": { + "children": { + "readable": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts", "note": "FLS read-mask." }, + "editable": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts", "note": "FLS write-deny." } + } + }, + "systemPermissions": { "status": "live", "evidence": "packages/plugins/plugin-hono-server/src/hono-plugin.ts", "note": "PARTIAL — app-entry/nav visibility only, not a general capability gate." }, + "tabPermissions": { "status": "live", "note": "PARTIAL — only 'hidden' is read." }, + "rowLevelSecurity": { + "children": { + "name": { "status": "live", "evidence": "packages/plugins/plugin-security/src/rls-compiler.ts" }, + "label": { "status": "live", "note": "display." }, + "description": { "status": "live", "note": "display." }, + "object": { "status": "live", "evidence": "packages/plugins/plugin-security/src/rls-compiler.ts" }, + "operation": { "status": "live", "evidence": "packages/plugins/plugin-security/src/rls-compiler.ts" }, + "using": { "status": "live", "evidence": "packages/plugins/plugin-security/src/rls-compiler.ts", "note": "compiled into find + analytics SQL." }, + "check": { "status": "live", "evidence": "packages/plugins/plugin-security/src/rls-compiler.ts" }, + "roles": { "status": "live", "evidence": "packages/plugins/plugin-security/src/rls-compiler.ts", "note": "flat match — no subordinate rollup." }, + "enabled": { "status": "live", "evidence": "packages/plugins/plugin-security/src/rls-compiler.ts" }, + "priority": { "status": "live", "evidence": "packages/plugins/plugin-security/src/rls-compiler.ts", "note": "ordering." }, + "tags": { "status": "live", "note": "organizational." } + } + }, + "contextVariables": { "status": "dead", "evidence": "rls-compiler.ts never reads it", "note": "RLS uses only current_user.* built-ins." } + } +} diff --git a/packages/spec/liveness/role.json b/packages/spec/liveness/role.json new file mode 100644 index 0000000000..d808cb9394 --- /dev/null +++ b/packages/spec/liveness/role.json @@ -0,0 +1,10 @@ +{ + "type": "role", + "_note": "RoleSchema. Seeded from docs/audits/2026-06-security-identity-property-liveness.md.", + "props": { + "name": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:113", "note": "sys_role.name reused as a permission-set name." }, + "label": { "status": "live", "note": "display (admin nav/forms, security-plugin.ts:153)." }, + "description": { "status": "live", "note": "display." }, + "parent": { "status": "dead", "evidence": "no consumer (grep); org hierarchy walks sys_department.parent_department_id via department-graph.ts, not sys_role.parent", "note": "'Reports To' rollup unimplemented — removal candidate (ADR-0049)." } + } +} diff --git a/packages/spec/liveness/security.json b/packages/spec/liveness/security.json deleted file mode 100644 index 93a3109fd9..0000000000 --- a/packages/spec/liveness/security.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "category": "security", - "_note": "Liveness classification for authorable security metadata. Seeded from docs/audits/2026-06-security-identity-property-liveness.md (file:line evidence) + greps, reconciled with ADR-0049 dispositions. Where the spec `.describe()` already carries an `[EXPERIMENTAL — not enforced]` marker, that drives the status and the prop is omitted here (marker is the source of truth). This ledger carries: live+evidence, dead-with-no-roadmap (removal candidates), internal exemptions, and sub-object refs that have no per-prop marker. Statuses: live | experimental | planned | dead | (schema-level) internal.", - "schemas": { - "ObjectPermission": { - "_note": "allowTransfer/allowRestore/allowPurge are marker-driven (experimental, ADR-0049 #1883) — destructive ops not in OPERATION_TO_PERMISSION; PermissionEvaluator now fails CLOSED for them (permission-evaluator.ts:27,45-51).", - "props": { - "allowCreate": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:8" }, - "allowRead": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:8" }, - "allowEdit": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:8" }, - "allowDelete": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:8" }, - "viewAllRecords": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:64" }, - "modifyAllRecords": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:60" } - } - }, - "FieldPermission": { - "props": { - "readable": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts", "note": "FLS read-mask enforced." }, - "editable": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts", "note": "FLS write-deny enforced." } - } - }, - "PermissionSet": { - "props": { - "name": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts", "note": "registration/assignment key." }, - "label": { "status": "live", "note": "display metadata (admin forms), not a security boundary." }, - "objects": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:8" }, - "fields": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts" }, - "rowLevelSecurity": { "status": "live", "evidence": "packages/plugins/plugin-security/src/rls-compiler.ts", "note": "enforced on find + analytics raw-SQL." }, - "systemPermissions": { "status": "live", "evidence": "packages/plugins/plugin-hono-server/src/hono-plugin.ts", "note": "PARTIAL — enforced only for app-entry/nav visibility, NOT a general capability gate." }, - "tabPermissions": { "status": "live", "note": "PARTIAL — only 'hidden' is read; default_on/default_off ignored; UI-only." }, - "isProfile": { "status": "dead", "evidence": "audit: profile-vs-permset never gates anything", "note": "enforce-or-remove (ADR-0049)." }, - "contextVariables": { "status": "dead", "evidence": "rls-compiler.ts never reads it", "note": "RLS uses only current_user.* built-ins." } - } - }, - "RowLevelSecurityPolicy": { - "props": { - "name": { "status": "live", "evidence": "packages/plugins/plugin-security/src/rls-compiler.ts" }, - "object": { "status": "live", "evidence": "packages/plugins/plugin-security/src/rls-compiler.ts" }, - "operation": { "status": "live", "evidence": "packages/plugins/plugin-security/src/rls-compiler.ts" }, - "using": { "status": "live", "evidence": "packages/plugins/plugin-security/src/rls-compiler.ts", "note": "compiled into find + analytics SQL." }, - "check": { "status": "live", "evidence": "packages/plugins/plugin-security/src/rls-compiler.ts" }, - "roles": { "status": "live", "evidence": "packages/plugins/plugin-security/src/rls-compiler.ts", "note": "flat role match — no subordinate rollup." }, - "enabled": { "status": "live", "evidence": "packages/plugins/plugin-security/src/rls-compiler.ts" }, - "priority": { "status": "live", "evidence": "packages/plugins/plugin-security/src/rls-compiler.ts", "note": "policy ordering." }, - "label": { "status": "live", "note": "display metadata." }, - "description": { "status": "live", "note": "display metadata." }, - "tags": { "status": "live", "note": "organizational metadata (not a boundary)." } - } - }, - "Policy": { - "_note": "PolicySchema is 100% DEAD but roadmapped (ADR-0049 #1882) → marked experimental. Per-prop markers in policy.zod.ts drive name/isDefault/assignedProfiles; the sub-object refs below carry no per-prop describe(), so they're classified here.", - "props": { - "password": { "status": "experimental", "evidence": "policy.zod.ts (PasswordPolicySchema all marked); better-auth hardcoded" }, - "network": { "status": "experimental", "evidence": "policy.zod.ts (NetworkPolicySchema all marked)" }, - "session": { "status": "experimental", "evidence": "policy.zod.ts (SessionPolicySchema all marked); forceMfa unenforced" }, - "audit": { "status": "experimental", "evidence": "policy.zod.ts (AuditPolicySchema all marked)" } - } - }, - "OwnerSharingRule": { "_schema": "experimental", "_note": "ADR-0049 #1887: spec SharingRule disconnected from the live sys_sharing_rule engine (packages/plugins/plugin-sharing/src/sharing-plugin.ts); marked experimental pending reconciliation (M2). Schema-level JSDoc in sharing.zod.ts.", "props": {} }, - "RLSConfig": { "_schema": "dead", "_note": "No runtime consumer (grep). RLS reads RowLevelSecurityPolicy via rls-compiler.ts, not this config. Removal candidate (no roadmap).", "props": {} }, - "RLSAuditConfig": { "_schema": "dead", "_note": "No runtime consumer (grep). Removal candidate.", "props": {} }, - "Territory": { "_schema": "dead", "_note": "Salesforce-style territory mgmt — no runtime consumer (grep). Removal candidate (no roadmap).", "props": {} }, - "TerritoryModel": { "_schema": "dead", "_note": "No runtime consumer (grep). Removal candidate.", "props": {} }, - "RLSEvaluationResult": { "_schema": "internal", "_note": "RLS runtime evaluation result — not authorable metadata." }, - "RLSAuditEvent": { "_schema": "internal", "_note": "RLS runtime audit event — not authorable metadata." }, - "RLSUserContext": { "_schema": "internal", "_note": "RLS runtime user context — not authorable metadata." } - } -} diff --git a/packages/spec/liveness/skill.json b/packages/spec/liveness/skill.json new file mode 100644 index 0000000000..9a575332f4 --- /dev/null +++ b/packages/spec/liveness/skill.json @@ -0,0 +1,15 @@ +{ + "type": "skill", + "_note": "SkillSchema. Seeded from docs/audits/2026-06-skillschema-property-liveness.md. skill-registry.ts + agent-runtime.ts are the runtime consumers.", + "props": { + "name": { "status": "live", "evidence": "packages/services/service-ai/src/skill-registry.ts" }, + "label": { "status": "live", "evidence": "packages/services/service-ai/src/skill-registry.ts:247", "note": "injected into the agent system prompt." }, + "description": { "status": "live", "evidence": "packages/services/service-ai/src/skill-registry.ts:247", "note": "injected into prompt." }, + "instructions": { "status": "live", "evidence": "packages/services/service-ai/src/skill-registry.ts:247", "note": "injected into prompt." }, + "tools": { "status": "live", "evidence": "packages/services/service-ai/src/skill-registry.ts:206", "note": "tool-contribution path incl. action_* wildcard." }, + "triggerConditions": { "status": "live", "evidence": "packages/services/service-ai/src/skill-registry.ts:153", "note": "THE activation gate — AND of {field,operator,value}." }, + "active": { "status": "live", "evidence": "packages/services/service-ai/src/skill-registry.ts:93", "note": "inactive skills dropped." }, + "triggerPhrases": { "status": "dead", "evidence": "no intent/phrase matcher; only the slash-command palette summary (toSummary)", "note": "intent routing unimplemented." }, + "permissions": { "status": "dead", "evidence": "no code restricts a skill by skill.permissions; naming drift (preview calls it requiredPermissions)" } + } +} diff --git a/packages/spec/liveness/tool.json b/packages/spec/liveness/tool.json new file mode 100644 index 0000000000..61243c11cc --- /dev/null +++ b/packages/spec/liveness/tool.json @@ -0,0 +1,17 @@ +{ + "type": "tool", + "_note": "ToolSchema. Seeded from docs/audits/2026-06-toolschema-property-liveness.md. Tool metadata is WRITE-ONLY (projected one-way for Studio; runtime uses a separate AIToolDefinition). LIVE props are live via that same-named surface, not metadata read-back.", + "props": { + "name": { "status": "live", "evidence": "packages/services/service-ai/src/adapters/vercel-adapter.ts:46", "note": "selection + LLM function key." }, + "label": { "status": "live", "note": "display." }, + "description": { "status": "live", "evidence": "packages/services/service-ai/src/adapters/vercel-adapter.ts:46", "note": "sent to the LLM." }, + "parameters": { "status": "live", "evidence": "packages/services/service-ai/src/adapters/vercel-adapter.ts:46", "note": "LLM function schema." }, + "objectName": { "status": "live", "evidence": "packages/services/service-ai/src/tools/action-tools.ts:535", "note": "action-tool dispatch." }, + "outputSchema": { "status": "experimental", "evidence": "packages/services/service-ai/src/tools/action-tools.ts:437", "note": "keys folded into description only; no validation exists." }, + "category": { "status": "dead", "evidence": "listing/preview tag only; enum drift vs AIToolDefinition free-string" }, + "requiresConfirmation": { "status": "dead", "evidence": "packages/services/service-ai/src/tools/action-tools.ts:239", "note": "HITL re-derives from action.ai.requiresConfirmation; never read off the def." }, + "permissions": { "status": "dead", "evidence": "tool.form.ts only; not on AIToolDefinition, no consumer" }, + "active": { "status": "dead", "evidence": "never set by any registration path" }, + "builtIn": { "status": "dead", "evidence": "never set by any registration path" } + } +} diff --git a/packages/spec/package.json b/packages/spec/package.json index 65f75f520c..68a11b3bb4 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -192,7 +192,7 @@ "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", - "check:liveness": "node scripts/liveness/check-liveness.mjs" + "check:liveness": "tsx scripts/liveness/check-liveness.mts" }, "keywords": [ "objectstack", diff --git a/packages/spec/scripts/liveness/check-liveness.mjs b/packages/spec/scripts/liveness/check-liveness.mjs deleted file mode 100644 index 4a9f95183b..0000000000 --- a/packages/spec/scripts/liveness/check-liveness.mjs +++ /dev/null @@ -1,187 +0,0 @@ -#!/usr/bin/env node -// Spec liveness gate. -// -// For a metadata-driven platform the spec IS the product surface: authors write -// metadata against these schemas. A property that is parsed but has no runtime -// consumer is a silent no-op (worst case: a *security* no-op — "false compliance"). -// The metadata-liveness audits (docs/audits/2026-06-*-property-liveness.md) found -// large swaths of such DEAD props. This gate makes the classification explicit and -// keeps it from regressing: in a GOVERNED category, every authorable property must -// declare a liveness status with evidence, or CI fails. -// -// Statuses (per property, or per schema via "_schema"): -// live — has a runtime consumer (cite it in `evidence`: file:line / test) -// experimental| planned — declared, intentionally not enforced yet (mark in spec -// `.describe()` as "[EXPERIMENTAL — not enforced]" or via the ledger) -// dead — parsed, no consumer; tracked for enforce-or-remove (cite the audit) -// internal — (schema-level only) not authorable metadata (runtime result/DTO, -// enum, helper); exempt from property classification -// -// Resolution order per property: ledger entry > spec `.describe()` marker > UNCLASSIFIED -// A GOVERNED category with any UNCLASSIFIED property → non-zero exit (the ratchet: -// you cannot add a new spec property without classifying it). -// -// Usage: -// node check-liveness.mjs # check all governed categories -// node check-liveness.mjs --dump security # inventory a category's props (seeding aid) -// node check-liveness.mjs --json # machine-readable report - -import { readFileSync, readdirSync, existsSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { dirname, join, resolve } from 'node:path'; - -const here = dirname(fileURLToPath(import.meta.url)); -const specRoot = resolve(here, '../..'); // packages/spec -const repoRoot = resolve(specRoot, '../..'); // repo root -const schemaRoot = join(specRoot, 'json-schema'); -const ledgerRoot = join(specRoot, 'liveness'); - -// Categories whose authorable schemas must be fully classified. Extend -// highest-risk-first as each category's ledger is seeded from its audit. -const GOVERNED = ['security', 'identity', 'ai']; - -const args = process.argv.slice(2); -const asJson = args.includes('--json'); -const dumpIdx = args.indexOf('--dump'); -const dumpCategory = dumpIdx !== -1 ? args[dumpIdx + 1] : null; - -const MARKER_RE = { - experimental: /\[experimental|not enforced|aspirational/i, - planned: /\[planned|not yet implemented|coming soon/i, -}; - -// Framework-managed provenance/lock overlay fields (ADR-0010 + metadata -// provenance stamping). These appear on EVERY authorable metadata type, are -// system-stamped (not type-specific authorable surface), and are consumed by the -// overlay/lock loader. Auto-classified `live` so each category's ledger doesn't -// have to repeat them. -const FRAMEWORK_FIELDS = new Set([ - '_lock', '_lockReason', '_lockSource', '_lockDocsUrl', - '_provenance', '_packageId', '_packageVersion', 'protection', -]); - -function loadSchemas(category) { - const dir = join(schemaRoot, category); - if (!existsSync(dir)) return []; - return readdirSync(dir) - .filter((f) => f.endsWith('.json')) - .map((f) => { - const schema = JSON.parse(readFileSync(join(dir, f), 'utf8')); - return { name: f.replace('.json', ''), schema }; - }); -} - -// top-level authorable properties of an object schema (one level; a nested object -// property represents its whole subtree, matching how the audits reason) -function topProps(schema) { - if (!schema || schema.type !== 'object' || !schema.properties) return []; - return Object.entries(schema.properties).map(([key, def]) => ({ - key, - description: (def && def.description) || '', - })); -} - -function markerStatus(description) { - if (MARKER_RE.planned.test(description)) return 'planned'; - if (MARKER_RE.experimental.test(description)) return 'experimental'; - return null; -} - -function loadLedger(category) { - const f = join(ledgerRoot, `${category}.json`); - if (!existsSync(f)) return null; - return JSON.parse(readFileSync(f, 'utf8')); -} - -if (dumpCategory) { - const rows = []; - for (const { name, schema } of loadSchemas(dumpCategory)) { - const kind = schema.type === 'object' && schema.properties ? 'object' : (schema.enum ? 'enum' : schema.type || 'other'); - if (kind !== 'object') { rows.push({ schema: name, kind, prop: '', marker: '' }); continue; } - for (const p of topProps(schema)) rows.push({ schema: name, kind, prop: p.key, marker: markerStatus(p.description) || '' }); - } - process.stdout.write(JSON.stringify(rows, null, 2) + '\n'); - process.exit(0); -} - -const report = { categories: {}, totals: { classified: 0, unclassified: 0, byStatus: {} }, unclassified: [], staleEvidence: [] }; - -for (const category of GOVERNED) { - const ledger = loadLedger(category) || { schemas: {} }; - // Two governance modes: - // - default: every authorable object schema in the category must be classified - // (`internal` exempts non-authorable ones). Right for clean, fully-authorable - // categories (security, identity). - // - allowlist (`"mode":"allowlist"` + `"governed":[...]`): only the named schemas - // are checked; the rest of the category is out of scope. Right for categories - // dominated by protocol/engine DTOs where the authorable types are a small - // subset (ai: Agent/Tool/Skill among embedder/knowledge/model DTOs). - const allowlist = ledger.mode === 'allowlist' ? new Set(ledger.governed || []) : null; - const cat = { classified: 0, unclassified: 0, byStatus: {} }; - const seen = new Set(); - for (const { name, schema } of loadSchemas(category)) { - if (allowlist && !allowlist.has(name)) continue; // allowlist: skip un-governed schemas - seen.add(name); - const props = topProps(schema); - if (props.length === 0) continue; // enums / scalars carry no authorable props - const entry = ledger.schemas[name] || {}; - if (entry._schema === 'internal') continue; // exempt: not authorable - for (const { key, description } of props) { - if (FRAMEWORK_FIELDS.has(key)) { // ADR-0010 provenance/lock — auto-live - cat.classified++; cat.byStatus.live = (cat.byStatus.live || 0) + 1; - report.totals.byStatus.live = (report.totals.byStatus.live || 0) + 1; - continue; - } - const led = entry.props && entry.props[key]; - const status = (led && led.status) || entry._schema || markerStatus(description); - if (!status) { - cat.unclassified++; - report.unclassified.push(`${category}/${name}.${key}`); - continue; - } - cat.classified++; - cat.byStatus[status] = (cat.byStatus[status] || 0) + 1; - report.totals.byStatus[status] = (report.totals.byStatus[status] || 0) + 1; - // soft evidence sanity for `live` - if (status === 'live' && led && led.evidence) { - const file = String(led.evidence).split(':')[0]; - if (/\//.test(file) && !existsSync(join(repoRoot, file))) report.staleEvidence.push(`${category}/${name}.${key} → ${led.evidence}`); - } - } - } - // allowlist sanity: a governed schema name that no longer exists is a typo or a - // rename that silently drops coverage — surface it as unclassified. - if (allowlist) { - for (const want of allowlist) { - if (!seen.has(want)) { - cat.unclassified++; - report.unclassified.push(`${category}/${want} (governed schema not found — renamed or removed?)`); - } - } - } - report.categories[category] = cat; - report.totals.classified += cat.classified; - report.totals.unclassified += cat.unclassified; -} - -if (asJson) { - process.stdout.write(JSON.stringify(report, null, 2) + '\n'); -} else { - console.log('Spec liveness gate — governed categories:', GOVERNED.join(', ')); - for (const [c, v] of Object.entries(report.categories)) { - const parts = Object.entries(v.byStatus).map(([s, n]) => `${s} ${n}`).join(', '); - console.log(` ${c}: ${v.classified} classified (${parts || '—'}), ${v.unclassified} unclassified`); - } - if (report.staleEvidence.length) { - console.log(`\n⚠ ${report.staleEvidence.length} 'live' entr(ies) cite a missing file (evidence may be stale):`); - report.staleEvidence.forEach((s) => console.log(` ${s}`)); - } - if (report.unclassified.length) { - console.log(`\n✗ ${report.unclassified.length} UNCLASSIFIED propert(ies) in governed categories — classify in packages/spec/liveness/.json:`); - report.unclassified.forEach((s) => console.log(` ${s}`)); - } else { - console.log('\n✓ all governed-category properties are classified.'); - } -} - -process.exit(report.unclassified.length > 0 ? 1 : 0); diff --git a/packages/spec/scripts/liveness/check-liveness.mts b/packages/spec/scripts/liveness/check-liveness.mts new file mode 100644 index 0000000000..2ef597030f --- /dev/null +++ b/packages/spec/scripts/liveness/check-liveness.mts @@ -0,0 +1,192 @@ +#!/usr/bin/env tsx +// Spec liveness gate (registry-rooted). +// +// For a metadata-driven platform the spec IS the product surface: authors write +// metadata against these schemas. A property that is parsed but has no runtime +// consumer is a silent no-op (worst case, a *security* no-op — false compliance). +// +// SOURCE OF TRUTH: the metadata-type registry (BUILTIN_METADATA_TYPE_SCHEMAS via +// listMetadataTypeSchemaTypes/getMetadataTypeSchema). This is the same registry the +// runtime `/api/v1/meta/types/:type` endpoint and the Studio metadata-admin forms +// use — i.e. exactly the set of *authorable* metadata types. (We walk the Zod schema +// directly rather than z.toJSONSchema, because a couple of schemas — object, action — +// throw in the JSON-schema converter, which is precisely why the old json-schema/-based +// gate was blind to them.) +// +// Governed types must declare every authorable property's liveness status with +// evidence in packages/spec/liveness/.json, or CI fails (the ratchet — no new +// undeclared surface). Property granularity is one level: a container property +// (object / record / array-of-object) may be drilled into via `"children"` so e.g. +// `permission.objects.allowCreate` stays distinguishable from a blanket `objects`. +// +// Statuses: live | experimental | planned | dead. Resolution per property: +// ledger entry → spec `.describe()` marker ([EXPERIMENTAL — not enforced]) → UNCLASSIFIED +// +// Usage: +// tsx check-liveness.mts # check all governed types +// tsx check-liveness.mts --dump # inventory a type's properties (seeding aid) +// tsx check-liveness.mts --json # machine-readable report + +process.env.OS_EAGER_SCHEMAS = '1'; + +import { readFileSync, existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, resolve } from 'node:path'; +import { getMetadataTypeSchema, listMetadataTypeSchemaTypes } from '../../src/kernel/metadata-type-schemas'; + +const here = dirname(fileURLToPath(import.meta.url)); +const specRoot = resolve(here, '../..'); // packages/spec +const repoRoot = resolve(specRoot, '../..'); +const ledgerRoot = join(specRoot, 'liveness'); + +// Governed metadata types, rolled out highest-frequency / highest-risk first. +const GOVERNED = ['object', 'field', 'flow', 'action', 'hook', 'permission', 'role', 'agent', 'tool', 'skill']; + +// ADR-0010 provenance/lock overlay fields — system-stamped, on every type; auto-live. +const FRAMEWORK_FIELDS = new Set([ + '_lock', '_lockReason', '_lockSource', '_lockDocsUrl', + '_provenance', '_packageId', '_packageVersion', 'protection', +]); + +const MARKER_RE = { + experimental: /\[experimental|not enforced|aspirational/i, + planned: /\[planned|not yet implemented|coming soon/i, +}; +function markerStatus(d: string): string | null { + if (MARKER_RE.planned.test(d)) return 'planned'; + if (MARKER_RE.experimental.test(d)) return 'experimental'; + return null; +} + +// ---- Zod schema walking (version-tolerant: prefer _zod.def, fall back to _def) ---- +function defOf(s: any): any { + return s && (s._zod?.def ?? s._def); +} +function unwrap(s: any, depth = 0): any { + if (!s || depth > 16) return s; + const def = defOf(s); + if (!def) return s; + if (def.type === 'lazy' && typeof def.getter === 'function') return unwrap(def.getter(), depth + 1); + if (['optional', 'default', 'nullable', 'readonly', 'catch', 'nonoptional', 'prefault'].includes(def.type)) return unwrap(def.innerType, depth + 1); + if (def.type === 'pipe') return unwrap(def.in ?? def.out, depth + 1); + return s; +} +function shapeOf(s: any): Record | null { + const u = unwrap(s); + const def = defOf(u); + if (def?.type !== 'object') return null; + return def.shape ?? u.shape ?? null; +} +function descOf(s: any): string { + let cur = s; + for (let i = 0; i < 16 && cur; i++) { + if (cur.description) return cur.description; + const def = defOf(cur); + if (def?.description) return def.description; + cur = def?.innerType ?? (def?.type === 'lazy' && def.getter ? def.getter() : undefined) ?? def?.in; + } + return ''; +} +// container drill: object → its shape; record → value shape; array → element shape +function childShape(s: any): Record | null { + const u = unwrap(s); + const def = defOf(u); + if (!def) return null; + if (def.type === 'object') return def.shape ?? u.shape ?? null; + if (def.type === 'record') return shapeOf(def.valueType); + if (def.type === 'array') return shapeOf(def.element); + return null; +} + +function topProps(type: string): Array<{ key: string; node: any; description: string }> { + const schema = getMetadataTypeSchema(type); + if (!schema) throw new Error(`metadata type '${type}' has no registered schema`); + const shape = shapeOf(schema); + if (!shape) throw new Error(`metadata type '${type}' is not an object schema (no walkable shape)`); + return Object.keys(shape).map((k) => ({ key: k, node: shape[k], description: descOf(shape[k]) })); +} + +function loadLedger(type: string): any { + const f = join(ledgerRoot, `${type}.json`); + return existsSync(f) ? JSON.parse(readFileSync(f, 'utf8')) : { props: {} }; +} + +// ---- dump mode ---- +const args = process.argv.slice(2); +const dumpIdx = args.indexOf('--dump'); +if (dumpIdx !== -1) { + const type = args[dumpIdx + 1]; + const rows: any[] = []; + for (const { key, node, description } of topProps(type)) { + const cs = childShape(node); + if (cs && !FRAMEWORK_FIELDS.has(key)) { + for (const ck of Object.keys(cs)) rows.push({ prop: `${key}.${ck}`, marker: markerStatus(descOf(cs[ck])) || '' }); + } else { + rows.push({ prop: key, container: !!cs, marker: markerStatus(description) || '' }); + } + } + process.stdout.write(JSON.stringify(rows, null, 2) + '\n'); + process.exit(0); +} + +// ---- check ---- +const asJson = args.includes('--json'); +const report: any = { types: {}, totals: { byStatus: {} as Record }, unclassified: [] as string[], staleEvidence: [] as string[] }; + +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; + 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}`); + } +} + +for (const type of GOVERNED) { + const ledger = loadLedger(type); + const props = ledger.props || {}; + const cat = { classified: 0, unclassified: 0, byStatus: {} as Record }; + for (const { key, node, description } of topProps(type)) { + if (FRAMEWORK_FIELDS.has(key)) { classify(type, key, 'live', null, cat); continue; } + const led = props[key]; + if (led?.children) { + // drill one level + const cs = childShape(node); + if (!cs) { cat.unclassified++; report.unclassified.push(`${type}/${key} (declared children but property is not a container)`); continue; } + for (const ck of Object.keys(cs)) { + const cled = led.children[ck]; + const status = cled?.status || markerStatus(descOf(cs[ck])) || led.childrenDefault; + if (!status) { cat.unclassified++; report.unclassified.push(`${type}/${key}.${ck}`); continue; } + classify(type, `${key}.${ck}`, status, cled, cat); + } + } else { + const status = led?.status || markerStatus(description); + if (!status) { cat.unclassified++; report.unclassified.push(`${type}/${key}`); continue; } + classify(type, key, status, led, cat); + } + } + report.types[type] = cat; +} + +const totalUnclassified = report.unclassified.length; +if (asJson) { + process.stdout.write(JSON.stringify(report, null, 2) + '\n'); +} else { + console.log('Spec liveness gate (registry-rooted) — governed types:', GOVERNED.join(', ')); + for (const [t, v] of Object.entries(report.types)) { + const parts = Object.entries(v.byStatus).map(([s, n]) => `${s} ${n}`).join(', '); + console.log(` ${t.padEnd(11)} ${v.classified} classified (${parts || '—'})${v.unclassified ? `, ${v.unclassified} UNCLASSIFIED` : ''}`); + } + if (report.staleEvidence.length) { + console.log(`\n⚠ ${report.staleEvidence.length} 'live' entr(ies) cite a missing file:`); + report.staleEvidence.forEach((s: string) => console.log(` ${s}`)); + } + if (totalUnclassified) { + console.log(`\n✗ ${totalUnclassified} UNCLASSIFIED — classify in packages/spec/liveness/.json:`); + report.unclassified.forEach((s: string) => console.log(` ${s}`)); + } else { + console.log('\n✓ all governed-type properties are classified.'); + } +} +process.exit(totalUnclassified > 0 ? 1 : 0);