Skip to content

Commit 07ee6f6

Browse files
fix(lint): one-line verdicts for the RLS-predicate and sharing-rule enforceability rules; os explain RULE_ID carries their reasoning (#22670)
Part of #22161 Clause-②: no Stage 2 of the card, slice 4: the 9 rule ids of the RLS-predicate and sharing-rule enforceability rules in `packages/lint`, two whole source files. The card stays open for the later slices listed under "Remaining" below. ## What changes - **One verdict line per finding.** Each finding of the 9 ids prints a `message` of one verdict sentence. Every finding the rules' own suites fire is now 194 characters or fewer, the CLI's RLS admission suite 195 or fewer, and the runtime publish gate's suite 140 (the longest of each id was 492 to 2,056 before). - **The verdict no longer echoes the predicate or the condition.** The finding's `path` already names the clause (`permissions[i].rowLevelSecurity[j].using` / `.check`, `sharingRules[i].condition`), and the predicate is author text of any length: the old message quoted up to 200 characters of an RLS predicate, and all of an unparseable one or a sharing condition. Every verdict still names the offending part: the compiler's, the shared filter check's or the engine's own refusal, the comparison and its columns or classes, the field (with the platform's "did you mean"), the `current_user` key, the anchor object and its `sharingModel`. - **A quoted refusal is quoted to its verdict.** The compiler's `detail` is quoted up to the first ` — ` (where its own suggested rewrite begins: `refusalVerdict()`); the shared filter check's and the engine's message to the first sentence (`firstSentence()`, already used for the shared check), then to the first ` — `. An engine verdict reads `RLS using (CODE / STATUS): ENGINE_SENTENCE`, so the runtime publish gate's existing pin that the message carries the engine's sentence after its code and status still holds unchanged. - **The reasoning moves to `RULE_EXPLANATIONS`** (`packages/lint/src/rule-explanations.ts`), 9 new entries, so `os explain RULE_ID` prints it and the CLI's `rule:` line ends with the pointer for these ids. No CLI source changes: `explainPointer()` and `os explain` resolve any key the table holds (`packages/cli/test/explain-rule-id.test.ts` iterates every key; run below). - **Nothing else moves.** Rule ids, severities (all 9 are `error`), `path`, `hint` (the `fix:` line) and what each rule accepts or refuses are unchanged. No condition, branch, dedupe key or skip moved; every hunk in the two rule files is a `message` expression, a comment, an import, or a shared verdict helper. Two internal (non-barrel) helper shapes changed with the text they fed: `ListHoldingComparison.columns` (declaration sentences) became `holders` (column names), and `CrossClassComparison.columns` became `why` (`text vs a number`); `listHoldingDeclaration()` became the boolean `holdsListOrObject()` with the same truth table, and it still gates the cross-class arm exactly as before. - `.changeset/22161-lint-slice-4-one-line.md`: `@objectstack/lint` `patch`, naming every door that prints the new text (below). `Clause-②: no` follows the contract review on slice 1 (its section ②): `RULE_EXPLANATIONS` entries are data in an existing export. - **Rider, text-only:** `packages/cli/test/rls-policy-authoring-admission.test.ts`, three message fragments it pins (the claim's declared rider; the `domain:cli` seat reviews it after the fact, as for slice 1). The verdict forms, one each, as the suites fired them (census below): ```text RLS using is not lowerable (`==` compares one value, but its comparand is a list literal), so the policy is dropped and grants no access RLS using does not parse as CEL, even after the legacy SQL bridge (Unexpected character: 'D'), so the policy is dropped and grants no access RLS using is valid CEL but overruns the parse bound maxListElements (platform limit 64) (Exceeded maxListElements (64)), so the policy is dropped and grants no access RLS using: `current_user.org_user_ids` is a membership set, a list on every request, used where one value belongs, so the policy is dropped and grants no access RLS using lowers to a refused comparand (Operator "$in" on field "status" does not accept null as a list member (at using.$not.status.$in[1])), so the policy is dropped and grants no access RLS using compares `record.status != record.tags`, where `tags` holds a list or an object (and 1 more), so the SQL drivers refuse every read it scopes (INVALID_FILTER / 400) RLS check compares `record.status != record.amount`, which no comparison class spans (text vs a number), so the write check refuses the writes it judges (INVALID_FILTER / 400) RLS using (INVALID_FILTER / 400): find('deal'): filter on 'close_date' compares a declared date field against "soon" at where.close_date.$gt, which is not a date value this platform can interpret RLS using predicate field "is_private_nope" is not a field on object "crm_opportunity". Did you mean "is_private"? The policy is dropped and grants no access. RLS using reads `current_user.nope`, which nothing pre-resolves, in a scalar position no request can ever fill, so the policy is dropped and grants no access condition is not lowerable (unsupported operator "call"), so the rule is never seeded and grants nothing condition compares `record.status != record.amount`, which no comparison class spans (text vs a number), so its criteria query is refused (INVALID_FILTER / 400) and it grants nothing condition reads a runtime variable (variable "current_user.id" is not resolvable), so the rule is never seeded and grants nothing anchor object "sys_audit_entry" declares no sharingModel and is a system object, which resolves to public, so the grant is refused (SHARING_NOT_ENABLED) and the rule restricts nothing anchor object "crm_opportunity" declares sharingModel 'controlled_by_parent', so its access is derived from its master and the grant is refused (SHARING_NOT_ENABLED) ``` ## Shared prose: written once (the dispatch's mechanism assumption 2) It holds: the two files shared four families of sentences, and the RLS file typed its drop consequence per arm. Each family now lives in one place for the verdict and one place for the explanation, and every id that uses it references it: | shared sentence | ids | verdict clause (rule files) | explanation (`rule-explanations.ts`) | |---|---|---|---| | what a dropped RLS policy does, per clause (was `consequence()` with `DROPPED_UNCOMPILABLE` / `DROPPED_EVERY_REQUEST` / `DROPPED_FACE_REFUSED`, `USING_INSERT_CONSEQUENCE`, `CHECK_SET_QUALIFIER`, and `referenceConsequence()`'s field and variable halves, which restated the same drop) | `rls-predicate-unparseable`, `-over-budget`, `-unenforceable` (shape, type and shared-check arms), `-unknown-field`, `-unknown-user-variable` | `droppedClause()` / `droppedSentence()` over `DROPPED_OUTCOME` ("so the policy is dropped and grants no access" / "… admits no write") | `RLS_UNCOMPILABLE_DROP`, `RLS_DROPPED_USING`, `RLS_DROPPED_CHECK` | | a field compared with a field that holds a list or an object (the class sentence typed in both files, plus the per-surface consequences) | `rls-predicate-unenforceable`, `sharing-rule-unlowerable-condition` | `listHoldingVerdict()` (exported, both rules), closed by `REFUSED_COMPARISON` (RLS) or `CRITERIA_QUERY_REFUSED` (sharing) | `LIST_HOLDING_COMPARISON` | | a comparison across comparison classes (was the exported `CROSS_CLASS_SENTENCE` plus `describeCrossClassComparisons()`, both rules) | the same two | `crossClassVerdict()` (exported, both rules), same closers | `CROSS_CLASS_COMPARISON` | | the sharing seeder's skip (was `skipped`, two ids) | `sharing-rule-unlowerable-condition`, `-runtime-variable-condition` | `NEVER_SEEDED` | `SHARING_RULE_SKIPPED` | | the inert-grant refusal (`assertNotInertGrant`, typed in both anchor messages) | `sharing-rule-object-not-shareable`, `-object-controlled-by-parent` | each verdict names `SHARING_NOT_ENABLED` | `SHARING_INERT_GRANT` | `CROSS_CLASS_SENTENCE`, `describeCrossClassComparisons()`, `quote()`, `ENGINE_REFUSED_CONSEQUENCE`, `HOLDING_SENTENCE` and `sourceOf()` had no caller left and are removed; none is on the package barrel (`index.ts`), and the package's `exports` map has no subpath that reaches them. `CROSS_CLASS_REMEDY` (a hint) stays. The measurement notes in the removed docblocks moved to the explanation constants' docblocks. ## Census (taken first, before any edit, at the base `1b99388505`) Method: a scratch preload (`NODE_OPTIONS=--import`, never committed) patched `Array.prototype.push` to record every finding-shaped object (`rule` + `message`) of the 9 ids, deduped by (rule, message), with its push site, in every vitest worker. Lengths are `message` alone; the printed line adds `where` and `: `. Rows from `os lint`'s own printer (`commands/lint.ts`) are excluded, as in slices 2 and 3. - **`packages/lint` suite** at the base: 134 files, 6,117 passed and 5 skipped. All 9 ids fired, every one over 200, from 16 push sites (the variants). - **CLI, scoped (declared narrowing):** `test/rls-policy-authoring-admission.test.ts`, against the base's lint dist after the CLI closure build: 39 passed. It fires two ids. Scope: the only `packages/cli` test whose fixtures carry `rowLevelSecurity` or `sharingRules` for the authoring rules (grep: the other two hits are a fixture probe set for `os verify` and a `migrate-meta` e2e that asserts migrated keys); all 9 ids are `error`, so a CLI test over an example app that fired one would fail its own `os validate`. Slice 3 recorded that a later slice could scope its cli census this way. - **Runtime publish gate:** `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts` (a consumer that pins this rule's text): 133 passed, fires two ids. - **No id dropped:** every id is over 200 on some variant. | rule id | severity | lint suite before: messages · longest | lint suite after: messages · range | cli admission suite before → after | runtime-gate suite before → after | |---|---|---|---|---|---| | `rls-predicate-unparseable` | error | 5 · 2,056 | 4 · 135–146 | — | 1,068 → 140 | | `rls-predicate-unenforceable` | error | 369 · 1,679 | 312 · 41–194 | 1,616 → 195 | 477 → 140 | | `rls-predicate-unknown-user-variable` | error | 7 · 1,544 | 6 · 157–167 | 1,519 → 157 | — | | `rls-predicate-unknown-field` | error | 34 · 1,399 | 21 · 109–158 | — | — | | `rls-predicate-over-budget` | error | 4 · 1,259 | 4 · 152–166 | — | — | | `sharing-rule-unlowerable-condition` | error | 92 · 1,093 | 60 · 100–192 | — | — | | `sharing-rule-object-not-shareable` | error | 3 · 774 | 3 · 140–183 | — | — | | `sharing-rule-object-controlled-by-parent` | error | 1 · 660 | 1 · 165 | — | — | | `sharing-rule-runtime-variable-condition` | error | 3 · 492 | 1 · 129 | — | — | The message counts fall because messages that differed only in the echoed predicate or condition are now one message. No variant was lost: all 16 push sites fire before and after (per site, before → after: the shape arm 21 → 13 messages, the type-fault arm 23 → 10, the shared-check arm 14 → 14, the list-holding arm 76 → 53, the cross-class arm 229 → 216, the engine arm 6 → 6, and so on for each id). The 41 is a hint-by-code case whose stub engine answers "refused.". ## Doors that print the new text Read from the registry (`authoring-rules.ts`: both entries are `commands: ALL`; the RLS entry is `CLI_AND_RUNTIME` with `runtimeTypes: ['permission']`, the sharing entry `CLI_ONLY`) and the runtime gate (`lint/src/runtime-gate.ts`, `metadata-protocol/src/runtime-authoring-gate.ts`): | door | ids | what changes | |---|---|---| | `os validate`, `os build` (and `os compile`, which `os dev` runs per compile), `os lint`, `os verify`, `os init` scaffold check | all 9 | the text-face verdict line; the `rule:` line gains the `os explain` pointer; `os validate --json` `errors` and `os build --json` author-time `issues` carry the new `message` | | runtime publish gate, `permission` writes (Studio, REST `/meta`, MCP) | the 5 `rls-predicate-*` ids, all errors | the 422 issue `message` and the `OS_ALLOW_UNLINTED_METADATA_WRITES` refusal log line | | never at the runtime gate | the 4 `sharing-rule-*` ids | the rule is registered for the CLI doors only (its registry entry records the publish-gate crossing as pending) | | unchanged | every `hint`; every other rule id | | Every row is named in the changeset. ## Tests The rule suites import the rule source; the CLI and runtime-gate suites read the rebuilt `@objectstack/lint` dist (marker: `which no comparison class spans` 1 hit each in `dist/index.js` and `dist/index.cjs`, the old `lowers, but compares` 0; `rls-predicate-unknown-user-variable` 1 hit each in `dist/rule-explanations.js` and `.cjs`). - **Each rule's own suites pin the new shape**, as in slices 2 and 3. All seven test files of the two rules wrap their rule import and record every finding their cases fire. A final case in each holds every recorded verdict to one line of at most 200 characters, behind a coverage control (the two main files: each of their ids fired; the five arm files: their arm's verdict fired). A second block in each main file pins, per id, that `explainRule(id)` exists and still names what the verdict stopped saying, and the RLS file holds the explanation's two written-out `current_user` key lists to `RESERVED_RLS_MEMBERSHIP_KEYS` and to its own per-key runtime-type table (the explanation module imports nothing). The measured-consequence pins (ZERO rows, `PermissionDeniedError`, the `PERMISSION_DENIED` / 403 read leg, the older-runtime fail-open sentence, the boot-skip, `buildReadFilter` returning `null`, "share the master record instead") moved from the message to the explanation. The "naming the declaration" cases now name the column. Every refusal assertion, rule id, severity, `path`, `where` and hint pin is unchanged. - **Lint suite:** `pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2` gave Test Files 134 passed (134), Tests 6,146 passed (6,146); lock VERDICT command-exit 0 (the 24 new cases; the 5 tests skipped at the base ran after the CLI closure build put the `dist/` they wait for in place). - **Lint typecheck:** `pnpm --filter @objectstack/lint run typecheck` gave VERDICT command-exit 0; `check:test-typecheck` OK, 2 files, 6 errors and 2 pinned signatures held. - **CLI**, against the rebuilt lint dist: `pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 test/rls-policy-authoring-admission.test.ts test/explain-rule-id.test.ts test/commands.test.ts test/validate-build-gate-parity.test.ts` gave Test Files 4 passed (4), Tests 127 passed (127). The admission file builds a real `ObjectQL` host, so it is integration-tier; it ran locally because the diff touches it. `explain-rule-id.test.ts` iterates every `RULE_EXPLANATIONS` key, so all 9 new ids resolve through `os explain` and `explainPointer`. `node bin/run-dev.js explain sharing-rule-object-controlled-by-parent` prints the entry. - **Runtime gate:** `pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2 src/protocol.runtime-authoring-gate.test.ts` gave 1 file, 133 passed, unchanged: its pin on the engine's sentence after `(CODE / STATUS): ` holds. - **Spec `repo` project (the dispatch's mechanism assumption 3):** `pnpm --filter @objectstack/spec exec vitest run --project repo --maxWorkers=2` gave Test Files 54 passed (54), Tests 915 passed (915); lock VERDICT command-exit 0, at `daf3fabf0d`. Two of its tests walk `packages/lint/src`: `src/shared/retired-key-migrate-sentence.test.ts` (the `os migrate meta` sentence scanner; the new text carries no such sentence, grep 0) and `src/identity/position-delegatable-enforcer.pin.test.ts` (exported `security-*` rule id constants; this slice declares none). No retired key is named. - **Tracker ids (mechanism assumption 4):** no `#` plus digits in any printed message or explanation (`rule-explanations.test.ts` refuses one in every paragraph; the RLS suite pins the unknown-field message, hint and explanation free of one); the citations stay in `//` comments. - **Ablation** (one-shot, from the committed state `daf3fabf0d`, through `scripts/ablation-replace.mjs` wrap mode under the verify lock, plus a shell `trap` restoring `HEAD`). The tests import the rule source, so there is no dist leg. `sharing-rule-object-controlled-by-parent`'s pre-slice message was restored verbatim through the anchor of its new message: anchor x1 to x0, blob `f3c366fe7d8a` to `689144b7d98a`. `src/validate-sharing-rule-enforceability.test.ts` then gave Test Files 1 failed (1), Tests 1 failed | 55 passed (56). The one red case was the bound pin, "every verdict the cases above fired is one line of at most 200 characters" (the restored verdict, 660 characters). Restored: blob after restore `f3c366fe7d8a` == blob at HEAD, `git diff HEAD` empty, `git status --porcelain` empty. A first attempt never acquired the lock (exit 99, queue timeout) and mutated nothing (blob unchanged). - **ESLint, narrowed:** `npx eslint --no-inline-config --format json` over the 11 changed `.ts` files gave 11 files in the report, 0 errors, 0 warnings. `eslint.config.mjs` never enables type-aware linting, so no untouched file's verdict can move. A control-character scan of every changed file found no match. - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` with no paths, on the final head `daf3fabf0d` (merge base `1b99388505`), derived 63 commands. I ran 62 of them sequentially, each with its own log and its exit code captured before any pipe: all 62 exited 0. The 63rd, `pnpm check:dual-build-cjs-loads`, is NOT MEASURED, reason: the dispatch forbids the whole-workspace build it needs. `--ran` gave `Run reconciliation — 63 derived, 62 run, 0 NOT-MEASURED, 1 UNRUN` (the one above). I also ran the 52-family "Artifact rosters" block the same run prints, 49 of them at `daf3fabf0d` (all exited 0; 14 of those are checker-health `--self-test` rows, which grade a checker and not this diff, and four keep their roster in a directory this diff touches: `check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing` and `check:filter-alias-parity`, all green). The three PR-context guards (`check-closing-target-claim`, `check-partof-closing-keyword`, `check-single-claim-paths`) need this PR's number and body, so they run against this PR after it opens; their readings are in the report on the card. The long ones on the shared box: `check:type-check-debt` 357s, `check:query-options-erasure` 206s. ## Remaining for later slices — 71 ids in `packages/lint`, by file Slice 3's list (PR #22548) minus this slice's two files and 9 ids. The lengths are slice 2's (PR #22448, census at `05c7c3fa3b`; "cli" marks a length only the cli suite reached), not re-measured here. - `data-model-rules.ts` (6): `unique/legacy-organization-composite` 480 cli, `relationship/master-detail-required` 462, `unique/unscoped-declared-index` 427, `unique/double-declaration` 402 cli, `rollup/non-numeric-aggregand` 364, `relationship/delete-behavior` 201 cli - `validate-flow-trigger-readiness.ts` (5): `flow-time-relative-descriptor-invalid` 796, `flow-time-relative-descriptor-unroutable` 532, `flow-trigger-unroutable` 518, `flow-api-trigger-secret-missing` 336, `flow-trigger-unknown-event` 222 - `validate-react-page-props.ts` (5): `react-chart-drilldown-invalid` 784, `react-chart-aggregate-invalid` 507, `react-chart-field-unprovisioned` 429, `react-block-needs-record-context` 267, `react-page-source-unparseable` 211 - `validate-ai-agent-authoring.ts` (3): `default-agent-legacy-alias` 466, `default-agent-outside-roster` 388, `agent-authoring-withdrawn` 352 - `validate-predicate-path-refs.ts` (3): `predicate-rhs-path-shaped` 648, `predicate-path-unrooted` 434, `predicate-path-unresolved` 371 - `validate-searchable-fields.ts` (3): `searchable-field-unprovisioned` 512, `searchable-field-unsearchable` 449, `searchable-field-unknown` 295 - `validate-sortable-fields.ts` (3): `sort-field-unprovisioned` 844, `sort-field-unsortable` 369, `sort-field-unknown` 287 - `lint-view-refs.ts` (2): `view-ref-nav-view-missing` 437, `view-key-collision` 278 cli - `validate-approval-approvers.ts` (2): `approval-approvers-may-resolve-empty` 451, `approval-approver-not-membership-tier` 272 - `validate-chart-bindings.ts` (2): `chart-measure-unknown` 442, `chart-axis-not-selected` 327 - `validate-dashboard-action-refs.ts` (2): `dashboard-action-route-unresolved` 242, `dashboard-action-target-undefined` 239 - `validate-dataset-measure-aggregates.ts` (2): `measure-aggregate-field-type-refused` 809, `dimension-json-stored-field-refused` 531 - `validate-empty-combinators.ts` (2): `filter-empty-combinator` 352, `filter-empty-node` 226 - `validate-list-view-field-refs.ts` (2): `list-view-field-dotted` 445, `list-view-field-unknown` 355 - `validate-rule-compilability.ts` (2): `validation-rule-json-schema-uncompilable` 517, `validation-rule-regex-uncompilable` 482 - `validate-translation-references.ts` (2): `translation-target-unknown` 345, `translation-option-key-unknown` 230 - `lint-flow-credential-literals.ts` (1): `flow-credential-literal` 390 - `validate-action-dispatch-contract.ts` (1): `action-dispatch-contract-mismatch` 927 - `validate-action-name-refs.ts` (1): `action-name-undefined` 409 - `validate-ai-surface-affinity.ts` (1): `ai-skill-surface-mismatch` 281 - `validate-ai-tool-references.ts` (1): `ai-skill-tool-unresolved` 441 - `validate-capability-references.ts` (1): `capability-reference-unknown` 219 - `validate-component-types.ts` (1): `component-type-unknown` 807 - `validate-flow-filter-tokens.ts` (1): `flow-filter-token-unknown` 278 - `validate-managed-api-methods.ts` (1): `object/managed-api-method-unaffordable` 412 - `validate-mapping-target-fields.ts` (1): `mapping-target-field-unknown` 466 - `validate-nav-access.ts` (1): `nav-object-ungranted` 353 - `validate-nav-object-servability.ts` (1): `nav-object-unservable` 528 - `validate-nav-target-refs.ts` (1): `nav-target-unresolved` 426 - `validate-object-field-refs.ts` (1): `object-field-ref-unknown` 320 - `validate-object-references.ts` (1): `object-reference-unregistered-platform` 324 - `validate-org-axis-red-lines.ts` (1): `org-axis-cross-org-bu-grant` 365 - `validate-page-visualization-bindings.ts` (1): `page/visualization-without-binding` 629 - `validate-preset-comparands.ts` (1): `filter-preset-comparand` 669 - `validate-retired-permission-residue.ts` (1): `permission-retired-lifecycle-residue` 235 - `validate-rule-schema-formats.ts` (1): `validation-rule-json-schema-unknown-format` 1049 - `validate-seed-replay-safety.ts` (1): `seed-insert-mode-duplicates-on-replay` 222 - `validate-seed-state-machine.ts` (1): `seed-value-outside-state-machine` 320 - `validate-semantic-roles.ts` (1): `semantic-role-field-unprovisioned` 291 - `validate-translatable-sections.ts` (1): `translation-section-name-missing` 553 - `validate-view-containers.ts` (1): `view-container-shape` 290 The 26 ids slice 2 fenced, the 9 ids owned by `packages/cli`, and the `action-governance.ts` boot-log lines stay as PR #22448's body lists them. This slice touched none of them. ## Acceptance notes - **The `fix:` lines are unchanged and several stay long.** The dispatch holds hints as they are. `sharing-rule-runtime-variable-condition`'s hint runs to 2,280 characters (which mechanism replaces a `current_user` condition, by the anchor's `sharingModel`), `rls-predicate-unknown-field`'s to 668 on the suite's fixture, and the over-budget, shape and type-fault hints to several hundred. Whether hints get the same one-line budget is the card's call, as slices 2 and 3 noted. - **The quoted engine sentence is the engine's.** `@objectstack/objectql`'s refusals run to several sentences (up to 728 characters in the admission suite); the verdict quotes the first, up to its first ` — `. That first sentence carries the engine's own location suffix (`at where.close_date.$gt`), and the longest one the suites fire leaves the verdict at 195. An engine sentence that is longer, or longer field names, can pass 200. The code-keyed `fix:` line carries the remedy the engine's later sentences spelled per instance. - **The compiler's suggested rewrite is no longer in the shape verdict.** For a few refusals the compiler's `detail` ends with a per-instance rewrite after ` — ` (for the bare `current_user` root, "compare one of its keys instead, e.g. `record.owner_id == current_user.id` …"; for a list literal, "spell 'one of these' as …"). The verdict keeps the half before it; the unchanged `fix:` line names the subset and the three traps in general terms. For the TYPE faults nothing is lost: their `fix:` line already carries the pasteable rewrite. - **The list-holding verdict names the column, not its declaration.** "where `reviewers` holds a list or an object" replaces "where `reviewers` is declared `type: 'lookup'`, `multiple: true`"; the explanation names the classes (a structured JSON type such as `json` or `address`, a multi-value field such as `multiselect`, `tags` or a lookup flagged `multiple: true`) as examples, not as a list held to the spec. - **Process:** the CLI closure build waited 8m45s and held the shared lock 7m28s; the spec `repo` project held it 13m35s on the shared box. --- _Generated by [Claude Code](https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e194ab4 commit 07ee6f6

12 files changed

Lines changed: 968 additions & 546 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
fix(lint): the RLS-predicate and sharing-rule findings print one verdict line, and `os explain <rule-id>` carries their reasoning
6+
7+
Clause-②: no
8+
9+
- **Shorter verdicts.** Each finding of these 9 rule ids now prints a `message` of one verdict sentence. Every finding the rules' own test suites, the CLI's RLS admission suite and the runtime publish gate's suite fire is at most 195 characters; before, the longest of each ran from 492 to 2,056 characters. The ids:
10+
- RLS policies (`permissions[].rowLevelSecurity[].using` / `.check`): `rls-predicate-unparseable`, `rls-predicate-unenforceable`, `rls-predicate-unknown-user-variable`, `rls-predicate-unknown-field`, `rls-predicate-over-budget`;
11+
- sharing rules (`sharingRules[]`): `sharing-rule-unlowerable-condition`, `sharing-rule-object-not-shareable`, `sharing-rule-object-controlled-by-parent`, `sharing-rule-runtime-variable-condition`.
12+
13+
A finding no longer echoes the predicate or the condition: its `path` names the clause, and the predicate is author text of any length (the old message quoted up to 200 characters of it, or all of it). Each verdict names what is wrong and closes on what it costs: an RLS policy the runtime drops ends "so the policy is dropped and grants no access" (a `using`) or "… and admits no write" (a `check`); a field compared with a field that holds a list or an object, or across comparison classes, quotes the first such comparison, names the column or the two classes, counts the rest, and ends on the `INVALID_FILTER / 400` refusal; a sharing condition that does not lower ends "so the rule is never seeded and grants nothing"; an anchor object a grant cannot widen names its `sharingModel` and the `SHARING_NOT_ENABLED` refusal. A quoted refusal — the compiler's, the platform's shared filter check's, or the engine's (`RLS using (CODE / STATUS): …`) — is quoted up to its own suggested rewrite, or to its first sentence. The `fix` (the CLI's `fix:` line, the runtime issue's `hint`), every rule id, severity and `path`, and what each rule accepts or refuses are unchanged. A tool that matched the old message text should match on `rule` and `path` instead.
14+
- **`os explain <rule-id>` takes these 9 ids**, for example `os explain rls-predicate-unenforceable`. It prints the reasoning the verdicts no longer carry: what `RLSCompiler` does with a policy it cannot compile (the deny sentinel and its ZERO rows, the OR with other policies, the INSERT check a `using` doubles as, the `check` set), the one WARN line each drop leaves, which `current_user` keys the kernel resolves and with which type, why a column that holds a list or an object — or two columns of different comparison classes — cannot be compared, what the engine's own filter admission refuses and what that costs analytics, and, for sharing rules, why a condition is compiled once at boot, what an unlowerable or refused condition leaves behind, and why a grant on a public or `controlled_by_parent` object is refused. The paragraphs the RLS and sharing rules share are one text, printed under every id they explain. The `rule:` line under each of these findings now ends with `` — `os explain <rule-id>` for … ``. The no-argument listing and its `--json` `rules` array list the 9 ids, and so does the unknown-id error's `Rules with an explanation:` line. `RULE_EXPLANATIONS` in `@objectstack/lint` gains the 9 entries.
15+
- **Where the new text prints.** On the CLI, all 9 ids: `os validate`, `os build` (and `os compile`, which `os dev` runs on every compile), `os lint`, `os verify` and `os init`'s scaffold check print the new `message` on the text face, and `os validate --json` and `os build --json` carry it in their `errors` and author-time `issues`. At the runtime publish gate (Studio, REST `/meta`, MCP), a `permission` write carries the five `rls-predicate-*` ids, all errors: the 422 issue's `message` and the refusal log line under `OS_ALLOW_UNLINTED_METADATA_WRITES` change. Each issue's `hint` is unchanged.
16+
- **Never at the runtime gate:** the four `sharing-rule-*` ids. The rule is registered for the CLI doors only (no write type dispatches it at the publish gate), so they speak only on the CLI doors above.

‎packages/cli/test/rls-policy-authoring-admission.test.ts‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -208,8 +208,7 @@ describe('RLS read-scope admission at authoring time — fifteen classes, two do
208208
if (row.engineCode) {
209209
// The engine's own verdict, quoted with its code — never withheld at
210210
// authoring time, where the text is the author's own.
211-
expect(cli[0].message).toContain(`(${row.engineCode} / 400): `);
212-
expect(cli[0].message).toContain('lowers, but the engine refuses to run the lowered filter on "deal"');
211+
expect(cli[0].message.startsWith(`RLS using (${row.engineCode} / 400): `)).toBe(true);
213212
}
214213
});
215214
}
@@ -276,7 +275,7 @@ describe('a field compared with a json / multiple field is refused at both doors
276275
expect(cli.map((f) => ({ severity: f.severity, rule: f.rule, path: f.path }))).toEqual([
277276
{ severity: 'error', rule: UNENFORCEABLE, path: `permissions[0].rowLevelSecurity[0].${row.clause}` },
278277
]);
279-
expect(cli[0].message).toContain('lowers, but compares a field with a field that holds a list or an object');
278+
expect(cli[0].message).toContain('holds a list or an object, so ');
280279

281280
expect(saved.accepted).toBe(false);
282281
expect({ code: saved.code, status: saved.status }).toEqual({ code: 'INVALID_METADATA', status: 422 });
@@ -333,7 +332,7 @@ describe('a field compared with a field of another comparison class is refused a
333332
expect(cli.map((f) => ({ severity: f.severity, rule: f.rule, path: f.path }))).toEqual([
334333
{ severity: 'error', rule: UNENFORCEABLE, path: `permissions[0].rowLevelSecurity[0].${row.clause}` },
335334
]);
336-
expect(cli[0].message).toContain('lowers, but compares two fields that share no comparison class');
335+
expect(cli[0].message).toContain('which no comparison class spans (');
337336

338337
expect(saved.accepted).toBe(false);
339338
expect({ code: saved.code, status: saved.status }).toEqual({ code: 'INVALID_METADATA', status: 422 });

0 commit comments

Comments
 (0)