Skip to content

Commit eb95d97

Browse files
authored
refactor(spec)!: v17 close-out of the #3896 line — release notes, tool inert-key removal, ADR-0113 (Proposed) (#3998)
Three parts, closing the #3896 security-audit line for the v17 window. 1. v17 release notes carry the whole security-hardening arc: a Highlights bullet (empty-criteria sharing + disabled RLS both fail closed now), two breaking-change sections with migration guidance, dead-cluster rows for DynamicLoadingConfig and rls.priority, Spec/kernel bullets for the 44-entry security-subset re-verification and the check:empty-state gate, and three upgrade-checklist items. implementation-status updated to match. 2. The four inert tool authoring keys are removed (major): category/permissions/active/builtIn parsed and did nothing — permissions promised an invocation gate nothing enforced, and active:false read as "withdrawn" while the tool kept reaching the LLM set and /execute kept running it (the rls.enabled shape). Full retirement kit per #3715: strict-rejection prescriptions (TOOL_RETIRED_KEY_GUIDANCE, 6 new tests), D2 conversion + D3 chain step (tool-inert-authoring-keys-removed), ToolCategorySchema removed with the key it typed, Studio tool form drops the retired inputs, the published AI skill's Tool section rewritten (it was teaching the removed keys, os:check'd), ledger entries deleted, api-surface + authorable-surface + json-schema.manifest + form-translation bundles updated deliberately, and the CLI authorWarn expectation retired with its ledger entry. 3. ADR-0113 merges as PROPOSED (not implemented): required as a write-time contract vs the column constraint as its own explicitly-authored axis — the tri-binding cited at its exact sites (record-validator; sql-driver.ts:4901; schema-drift.ts:249), criteria_json as first consumer. Q1 (spelling) / Q2 (new-field default) / Q3 (requiredWhen) remain open for adjudication. spec: 6895 tests green; all gates green; 198 prose examples type-check; dogfood green. Union-merged over #4010's step-17 rationale (chain-replay gate verified the composition).
1 parent 0f12193 commit eb95d97

25 files changed

