Skip to content
41 changes: 41 additions & 0 deletions .changeset/tool-inert-keys-removed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
"@objectstack/spec": major
---

refactor(spec)!: remove the four inert tool authoring keys — two of them promised safety they never delivered (#3896 close-out)

`tool.category`, `tool.permissions`, `tool.active` and `tool.builtIn` were
authorable and inert: none is part of `AIToolDefinition`, and no execution path
read them. The liveness ledger had already corrected all four to dead+authorWarn
(#3686); this finishes the enforce-or-remove disposition inside the v17 window,
following the `requiresConfirmation` precedent (#3715) — because two of the four
were misleading in the dangerous direction:

- **`permissions`** promised a capability gate on tool invocation. Nothing
enforced it — a tool "requiring" capabilities ran for everyone. The real gates
are `action.requiredPermissions` (ADR-0066) and permission sets on the objects
the tool touches.
- **`active: false`** read as "withdrawn". It withdrew nothing: `ToolRegistry.getAll()`
returns everything, the tool kept reaching the LLM tool set, and
`POST /ai/tools/:name/execute` kept running it — unlike `agent.active` /
`skill.active`, which are enforced. To withdraw a tool, remove it from the
skills/agents that reference it.

The retirement kit:

- The `.strict()` ToolSchema rejects each retired key with its own prescription
(`TOOL_RETIRED_KEY_GUIDANCE`, the #3715 pattern) — no silent strip.
- **ADR-0087 D2 conversion + D3 chain step** (`tool-inert-authoring-keys-removed`):
`os migrate meta` deletes the keys mechanically; a pure lossless delete, since
they never had any effect to lose.
- `ToolCategorySchema` / `ToolCategory` are removed with the key they typed
(zero consumers; `action.zod.ts` deliberately keeps its own inline vocabulary).
- The Studio tool form drops its inputs for the retired keys — a form input for
an unenforced gate is the UI half of false compliance, the same
"advertising the failure mode" shape objectui#2962 removed from the
sharing-criteria builder.
- Ledger entries deleted per the #3715 precedent; baselines
(`authorable-surface.json`, `json-schema.manifest.json`) updated deliberately;
reference docs and the v17 release notes regenerated/extended.

No runtime behaviour changes — that impossibility is the reason for the removal.
33 changes: 8 additions & 25 deletions content/docs/references/ai/tool.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@ description: Tool protocol schemas

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

Tool Category
Retired `ToolSchema` keys — the rejection carries the upgrade prescription,

Classifies the tool by its operational domain.
because the parse error is the one channel every consumer bumping

`@objectstack/spec` is guaranteed to hit (pattern of `object.zod.ts`'s

`UNKNOWN_KEY_GUIDANCE`, ADR-0049 enforce-or-remove).

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

```typescript
import { Tool, ToolCategory } from '@objectstack/spec/ai';
import type { Tool, ToolCategory } from '@objectstack/spec/ai';
import { Tool } from '@objectstack/spec/ai';
import type { Tool } from '@objectstack/spec/ai';

// Validate data
const result = Tool.parse(data);
Expand All @@ -36,13 +40,9 @@ AI tool definition. [READ-ONLY PROJECTION — not an execution entry point] Auth
| **name** | `string` | ✅ | Tool unique identifier (snake_case) |
| **label** | `string` | ✅ | Tool display name |
| **description** | `string` | ✅ | Tool description for LLM function calling |
| **category** | `Enum<'data' \| 'action' \| 'flow' \| 'integration' \| 'vector_search' \| 'analytics' \| 'utility'>` | optional | Tool category for grouping and filtering |
| **parameters** | `Record<string, any>` | ✅ | JSON Schema for tool parameters |
| **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). |
| **objectName** | `string` | optional | Target object name (snake_case) |
| **permissions** | `string[]` | optional | Required permission-set capabilities |
| **active** | `boolean` | ✅ | Whether the tool is enabled |
| **builtIn** | `boolean` | ✅ | Platform built-in tool flag |
| **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this tool. |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
Expand All @@ -55,20 +55,3 @@ AI tool definition. [READ-ONLY PROJECTION — not an execution entry point] Auth

---

## ToolCategory

Tool operational category

### Allowed Values

* `data`
* `action`
* `flow`
* `integration`
* `vector_search`
* `analytics`
* `utility`


---

4 changes: 2 additions & 2 deletions content/docs/releases/implementation-status.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ The `auth` service in `CoreServiceName` covers both **authentication** (identity
- Auth route (`/auth/*`) only appears in Discovery when the auth plugin is registered
- Fine-grained authorization (RLS, sharing, territory) is internal to the auth plugin
- **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).
- **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).
- **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.

---

Expand Down Expand Up @@ -419,7 +419,7 @@ describe that separate runtime.
### Phase 9: Security (Plugin) 🟡 **PHASE-1 LANDED**
- [x] Authentication Plugin (`@objectstack/plugin-auth`, better-auth)
- [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)
- [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
- [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)
- [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
- [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)
- [x] Legacy `objectql.registerTenantMiddleware` removed — SecurityPlugin is now the sole tenant-isolation authority
Expand Down
87 changes: 86 additions & 1 deletion content/docs/releases/v17.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,25 @@ parsed-but-never-enforced spec clusters are removed rather than maintained.
`listRequests` / `countRequests` applied only the tenant half of the
visibility rule, so any authenticated user could read any request in their
tenant — payload snapshot, decision history, and attachments.
- **A sharing rule with no criteria shares nothing, and a disabled RLS policy
is disabled.** Both were documented contracts whose real behaviour was wider:
a rule stored without criteria evaluated as *every record of the object*
(reachable by a typo through three unvalidated write paths, #3896), and an
RLS policy switched off with `enabled: false` kept contributing its
OR-branch grant. Both now fail closed — found by re-verifying every
security-subset claim in the spec liveness ledger against the actual call
graph, a sweep that also removed the void RLS `priority` knob and the
never-wired plugin sandboxing/integrity config, and left two new CI gates
behind the whole class: a permissive empty state must be classified on
purpose, and the security entries now carry runtime proofs.
- **Node.js 22 is the floor.** `engines.node` said `>=18` across all 50
manifests while CI, the release pipeline and every shipped Docker image ran
22. The promise now matches the evidence.
- **The dead-metadata sweep continues.** GraphQL, `PortalSchema`, `AuditConfig`,
the capabilities-descriptor cluster, `FeatureFlagSchema`, `SkillSchema.permissions`,
`tool.requiresConfirmation`, `agent.tools[]`, `object.enable.trash`/`mru`,
report `aria`/`performance`, `DEFAULT_DISPATCHER_ROUTES` and the last three
report `aria`/`performance`, `DEFAULT_DISPATCHER_ROUTES`, the four inert tool
authoring keys (`category`/`permissions`/`active`/`builtIn`) and the last three
deprecated authorable aliases are removed — each one had been parsed and
ignored.

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

### Sharing rules: an empty criteria shares nothing (#3896)

A rule stored without criteria — missing, `null`, `{}`, unparsable, or a
misspelled key (`criterias`) — used to evaluate as `find(object, { filter: {} })`
under the system context: **every record of the object, granted to the
recipient**, up to the evaluator's 5000-record window. Three write paths
accepted that shape without validation: `POST /api/v1/sharing/rules`, a direct
`sys_sharing_rule` insert (what authoring in Setup issues), and the seed
bootstrap's own match-all branch. The field description even advertised it —
*"leave empty to share every record"* — which was never a feature, only a name
for the bug.

Now, in three layers:

- **`defineRule` rejects** a match-all criteria with `VALIDATION_FAILED`.
- **The evaluator matches nothing** for such a rule and logs why. This is the
layer that matters for already-deployed tenants: a row stored before this
gate **under-shares instead of over-sharing, and the next reconcile revokes
the grants it had materialised**. No data migration is needed.
- **A `sys_sharing_rule` insert fails field-level** (`fields[].field =
'criteria_json'`), so Setup marks the Criteria input instead of saving a
rule that silently does nothing. The Console builder says so before you
save (objectui#2962) and renders the server's reason on the field
(objectui#2966).

There is no "share every record" sharing rule: object-wide read is the
object's organization-wide default (`sharingModel`). A rule that relied on the
empty shape must state its predicate, or the object should use `sharingModel`.

### RLS: `enabled` is enforced, `priority` is removed (#3980, #3990)

- **`enabled: false` now actually disables a policy.** The schema always said
*"Disabled policies are not evaluated"*, but nothing read the property — and
because applicable policies OR-combine (any match allows access), a policy an
admin switched off **kept granting row access**. The gate is at the single
choke point both the find and analytics paths flow through; `enabled` absent
stays active, so no stored policy changes behaviour. Access-narrowing only —
but re-check any policy you set `enabled: false` on expecting no effect,
because until now it had none.
- **`priority` is removed.** It promised "conflict resolution" that cannot
exist: with OR-combination there is never a conflict to order, and evaluation
order cannot change an outcome. Nothing ever read it. Authored values are
tombstoned with a fix-it prescription; `os migrate meta` deletes the key
mechanically, and policy outcomes are identical with or without it.

### Membership grade is not a capability channel (ADR-0108, #3723)

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

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