Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .changeset/spec-liveness-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
---

chore(spec): add a spec liveness gate — every authorable property in a governed category must declare a runtime-liveness status (live/experimental/planned/dead) with evidence in `packages/spec/liveness/<category>.json`, enforced by CI on PRs touching `packages/spec/**`. Seeds the `security` category from the liveness audit (93 props: 66 dead, 26 live, 1 experimental); the dead set is the worklist for the security enforce-or-remove ADR. Repo-internal tooling; no package version impact.
42 changes: 42 additions & 0 deletions .github/workflows/spec-liveness-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
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/<category>.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.

on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- 'packages/spec/**'

permissions:
contents: read

jobs:
liveness:
name: Spec property liveness
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6

- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '20'

- name: Enable Corepack
run: corepack enable

- 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
66 changes: 66 additions & 0 deletions packages/spec/liveness/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# 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.

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).

## 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": "<status>"` 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.

## Files

- `<category>.json` — the ledger for a governed category (currently: `security`).
- `../scripts/liveness/check-liveness.mjs` — the gate. Reads the generated
`packages/spec/json-schema/<category>/*.json`, resolves each authorable
property's status, and exits non-zero on any UNCLASSIFIED property.

## Usage

```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)
```

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`, `data`):

1. `--dump <category>` to inventory its authorable properties.
2. Seed `<category>.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 — `security`

93 authorable properties: **66 dead, 26 live, 1 experimental.** ~71% of the
authorable security surface is parsed-but-unenforced. The `dead` entries are the
worklist for the security enforce-or-remove ADR — most urgently the destructive
`ObjectPermission.allow{Transfer,Restore,Purge}` (ungated) and the entirely-dead
`Policy` tree (password/session/`forceMfa`/network/audit).
77 changes: 77 additions & 0 deletions packages/spec/liveness/security.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
{
"category": "security",
"_note": "Liveness classification for authorable security metadata. Seeded from docs/audits/2026-06-security-identity-property-liveness.md (file:line evidence) plus targeted greps for schemas the audit did not cover. Statuses: live | experimental | planned | dead | (schema-level) internal. 'dead' = parsed, no runtime consumer → enforce-or-remove (security ADR). Security-critical: a 'dead' boundary prop = false compliance.",
"schemas": {
"ObjectPermission": {
"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" },
"modifyAllRecords": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts" },
"allowTransfer": { "status": "dead", "evidence": "permission-evaluator.ts:8-16 (absent from OPERATION_TO_PERMISSION)", "note": "destructive op (ownership transfer) NOT gated by RBAC — enforce-or-remove. Spec marks experimental." },
"allowRestore": { "status": "dead", "evidence": "permission-evaluator.ts:8-16 (absent)", "note": "undelete NOT gated — enforce-or-remove." },
"allowPurge": { "status": "dead", "evidence": "permission-evaluator.ts:8-16 (absent)", "note": "hard-delete/GDPR purge NOT gated — enforce-or-remove." }
}
},
"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": "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 as a general capability gate (e.g. manage_users unchecked in data path)." },
"tabPermissions": { "status": "live", "note": "PARTIAL — only 'hidden' is read; default_on/default_off ignored; UI-only, not a boundary." },
"isProfile": { "status": "dead", "evidence": "audit: profile-vs-permset never gates anything", "note": "enforce-or-remove." },
"contextVariables": { "status": "dead", "evidence": "rls-compiler.ts never reads it", "note": "RLS uses only current_user.* built-ins; doc claim of runtime evaluation is false." }
}
},
"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 — not even registered as a metadata type; better-auth runs its own hardcoded config (packages/plugins/plugin-auth/src/index.ts). Authoring a compliance Policy = false compliance.",
"props": {
"name": { "status": "experimental", "evidence": "spec describe(): [EXPERIMENTAL — not enforced]" },
"password": { "status": "dead", "evidence": "auth: better-auth hardcoded scrypt/session", "note": "false compliance — enforce or remove." },
"network": { "status": "dead", "evidence": "no consumer", "note": "IP allow-list/VPN unenforced." },
"session": { "status": "dead", "evidence": "auth: better-auth hardcoded session", "note": "idle/absolute timeout + forceMfa unenforced." },
"audit": { "status": "dead", "evidence": "no consumer", "note": "retention/redaction unenforced." },
"isDefault": { "status": "dead", "evidence": "no consumer" },
"assignedProfiles": { "status": "dead", "evidence": "no consumer" }
}
},
"PasswordPolicy": { "_schema": "dead", "_note": "Policy.password subtree — better-auth hardcoded; unenforced. enforce-or-remove.", "props": {} },
"SessionPolicy": { "_schema": "dead", "_note": "Policy.session subtree — idle/absoluteTimeout/forceMfa all unenforced; better-auth hardcoded. forceMfa=true is a silent no-op (false compliance).", "props": {} },
"NetworkPolicy": { "_schema": "dead", "_note": "Policy.network subtree — trustedRanges/blockUnknown/vpnRequired unenforced.", "props": {} },
"AuditPolicy": { "_schema": "dead", "_note": "Policy.audit subtree — logRetentionDays/sensitiveFields/captureRead unenforced.", "props": {} },
"OwnerSharingRule": { "_schema": "dead", "_note": "Spec SharingRule has NO runtime consumer; runtime enforces a divergent sys_sharing_rule model (packages/plugins/plugin-sharing/src/sharing-plugin.ts). Authoring the spec rule has no effect — reconcile to one contract.", "props": {} },
"RLSConfig": { "_schema": "dead", "_note": "No runtime consumer (grep). RLS enforcement reads RowLevelSecurityPolicy via rls-compiler.ts, not this config object.", "props": {} },
"RLSAuditConfig": { "_schema": "dead", "_note": "No runtime consumer (grep).", "props": {} },
"Territory": { "_schema": "dead", "_note": "Salesforce-style territory management — no runtime consumer (grep).", "props": {} },
"TerritoryModel": { "_schema": "dead", "_note": "No runtime consumer (grep).", "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." }
}
}
3 changes: 2 additions & 1 deletion packages/spec/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,8 @@
"gen:sbom": "tsx scripts/generate-sbom.ts",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
"test:coverage": "vitest run --coverage",
"check:liveness": "node scripts/liveness/check-liveness.mjs"
},
"keywords": [
"objectstack",
Expand Down
Loading
Loading