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
14 changes: 14 additions & 0 deletions .changeset/20299-rls-policy-rows-live.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@objectstack/spec": patch
---

Liveness ledger: a permission set's row-level security policy `label` and `description` (`rowLevelSecurity[].label` / `.description`) are now `live`, not `dead`. Studio's permission editor shows both on every policy. Ledger data and its generated count shard only.

Clause-②: no

- **What shows them.** These are display keys, so under the ledger's "Designer previews count as consumers" ruling, being shown to a human is the whole of their claimed effect. The Row-Level Security section of the permission editor (`PermissionAdvancedFacets` in objectui) now heads each policy card with the policy's `label` and, beneath it, its `description`, exactly as written. Both rows cite that reader at the `.objectui-sha` pin `89cad75d557`. The registered permission preview also draws both, but no route mounts it for `permission`, so it is not cited.
- **Where the values come from.** Each row names its producer: the `permission` edit page registration and the Studio edit route that mounts it, the editor's `GET /api/v1/meta/permission/:name/layers` read, and this repo's shared layered answer (`createMetaLayeredAnswer`), which serves a permission set whole. The showcase's contributor permission set authors both keys on all three of its policies.
- **Author-facing effect.** `os lint` / `os validate` no longer warn `liveness-dead-property` on a policy that sets `label` or `description`. A warning is not a refusal, so the accept set is unchanged.
- **Still kept.** The re-grade reverses no ADR-0033 decision. Both rows stay docs-shaped annotation, deliberately kept and not `authorWarn`'d.
- The regenerated liveness count is the `liveness/state-counts/permission.md` shard: `permission` has 38 live and 4 dead (was 36 and 6). The `view` container's own `label` stays `dead`.
- ⛔ No schema, parse, `.describe()`, export or accept-set change.
76 changes: 33 additions & 43 deletions packages/lint/src/lint-liveness-properties.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { afterAll, describe, it, expect } from 'vitest';
import { cpSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { PermissionSetSchema } from '@objectstack/spec/security';
import { ViewSchema } from '@objectstack/spec/ui';
import {
authorWarnedProperties,
Expand Down Expand Up @@ -1632,45 +1631,40 @@ describe('a per-type ledger that could not be READ is reported once (#19276)', (
// These pins test it, against the real ledgers, so a ledger change that leaves
// either id with no reachable row fails here by name.
describe('the dead and live-elsewhere verdicts warn on their own (#16094)', () => {
// `rowLevelSecurity.label` / `.description` are `dead` rows (no mounted
// surface draws a policy's label or description) that an author really
// writes: the fixture parses through the shipped `PermissionSetSchema`, so
// neither key is a tombstone. If either row changes verdict, re-subject this
// pin to another `dead` row of a type the walk visits; if none is left, the
// rule id is unreachable again and that is what this pin exists to say.
const permissionSet = {
name: 'fx_reader',
label: 'Fx Reader',
objects: { fx_account: { allowRead: true, readScope: 'org' } },
rowLevelSecurity: [
// The first policy authors neither dead key, so the findings below can
// only come from the second: the dotted path fans out past index 0.
{ name: 'fx_any_rows', object: 'fx_account', operation: 'select', using: 'name == current_user.email' },
{
name: 'fx_own_rows',
label: 'Own rows',
description: 'Readers see their own rows.',
object: 'fx_account',
operation: 'select',
using: 'name == current_user.email',
},
],
};
// The `view` container's own `name` / `label` are `dead` rows (Studio
// enumerates view items, never the aggregated container, so nothing draws or
// keys on them) that an author really writes: the fixture parses through the
// shipped `ViewSchema`, so neither key is a tombstone. If either row changes
// verdict, re-subject this pin to another `dead` row of a type the walk
// visits; if none is left, the rule id is unreachable again and that is what
// this pin exists to say. (It was re-subjected here when
// `permission.rowLevelSecurity.label` / `.description` went `live`, #20299.)
const list = (object: string) => ({
type: 'grid',
data: { provider: 'object', object },
columns: [{ field: 'name' }],
});
const views = [
// The first container authors neither dead key, so the findings below can
// only come from the second: the collection walk reaches past index 0.
{ object: 'fx_account', list: list('fx_account') },
{ name: 'fx_contact', label: 'Contacts', object: 'fx_contact', list: list('fx_contact') },
];

it('the fixture is authorable — it parses through the shipped schema (the keys are not tombstones)', () => {
expect(PermissionSetSchema.safeParse(permissionSet).success).toBe(true);
for (const view of views) expect(ViewSchema.safeParse(view).success, view.object).toBe(true);
});

it('END TO END: an authored dead key produces liveness-dead-property, and the live keys beside it stay silent', () => {
const findings = lintLivenessProperties({ permissions: [permissionSet] });
expect(ruleOf(findings, 'rowLevelSecurity.label')).toBe(LIVENESS_DEAD_PROPERTY);
expect(ruleOf(findings, 'rowLevelSecurity.description')).toBe(LIVENESS_DEAD_PROPERTY);
// `live` is silent — same policy, same ledger load, so this is a verdict
// and not a walk that never ran.
for (const live of ['rowLevelSecurity.name', 'rowLevelSecurity.object', 'rowLevelSecurity.operation', 'rowLevelSecurity.using']) {
const findings = lintLivenessProperties({ views });
expect(ruleOf(findings, 'name')).toBe(LIVENESS_DEAD_PROPERTY);
expect(ruleOf(findings, 'label')).toBe(LIVENESS_DEAD_PROPERTY);
// `live` is silent — same container, same ledger load, so this is a
// verdict and not a walk that never ran.
for (const live of ['object', 'list.type', 'list.data', 'list.columns']) {
expect(ruleOf(findings, live), live).toBeUndefined();
}
expect(findings.map((f) => f.where)).toEqual(["permission 'fx_reader'", "permission 'fx_reader'"]);
expect(findings.map((f) => f.where)).toEqual(["view 'fx_contact'", "view 'fx_contact'"]);
});

// The control the triage notes asked for: a TOMBSTONED key is still refused
Expand Down Expand Up @@ -1838,20 +1832,16 @@ describe('the hint a warned row shows an author (#16094, #21096)', () => {
}
});

it('END TO END: the authored dead RLS keys show the dead default hint, not the ledger note', () => {
const ledger = JSON.parse(readFileSync(join(shippedLedgerDir(), 'permission.json'), 'utf8'));
const rls = ledger.props.rowLevelSecurity.children;
it('END TO END: the authored dead view-container keys show the dead default hint, not the ledger note', () => {
const ledger = JSON.parse(readFileSync(join(shippedLedgerDir(), 'view.json'), 'utf8'));
const findings = lintLivenessProperties({
permissions: [{
name: 'fx_reader',
rowLevelSecurity: [{ name: 'p', label: 'Own rows', description: 'Readers see their own rows.', object: 'fx_account' }],
}],
views: [{ name: 'fx_contact', label: 'Contacts', object: 'fx_contact' }],
});
const deadDefault = checkItemAgainstWarnMap('gadget', { name: 'g1', gizmo: 'x' }, "gadget 'g1'", gizmoEntry({ status: 'dead' }))[0].hint;
for (const key of ['label', 'description']) {
const f = findings.find((x) => x.message.includes(`sets \`rowLevelSecurity.${key}\``));
for (const key of ['name', 'label']) {
const f = findings.find((x) => x.message.includes(`sets \`${key}\``));
expect(f, key).toBeDefined();
expect(f!.hint, key).not.toBe(rls[key].note);
expect(f!.hint, key).not.toBe(ledger.props[key].note);
expect(f!.hint, key).toBe(deadDefault);
}
});
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/liveness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -960,7 +960,7 @@ marker where the Notes cell goes, never a guess at what belongs there.
| flow | dead count = **5 tombstone entries**: `active`/`template`/nodes.`outputSchema`/errorHandling.`fallbackNodeId` REMOVED 2026-07-30 (#3896 close-out sweep — `active: false` never stopped a flow, `status` is the enforced lifecycle; faults route via per-node fault edges), plus errorHandling.`retryDelayMs` RENAMED to `backoffMs` 2026-08-04 (#4964). The rename is why the dead column moved while live did not: a rename is a removal on this ledger, so the old spelling is tombstoned (`retiredKey` keeps it in the walked shape) and the new spelling enters as its own `live` row. Read it beside the four above as the one entry here that cost an author nothing — the block was a THIRD encoding of the retry policy #4661 converged, invisible to that pass because it is an anonymous inline block with no exported name, and #4964 spelled its base delay `backoffMs` to match `job.retryPolicy` and a `try_catch` node's `retry`. `description` is the kept docs field — KEPT deliberately, docs-shaped, exempt from enforce-or-remove — and it left the dead set in #20299: the Studio metadata list's default columns and the metadata quick-find draw it for every flow, which for a display key is the whole of the claimed effect (the #7131 ruling above). Still not authorWarn'd |
| action | `type:'form'` CORRECTED to live (objectui ActionRunner.executeForm, #2377); dead `timeout` REMOVED (#2377); `disabled` live since objectui#2863; `undoable` CORRECTED to live (#3714); `shortcut` + `bulkEnabled` REMOVED 2026-07-30 (#3896 close-out sweep — no keydown path dispatches shortcuts; the multi-select toolbar reads the view's bulkActions). **#7367** (PR #7430) adds `description` as an authorable key, `live` on arrival — the only row this type has gained since that sweep. **Commit cae2169cf** makes the dead set three: `execute` joins it, re-classified `live` → `dead` 2026-08-29 with no key added or removed. Its `live` verdict rested on a `.transform` lowering `execute` → `target` that protocol 17 (#3855) removed along with the alias; the key has been a `retiredKey` tombstone since 2026-07-28, so the row stays (the `rls.priority` precedent) while the verdict does not. The rot was invisible to every citation check — the pointer was in range, in the right file, and the file names the key — and the entry carried no `verifiedAt`, so nothing ever re-asked. **#20323** makes the dead set four: `aria` re-classified `live` → `dead` 2026-09-28 and tombstoned (the `rls.priority` precedent again). Its `live` verdict rested on an uncited 「PARTIAL — honored by a few objectui renderers」 note; re-measured at the `.objectui-sha` pin, no surface that renders an action reads an action's `aria`, and each takes the accessible name from the required `label` |
| hook | model-healthy; label/description KEPT deliberately (2026-07-30 sweep) — docs-shaped annotation fields, exempt from enforce-or-remove — and **`live` since #20299**: `hook` has no registered preview, but the Studio metadata list's default columns and the metadata quick-find draw both keys for every hook, which for a display key is the whole of the claimed effect (the #7131 ruling above). Still not authorWarn'd |
| permission | CRUD/FLS/RLS live; dead `contextVariables` REMOVED (ADR-0105 D11 — RLS resolves only the `current_user.*` built-ins plus runtime-staged `rlsMembership` sets). 2026-07-30 security-subset re-verification (all 33 entries `verifiedAt`-stamped): `rowLevelSecurity.enabled` was live-with-wrong-evidence and UNREAD — a disabled policy kept contributing its OR-branch grant; ENFORCED same day in rls-compiler (`getApplicablePolicies`), the `positions` ADR-0049 resolution repeated. `rowLevelSecurity.priority` CORRECTED to dead+authorWarn — semantically void under OR-combination (no conflict exists to order), a REMOVE candidate. `rls.label`/`description`/`tags` CORRECTED to dead (benign display, no consumer in either repo). `tabPermissions` was UNDERSTATED ("only hidden read" → the rank merge reads all four values; me-apps dogfood test exercises it). `allowExport` re-verified TRUE end-to-end (server-side 403 gate, not just the /me projection). `objects.allowRestore`/`allowPurge` REMOVED 2026-08-26 (#12497, ADR-0049 — the `restore`/`purge` ops never existed; the 2026-07-30 'live' verdict cited only the evaluator pre-mapping, retired in the same batch; `retiredKey` tombstones, keys return with M2 per the #1883 ruling). `rowLevelSecurity.tags` REMOVED 2026-09-27 (#20321, ADR-0049 — graded RETIRE by the maintainer's criterion: no mainstream platform tags a row-level policy; a `retiredKey` tombstone, so the row stays `dead` beside `priority`'s) |
| permission | CRUD/FLS/RLS live; dead `contextVariables` REMOVED (ADR-0105 D11 — RLS resolves only the `current_user.*` built-ins plus runtime-staged `rlsMembership` sets). 2026-07-30 security-subset re-verification (all 33 entries `verifiedAt`-stamped): `rowLevelSecurity.enabled` was live-with-wrong-evidence and UNREAD — a disabled policy kept contributing its OR-branch grant; ENFORCED same day in rls-compiler (`getApplicablePolicies`), the `positions` ADR-0049 resolution repeated. `rowLevelSecurity.priority` CORRECTED to dead+authorWarn — semantically void under OR-combination (no conflict exists to order), a REMOVE candidate. `rls.label`/`description`/`tags` CORRECTED to dead (benign display, no consumer in either repo). `tabPermissions` was UNDERSTATED ("only hidden read" → the rank merge reads all four values; me-apps dogfood test exercises it). `allowExport` re-verified TRUE end-to-end (server-side 403 gate, not just the /me projection). `objects.allowRestore`/`allowPurge` REMOVED 2026-08-26 (#12497, ADR-0049 — the `restore`/`purge` ops never existed; the 2026-07-30 'live' verdict cited only the evaluator pre-mapping, retired in the same batch; `retiredKey` tombstones, keys return with M2 per the #1883 ruling). `rowLevelSecurity.tags` REMOVED 2026-09-27 (#20321, ADR-0049 — graded RETIRE by the maintainer's criterion: no mainstream platform tags a row-level policy; a `retiredKey` tombstone, so the row stays `dead` beside `priority`'s). `rowLevelSecurity.label` / `description` RE-GRADED `live` 2026-10-03 (#20299 — the permission editor's Row-Level Security section draws both on each policy card, read at the `.objectui-sha` pin 89cad75d557; still benign display, KEPT, not authorWarn'd), so the dead set is now `priority` and `tags` beside the two `objects.allowRestore` / `allowPurge` tombstones |
| position | (role's ADR-0090 successor) fully live; all 4 `verifiedAt`-stamped 2026-07-30 |
| agent | dead `tenantId` + `planning.strategy`/`allowReplan` REMOVED (#2377); the autonomy tier is experimental no longer — `structuredOutput` (#21277) and `memory` (#20274) flipped `live` on the cloud AI runtime's reading, and `lifecycle` REMOVED 2026-10-02 (#21320; ruled D on objectstack-ai/cloud#2569 — the conversation state machine was parsed and never read; phases are skills with `triggerConditions`, orchestration is Flow, record transitions are the `state_machine` validation rule; the XState `StateMachineSchema` family, which only it still reached, left with it), so no `agent` row is experimental; `knowledge` REMOVED 2026-07-30 (#3896 close-out sweep — declaring sources never scoped retrieval; AIKnowledgeSchema removed with it, the topics→sources rename absorbed pre-release); **#18304** re-classifies `tools` `live` -> `dead` with no key added or removed — the row asserted `live` on a key `agent.zod.ts` had tombstoned in protocol 17 (#3894), and it sat that way from the 2026-06 audit because its citation was EXEMPT from resolution rather than resolved (`packages/services/service-ai/...` matched `FOREIGN_PATH_PREFIXES`; the `cloud` realm marker that replaced it in #13309 is equally unresolvable, so no gate could ever fail on it). The load-bearing evidence is local and re-measurable — the `retiredKey` tombstone plus the `agent-tools-to-skills` strip cover authored and stored input respectively, so nothing can carry a value for any consumer to read; the cloud zero-consumer census (cloud @cb8ee7ff, #13272, 2026-09-15) is attributed, not re-taken. `live-elsewhere` is refused for want of a foreign enforcer, not left undeclared |
| tool | the inert authoring surface is now REMOVED, not merely marked: `category`/`permissions`/`active`/`builtIn` retired 2026-07-30 (#3896 close-out) after `requiresConfirmation` set the precedent (#3715, ADR-0033 §2). `permissions` promised an invocation gate nothing enforced and `active:false` withdrew nothing — false compliance, same shape as rls.enabled. The `.strict()` ToolSchema rejects each retired key with its prescription; the `tool-inert-authoring-keys-removed` conversion strips them from authored sources |
Expand Down
Loading
Loading