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
68 changes: 68 additions & 0 deletions .changeset/20321-rls-policy-tags-retired.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- adr-0087: registered permission-rls-tags-removed, permission-rls-tags-retired -->
2 changes: 1 addition & 1 deletion content/docs/references/security/permission.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/security/rls.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. |


---
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/authorable-surface/security.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
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 @@ -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 |
Expand Down
6 changes: 3 additions & 3 deletions packages/spec/liveness/permission.json
Original file line number Diff line number Diff line change
Expand Up @@ -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`."
}
}
}
Expand Down
96 changes: 96 additions & 0 deletions packages/spec/src/conversions/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<number, readonly MetadataConversion[]>> = {
11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename],
13: [stackRolesToPositions, owdLegacyReadAliases, sharingRecipientRoleToPosition],
Expand Down Expand Up @@ -11728,6 +11823,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly<Record<number, readonly MetadataConv
viewOverlayOwnerHiddenRemoved,
formLayoutInlineGridToVertical,
currencyConfigPrecisionRemoved,
permissionRlsTagsRemoved,
],
};

Expand Down
Loading
Loading