diff --git a/.changeset/20321-rls-policy-tags-retired.md b/.changeset/20321-rls-policy-tags-retired.md new file mode 100644 index 00000000000..c8148b2ec56 --- /dev/null +++ b/.changeset/20321-rls-policy-tags-retired.md @@ -0,0 +1,68 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: retire `rowLevelSecurity[].tags` — no mainstream platform tags a row-level policy, and nothing here ever read one (#20321) + +Clause-②: no (narrowing) + +**BREAKING** — shipped as `minor` under the launch-window convention +(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by +this banner, the `(narrowing)` arm above and the ADR-0087 disposition below). + +`tags` is removed from the row-level security policy (`RowLevelSecurityPolicySchema`, +the entries of a permission set's `rowLevelSecurity`). ADR-0049 +enforce-or-remove, graded RETIRE by the maintainer's criterion for +declared-but-unenforced families — does a mainstream platform have the +capability? None does: Salesforce sharing rules, Dataverse security roles and +PostgreSQL RLS policies carry no tag attribute, and compliance reporting there +keys on the rule itself. + +The key promised "categorization and reporting" for governance and compliance. +Nothing ever read it. Measured before removal, each against a lit control: the +RLS compiler reads a policy's `name`, `object`, `operation`, `positions`, +`enabled` and predicates, never `tags`; objectui's permission preview renders +the policy COUNT and its policy editor neither seeds nor reads the key; cloud +has no reader. No example, default permission set or cloud source wrote it. + +### FROM → TO + +| removed | what to write instead | +| --- | --- | +| `rowLevelSecurity[].tags` | delete the key. To limit whom a policy applies to, list the positions in `positions` — a tag never did that. To say why a policy exists, use `description`. | + +**The one-line fix: delete `tags:` from every row-level security policy.** +`os migrate meta --from 17` lists the mechanical edits for existing sources; +apply them by hand. + +⚠️ Runtime behaviour is deliberately **unchanged**. No access decision ever +depended on a tag, so removing the key removes no behaviour. What changes is the +answer an author gets: a policy carrying `tags` is now refused at parse, with the +prescription, instead of being stored with no effect. An author who wrote a tag +such as `managers_only` believing it scoped the policy now learns that only +`positions` does. + +### The retirement kit + +- **A `retiredKey()` tombstone** on `RowLevelSecurityPolicySchema` (the + `priority` posture one key over): `tsc` types the key `never`, and every parse + raises the prescription rather than a bare unknown-key verdict. The shape's + did-you-mean never offers it: a near-miss `tag` is refused as unknown. +- **D2 conversion `permission-rls-tags-removed`** (step 18, retired from the load + path): a lossless delete over `permissions[].rowLevelSecurity[]`, so a stored + permission row that still carries the key replays clean through the + rehydration seam, while a live author is refused rather than rewritten. +- **`RETIRED_KEYS_BY_MAJOR[18]`**: `security/RowLevelSecurityPolicy:tags`, and + the family's D3 entry `permission-rls-tags-retired`, which states what the + strip cannot decide — any report, audit filter or review process built on the + belief that policy tags were read needs another path. +- **The liveness row stays**, `dead`, under its tombstone (the key is still in + the walked shape); `authorable-surface/security.json` carries it as + `security/RowLevelSecurityPolicy:tags [RETIRED]`, and the generated reference + pages print the prescription in place of the old describe. +- **No deprecation window**, per the project's startup-stage posture. + +⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` +is published, so this is breaking for consumers no telemetry was consulted for. + + diff --git a/content/docs/references/security/permission.mdx b/content/docs/references/security/permission.mdx index 278379bb828..6efae494475 100644 --- a/content/docs/references/security/permission.mdx +++ b/content/docs/references/security/permission.mdx @@ -172,7 +172,7 @@ const result = AdminScopeSchema.parse(data); | **positions** | `string[]` | optional | Positions this policy applies to (omit for all) | | **enabled** | `boolean` | optional (default: `true`) | Whether this policy is active | | **priority** | `never` | optional | [REMOVED] `rowLevelSecurity[].priority` was removed in @objectstack/spec 17.0.0. It never had an effect. Delete the key — policy outcomes are unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **tags** | `string[]` | optional | Policy categorization tags | +| **tags** | `never` | optional | [REMOVED] `rowLevelSecurity[].tags` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — nothing ever read it: the RLS compiler never consulted a policy's tags and nothing else acted on them, so a tag scoped, restricted and reported nothing. Delete the key. A tag never limited whom a policy applies to; to do that, list the positions in `positions`. A policy is identified by its `name` and its `object`; say why it exists in `description`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | ### Nested Shape: `PermissionSet.adminScope` diff --git a/content/docs/references/security/rls.mdx b/content/docs/references/security/rls.mdx index 362d1393007..c1939a2b52a 100644 --- a/content/docs/references/security/rls.mdx +++ b/content/docs/references/security/rls.mdx @@ -180,7 +180,7 @@ const result = RLSEvaluationResultSchema.parse(data); | **positions** | `string[]` | optional | Positions this policy applies to (omit for all) | | **enabled** | `boolean` | optional (default: `true`) | Whether this policy is active | | **priority** | `never` | optional | [REMOVED] `rowLevelSecurity[].priority` was removed in @objectstack/spec 17.0.0. It never had an effect. Delete the key — policy outcomes are unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **tags** | `string[]` | optional | Policy categorization tags | +| **tags** | `never` | optional | [REMOVED] `rowLevelSecurity[].tags` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — nothing ever read it: the RLS compiler never consulted a policy's tags and nothing else acted on them, so a tag scoped, restricted and reported nothing. Delete the key. A tag never limited whom a policy applies to; to do that, list the positions in `positions`. A policy is identified by its `name` and its `object`; say why it exists in `description`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | --- diff --git a/packages/spec/authorable-surface/security.json b/packages/spec/authorable-surface/security.json index b65465e7fb8..4823721cbf7 100644 --- a/packages/spec/authorable-surface/security.json +++ b/packages/spec/authorable-surface/security.json @@ -150,7 +150,7 @@ "security/RowLevelSecurityPolicy:operation", "security/RowLevelSecurityPolicy:positions", "security/RowLevelSecurityPolicy:priority [RETIRED]", - "security/RowLevelSecurityPolicy:tags", + "security/RowLevelSecurityPolicy:tags [RETIRED]", "security/RowLevelSecurityPolicy:using", "security/SharingRule:_lock", "security/SharingRule:_lockDocsUrl", diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index 560b83313a8..4567d63d30d 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -906,7 +906,7 @@ marker where the Notes cell goes, never a guess at what belongs there. | flow | dead count = **5 tombstone entries** + the kept docs field: `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`. Remaining dead = `description`, KEPT deliberately: docs-shaped, exempt from enforce-or-remove | | 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. **#13036** 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 | | hook | model-healthy; label/description dead but KEPT deliberately (2026-07-30 sweep) — docs-shaped annotation fields, exempt from enforce-or-remove | -| 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) | +| 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) | | position | (role's ADR-0090 successor) fully live; all 4 `verifiedAt`-stamped 2026-07-30 | | agent | dead `tenantId` + `planning.strategy`/`allowReplan` REMOVED (#2377); autonomy tier 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 | diff --git a/packages/spec/liveness/permission.json b/packages/spec/liveness/permission.json index bb0d3a60937..7907b5126da 100644 --- a/packages/spec/liveness/permission.json +++ b/packages/spec/liveness/permission.json @@ -224,9 +224,9 @@ }, "tags": { "status": "dead", - "evidenceScope": "cross-repo", - "verifiedAt": "2026-08-10", - "note": "CORRECTED 2026-07-30 (was live with no evidence): no reader in either repo — governance/compliance reporting never consumes policy tags. RE-TESTED AND UPHELD 2026-08-10 (#7427) under the previews ruling (#7131), same measurement as this block's `label`: at objectui @e9ab52f9 the permission preview reads `rowLevelSecurity` as an array and renders its LENGTH (PermissionPreview.tsx:111, :164), never a policy's fields, so no tag value reaches a human there. Benign organizational metadata — not authorWarn'd." + "verifiedAt": "2026-09-27", + "evidence": "packages/spec/src/security/rls.zod.ts (retiredKey tombstone — authored values REJECT with the prescription; z.input types the key never)", + "note": "REMOVED 2026-09-27 (#20321, ADR-0049 enforce-or-remove — graded RETIRE by the maintainer's criterion for declared-but-unenforced families: no mainstream platform tags a row-level policy; Salesforce sharing rules, Dataverse security roles and PostgreSQL RLS policies carry no tag attribute, and compliance reporting there keys on the rule itself). Tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources and stored permission rows by the protocol-18 conversion `permission-rls-tags-removed`. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent). The dead verdict it replaces was measured twice (CORRECTED 2026-07-30; RE-TESTED 2026-08-10, #7427) and re-measured before removal on 2026-09-27: no reader in this repo, in objectui at the .objectui-sha pin f8a9d0fb or at main 972c1685 (the permission preview renders the policy COUNT; the RLS facet neither seeds nor reads the key), or in cloud main 96eb092f, each with a lit control. To say whom a policy applies to, use `positions`; to say why it exists, use `description`." } } } diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 935de8ba091..f6f9168a14c 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -11616,6 +11616,101 @@ const currencyConfigPrecisionRemoved: MetadataConversion = { }, }; +/** + * RLS-policy `tags` removed (protocol 18, #20321 — ADR-0049 enforce-or-remove, + * graded RETIRE by the maintainer's criterion for declared-but-unenforced + * families: does a mainstream platform have the capability?). + * + * The key promised "categorization and reporting" for governance and + * compliance, and nothing ever read it: the RLS compiler reads a policy's + * `name`, `object`, `operation`, `positions`, `enabled` and predicates, and + * nothing else acts on its tags (objectui's permission preview renders the + * policy COUNT; its policy editor neither seeds nor reads the key). No + * mainstream platform tags a row-level policy — Salesforce sharing rules, + * Dataverse security roles and PostgreSQL RLS policies carry no such + * attribute. So the delete is lossless: no access decision changes, and + * nothing that consumes a policy loses an input. Sibling of `permission-rls-priority-removed` (one + * major earlier, same carrier, same walk). + * + * `retiredFromLoadPath`: the schema tombstones the key (`retiredKey`, tsc + * `never` + the parse-time prescription), so a live author is refused at parse + * rather than silently rewritten. The entry exists so a stored permission row + * that still carries the key replays clean through + * `applyConversionsToStoredItem`, and so `os migrate meta --from 17` lists the + * mechanical edits for author sources. `stripKeys` deletion is idempotent by + * construction. + */ +const permissionRlsTagsRemoved: MetadataConversion = { + id: 'permission-rls-tags-removed', + toMajor: 18, + retiredFromLoadPath: true, + surface: 'permission.rowLevelSecurity[].tags', + summary: + "RLS-policy key 'tags' removed (#20321, ADR-0049 — nothing ever read a policy's tags and no " + + 'mainstream platform tags a row-level policy; dropping it changes no access decision)', + apply(stack, emit) { + return mapCollection(stack, 'permissions', (ps, path) => { + const rls = (ps as { rowLevelSecurity?: unknown }).rowLevelSecurity; + if (!Array.isArray(rls)) return ps; + let touched = false; + const next = rls.map((policy, i) => { + if (!isDict(policy)) return policy; + const stripped = stripKeys(policy, ['tags'], emit, `${path}.rowLevelSecurity[${i}]`); + if (stripped !== policy) touched = true; + return stripped; + }); + return touched ? { ...ps, rowLevelSecurity: next } : ps; + }); + }, + fixture: { + before: { + permissions: [{ + name: 'compliance_reviewer', + label: 'Compliance Reviewer', + rowLevelSecurity: [ + { + name: 'reviewed_cases', + object: 'crm_case', + operation: 'select', + using: "status == 'closed'", + tags: ['compliance', 'gdpr'], + }, + // A policy WITHOUT the key rides through untouched — the strip + // dispatches on key presence. + { + name: 'own_cases', + object: 'crm_case', + operation: 'select', + using: 'owner == current_user.id', + }, + ], + }], + }, + after: { + permissions: [{ + name: 'compliance_reviewer', + label: 'Compliance Reviewer', + rowLevelSecurity: [ + { + name: 'reviewed_cases', + object: 'crm_case', + operation: 'select', + using: "status == 'closed'", + }, + { + name: 'own_cases', + object: 'crm_case', + operation: 'select', + using: 'owner == current_user.id', + }, + ], + }], + }, + // One notice: the one policy carrying the key. + expectedNotices: 1, + }, +}; + export const CONVERSIONS_BY_MAJOR: Readonly> = { 11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename], 13: [stackRolesToPositions, owdLegacyReadAliases, sharingRecipientRoleToPosition], @@ -11728,6 +11823,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly> // D2 conversion `permission-allow-restore-purge-removed`, which strips the // key from every object grant in `permissions[].objects`. 'security/ObjectPermission:allowRestore', + // #20321 (ADR-0049 enforce-or-remove; graded RETIRE by the maintainer's + // criterion for declared-but-unenforced families — does a mainstream platform + // have the capability?). `RowLevelSecurityPolicy.tags` promised categorization + // and reporting for governance and compliance, and nothing ever read it: the + // RLS compiler never consults it, objectui's permission preview renders only + // the policy count and its policy editor neither seeds nor reads the key, and + // cloud has no reader. No mainstream platform tags a row-level policy. The + // policy shape is `strictObject`, but the def is reachable from the + // `permission` metadata root, so the route is the `retiredKey()` tombstone + // (the `rls.priority` posture one key over): the key stays in the walked shape + // as `[RETIRED]`, and authoring it is a tsc error and a parse error carrying + // the prescription. D2: `permission-rls-tags-removed`; D3: + // `permission-rls-tags-retired`. + 'security/RowLevelSecurityPolicy:tags', // #15679 (stack card 4/6 of #14478) — ruling B. `AccessControlConfig.maxAge` said // "CORS preflight cache duration in seconds" in prose and nothing else. // ⚠️ This key is deliberately a RENAME and not an `externalVocabulary` marker, diff --git a/packages/spec/src/security/rls-tags-retirement.test.ts b/packages/spec/src/security/rls-tags-retirement.test.ts new file mode 100644 index 00000000000..47bbb5a6df3 --- /dev/null +++ b/packages/spec/src/security/rls-tags-retirement.test.ts @@ -0,0 +1,523 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The RLS policy's `tags` RETIRED (#20321) — ADR-0049 enforce-or-remove, + * graded RETIRE by the maintainer's criterion for declared-but-unenforced + * families: does a mainstream platform have the capability? None does — + * Salesforce sharing rules, Dataverse security roles and PostgreSQL RLS + * policies carry no tag attribute, and compliance reporting there keys on the + * rule itself. + * + * Measured before removal, with lit controls, and recorded on the tombstone in + * `rls.zod.ts` and on the ledger row: no reader of a policy's `tags` in this + * repo, in objectui at its pin and at `main`, or in cloud; no writer in the + * examples, the default permission sets or cloud. + * + * Bookkeeping shapes, pinned below: + * 1. A `retiredKey()` tombstone on `RowLevelSecurityPolicySchema` — a + * `strictObject` whose def is reachable from the `permission` root, so the + * tombstone (the `priority` posture one key over) is what carries the + * prescription instead of a bare unknown-key verdict. + * 2. D2 conversion `permission-rls-tags-removed` (step 18), a lossless + * delete over `permissions[].rowLevelSecurity[]`, retired from the load + * path: a live author is refused, a stored row replays clean. + * 3. `RETIRED_KEYS_BY_MAJOR[18]` carries `security/RowLevelSecurityPolicy:tags`, + * and the family's D3 entry is `permission-rls-tags-retired` (one D3 + * entry per retirement family, even when D2 is lossless). + * 4. The liveness row STAYS, `dead`, because the tombstone keeps the key in + * the walked shape (`check:liveness` is the judge of that half). + * + * On the assertion set: a schema refusal raises a `ZodError` whose issues + * carry `code` and `path` but no ADR-0112 `status` — that envelope belongs to + * the authoring door, `defineStack`, which is pinned with its `code` and + * `status` below. Everywhere else: refusal, the issue `code`, the `path` + * naming the key, and the prescription text. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { collectConversionNotices } from '../conversions/apply'; +import { applyConversionsToStoredItem } from '../conversions/stored'; +import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; +import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; +import { defineStack } from '../stack.zod'; +import { PermissionSetSchema } from './permission.zod'; +import { RowLevelSecurityPolicySchema, type RowLevelSecurityPolicy } from './rls.zod'; + +/** A well-formed policy — every live key an author commonly writes, not the retired one. */ +const POLICY = { + name: 'reviewed_cases', + label: 'Reviewed cases', + description: 'Compliance reviewers read closed cases only', + object: 'crm_case', + operation: 'select', + using: "status == 'closed'", + positions: ['compliance_reviewer'], + enabled: true, +} as const; + +const TAGS = ['compliance', 'gdpr']; + +// Unanchored, because a thrown `ZodError`'s message is the JSON of its issues; +// the key-first house convention is asserted on the issue message itself below. +const PRESCRIPTION = + /`rowLevelSecurity\[\]\.tags` was removed in @objectstack\/spec 17\.5\.0 \(ADR-0049 enforce-or-remove\).*Delete the key\..*`positions`.*Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand\./s; + +const permissionSet = (policy: Record) => ({ + name: 'compliance_reviewer', + objects: {}, + rowLevelSecurity: [policy], +}); + +describe('rls tags retirement — the tombstone, at every door that carries a policy', () => { + it('the policy schema refuses `tags` at its path, with the prescription', () => { + const r = RowLevelSecurityPolicySchema.safeParse({ ...POLICY, tags: TAGS }); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues).toHaveLength(1); + const issue = r.error.issues[0]!; + expect(issue.code).toBe('invalid_type'); + expect(issue.path).toEqual(['tags']); + expect(issue.message).toMatch(PRESCRIPTION); + // House convention 1: the fully-qualified key, in backticks, opens it. + expect(issue.message.startsWith('`rowLevelSecurity[].tags` was removed')).toBe(true); + }); + + it('refuses every value shape, the empty list included — the tombstone accepts only absence', () => { + for (const value of [[], ['gdpr'], 'gdpr', null, {}]) { + const r = RowLevelSecurityPolicySchema.safeParse({ ...POLICY, tags: value }); + expect(r.success, `tags: ${JSON.stringify(value)}`).toBe(false); + if (r.success) continue; + expect(r.error.issues[0]!.path).toEqual(['tags']); + expect(r.error.issues[0]!.message).toMatch(PRESCRIPTION); + } + }); + + it('the `permission` write door (the registry binding) refuses it at rowLevelSecurity[N].tags', () => { + // `getMetadataTypeSchema('permission')` is what `saveMetaItem` validates a + // `PUT /api/v1/meta/permission` body against; a rebinding to some other + // shape would pass the pin above and still accept the key in production. + const door = getMetadataTypeSchema('permission'); + expect(door, 'no schema bound for `permission`').toBeDefined(); + expect(door).toBe(PermissionSetSchema); + const r = door!.safeParse(permissionSet({ ...POLICY, tags: TAGS })); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues).toHaveLength(1); + const issue = r.error.issues[0]!; + expect(issue.code).toBe('invalid_type'); + expect(issue.path).toEqual(['rowLevelSecurity', 0, 'tags']); + expect(issue.message).toMatch(PRESCRIPTION); + }); + + it('the authoring door, defineStack, refuses it with the STACK_SCHEMA_INVALID envelope — never rewrites it', () => { + const stack = (policy: Record) => ({ + manifest: { id: 'com.example.rls-tags', name: 'rls_tags', version: '1.0.0', type: 'app' }, + permissions: [permissionSet(policy)], + }); + let thrown: unknown; + try { + defineStack(stack({ ...POLICY, tags: TAGS }) as never); + } catch (e) { + thrown = e; + } + const refusal = thrown as { code?: string; status?: number; issues?: Array<{ path: PropertyKey[]; message: string }> }; + expect(refusal?.code).toBe('STACK_SCHEMA_INVALID'); + expect(refusal?.status).toBe(422); + expect(refusal.issues).toHaveLength(1); + expect(refusal.issues?.[0]?.path).toEqual(['permissions', 0, 'rowLevelSecurity', 0, 'tags']); + expect(refusal.issues?.[0]?.message).toMatch(PRESCRIPTION); + // CONTROL: the same stack without the key is accepted by the same door. + expect(() => defineStack(stack({ ...POLICY }) as never)).not.toThrow(); + }); + + it('CONTROL: the same policy without the key passes both doors, every live key intact', () => { + const policy = RowLevelSecurityPolicySchema.safeParse(POLICY); + expect(policy.success).toBe(true); + if (!policy.success) return; + // Absence stays absence: the tombstone materializes nothing. + expect(policy.data).not.toHaveProperty('tags'); + expect(policy.data).toEqual(POLICY); + + const set = PermissionSetSchema.safeParse(permissionSet({ ...POLICY })); + expect(set.success).toBe(true); + if (!set.success) return; + expect(set.data.rowLevelSecurity?.[0]).not.toHaveProperty('tags'); + expect(set.data.rowLevelSecurity?.[0]?.positions).toEqual(['compliance_reviewer']); + }); + + it('the did-you-mean never offers the tombstone: a near-miss `tag` is refused as unknown, not steered onto `tags`', () => { + // `strictObject` excludes a key that accepts nothing from its suggestion + // pool (`acceptsNothing`), so an author who typed `tag` is not told to + // write the retired key and meet a second refusal. + const r = RowLevelSecurityPolicySchema.safeParse({ ...POLICY, tag: 'gdpr' }); + expect(r.success).toBe(false); + if (r.success) return; + const issue = r.error.issues[0]!; + expect(issue.code).toBe('unrecognized_keys'); + expect(issue.message).toContain('`tag`'); + expect(issue.message).not.toMatch(/`tag` → `tags`/); + }); + + it('fails tsc at the authoring site: the input type of `tags` is `never`', () => { + const policy: RowLevelSecurityPolicy = { + ...POLICY, + positions: ['compliance_reviewer'], + // @ts-expect-error — `tags` is a retiredKey() tombstone: its input type is `never`. + tags: ['gdpr'], + }; + // The parse channel agrees with the type channel on the same literal. + expect(() => RowLevelSecurityPolicySchema.parse(policy)).toThrow(PRESCRIPTION); + }); +}); + +describe('rls tags retirement — the D2 conversion', () => { + it('a STORED permission row carrying the key replays clean through the rehydration seam', () => { + // The seam wraps a `permission` row as `{ permissions: [row] }` and + // replays the full chain, retired entries included. + const stored = permissionSet({ ...POLICY, tags: TAGS }); + const notices: { conversionId?: string; path?: string }[] = []; + const rehydrated = applyConversionsToStoredItem('permission', stored, { + onNotice: (n) => notices.push(n as { conversionId?: string; path?: string }), + }) as { rowLevelSecurity: Record[] }; + + expect(notices.map((n) => [n.conversionId, n.path])).toEqual([ + ['permission-rls-tags-removed', 'permissions[0].rowLevelSecurity[0].tags'], + ]); + expect(rehydrated.rowLevelSecurity[0]).not.toHaveProperty('tags'); + // CONTROL: every live key on the same policy survives byte-for-byte. + expect(rehydrated.rowLevelSecurity[0]).toEqual(POLICY); + // And the rehydrated row is exactly what the write door accepts now. + expect(PermissionSetSchema.safeParse(rehydrated).success).toBe(true); + }); + + it('strips only the policies that carry the key, and is idempotent by construction', () => { + const input = { + permissions: [ + permissionSet({ ...POLICY, tags: [] }), + // A set with no `rowLevelSecurity` at all, and one whose policy has no tags. + { name: 'reader', objects: {} }, + permissionSet({ ...POLICY, name: 'own_cases' }), + ], + }; + const { stack, notices } = collectConversionNotices(input, { includeRetired: true }); + expect(notices.filter((n) => n.conversionId === 'permission-rls-tags-removed').map((n) => n.path)).toEqual([ + 'permissions[0].rowLevelSecurity[0].tags', + ]); + const permissions = stack.permissions as Array<{ rowLevelSecurity?: unknown[] }>; + expect(permissions[0]!.rowLevelSecurity).toEqual([POLICY]); + // Copy-on-write: the untouched entries are handed back by reference. + expect(permissions[1]).toBe(input.permissions[1]); + expect(permissions[2]).toBe(input.permissions[2]); + + // Idempotence, measured: a second replay converts nothing and hands the + // input back by reference. + const replay = collectConversionNotices(stack, { includeRetired: true }); + expect(replay.notices).toHaveLength(0); + expect(replay.stack).toBe(stack); + }); + + it('is retired from the load path — a live author is refused at parse, never silently rewritten', () => { + const input = { permissions: [permissionSet({ ...POLICY, tags: TAGS })] }; + const { stack, notices } = collectConversionNotices(input); + expect(notices).toHaveLength(0); + expect(stack).toEqual(input); + }); +}); + +describe('rls tags retirement — ADR-0087 registration', () => { + it('declares the key under major 18, wires the D2 into the step-18 chain and carries the family D3 entry', () => { + expect(RETIRED_KEYS_BY_MAJOR[18]).toContain('security/RowLevelSecurityPolicy:tags'); + const step = MIGRATIONS_BY_MAJOR[18]!; + expect(step.conversionIds).toContain('permission-rls-tags-removed'); + const d3 = step.semantic.find((s) => s.id === 'permission-rls-tags-retired'); + expect(d3, 'the family D3 entry').toBeDefined(); + // The D3 entry names its D2 by its whole id. + expect(d3!.reason).toContain('`permission-rls-tags-removed`'); + expect(d3!.acceptanceCriteria.length).toBeGreaterThan(0); + }); +}); + +// ─── Tree-scoped absence, with a DECLARED radius ───────────────────────────── +// +// `tsc` is the primary sweeper — `retiredKey()` types the key `never` on +// `RowLevelSecurityPolicy`, so every typed authoring site fails to compile. The +// residue is what `tsc` never judges: JSON, YAML, MD/MDX code fences, untyped +// `.js`, and TS literals typed `unknown` (a test body handed to a write door). +// This walk covers that residue across five roots, each already declared for +// `@objectstack/spec#test` in `scripts/cross-package-test-inputs.mjs` and +// mirrored in `turbo.json` — the same roots and extensions the view-item +// `owner` / `hidden` pin walks. +// +// ⭐ `tags` is among the commonest key names in this tree (fields, widgets, +// manifests, OpenAPI routes, records), so a textual matcher would be all noise. +// The matcher is STRUCTURAL instead: an offender is one object literal (or one +// YAML mapping) whose OWN keys include `tags`, `operation` and a predicate +// clause (`using` or `check`) — the RLS policy spelling. A record carrying a +// `tags` column, a sharing rule (`condition`) and a route (`method`) are not +// matched. +// +// The bound, stated: a policy assembled by SPREAD (`{ ...policy, tags }`) or +// computed keys is invisible to a text walk; `docs/**`, `.claude/**`, +// `.github/**` and the repo-root files are outside the radius. +describe('tree-scoped absence: no RLS policy inside the declared radius still carries tags', () => { + const SPEC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + const REPO_ROOT = path.resolve(SPEC_ROOT, '../..'); + const THIS_FILE = path.relative(REPO_ROOT, fileURLToPath(import.meta.url)).split(path.sep).join('/'); + + /** The walked roots — declared in `scripts/cross-package-test-inputs.mjs` under `@objectstack/spec`. */ + const WALK_ROOTS = ['packages', 'examples', 'skills', 'content', 'scripts']; + const SCANNED_EXT = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx', '.yaml', '.yml']); + /** Under `examples/` only the non-code extensions are scanned AND declared. */ + const EXAMPLES_EXT = new Set(['.json', '.md', '.mdx', '.yaml', '.yml']); + const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); + + /** + * Structural exclusions — the retirement kit, each with its reason. ⛔ NOT an + * allowlist file (`spec-property-retirement` §4): every entry's JOB is to + * spell the retired key on a policy. + */ + const EXCLUDED = new Set([ + // The tombstone itself: the policy SHAPE declares `tags` beside + // `operation` / `using` / `check` (schema source, not an authoring). + 'packages/spec/src/security/rls.zod.ts', + // The strict-door refusal pin authors the retired key on purpose. + 'packages/spec/src/security/rls.test.ts', + // This pin names the key to assert its absence. + THIS_FILE, + ]); + const EXCLUDED_PREFIXES = [ + // The D2 conversion's fixture authors the pre-retirement policy on purpose. + 'packages/spec/src/conversions/', + // GITIGNORED build output reached only because this is a FILESYSTEM walk: + // `retiredKey()` emits the tombstone into the generated JSON Schema's + // `properties` beside `operation` and `using`. Its source, `rls.zod.ts`, + // is excluded above for the same reason. + 'packages/spec/json-schema/', + // Release-owned prose records the removal; never edited by a code PR. + 'content/docs/releases/', + // The liveness ledgers key one row per schema PROPERTY, so the RLS block of + // `permission.json` carries `operation`, `using`, `check` and the retired + // `tags` row side by side — a classification of the shape, not an + // authoring (measured: the one hit before this exclusion, and the row the + // tombstone route requires to STAY). + 'packages/spec/liveness/', + ]; + /** tsup's own bundle of `tsup.config.ts`, written and deleted mid-build. */ + const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/; + + const isOffendingKeySet = (keys: Set): boolean => + keys.has('tags') && keys.has('operation') && (keys.has('using') || keys.has('check')); + + /** + * One pass over JS/TS/JSON text: a stack of bracket frames, each `{` frame + * collecting its OWN keys — an identifier or quoted string in key position + * (after `{` or `,`) followed by `:`. Strings and comments are skipped; a + * single- or double-quoted string never spans a line, so a mis-lexed quote + * (a regex literal) costs at most that line. Returns the 1-based line of each + * closing brace whose frame is an offender. (Copied from the view-item + * `owner` / `hidden` pin, never imported: a shared helper would be one point + * of failure for every retirement's absence leg at once.) + */ + const lexOffenders = (text: string): number[] => { + const out: number[] = []; + const stack: { kind: string; keys: Set }[] = []; + let lastSig = ''; + let line = 1; + let i = 0; + const n = text.length; + while (i < n) { + const c = text[i]!; + if (c === '\n') { line += 1; i += 1; continue; } + if (c === '/' && text[i + 1] === '/') { while (i < n && text[i] !== '\n') i += 1; continue; } + if (c === '/' && text[i + 1] === '*') { + i += 2; + while (i < n && !(text[i] === '*' && text[i + 1] === '/')) { if (text[i] === '\n') line += 1; i += 1; } + i += 2; + continue; + } + if (c === '"' || c === "'" || c === '`') { + const start = i; + i += 1; + while (i < n && text[i] !== c) { + if (text[i] === '\\') i += 1; + else if (text[i] === '\n') { if (c !== '`') break; line += 1; } + i += 1; + } + const token = text.slice(start + 1, i); + i += 1; + let j = i; + while (j < n && (text[j] === ' ' || text[j] === '\t')) j += 1; + const top = stack[stack.length - 1]; + if (c !== '`' && text[j] === ':' && top?.kind === '{' && (lastSig === '{' || lastSig === ',')) top.keys.add(token); + lastSig = 'str'; + continue; + } + if (/[A-Za-z_$]/.test(c)) { + const start = i; + while (i < n && /[\w$]/.test(text[i]!)) i += 1; + const token = text.slice(start, i); + let j = i; + while (j < n && (text[j] === ' ' || text[j] === '\t')) j += 1; + const top = stack[stack.length - 1]; + if (text[j] === ':' && top?.kind === '{' && (lastSig === '{' || lastSig === ',')) top.keys.add(token); + lastSig = 'id'; + continue; + } + if (c === '{' || c === '[' || c === '(') stack.push({ kind: c, keys: new Set() }); + else if (c === '}' || c === ']' || c === ')') { + const frame = stack.pop(); + if (frame?.kind === '{' && c === '}' && isOffendingKeySet(frame.keys)) out.push(line); + } + if (!/\s/.test(c)) lastSig = c; + i += 1; + } + return out; + }; + + /** + * YAML: a mapping's OWN keys are the key lines at one column, bounded by a + * line at a smaller column or by a new list item at that column. Returns the + * 1-based line of each `tags` key whose mapping is an offender. + */ + const yamlOffenders = (text: string): number[] => { + const rows = text.split('\n').map((raw, idx) => { + const m = /^(\s*)(-\s+)?([A-Za-z_][\w]*)\s*:/.exec(raw); + return m ? { idx, col: m[1]!.length + (m[2]?.length ?? 0), key: m[3]!, item: Boolean(m[2]) } : null; + }); + const out: number[] = []; + rows.forEach((row, at) => { + if (!row || row.key !== 'tags') return; + const keys = new Set([row.key]); + // Backward — only when `tags` is not itself the item's first key: the + // same mapping runs up to (and includes) the line that opened the item. + if (!row.item) { + for (let k = at - 1; k >= 0; k -= 1) { + const r = rows[k]; + if (!r) continue; + if (r.col < row.col) break; + if (r.col === row.col) { keys.add(r.key); if (r.item) break; } + } + } + // Forward — until a shallower line, or the next item at this column. + for (let k = at + 1; k < rows.length; k += 1) { + const r = rows[k]; + if (!r) continue; + if (r.col < row.col || (r.col === row.col && r.item)) break; + if (r.col === row.col) keys.add(r.key); + } + if (isOffendingKeySet(keys)) out.push(row.idx + 1); + }); + return out; + }; + + /** MD/MDX: only fenced code is judged — prose mentions are not authorings. */ + const markdownOffenders = (text: string): number[] => { + const out: number[] = []; + const fence = /^```([\w-]*)[^\n]*\n([\s\S]*?)^```/gm; + for (let m = fence.exec(text); m; m = fence.exec(text)) { + const lang = m[1]!.toLowerCase(); + const body = m[2]!; + const offset = text.slice(0, m.index).split('\n').length; + const hits = lang === 'yaml' || lang === 'yml' ? yamlOffenders(body) : lexOffenders(body); + for (const h of hits) out.push(offset + h); + } + return out; + }; + + /** Cheap pre-filter: a file that never spells both halves cannot hold an offender. */ + const mayHoldPolicy = (text: string): boolean => + text.includes('tags') && text.includes('operation') && (text.includes('using') || text.includes('check')); + + const offendersIn = (ext: string, text: string): number[] => { + if (!mayHoldPolicy(text)) return []; + if (ext === '.yaml' || ext === '.yml') return yamlOffenders(text); + if (ext === '.md' || ext === '.mdx') return markdownOffenders(text); + return lexOffenders(text); + }; + + const vanished: string[] = []; + /** Tolerates ONLY a path's disappearance mid-walk; every other fault is re-raised. */ + const readIfPresent = (full: string, rel: string): string | undefined => { + try { + return fs.readFileSync(full, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err; + vanished.push(rel); + return undefined; + } + }; + + it('the matcher finds a policy authoring and ignores every neighbouring shape (anti-vacuity)', () => { + // Offenders — the policy spelling, in each syntax the walk reads. + expect(offendersIn('.ts', "rowLevelSecurity: [{ name: 'p', object: 'a', operation: 'select', using: 'x == 1', tags: ['gdpr'] }]")).toEqual([1]); + expect(offendersIn('.ts', "const p = {\n name: 'p',\n operation: 'insert',\n check: 'x == 1',\n tags: [],\n};")).toEqual([6]); + expect(offendersIn('.json', '{ "rowLevelSecurity": [ { "operation": "all", "using": "true", "tags": ["pci"] } ] }')).toEqual([1]); + expect(offendersIn('.yaml', 'rowLevelSecurity:\n - name: p\n operation: select\n using: x == 1\n tags: [gdpr]\n')).toEqual([5]); + expect(offendersIn('.yaml', '- tags: [gdpr]\n operation: select\n check: x == 1\n')).toEqual([1]); + expect(offendersIn('.md', "Prose.\n\n```ts\n({ operation: 'select', using: 'x == 1', tags: ['a'] });\n```\n")).toEqual([4]); + expect(offendersIn('.md', 'Prose.\n\n```yaml\noperation: select\nusing: x == 1\ntags: [a]\n```\n')).toEqual([6]); + // Neighbours that must NOT match. + // A policy without the key; the key on a NESTED object inside the policy. + expect(offendersIn('.ts', "({ operation: 'select', using: 'x == 1', meta: { tags: ['a'] } })")).toEqual([]); + // A record carrying a `tags` column, and a route carrying OpenAPI `tags`. + expect(offendersIn('.ts', "insert({ name: 'a', tags: ['x'], operation: 'create' })")).toEqual([]); + expect(offendersIn('.ts', "route({ method: 'GET', metadata: { summary: 's', tags: ['meta'] }, operation: 'list' })")).toEqual([]); + // A predicate that READS a record's `tags` is not an authoring of the key. + expect(offendersIn('.ts', "({ operation: 'select', using: 'size(record.tags) > 0' })")).toEqual([]); + // Prose and quoted strings are not authorings. + expect(offendersIn('.md', 'A policy once took `operation`, `using` and `tags: [gdpr]`.')).toEqual([]); + expect(offendersIn('.ts', "const s = \"{ operation: 'select', using: 'x', tags: [] }\";")).toEqual([]); + // A YAML mapping whose `tags` belongs to a sibling item. + expect(offendersIn('.yaml', '- operation: select\n using: x == 1\n- name: y\n tags: [a]\n')).toEqual([]); + }); + + it('a path that VANISHES mid-walk is not a finding, and every other read fault still is', () => { + const before = vanished.length; + const gone = path.join(REPO_ROOT, 'packages/spec/does-not-exist.bundled_probe.mjs'); + expect(fs.existsSync(gone)).toBe(false); + expect(readIfPresent(gone, 'probe/gone')).toBeUndefined(); + expect(vanished.slice(before)).toEqual(['probe/gone']); + expect(readIfPresent(fileURLToPath(import.meta.url), THIS_FILE)).toContain('tree-scoped absence'); + expect(() => readIfPresent(path.join(REPO_ROOT, 'packages/spec'), 'probe/dir')).toThrow(); + expect(vanished.length).toBe(before + 1); + }); + + it('no RLS policy carrying tags survives inside the declared radius', () => { + const offenders: string[] = []; + let visited = 0; + let policyBearing = 0; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); + if (entry.isDirectory()) { + if (SKIPPED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue; + walk(full); + continue; + } + if (!entry.isFile()) continue; + const ext = path.extname(entry.name); + if (!(rel.startsWith('examples/') ? EXAMPLES_EXT : SCANNED_EXT).has(ext)) continue; + if (entry.name === 'CHANGELOG.md') continue; // release prose records the removal + if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; + if (TSUP_BUNDLED_CONFIG.test(entry.name)) continue; + visited += 1; + const text = readIfPresent(full, rel); + if (text === undefined) continue; + if (text.includes('rowLevelSecurity')) policyBearing += 1; + for (const lineNo of offendersIn(ext, text)) offenders.push(`${rel}:${lineNo}`); + } + }; + for (const root of WALK_ROOTS) walk(path.join(REPO_ROOT, root)); + // Anti-vacuity: the walk covered the tree, and the files that CAN hold a + // policy were really read. + expect(visited).toBeGreaterThan(1000); + expect(policyBearing).toBeGreaterThan(20); + expect(offenders, 'an RLS policy carrying `tags` means the retirement is being undone').toEqual([]); + }); +}); diff --git a/packages/spec/src/security/rls.test.ts b/packages/spec/src/security/rls.test.ts index ac8be4ad73b..48ae9650de0 100644 --- a/packages/spec/src/security/rls.test.ts +++ b/packages/spec/src/security/rls.test.ts @@ -39,6 +39,7 @@ describe('Row-Level Security (RLS) Protocol', () => { expect(result.name).toBe('tenant_isolation'); expect(result.enabled).toBe(true); // default expect('priority' in result, 'retired key contributes nothing to the parsed output').toBe(false); + expect('tags' in result, 'retired key contributes nothing to the parsed output').toBe(false); }); it('should validate a complete policy with all fields', () => { @@ -55,7 +56,6 @@ describe('Row-Level Security (RLS) Protocol', () => { check: 'assigned_to_id IN (SELECT id FROM users WHERE manager_id = current_user.id)', positions: ['manager', 'director'], enabled: true, - tags: ['team_access', 'hierarchy'], }; const result = RowLevelSecurityPolicySchema.parse(policy); @@ -167,17 +167,24 @@ describe('Row-Level Security (RLS) Protocol', () => { expect(result.positions).toEqual(['sales_rep', 'sales_manager']); }); - it('should validate tags', () => { - const policy = { + it('refuses the retired `tags` at its path, with the prescription (#20321)', () => { + // This case used to pin `tags` round-tripping; that branch is retired + // (ADR-0049 enforce-or-remove — nothing ever read a policy's tags). The + // full pin set, door by door, is `rls-tags-retirement.test.ts`. + const r = RowLevelSecurityPolicySchema.safeParse({ name: 'gdpr_policy', object: 'customer', operation: 'select', using: 'country IN (SELECT country FROM gdpr_countries)', tags: ['compliance', 'gdpr', 'privacy'], - }; - - const result = RowLevelSecurityPolicySchema.parse(policy); - expect(result.tags).toEqual(['compliance', 'gdpr', 'privacy']); + }); + expect(r.success).toBe(false); + if (r.success) return; + const issue = r.error.issues.find((i) => i.path[0] === 'tags'); + expect(issue, 'the refusal must name `tags`').toBeDefined(); + expect(issue!.code).toBe('invalid_type'); + expect(issue!.path).toEqual(['tags']); + expect(issue!.message).toMatch(/^`rowLevelSecurity\[\]\.tags` was removed in @objectstack\/spec 17\.5\.0 \(ADR-0049.*Delete the key\..*`positions`.*`os migrate meta --from 17`/s); }); }); @@ -370,7 +377,6 @@ describe('Row-Level Security (RLS) Protocol', () => { using: 'organization_id == current_user.organization_id', check: 'organization_id == current_user.organization_id', enabled: true, - tags: ['multi-tenant', 'security'], }; const result = RowLevelSecurityPolicySchema.parse(policy); @@ -433,11 +439,15 @@ describe('Row-Level Security (RLS) Protocol', () => { operation: 'select', using: 'country IN (SELECT country FROM user_allowed_countries WHERE user_id = current_user.id)', enabled: true, - tags: ['gdpr', 'compliance', 'privacy'], }; + // The compliance purpose is carried by the policy itself — its name, its + // description and its predicate — never by a tag (`tags` is retired, + // #20321; the refusal is pinned in the RowLevelSecurityPolicySchema block). const result = RowLevelSecurityPolicySchema.parse(policy); - expect(result.tags).toContain('gdpr'); + expect(result.description).toContain('allowed regions'); + expect(result.using).toContain('user_allowed_countries'); + expect('tags' in result).toBe(false); }); it('should support shared team records', () => { diff --git a/packages/spec/src/security/rls.zod.ts b/packages/spec/src/security/rls.zod.ts index b61e58636d4..febec4e618c 100644 --- a/packages/spec/src/security/rls.zod.ts +++ b/packages/spec/src/security/rls.zod.ts @@ -118,6 +118,24 @@ export const RLSOperation = z.enum(['select', 'insert', 'update', 'delete', 'all export type RLSOperation = z.input; +/** + * The upgrade prescription for the retired `rowLevelSecurity[].tags` (#20321). + * Declared above the schema that reads it: under `OS_EAGER_SCHEMAS=1` every + * `lazySchema` factory runs at module init in file order, and a `const` below + * its first eager reader is a TDZ error. + * + * ⚠️ The version sentence names the npm release this ships in, never the + * protocol major the migration entries are numbered at (ADR-0087, the level + * half of its 2026-09-13 amendment). + */ +const RLS_POLICY_TAGS_RETIRED = + '`rowLevelSecurity[].tags` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) ' + + '— nothing ever read it: the RLS compiler never consulted a policy\'s tags and nothing else acted ' + + 'on them, so a tag scoped, restricted and reported nothing. Delete the key. A tag never limited ' + + 'whom a policy applies to; to do that, list the positions in `positions`. A policy is identified ' + + 'by its `name` and its `object`; say why it exists in `description`. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.'; + /** * Row-Level Security Policy Schema * @@ -222,8 +240,8 @@ export const RowLevelSecurityPolicySchema = lazySchema(() => strictObject( { surface: 'this RLS policy', // The suggestion pool is `Object.keys(shape)` minus anything that accepts - // nothing (#5593). `priority` is exactly that case and the exclusion is - // deliberate: it is a {@link retiredKey} tombstone, declared so its + // nothing (#5593). `priority` and `tags` are exactly that case and the + // exclusion is deliberate: each is a {@link retiredKey} tombstone, declared so its // rejection carries the upgrade prescription, never offered as a rename // target. The hand-transcribed list this replaced had to state the same // exclusion in prose and be trusted to keep it. @@ -534,15 +552,24 @@ export const RowLevelSecurityPolicySchema = lazySchema(() => strictObject( ), /** - * Tags for policy categorization and reporting. - * Useful for governance, compliance, and auditing. - * - * @example ["compliance", "gdpr", "pci"] - * @example ["multi-tenant", "security"] + * REMOVED — `tags` promised "categorization and reporting" for governance and + * compliance, and nothing ever read it (#20321, ADR-0049 enforce-or-remove). + * + * The census before removal found no reader in this repo, in objectui (the + * permission preview renders the policy COUNT, never a policy's fields; the + * policy editor neither seeds nor reads the key) or in cloud, and no writer + * in the examples, the default permission sets or cloud. The capability was + * judged by the maintainer's criterion for this family — does a mainstream + * platform have it? — and none does: Salesforce sharing rules, Dataverse + * security roles and PostgreSQL RLS policies carry no tag attribute, and + * compliance reporting there keys on the rule itself. On a SECURITY policy an + * inert free-form label is worse than dead: an author (very often an AI, + * ADR-0033) may write `tags: ['managers_only']` believing it scopes the + * policy, when only `positions` does. Tombstoned, like `priority` above, so + * the removal is audible (tsc `never` + the parse-time prescription) instead + * of an unknown-key verdict with no upgrade text. */ - tags: z.array(z.string()) - .optional() - .describe('Policy categorization tags'), + tags: retiredKey(RLS_POLICY_TAGS_RETIRED), }).superRefine((data, ctx) => { // Ensure at least one of USING or CHECK is provided if (!data.using && !data.check) { diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index 58ad0319534..7dc672c2b4d 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -27,6 +27,7 @@ "src/data/api-methods-batch-conformance.test.ts", "src/identity/position-delegatable-enforcer.pin.test.ts", "src/integration/connector-connection-timeout-retirement.test.ts", + "src/security/rls-tags-retirement.test.ts", "src/shared/retired-key-migrate-sentence.test.ts", "src/system/compliance-families-retirement.test.ts", "src/system/constants/platform-object-names.test.ts",