Lines changed: 519 additions & 262 deletions
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@objectstack/spec": major
3+
---
4+
5+
refactor(spec)!: remove the four inert tool authoring keys — two of them promised safety they never delivered (#3896 close-out)
6+
7+
`tool.category`, `tool.permissions`, `tool.active` and `tool.builtIn` were
8+
authorable and inert: none is part of `AIToolDefinition`, and no execution path
9+
read them. The liveness ledger had already corrected all four to dead+authorWarn
10+
(#3686); this finishes the enforce-or-remove disposition inside the v17 window,
11+
following the `requiresConfirmation` precedent (#3715) — because two of the four
12+
were misleading in the dangerous direction:
13+
14+
- **`permissions`** promised a capability gate on tool invocation. Nothing
15+
enforced it — a tool "requiring" capabilities ran for everyone. The real gates
16+
are `action.requiredPermissions` (ADR-0066) and permission sets on the objects
17+
the tool touches.
18+
- **`active: false`** read as "withdrawn". It withdrew nothing: `ToolRegistry.getAll()`
19+
returns everything, the tool kept reaching the LLM tool set, and
20+
`POST /ai/tools/:name/execute` kept running it — unlike `agent.active` /
21+
`skill.active`, which are enforced. To withdraw a tool, remove it from the
22+
skills/agents that reference it.
23+
24+
The retirement kit:
25+
26+
- The `.strict()` ToolSchema rejects each retired key with its own prescription
27+
(`TOOL_RETIRED_KEY_GUIDANCE`, the #3715 pattern) — no silent strip.
28+
- **ADR-0087 D2 conversion + D3 chain step** (`tool-inert-authoring-keys-removed`):
29+
`os migrate meta` deletes the keys mechanically; a pure lossless delete, since
30+
they never had any effect to lose.
31+
- `ToolCategorySchema` / `ToolCategory` are removed with the key they typed
32+
(zero consumers; `action.zod.ts` deliberately keeps its own inline vocabulary).
33+
- The Studio tool form drops its inputs for the retired keys — a form input for
34+
an unenforced gate is the UI half of false compliance, the same
35+
"advertising the failure mode" shape objectui#2962 removed from the
36+
sharing-criteria builder.
37+
- Ledger entries deleted per the #3715 precedent; baselines
38+
(`authorable-surface.json`, `json-schema.manifest.json`) updated deliberately;
39+
reference docs and the v17 release notes regenerated/extended.
40+
41+
No runtime behaviour changes — that impossibility is the reason for the removal.

content/docs/references/ai/tool.mdx

Lines changed: 8 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,13 @@ description: Tool protocol schemas
55

66
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
77

8-
Tool Category
8+
Retired `ToolSchema` keys — the rejection carries the upgrade prescription,
99

10-
Classifies the tool by its operational domain.
10+
because the parse error is the one channel every consumer bumping
11+
12+
`@objectstack/spec` is guaranteed to hit (pattern of `object.zod.ts`'s
13+
14+
`UNKNOWN_KEY_GUIDANCE`, ADR-0049 enforce-or-remove).
1115

1216
<Callout type="info">
1317
**Source:** `packages/spec/src/ai/tool.zod.ts`
@@ -16,8 +20,8 @@ Classifies the tool by its operational domain.
1620
## TypeScript Usage
1721

1822
```typescript
19-
import { Tool, ToolCategory } from '@objectstack/spec/ai';
20-
import type { Tool, ToolCategory } from '@objectstack/spec/ai';
23+
import { Tool } from '@objectstack/spec/ai';
24+
import type { Tool } from '@objectstack/spec/ai';
2125

2226
// Validate data
2327
const result = Tool.parse(data);
@@ -36,13 +40,9 @@ AI tool definition. [READ-ONLY PROJECTION — not an execution entry point] Auth
3640
| **name** | `string` || Tool unique identifier (snake_case) |
3741
| **label** | `string` || Tool display name |
3842
| **description** | `string` || Tool description for LLM function calling |
39-
| **category** | `Enum<'data' \| 'action' \| 'flow' \| 'integration' \| 'vector_search' \| 'analytics' \| 'utility'>` | optional | Tool category for grouping and filtering |
4043
| **parameters** | `Record<string, any>` || JSON Schema for tool parameters |
4144
| **outputSchema** | `Record<string, any>` | optional | [EXPERIMENTAL — not enforced] JSON Schema for tool output. Keys are folded into the tool description only; outputs are not validated (liveness #1878/#1893). |
4245
| **objectName** | `string` | optional | Target object name (snake_case) |
43-
| **permissions** | `string[]` | optional | Required permission-set capabilities |
44-
| **active** | `boolean` || Whether the tool is enabled |
45-
| **builtIn** | `boolean` || Platform built-in tool flag |
4646
| **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this tool. |
4747
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
4848
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
@@ -55,20 +55,3 @@ AI tool definition. [READ-ONLY PROJECTION — not an execution entry point] Auth
5555

5656
---
5757

58-
## ToolCategory
59-
60-
Tool operational category
61-
62-
### Allowed Values
63-
64-
* `data`
65-
* `action`
66-
* `flow`
67-
* `integration`
68-
* `vector_search`
69-
* `analytics`
70-
* `utility`
71-
72-
73-
---
74-

content/docs/releases/implementation-status.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,7 @@ The `auth` service in `CoreServiceName` covers both **authentication** (identity
287287
- Auth route (`/auth/*`) only appears in Discovery when the auth plugin is registered
288288
- Fine-grained authorization (RLS, sharing, territory) is internal to the auth plugin
289289
- **Phase-1 RBAC enforcement is live end-to-end**: REST → ObjectQL → SecurityPlugin middleware now receives a populated `ExecutionContext` (userId, tenantId, positions, permissions). Tenant isolation is enforced as a Layer 0 tenant wall (`plugin-security/tenant-layer.ts`, ADR-0095 D1) that AND-composes `organization_id == current_user.organization_id` ahead of and independently of business RLS — the earlier wildcard `tenant_isolation` RLS policy on `member_default` was retired (an OR-merged business policy could widen it). The default `member_default` set still ships per-object overrides `sys_organization_self` (`id == current_user.organization_id`) and `sys_user_self` (`id == current_user.id`) for the global tables that lack an `organization_id` column. The earlier `tenantField` indirection (RLS expressions written against an abstract `tenant_id` column then rewritten to the configured physical column at compile time) was removed — the placeholder, the column name, and `RLSUserContext.organization_id` are now the same name end-to-end. The legacy `objectql.registerTenantMiddleware` (hardcoded `where.tenant_id` injection that pre-dated SecurityPlugin) has been removed; SecurityPlugin is the sole authority for tenant isolation. Analytics now uses the same reusable read scope via `security.getReadFilter`, so dataset-bound dashboards/reports do not bypass RLS. Verified cross-organization isolation on `pnpm dev:crm` across `sys_organization`, `sys_member`, `sys_user`, `sys_user_permission_set`, `sys_position_permission_set`. **Anonymous traffic is denied by default** (the ADR-0056 D2 default-deny flip landed: `requireAuth` defaults to `true`); an explicit `requireAuth: false` opt-out logs a boot-time warning, and public forms do not depend on any fall-open — they carry a declaration-derived `publicFormGrant` (ADR-0056 Option A).
290-
- **OWD / sharing-model enforcement is live and proven end-to-end (ADR-0056)**: `private`, `public_read`, `public_read_write`, and `controlled_by_parent` are enforced through `plugin-sharing` + `plugin-security` and verified by dogfood proofs over the real HTTP stack. `object.sharingModel` accepts the canonical OWD vocabulary only (`private` / `public_read` / `public_read_write` / `controlled_by_parent`) — the legacy `read` / `read_write` / `full` aliases were removed from the enum (ADR-0090 D4), and an unset `sharingModel` on a custom object resolves to `private` (ADR-0090 D1). RLS owner policies resolve `current_user.email` in addition to `id` / `organization_id` / `positions` (#2054). Permission sets may declare `isDefault: true` as the install-time suggestion to bind the set to the built-in `everyone` position (ADR-0090 D5, superseding the ADR-0056 D7 fallback-profile mechanism).
290+
- **OWD / sharing-model enforcement is live and proven end-to-end (ADR-0056)**: `private`, `public_read`, `public_read_write`, and `controlled_by_parent` are enforced through `plugin-sharing` + `plugin-security` and verified by dogfood proofs over the real HTTP stack. `object.sharingModel` accepts the canonical OWD vocabulary only (`private` / `public_read` / `public_read_write` / `controlled_by_parent`) — the legacy `read` / `read_write` / `full` aliases were removed from the enum (ADR-0090 D4), and an unset `sharingModel` on a custom object resolves to `private` (ADR-0090 D1). RLS owner policies resolve `current_user.email` in addition to `id` / `organization_id` / `positions` (#2054). Permission sets may declare `isDefault: true` as the install-time suggestion to bind the set to the built-in `everyone` position (ADR-0090 D5, superseding the ADR-0056 D7 fallback-profile mechanism). **A sharing rule must state its criteria** (#3896): all three write paths reject a match-all shape, a stored criteria-less rule matches nothing, and its materialised grants are revoked on the next reconcile.
291291

292292
---
293293

@@ -419,7 +419,7 @@ describe that separate runtime.
419419
### Phase 9: Security (Plugin) 🟡 **PHASE-1 LANDED**
420420
- [x] Authentication Plugin (`@objectstack/plugin-auth`, better-auth)
421421
- [x] Authorization Plugin — `@objectstack/plugin-security` enforces CRUD/FLS/RLS in the ObjectQL middleware chain; REST → ObjectQL now propagates `ExecutionContext` end-to-end (Phase-1)
422-
- [x] Row-Level Security — tenant isolation is a Layer 0 tenant wall (`plugin-security/tenant-layer.ts`, ADR-0095 D1) that AND-composes `organization_id == current_user.organization_id` independently of business RLS (the earlier wildcard `tenant_isolation` policy on `member_default` was retired); `member_default` still applies per-object overrides `sys_organization_self` / `sys_user_self`. The earlier `tenantField` rewrite indirection was removed: RLS column, placeholder, and `RLSUserContext.organization_id` use the same canonical name end-to-end
422+
- [x] Row-Level Security — tenant isolation is a Layer 0 tenant wall (`plugin-security/tenant-layer.ts`, ADR-0095 D1) that AND-composes `organization_id == current_user.organization_id` independently of business RLS (the earlier wildcard `tenant_isolation` policy on `member_default` was retired); `member_default` still applies per-object overrides `sys_organization_self` / `sys_user_self`. The earlier `tenantField` rewrite indirection was removed: RLS column, placeholder, and `RLSUserContext.organization_id` use the same canonical name end-to-end. Policy `enabled: false` is enforced at the compiler choke point — a disabled policy no longer contributes its OR-branch grant (#3980) — and the void `priority` key is removed (#3990)
423423
- [x] Analytics RLS bridge — `@objectstack/service-analytics` auto-bridges to `security.getReadFilter(object, context)` and fails closed when read-scope resolution cannot be safely applied
424424
- [x] Multi-tenancy — verified cross-organization isolation on `pnpm dev:crm` (Alice@OrgAlpha vs. Bob@OrgBeta only see their own records across `sys_organization`, `sys_member`, `sys_user`, and `sys_*_permission_set` link tables)
425425
- [x] Legacy `objectql.registerTenantMiddleware` removed — SecurityPlugin is now the sole tenant-isolation authority

content/docs/releases/v17.mdx

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,25 @@ parsed-but-never-enforced spec clusters are removed rather than maintained.
5858
`listRequests` / `countRequests` applied only the tenant half of the
5959
visibility rule, so any authenticated user could read any request in their
6060
tenant — payload snapshot, decision history, and attachments.
61+
- **A sharing rule with no criteria shares nothing, and a disabled RLS policy
62+
is disabled.** Both were documented contracts whose real behaviour was wider:
63+
a rule stored without criteria evaluated as *every record of the object*
64+
(reachable by a typo through three unvalidated write paths, #3896), and an
65+
RLS policy switched off with `enabled: false` kept contributing its
66+
OR-branch grant. Both now fail closed — found by re-verifying every
67+
security-subset claim in the spec liveness ledger against the actual call
68+
graph, a sweep that also removed the void RLS `priority` knob and the
69+
never-wired plugin sandboxing/integrity config, and left two new CI gates
70+
behind the whole class: a permissive empty state must be classified on
71+
purpose, and the security entries now carry runtime proofs.
6172
- **Node.js 22 is the floor.** `engines.node` said `>=18` across all 50
6273
manifests while CI, the release pipeline and every shipped Docker image ran
6374
22. The promise now matches the evidence.
6475
- **The dead-metadata sweep continues.** GraphQL, `PortalSchema`, `AuditConfig`,
6576
the capabilities-descriptor cluster, `FeatureFlagSchema`, `SkillSchema.permissions`,
6677
`tool.requiresConfirmation`, `agent.tools[]`, `object.enable.trash`/`mru`,
67-
report `aria`/`performance`, `DEFAULT_DISPATCHER_ROUTES` and the last three
78+
report `aria`/`performance`, `DEFAULT_DISPATCHER_ROUTES`, the four inert tool
79+
authoring keys (`category`/`permissions`/`active`/`builtIn`) and the last three
6880
deprecated authorable aliases are removed — each one had been parsed and
6981
ignored.
7082

@@ -338,6 +350,51 @@ the admin override).
338350
- `guest` and the owner-type rules are pruned. Every authorable recipient and
339351
rule type is now enforced.
340352

353+
### Sharing rules: an empty criteria shares nothing (#3896)
354+
355+
A rule stored without criteria — missing, `null`, `{}`, unparsable, or a
356+
misspelled key (`criterias`) — used to evaluate as `find(object, { filter: {} })`
357+
under the system context: **every record of the object, granted to the
358+
recipient**, up to the evaluator's 5000-record window. Three write paths
359+
accepted that shape without validation: `POST /api/v1/sharing/rules`, a direct
360+
`sys_sharing_rule` insert (what authoring in Setup issues), and the seed
361+
bootstrap's own match-all branch. The field description even advertised it —
362+
*"leave empty to share every record"* — which was never a feature, only a name
363+
for the bug.
364+
365+
Now, in three layers:
366+
367+
- **`defineRule` rejects** a match-all criteria with `VALIDATION_FAILED`.
368+
- **The evaluator matches nothing** for such a rule and logs why. This is the
369+
layer that matters for already-deployed tenants: a row stored before this
370+
gate **under-shares instead of over-sharing, and the next reconcile revokes
371+
the grants it had materialised**. No data migration is needed.
372+
- **A `sys_sharing_rule` insert fails field-level** (`fields[].field =
373+
'criteria_json'`), so Setup marks the Criteria input instead of saving a
374+
rule that silently does nothing. The Console builder says so before you
375+
save (objectui#2962) and renders the server's reason on the field
376+
(objectui#2966).
377+
378+
There is no "share every record" sharing rule: object-wide read is the
379+
object's organization-wide default (`sharingModel`). A rule that relied on the
380+
empty shape must state its predicate, or the object should use `sharingModel`.
381+
382+
### RLS: `enabled` is enforced, `priority` is removed (#3980, #3990)
383+
384+
- **`enabled: false` now actually disables a policy.** The schema always said
385+
*"Disabled policies are not evaluated"*, but nothing read the property — and
386+
because applicable policies OR-combine (any match allows access), a policy an
387+
admin switched off **kept granting row access**. The gate is at the single
388+
choke point both the find and analytics paths flow through; `enabled` absent
389+
stays active, so no stored policy changes behaviour. Access-narrowing only —
390+
but re-check any policy you set `enabled: false` on expecting no effect,
391+
because until now it had none.
392+
- **`priority` is removed.** It promised "conflict resolution" that cannot
393+
exist: with OR-combination there is never a conflict to order, and evaluation
394+
order cannot change an outcome. Nothing ever read it. Authored values are
395+
tombstoned with a fix-it prescription; `os migrate meta` deletes the key
396+
mechanically, and policy outcomes are identical with or without it.
397+
341398
### Membership grade is not a capability channel (ADR-0108, #3723)
342399

343400
`sys_member.role` answers "what is your standing in this organization", not
@@ -588,6 +645,9 @@ import or the authored key.
588645
| `ReportColumnSchema` / `ReportGroupingSchema` + report chart `groupBy` | unread (#3463) |
589646
| Report `aria` / `performance` props | report-liveness close-out |
590647
| `DataQualityRulesSchema` / `ComputedFieldCacheSchema` | orphaned value schemas (#3726, #3733) |
648+
| `DynamicLoadingConfig` / `PluginDiscoveryConfig` / `PluginDiscoverySource` | promised plugin sandboxing / integrity verification / source allow-listing / load approval; none of it was ever wired (#3950) |
649+
| `RowLevelSecurityPolicy.priority` | conflict-resolution semantics that cannot exist under OR-combination (#3990) |
650+
| `tool.category` / `.permissions` / `.active` / `.builtIn` (+ `ToolCategorySchema`) | authorable and inert`permissions` gated nothing, `active: false` withdrew nothing; strict-rejected with prescriptions (#3896 close-out) |
591651
| `DEFAULT_DISPATCHER_ROUTES` | dead route table |
592652
| Aspirational config on Theme / Translation / Webhook | still-dead after #3494 |
593653
| `ChartInteraction.zoom` / `.clickAction` | never implemented (#3752) |
@@ -815,6 +875,20 @@ most of its rows in silence.
815875
- Liveness entries gain a `verifiedAt` re-verification clock, and a batch of
816876
ledger claims were re-verified against the real Console consumer — eight of the
817877
last ten preview-only `live` claims were wrong.
878+
- **The ledger's security subset had its first full re-verification** (#3896
879+
follow-up): all 44 permission/position/object-sharing entries call-graph-closed
880+
by hand and dated. It found the unenforced RLS `enabled` (fixed the same day)
881+
and the void `priority` (removed), refuted two standing suspicions
882+
(`allowExport` *is* enforced server-side; the transfer/restore/purge gates are
883+
pre-mapped fail-closed), and bound two new runtime proofs
884+
(`permission.tabPermissions`, `permission.objects.writeScope`) so the claims
885+
re-prove on every CI run.
886+
- **A new `check:empty-state` gate** scans the authorable surface — spec schemas
887+
*and* platform-object field descriptions, where #3896's *"leave empty to share
888+
every record"* actually lived — for statements declaring a permissive empty
889+
state, and requires each to be classified (`scope` / `closed` / `open` /
890+
`output`) with a rationale. Omission is the commonest authoring error a model
891+
makes; it must not also be the widest grant.
818892
- Metadata-plane FLS (per-caller masking) is proposed as ADR-0106.
819893

820894
## New in Console (Studio) — bundled objectui 17.0
@@ -935,6 +1009,17 @@ objectui commits on top of the pin 16.1.0 shipped.
9351009
instead of failing every later query.
9361010
- **Sharing rules:** rewrite `sharedWith.type: 'group'``'team'`; drop `guest`
9371011
and owner-type rules; expect `accessLevel: 'full'` to convert to `'edit'`.
1012+
**A rule must state its criteria** — authoring one without is rejected, and a
1013+
stored criteria-less rule stops granting (its materialised grants are revoked
1014+
on the next reconcile). State the predicate, or use the object's
1015+
`sharingModel` if everyone should read it.
1016+
- **RLS policies:** delete `priority` (`os migrate meta` does it; outcomes are
1017+
unchanged). Re-check any policy you set `enabled: false` on — disabling now
1018+
actually withdraws its grant, which until v17 it silently did not.
1019+
- **Tools:** delete `category` / `permissions` / `active` / `builtIn` from tool
1020+
metadata (`os migrate meta` does it; none ever had an effect). To gate a tool,
1021+
gate the underlying action (`action.requiredPermissions`); to withdraw one,
1022+
remove it from the skills/agents that reference it.
9381023
- **Membership:** move business capability off `sys_member.role` and onto
9391024
positions (`sys_user_position`).
9401025
- **SDK callers:** remove calls to `client.permissions.*`, `client.realtime.*`,

0 commit comments

Comments
 (0)