From 2d709b5eaa6d4093c35861f44fc453d0c7f85e52 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 01:55:46 +0000 Subject: [PATCH 1/6] docs(releases): v17 carries the #3896 security-hardening line The release page had zero mention of the arc: sharing criteria fail-closed (#3929), RLS enabled enforced + priority removed (#3980/#3990), the DynamicLoadingConfig false-compliance removal (#3950), the empty-state gate, and the security-subset ledger re-verification with its two new proof bindings. Added: a Highlights bullet, two breaking-change sections with migration guidance, two dead-cluster table rows, two Spec/kernel bullets, and two upgrade-checklist items. implementation-status gains one sentence each on the sharing-criteria invariant and the RLS corrections. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QuViRSR1j6GJjf9qGbnqFX --- .../docs/releases/implementation-status.mdx | 4 +- content/docs/releases/v17.mdx | 79 +++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/content/docs/releases/implementation-status.mdx b/content/docs/releases/implementation-status.mdx index 9e2279e0d0..015beca5aa 100644 --- a/content/docs/releases/implementation-status.mdx +++ b/content/docs/releases/implementation-status.mdx @@ -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. --- @@ -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 diff --git a/content/docs/releases/v17.mdx b/content/docs/releases/v17.mdx index 01a4b5ccd5..f40360e4a8 100644 --- a/content/docs/releases/v17.mdx +++ b/content/docs/releases/v17.mdx @@ -58,6 +58,17 @@ 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. @@ -338,6 +349,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 @@ -586,6 +642,8 @@ 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) | | `DEFAULT_DISPATCHER_ROUTES` | dead route table | | Aspirational config on Theme / Translation / Webhook | still-dead after #3494 | | `ChartInteraction.zoom` / `.clickAction` | never implemented (#3752) | @@ -813,6 +871,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 @@ -933,6 +1005,13 @@ 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. - **Membership:** move business capability off `sys_member.role` and onto positions (`sys_user_position`). - **SDK callers:** remove calls to `client.permissions.*`, `client.realtime.*`, From 7e286f745a5c623cefebf2074a2857be1904777c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 02:06:28 +0000 Subject: [PATCH 2/6] refactor(spec)!: remove the four inert tool authoring keys (#3896 close-out) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit category, permissions, active and builtIn were authorable and inert — none is part of AIToolDefinition and no execution path read them (#3686 had already corrected all four to dead+authorWarn). Two were misleading in the dangerous direction: permissions promised a capability gate on invocation that nothing enforced (a tool "requiring" capabilities ran for everyone), and active:false read as "withdrawn" while the tool kept reaching the LLM tool set and POST /ai/tools/:name/execute kept running it — the same false-compliance shape as rls.enabled, resolved the same week. The retirement kit, per the requiresConfirmation precedent (#3715): - .strict() ToolSchema rejects each retired key with its own prescription (TOOL_RETIRED_KEY_GUIDANCE); 6 new tests pin the rejections and that the two dangerous ones name the REAL mechanism (action.requiredPermissions / drop from skills-agents). - D2 conversion + D3 chain step (tool-inert-authoring-keys-removed): os migrate meta strips the keys mechanically; lossless by construction. - ToolCategorySchema/ToolCategory removed with the key they typed (zero consumers; action.zod.ts keeps its own inline vocabulary on purpose). - The Studio tool form drops the retired inputs — a form input for an unenforced gate is the UI half of false compliance (objectui#2962 shape). - Ledger entries deleted (#3715 precedent), README tool row updated, authorable-surface + json-schema.manifest baselines edited deliberately, reference docs + v17 release notes regenerated/extended. spec: 6883 tests green, tsc clean, all gates green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QuViRSR1j6GJjf9qGbnqFX --- .changeset/tool-inert-keys-removed.md | 41 ++++++++++ content/docs/references/ai/tool.mdx | 33 ++------ content/docs/releases/v17.mdx | 8 +- docs/protocol-upgrade-guide.md | 3 + packages/spec/authorable-surface.json | 4 - packages/spec/json-schema.manifest.json | 1 - packages/spec/liveness/README.md | 2 +- packages/spec/liveness/tool.json | 29 +------ packages/spec/spec-changes.json | 12 +++ packages/spec/src/ai/tool.form.ts | 29 +++---- packages/spec/src/ai/tool.test.ts | 98 +++++++++++------------ packages/spec/src/ai/tool.zod.ts | 71 +++++++++------- packages/spec/src/contracts/ai-service.ts | 7 +- packages/spec/src/conversions/registry.ts | 61 ++++++++++++++ packages/spec/src/migrations/registry.ts | 9 ++- 15 files changed, 247 insertions(+), 161 deletions(-) create mode 100644 .changeset/tool-inert-keys-removed.md diff --git a/.changeset/tool-inert-keys-removed.md b/.changeset/tool-inert-keys-removed.md new file mode 100644 index 0000000000..23f17db782 --- /dev/null +++ b/.changeset/tool-inert-keys-removed.md @@ -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. diff --git a/content/docs/references/ai/tool.mdx b/content/docs/references/ai/tool.mdx index b7c198e283..2bfa0666da 100644 --- a/content/docs/references/ai/tool.mdx +++ b/content/docs/references/ai/tool.mdx @@ -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). **Source:** `packages/spec/src/ai/tool.zod.ts` @@ -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); @@ -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` | ✅ | JSON Schema for tool parameters | | **outputSchema** | `Record` | 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. | @@ -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` - - ---- - diff --git a/content/docs/releases/v17.mdx b/content/docs/releases/v17.mdx index f40360e4a8..8f603f8871 100644 --- a/content/docs/releases/v17.mdx +++ b/content/docs/releases/v17.mdx @@ -75,7 +75,8 @@ parsed-but-never-enforced spec clusters are removed rather than maintained. - **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. @@ -644,6 +645,7 @@ import or the authored key. | `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) | @@ -1012,6 +1014,10 @@ objectui commits on top of the pin 16.1.0 shipped. - **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.*`, diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index de1fd7fa51..4638e73ec9 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -126,6 +126,8 @@ Beyond those spec-surface removals, it graduates the seven flow-node config key And it removes the RLS-policy key `priority` (#3896 security audit): promised "conflict resolution" that cannot exist, because applicable policies OR-combine (most permissive wins) — there is never a conflict to order, and nothing ever read the key (call graph closed across the collection site, the projection round-trip and the compiler). A pure lossless delete: outcomes are identical with or without it; the schema tombstones the key with the same prescription. +The same close-out retires the four inert tool authoring keys (`category`, `permissions`, `active`, `builtIn`): none is part of AIToolDefinition and no execution path read them. Two were misleading in the dangerous direction — `permissions` promised an invocation gate nothing enforced, and `active: false` read as "withdrawn" while the tool kept reaching the LLM tool set. Lossless deletes; the strict ToolSchema rejects each with its prescription. + ### Mechanical (applied for you) | Conversion | Surface | Change | Load window | @@ -139,6 +141,7 @@ And it removes the RLS-policy key `priority` (#3896 security audit): promised "c | `flow-node-notify-config-aliases` | `flow.node.notify.config` | notify flow-node config keys 'to' → 'recipients', 'subject' → 'title', 'body' → 'message', 'url' → 'actionUrl' (#3796) | live — protocol 17 loader accepts the old shape | | `flow-node-script-config-aliases` | `flow.node.script.config` | script flow-node config keys 'functionName' → 'function', 'input' → 'inputs' (#3796) | live — protocol 17 loader accepts the old shape | | `permission-rls-priority-removed` | `permission.rowLevelSecurity.priority` | RLS-policy key 'priority' removed (#3896 audit — policies OR-combine, so the promised conflict-resolution semantics cannot exist; dropping it changes no outcome) | retired — `migrate meta` only | +| `tool-inert-authoring-keys-removed` | `tool.category / tool.permissions / tool.active / tool.builtIn` | tool keys 'category'/'permissions'/'active'/'builtIn' removed (#3896 close-out — authorable and inert; permissions gated nothing, active:false withdrew nothing) | retired — `migrate meta` only | --- diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index 542ee3bfec..c27062aea9 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -361,16 +361,12 @@ "ai/Tool:_packageId", "ai/Tool:_packageVersion", "ai/Tool:_provenance", - "ai/Tool:active", - "ai/Tool:builtIn", - "ai/Tool:category", "ai/Tool:description", "ai/Tool:label", "ai/Tool:name", "ai/Tool:objectName", "ai/Tool:outputSchema", "ai/Tool:parameters", - "ai/Tool:permissions", "ai/Tool:protection", "ai/ToolCall:function", "ai/ToolCall:id", diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index 0bf0e0fdd5..de63cffe5b 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -63,7 +63,6 @@ "ai/TokenUsageStats", "ai/Tool", "ai/ToolCall", - "ai/ToolCategory", "ai/TransformPipelineStep", "ai/VectorStore", "ai/VectorStoreProvider", diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index 168fcb9d21..2db316063a 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -471,7 +471,7 @@ EOF | permission | 29 | – | 4 | – | 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) | | position | 4 | – | – | – | (role's ADR-0090 successor) fully live; all 4 `verifiedAt`-stamped 2026-07-30 | | agent | 13 | 5 | 1 | – | dead `tenantId` + `planning.strategy`/`allowReplan` REMOVED (#2377) — only `planning.maxIterations` live; autonomy tier experimental; `knowledge` CORRECTED to dead 2026-07 — `search_knowledge` takes `sourceIds` from the LLM's tool-call args, never from the agent record (#1878 §3 recheck) | -| tool | 5 | 1 | 4 | – | the whole authoring surface is inert: `permissions` (not permission-gated), plus `category`/`active`/`builtIn` CORRECTED to dead 2026-07 (#3686 sweep). `requiresConfirmation` REMOVED (#3715, ADR-0033 §2) — SAFETY-shaped and unenforced on every path, so it was false compliance, not merely dead; ToolSchema is now `.strict()` so the retired key REJECTS with the FROM → TO prescription instead of being silently stripped | +| tool | 5 | 1 | 0 | – | 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 | | skill | 8 | – | 1 | – | `permissions` REMOVED 2026-07 (never gated anything — owner call was prune, #3704); `triggerPhrases` CORRECTED to dead — phrases are never matched against user messages; activation is `triggerConditions` + the agent's `skills[]` + explicit /skill-name pinning (#3686 sweep) | | dataset | 19 | – | 0 | – | `measures.certified` (declared-but-unenforced governance flag) REMOVED in 16.0 (#2377) | | page | 16 | – | – | 1 | fully live + one planned | diff --git a/packages/spec/liveness/tool.json b/packages/spec/liveness/tool.json index 2bedce37f5..3df8be3398 100644 --- a/packages/spec/liveness/tool.json +++ b/packages/spec/liveness/tool.json @@ -1,6 +1,6 @@ { "type": "tool", - "_note": "ToolSchema. Seeded from docs/audits/2026-06-toolschema-property-liveness.md. Tool metadata is WRITE-ONLY (projected one-way for Studio; runtime uses a separate AIToolDefinition). LIVE props are live via that same-named surface, not metadata read-back. ⚠ EVIDENCE LIVES IN CLOUD/EE: the `packages/services/service-ai/...` paths cited below are the closed `@objectstack/service-ai` runtime in the CLOUD repo, NOT git-tracked framework code (the framework's own service-ai tree is a stale build artifact with no src/). These props are `live` because that cloud runtime consumes them; the OPEN framework edition does not — see content/docs/ai for the open/cloud boundary.", + "_note": "ToolSchema. Seeded from docs/audits/2026-06-toolschema-property-liveness.md. Tool metadata is WRITE-ONLY (projected one-way for Studio; runtime uses a separate AIToolDefinition). LIVE props are live via that same-named surface, not metadata read-back. ⚠ EVIDENCE LIVES IN CLOUD/EE: the `packages/services/service-ai/...` paths cited below are the closed `@objectstack/service-ai` runtime in the CLOUD repo, NOT git-tracked framework code (the framework's own service-ai tree is a stale build artifact with no src/). These props are `live` because that cloud runtime consumes them; the OPEN framework edition does not — see content/docs/ai for the open/cloud boundary. 2026-07-30 (#3896 close-out): the four inert authoring keys (category/permissions/active/builtIn) were REMOVED from ToolSchema — permissions promised an invocation gate nothing enforced, active:false withdrew nothing; strict-rejected with prescriptions (TOOL_RETIRED_KEY_GUIDANCE) and stripped by the tool-inert-authoring-keys-removed conversion. Entries deleted per the requiresConfirmation precedent (#3715).", "props": { "name": { "status": "live", @@ -30,33 +30,6 @@ "status": "experimental", "evidence": "packages/services/service-ai/src/tools/action-tools.ts:437", "note": "keys folded into description only; no validation exists." - }, - "category": { - "status": "dead", - "evidence": "only reader is a serializer pass-through in cloud service-ai/src/routes/tool-routes.ts:48 (GET /api/v1/ai/tools); no client consumes it, nothing groups/filters/routes by it", - "authorWarn": true, - "authorHint": "Display-only — no runtime groups, filters or routes tools by category.", - "note": "RE-VERIFIED 2026-07 (#3686 preview-claim sweep): the prior `live` verdict cited only a metadata-admin PREVIEW panel, which echoes what the author typed." - }, - "permissions": { - "status": "dead", - "evidence": "tool.form.ts only; not on AIToolDefinition, no consumer", - "authorWarn": true, - "authorHint": "Not part of AIToolDefinition and no consumer — tool invocation is NOT permission-gated by this list. Gate the underlying action via requiredPermissions / permission sets (ADR-0066)." - }, - "active": { - "status": "dead", - "evidence": "AIToolDefinition (packages/spec/src/contracts/ai-service.ts:157-191) has no `active` field; ToolRegistry.getAll() returns everything (cloud tool-registry.ts:159-161) and selection is by name only — contrast agent.active (agent-runtime.ts:212,501,510) and skill.active (skill-registry.ts:93,110), which ARE enforced", - "authorWarn": true, - "authorHint": "`active: false` does NOT withdraw the tool — it still reaches the LLM tool set and POST /ai/tools/:name/execute still runs it (unlike agent.active / skill.active, which are enforced). To withdraw a tool, drop it from the skill/agent that references it.", - "note": "RE-VERIFIED 2026-07 (#3686 preview-claim sweep): the prior `live` verdict cited only a metadata-admin PREVIEW panel, which echoes what the author typed. Also never set by any registration path." - }, - "builtIn": { - "status": "dead", - "evidence": "not part of AIToolDefinition; whole-repo search across all three repos finds only a test assertion and the objectui preview pill — nothing branches on it", - "authorWarn": true, - "authorHint": "A display/authoring hint only — no runtime branches on it; it never affects registration, selection or execution.", - "note": "RE-VERIFIED 2026-07 (#3686 preview-claim sweep): the prior `live` verdict cited only a metadata-admin PREVIEW panel, which echoes what the author typed." } } } diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 212c8e650d..9ed874f862 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -121,6 +121,12 @@ "to": "RLS-policy key 'priority' removed (#3896 audit — policies OR-combine, so the promised conflict-resolution semantics cannot exist; dropping it changes no outcome)", "conversionId": "permission-rls-priority-removed", "toMajor": 17 + }, + { + "surface": "tool.category / tool.permissions / tool.active / tool.builtIn", + "to": "tool keys 'category'/'permissions'/'active'/'builtIn' removed (#3896 close-out — authorable and inert; permissions gated nothing, active:false withdrew nothing)", + "conversionId": "tool-inert-authoring-keys-removed", + "toMajor": 17 } ], "migrated": [ @@ -443,6 +449,12 @@ "to": "RLS-policy key 'priority' removed (#3896 audit — policies OR-combine, so the promised conflict-resolution semantics cannot exist; dropping it changes no outcome)", "conversionId": "permission-rls-priority-removed", "toMajor": 17 + }, + { + "surface": "tool.category / tool.permissions / tool.active / tool.builtIn", + "to": "tool keys 'category'/'permissions'/'active'/'builtIn' removed (#3896 close-out — authorable and inert; permissions gated nothing, active:false withdrew nothing)", + "conversionId": "tool-inert-authoring-keys-removed", + "toMajor": 17 } ], "migrated": [], diff --git a/packages/spec/src/ai/tool.form.ts b/packages/spec/src/ai/tool.form.ts index 80f5644ded..815b043512 100644 --- a/packages/spec/src/ai/tool.form.ts +++ b/packages/spec/src/ai/tool.form.ts @@ -4,8 +4,16 @@ import { defineForm } from '../ui/view.zod'; /** * Tool Metadata Form - * + * * Form layout for creating/editing AI tool metadata definitions. + * + * The former "Declarative metadata (not enforced)" section is gone with the + * keys it declared: `category`, `permissions`, `active` and `builtIn` were + * removed from ToolSchema (#3896 close-out, after `requiresConfirmation` set + * the precedent in #3715). A form input for a rejected key would author + * parse errors; a form input for an unenforced gate is the UI half of false + * compliance — the same "advertising the failure mode" objectui#2962 removed + * from the sharing-criteria builder. */ export const toolForm = defineForm({ schemaId: 'tool', @@ -19,10 +27,7 @@ export const toolForm = defineForm({ { field: 'name', required: true, colSpan: 1, helpText: 'Unique identifier (snake_case)' }, { field: 'label', required: true, colSpan: 1, helpText: 'Display name for Studio UI' }, { field: 'description', required: true, widget: 'textarea', colSpan: 2, helpText: 'Tell AI when to use this tool — be specific!' }, - { field: 'category', colSpan: 1, helpText: 'Tool category (data, action, flow, integration, etc.)' }, { field: 'objectName', widget: 'ref:object', colSpan: 1, helpText: 'Related object (if this tool operates on a specific object)' }, - { field: 'active', colSpan: 1, helpText: 'Enable/disable this tool' }, - { field: 'builtIn', colSpan: 1, helpText: 'Platform built-in tool (vs user-defined)' }, ], }, { @@ -33,21 +38,5 @@ export const toolForm = defineForm({ { field: 'outputSchema', type: 'composite', helpText: 'Output schema for validation (optional)' }, ], }, - { - // NOT an enforcement section — both fields are declared-but-dead (#3715, - // liveness/tool.json). The label used to read "Access & safety" with - // copy that told authors to rely on them for destructive operations, - // which is exactly the false promise this section must not make. - label: 'Declarative metadata (not enforced)', - description: 'Recorded on the tool definition but read by no execution path — see the per-field notes for where the real gates live.', - collapsible: true, - collapsed: true, - fields: [ - // `requiresConfirmation` was REMOVED from ToolSchema (#3715, ADR-0033 - // §2). A safety flag no path enforced is false compliance; the real - // gate is the action-level `ai.requiresConfirmation` + approval queue. - { field: 'permissions', widget: 'string-tags', helpText: 'NOT ENFORCED — tool invocation is not permission-gated by this list. Gate the underlying action via permission sets (ADR-0066), or restrict the agent that exposes the tool.' }, - ], - }, ], }); diff --git a/packages/spec/src/ai/tool.test.ts b/packages/spec/src/ai/tool.test.ts index 59ba70ded6..bc50ab3e0a 100644 --- a/packages/spec/src/ai/tool.test.ts +++ b/packages/spec/src/ai/tool.test.ts @@ -1,25 +1,10 @@ import { describe, it, expect } from 'vitest'; import { ToolSchema, - ToolCategorySchema, defineTool, type Tool, } from './tool.zod'; -describe('ToolCategorySchema', () => { - it('should accept all tool categories', () => { - const categories = ['data', 'action', 'flow', 'integration', 'vector_search', 'analytics', 'utility'] as const; - - categories.forEach(category => { - expect(ToolCategorySchema.parse(category)).toBe(category); - }); - }); - - it('should reject invalid category', () => { - expect(() => ToolCategorySchema.parse('unknown')).toThrow(); - }); -}); - describe('ToolSchema', () => { it('should accept minimal tool', () => { const tool: Tool = { @@ -37,8 +22,6 @@ describe('ToolSchema', () => { const result = ToolSchema.parse(tool); expect(result.name).toBe('list_records'); - expect(result.active).toBe(true); - expect(result.builtIn).toBe(false); }); it('should accept full tool', () => { @@ -46,7 +29,6 @@ describe('ToolSchema', () => { name: 'create_case', label: 'Create Support Case', description: 'Creates a new support case record', - category: 'action' as const, parameters: { type: 'object', properties: { @@ -63,16 +45,11 @@ describe('ToolSchema', () => { }, }, objectName: 'support_case', - permissions: ['case.create', 'support.agent'], - active: true, - builtIn: false, }; const result = ToolSchema.parse(tool); expect(result.name).toBe('create_case'); - expect(result.category).toBe('action'); expect(result.objectName).toBe('support_case'); - expect(result.permissions).toEqual(['case.create', 'support.agent']); }); it('should enforce snake_case for tool name', () => { @@ -114,17 +91,6 @@ describe('ToolSchema', () => { objectName: 'support_case', })).not.toThrow(); }); - - it('should accept built-in tool flag', () => { - const tool = ToolSchema.parse({ - name: 'describe_object', - label: 'Describe Object', - description: 'Get object schema and field metadata', - parameters: { type: 'object', properties: { objectName: { type: 'string' } } }, - builtIn: true, - }); - expect(tool.builtIn).toBe(true); - }); }); describe('defineTool', () => { @@ -133,7 +99,6 @@ describe('defineTool', () => { name: 'query_records', label: 'Query Records', description: 'Search and filter records', - category: 'data', parameters: { type: 'object', properties: { @@ -145,20 +110,6 @@ describe('defineTool', () => { }); expect(tool.name).toBe('query_records'); - expect(tool.category).toBe('data'); - expect(tool.active).toBe(true); - }); - - it('should apply defaults', () => { - const tool = defineTool({ - name: 'simple_tool', - label: 'Simple Tool', - description: 'A simple tool', - parameters: {}, - }); - - expect(tool.active).toBe(true); - expect(tool.builtIn).toBe(false); }); it('should throw on invalid tool name', () => { @@ -207,4 +158,53 @@ describe('defineTool', () => { name: 't', label: 'T', description: 'd', parameters: {}, notAToolField: 1, })).toThrow(/notAToolField/); }); + + // ── #3896 audit close-out — the four inert authoring keys are retired ── + // `permissions` promised an invocation gate nothing enforced; `active: false` + // read as "withdrawn" while the tool kept reaching the LLM set; `category` / + // `builtIn` were display-only. Each rejection must carry its own + // prescription, and the two dangerous ones must name the REAL mechanism. + + it.each([ + ['permissions', ['case.create']], + ['active', false], + ['category', 'action'], + ['builtIn', true], + ])('REJECTS the retired `%s` with its prescription', (key, value) => { + let message = ''; + try { + ToolSchema.parse({ + name: 't', label: 'T', description: 'd', parameters: {}, [key]: value, + }); + } catch (e) { + message = String((e as Error).message); + } + expect(message).toContain(key); + expect(message).toMatch(/#3896/); + }); + + it('the `permissions` rejection points at the gate the middleware actually runs', () => { + let message = ''; + try { + ToolSchema.parse({ + name: 't', label: 'T', description: 'd', parameters: {}, permissions: ['x'], + }); + } catch (e) { + message = String((e as Error).message); + } + expect(message).toMatch(/requiredPermissions/); + expect(message).toMatch(/ADR-0066/); + }); + + it('the `active` rejection says how to actually withdraw a tool', () => { + let message = ''; + try { + ToolSchema.parse({ + name: 't', label: 'T', description: 'd', parameters: {}, active: false, + }); + } catch (e) { + message = String((e as Error).message); + } + expect(message).toMatch(/skills?\/agents|skill\/agent/i); + }); }); diff --git a/packages/spec/src/ai/tool.zod.ts b/packages/spec/src/ai/tool.zod.ts index 3b5b73f4cd..6b975c11bb 100644 --- a/packages/spec/src/ai/tool.zod.ts +++ b/packages/spec/src/ai/tool.zod.ts @@ -8,22 +8,19 @@ import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; // Tool Category // ========================================== -/** - * Tool Category - * Classifies the tool by its operational domain. - */ import { lazySchema } from '../shared/lazy-schema'; -export const ToolCategorySchema = lazySchema(() => z.enum([ - 'data', // CRUD / query operations - 'action', // Side-effect actions (send email, create record) - 'flow', // Trigger a visual flow - 'integration', // External API / webhook calls - 'vector_search', // RAG / vector search - 'analytics', // Aggregation & reporting - 'utility', // Formatters, parsers, helpers -]).describe('Tool operational category')); - -export type ToolCategory = z.infer; + +/* + * REMOVED — `ToolCategorySchema` / `ToolCategory` (#3896 audit close-out). + * + * The enum existed to type `tool.category`, which was removed with the other + * inert authoring keys: nothing ever grouped, filtered or routed tools by it + * (the only reader was a serializer pass-through). With the key gone the + * exported enum had zero consumers — `action.zod.ts` deliberately keeps its + * own INLINE copy of the vocabulary rather than importing this one, and says + * so. Removing rather than orphaning per the #3950 precedent: an exported + * schema with no consumer is read as a capability by whoever finds it. + */ // ========================================== // Tool Schema @@ -36,6 +33,28 @@ export type ToolCategory = z.infer; * `UNKNOWN_KEY_GUIDANCE`, ADR-0049 enforce-or-remove). */ const TOOL_RETIRED_KEY_GUIDANCE: Record = { + permissions: + '`tool.permissions` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — it ' + + 'promised a capability gate on tool invocation that nothing ever enforced: the key is not ' + + 'part of AIToolDefinition and no execution path read it, so a tool "requiring" capabilities ' + + 'ran for everyone. Delete the key. To gate what a tool can DO, gate the underlying action ' + + '(`action.requiredPermissions`, ADR-0066) or the object it touches (permission sets) — those ' + + 'are the checks the middleware actually runs.', + active: + '`tool.active` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — ' + + '`active: false` read as "withdrawn" but withdrew nothing: AIToolDefinition has no such ' + + 'field, ToolRegistry.getAll() returns everything, and 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). Delete the key. To withdraw a tool, remove it from the skills/agents ' + + 'that reference it.', + category: + '`tool.category` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — nothing ' + + 'groups, filters or routes tools by it; the only reader was a serializer pass-through. ' + + 'Delete the key. Organizational grouping belongs in the skill that carries the tool.', + builtIn: + '`tool.builtIn` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no ' + + 'runtime branches on it; it never affected registration, selection or execution. Delete ' + + 'the key.', requiresConfirmation: '`tool.requiresConfirmation` was removed from @objectstack/spec in the 16.x line ' + '(#3715, ADR-0033 §2) — it never had a consumer, and a SAFETY flag that is merely ' + @@ -90,7 +109,6 @@ const strictToolError: z.core.$ZodErrorMap = (issue) => { * name: 'create_case', * label: 'Create Support Case', * description: 'Creates a new support case record', - * category: 'action', * parameters: { * type: 'object', * properties: { @@ -113,9 +131,6 @@ export const ToolSchema = lazySchema(() => z.object({ /** Detailed description for LLM consumption (the model reads this to decide when to call the tool) */ description: z.string().describe('Tool description for LLM function calling'), - /** Operational category */ - category: ToolCategorySchema.optional().describe('Tool category for grouping and filtering'), - /** * JSON Schema describing the tool input parameters. * Must be a valid JSON Schema object. The AI model generates @@ -139,14 +154,15 @@ export const ToolSchema = lazySchema(() => z.object({ */ objectName: z.string().regex(/^[a-z_][a-z0-9_]*$/).optional().describe('Target object name (snake_case)'), - /** Permission-set capabilities required to use this tool */ - permissions: z.array(z.string()).optional().describe('Required permission-set capabilities'), - - /** Whether the tool is enabled */ - active: z.boolean().default(true).describe('Whether the tool is enabled'), - - /** Whether this is a platform built-in tool (vs. user-defined) */ - builtIn: z.boolean().default(false).describe('Platform built-in tool flag'), + // `category`, `permissions`, `active` and `builtIn` were REMOVED by the + // 2026-07 #3896 security-audit close-out — all four were authorable and + // inert, and two were misleading in the dangerous direction: `permissions` + // promised a capability gate on invocation that nothing enforced, and + // `active: false` read as "withdrawn" while the tool kept reaching the LLM + // set and `POST /ai/tools/:name/execute` kept running it. The `.strict()` + // parse rejects each with its prescription (TOOL_RETIRED_KEY_GUIDANCE), and + // the `tool-inert-authoring-keys-removed` conversion strips them from + // authored sources. /** * ADR-0010 §3.7 — Package-level protection envelope. Package * authors declare lock policy here; the loader translates it @@ -180,7 +196,6 @@ export type Tool = z.infer; * name: 'query_orders', * label: 'Query Orders', * description: 'Search and filter customer orders', - * category: 'data', * parameters: { * type: 'object', * properties: { diff --git a/packages/spec/src/contracts/ai-service.ts b/packages/spec/src/contracts/ai-service.ts index 8feec2fe49..e217824156 100644 --- a/packages/spec/src/contracts/ai-service.ts +++ b/packages/spec/src/contracts/ai-service.ts @@ -171,9 +171,10 @@ export interface AIToolDefinition { /** JSON Schema describing the tool parameters */ parameters: Record; /** - * Optional tool category (mirrors `ToolSchema.category`). Carried by - * action-backed tools from `action.ai.category`; surfaced by tool-listing - * routes. Not sent to the model. + * Optional tool category. Carried by action-backed tools from + * `action.ai.category` — the live source; the metadata `ToolSchema.category` + * was removed in the #3896 close-out. Surfaced by tool-listing routes. + * Not sent to the model. */ category?: string; /** diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 4647c4083a..d95644cf3c 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -1179,6 +1179,66 @@ const permissionRlsPriorityRemoved: MetadataConversion = { }, }; +/** + * Tool inert authoring keys removed (protocol 17, #3896 audit close-out). + * + * `category`, `permissions`, `active` and `builtIn` were authorable and inert — + * none is part of `AIToolDefinition` and no execution path read them. Two were + * misleading in the dangerous direction: `permissions` promised a capability + * gate on invocation that nothing enforced (a tool "requiring" capabilities ran + * for everyone), and `active: false` read as "withdrawn" while the tool kept + * reaching the LLM tool set and `POST /ai/tools/:name/execute` kept running it. + * A pure lossless delete: dropping the keys changes no runtime behaviour, + * because they never had any. + * + * `retiredFromLoadPath`: ToolSchema is `.strict()` and rejects each key with + * its prescription (`TOOL_RETIRED_KEY_GUIDANCE`), the #3715 pattern. + */ +const toolInertAuthoringKeysRemoved: MetadataConversion = { + id: 'tool-inert-authoring-keys-removed', + toMajor: 17, + retiredFromLoadPath: true, + surface: 'tool.category / tool.permissions / tool.active / tool.builtIn', + summary: "tool keys 'category'/'permissions'/'active'/'builtIn' removed (#3896 close-out — authorable and inert; permissions gated nothing, active:false withdrew nothing)", + apply(stack, emit) { + const RETIRED = ['category', 'permissions', 'active', 'builtIn'] as const; + return mapCollection(stack, 'tools', (tool, path) => { + let touched = false; + const next: Record = { ...tool }; + for (const key of RETIRED) { + if (!(key in next)) continue; + delete next[key]; + emit({ from: key, to: '(removed)', path: `${path}.${key}` }); + touched = true; + } + return touched ? next : tool; + }); + }, + fixture: { + before: { + tools: [{ + name: 'create_case', + label: 'Create Case', + description: 'Creates a support case', + parameters: { type: 'object' }, + category: 'action', + permissions: ['case.create'], + active: true, + builtIn: false, + }], + }, + after: { + tools: [{ + name: 'create_case', + label: 'Create Case', + description: 'Creates a support case', + parameters: { type: 'object' }, + }], + }, + expectedNotices: 4, + }, +}; + /** * All conversions, keyed by the protocol major that introduced the canonical * shape. Newest majors last; ordering within a major is application order. @@ -1198,6 +1258,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly Date: Thu, 30 Jul 2026 02:09:05 +0000 Subject: [PATCH 3/6] =?UTF-8?q?docs(adr):=20ADR-0113=20(Proposed)=20?= =?UTF-8?q?=E2=80=94=20required=20as=20a=20write=20contract,=20the=20colum?= =?UTF-8?q?n=20constraint=20as=20its=20own=20axis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #3896 pattern generalized: field.required binds three meanings through one knob (record-validator write check, sql-driver.ts:4901 NOT NULL DDL, schema-drift.ts:249 drift expectation), so tightening any invariant on a deployed object is a destructive migration blocked by the very legacy nulls that motivated it. criteria_json's three imperative guards and its missing required marker are the observed cost. Proposed: required keeps the write-time + UI meanings (insert must provide, update may not null out, legacy rows rest); an explicit storage property owns the DDL and the destructive-migration ceremony; drift reads the storage property. criteria_json is the first consumer. Three open questions marked for adjudication — this lands as Proposed, not Accepted. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QuViRSR1j6GJjf9qGbnqFX --- ...red-write-contract-vs-column-constraint.md | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 docs/adr/0113-required-write-contract-vs-column-constraint.md diff --git a/docs/adr/0113-required-write-contract-vs-column-constraint.md b/docs/adr/0113-required-write-contract-vs-column-constraint.md new file mode 100644 index 0000000000..5dbaa0f7ee --- /dev/null +++ b/docs/adr/0113-required-write-contract-vs-column-constraint.md @@ -0,0 +1,173 @@ +# ADR-0113: `required` is a write-time contract — the column constraint becomes its own, explicitly-authored axis + +**Status**: Proposed (2026-07-30) — **awaiting adjudication**. Drafted from the #3896 close-out; no code in this ADR has been implemented. +**Deciders**: ObjectStack Protocol Architects +**Builds on**: [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove), [ADR-0078](./0078-no-silently-inert-metadata.md) (no silently inert metadata), the #3896/#3929 sharing-criteria case study, `driver-sql/schema-drift.ts` (the drift classifier whose `destructive` class this ADR exists to route around) +**Consumers**: `@objectstack/spec` (`FieldSchema`), `@objectstack/objectql` (`validation/record-validator.ts`), `@objectstack/driver-sql` (`sql-driver.ts` column DDL, `schema-drift.ts`), `@objectstack/lint`, objectui form renderers (the required marker) +**Surfaced by**: [#3896](https://github.com/objectstack-ai/objectstack/issues/3896) — `sys_sharing_rule.criteria_json` is mandatory in substance but `required: false` in metadata, enforced by three hand-written imperative guards, and its Setup form shows no required marker. The pattern, not the instance, is the subject here. + +--- + +## TL;DR + +`field.required: true` means three different things through one knob, at three +verified sites: + +| Meaning | Where it lives today | +|---|---| +| a write must provide the value | `objectql/validation/record-validator.ts` | +| the COLUMN is `NOT NULL` | `driver-sql/sql-driver.ts:4901` — `if (field.required) col.notNullable()` | +| required-vs-nullable divergence is DRIFT | `driver-sql/schema-drift.ts:249-277` — metadata-required + nullable column ⇒ expected `NOT NULL`, and imposing `NOT NULL` over possibly-null data is the classifier's `destructive` class | + +Because all three ride one flag, **tightening any invariant on a deployed +object is a destructive migration, blocked by the very legacy nulls that +motivated the tightening**. The observed consequence (#3929): the platform's +own `criteria_json` — whose emptiness was a P0 over-share — could not be +declared required. The invariant had to be built as three imperative guards +(`defineRule` validation, a `sys_sharing_rule` `beforeInsert` hook, evaluator +fail-closed), and the Setup form renders Name, Object and Recipient with +required markers while the one field whose absence was a security bug shows +none. Every post-GA tightening hits the same wall; the predictable outcome is +that invariants which should be one declarative line become N guards — or +don't happen. + +**Decision (proposed)**: split the axes. `required` keeps the write-time and +UI meanings; the physical constraint becomes an explicit, separately-authored +storage property that carries its own destructive-migration ceremony. A +declared-required field over a legacy-nullable column stops being "drift" and +becomes a recognized posture: *new writes must provide; old rows may rest.* + +--- + +## Context + +### The tri-binding, concretely + +Authoring `required: true` on a field of a **new** object is unremarkable: the +column is created `NOT NULL` (sql-driver.ts:4901), the validator enforces +presence, the form shows the marker, drift never fires. The knob works — +until the object has deployed data. + +On a **deployed** object with legacy null rows, flipping `required` to `true` +produces, in order: + +1. `schema-drift.ts` reports the nullable column as drift with expected + `NOT NULL` (line 265-277) and classifies applying it as `destructive` — + correctly, because a `NOT NULL` constraint over possibly-null data fails or + corrupts. +2. The operator now needs a backfill — but for a field like `criteria_json` + **no correct backfill value exists** (what predicate should a formerly + match-all rule get? any answer either preserves the over-share or invents + intent). #3929 adjudicated this: the nulls must stay, as inert rows whose + grants the evaluator revokes. +3. So `required` stays `false`, and the invariant migrates into imperative + guards — per entry point, hand-maintained, invisible to the UI layer, and + invisible to any tool that reads the metadata as the contract (which, per + ADR-0033, is very often a model). + +### Why this is a platform problem and not a sharing problem + +The #3896 line produced two of these in one week: `criteria_json` (three +guards) and the empty-state registry's whole `closed` category — entries that +exist precisely because the invariant they record cannot be expressed in the +metadata. A platform whose premise is "typed metadata an AI can hold in +context and reason about" pays double here: the enforcement is real but the +declaration says `required: false`, so the metadata **understates the +contract**, which is the same claim-vs-reality divergence the liveness ledger +polices in the other direction. + +## Decision — proposed rulings + +### D1 — `required` means the write contract + +`required: true` asserts: **an insert must provide a non-null value, and an +update may not null it out.** Existing rows are untouched — a legacy null row +remains readable and editable so long as the write does not touch the required +field. This is exactly the semantics #3929 hand-built for `criteria_json`, +promoted from three guards to the platform's one word. + +### D2 — the column constraint becomes explicit + +A new field-level storage property owns the DDL: + +```ts +budget: Field.currency({ + required: true, // write contract + UI marker + storage: { notNull: true }, // column constraint — separate, explicit +}) +``` + +`storage.notNull` (exact spelling open, see Q1) is what emits +`col.notNullable()` and what schema-drift compares against the physical +column. Declaring it on a column with existing nulls remains a destructive +migration and SHOULD fail loudly until a backfill is provided — that ceremony +is correct; the defect was only that the write contract couldn't be declared +without buying into it. + +### D3 — drift semantics change + +`required: true` + nullable column stops being drift (it is the D1 posture). +`storage.notNull: true` + nullable column IS drift, destructive class, +unchanged. The `schema-drift.ts:249` comparison reads the storage property +instead of `required`. + +### D4 — the UI marker follows the write contract + +The form renderer derives the required marker from `required` (the write +contract), not from the column. `criteria_json` gets its asterisk back, and +client-side validation aligns with what the server will actually reject — +removing the last reason for the objectui#2962-style mirror hints. + +### D5 — back-compat is the deliberate asymmetry + +For existing metadata, `required: true` today implies both meanings, so the +migration must not silently drop the column constraint fields already have: + +- Fields whose column is **already `NOT NULL`** (created as required): loader + treats them as `required + storage.notNull` — nothing changes, no DDL, no + drift. +- Fields declared required whose column is nullable: today that is + un-declarable without destructive drift; under this ADR it becomes the D1 + posture. This set is currently EMPTY by construction (nobody could declare + it), so no deployed tenant changes behaviour. +- New fields: `required: true` alone creates a **nullable** column with a + write-contract gate. Authors who want the constraint say so (Q2 disputes + this default). + +### D6 — `criteria_json` is the first consumer + +`sys_sharing_rule.criteria_json` flips to `required: true` (no +`storage.notNull`). The `defineRule` validation and the `beforeInsert` guard +reduce to defense-in-depth or retire; the evaluator's fail-closed stays +regardless (ADR-0049 — enforcement where it can explain itself). The +empty-state registry entry for sharing `condition` gains the declarative +pointer as evidence. + +## Rollout (proposed) + +- **P0** (`@objectstack/spec` + `objectql`): the storage property + D1 + validator semantics + D3 drift change. Additive; no tenant behaviour change. +- **P1** (objectui): D4 marker + client validation from the write contract. +- **P2**: D6 criteria_json flip + guard consolidation; sweep for other + "mandatory in substance, optional in metadata" fields (the empty-state + registry's `closed` entries are the seed list). + +Not v17-blocking: additive surface, no breaking change. Target: early 17.x. + +## Open questions for adjudication + +- **Q1 — spelling.** `storage: { notNull: true }` (proposed: room for future + storage knobs — collation, computed defaults) vs a flat + `requiredEnforcement: 'write' | 'column'` (one knob, no nesting, but closes + the namespace). +- **Q2 — the new-field default.** This draft says `required: true` alone + creates a nullable column (uniform semantics; the constraint is opt-in). + The alternative — new objects still get `NOT NULL`, deployed objects don't — + keeps today's clean-database behaviour but makes the same declaration mean + different DDL depending on when it was authored, which is the kind of + history-dependent semantics ADR-0087 exists to eliminate. +- **Q3 — `requiredWhen` interplay.** The conditional variant evaluates + server-side against the merged record on update (#3929 rejected it for + criteria_json because it blocks legacy-row edits). Should `requiredWhen` + adopt D1's insert-vs-update asymmetry too? Out of scope here unless the + deciders want it folded in. From 1cbdec9b800a5616bfd6451b17da0db4eb25ed78 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 02:24:06 +0000 Subject: [PATCH 4/6] docs(skills): the AI skill stops teaching the retired tool keys; skill refs regenerated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI caught the two things the tool-key removal missed: the generated skills/objectstack-ai/references/_index.md (gen:skill-refs was the one generator not run), and — found by sweeping hand-written docs the corrected way — the SKILL.md Tool Configuration section, whose prose listed category/permissions/active as optional keys and whose os:check'd defineTool example authored category. That file is published FOR models to read as the authoring contract; with the tombstone it was teaching a parse error. Prose now names the retirement and where each concern actually lives (action.requiredPermissions / drop-from-skills / action.ai.category); the category table is gone with the key. Other sweep hits were false alarms: action.ai.category and skill.active are live, different surfaces. check:skill-refs / skill-docs / skill-examples all green (198 prose examples type-check against the rebuilt spec). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QuViRSR1j6GJjf9qGbnqFX --- skills/objectstack-ai/SKILL.md | 26 +++++++++------------- skills/objectstack-ai/references/_index.md | 2 +- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/skills/objectstack-ai/SKILL.md b/skills/objectstack-ai/SKILL.md index 9edd178131..71757ac1ec 100644 --- a/skills/objectstack-ai/SKILL.md +++ b/skills/objectstack-ai/SKILL.md @@ -302,9 +302,12 @@ Tools are the atomic operations that skills expose to agents. A tool authored as metadata (`type: 'tool'`, `*.tool.ts`) is validated by `ToolSchema`: required `name` / `label` / `description`, a **JSON Schema** -`parameters` object, plus optional `category`, `objectName`, `outputSchema`, -`permissions`, `active`. `ToolSchema` is **strict** — an unknown key (a typo, or -the retired `requiresConfirmation`) is a parse error, not a silent strip. +`parameters` object, plus optional `objectName` and `outputSchema`. `ToolSchema` +is **strict** — an unknown key (a typo, or a retired key) is a parse error, not +a silent strip. Retired in the #3896 close-out: `category`, `permissions`, +`active` and `builtIn` (all were authorable and inert; `permissions` gated +nothing and `active: false` withdrew nothing — the rejection message carries +each key's replacement), joining `requiresConfirmation` (#3715). ```typescript @@ -314,7 +317,6 @@ export default defineTool({ name: 'create_case', label: 'Create Support Case', description: 'Creates a new support case record', - category: 'action', parameters: { type: 'object', properties: { @@ -327,17 +329,11 @@ export default defineTool({ }); ``` -The optional `category` classifies the tool's operational domain: - -| Category | Purpose | -|:---------|:--------| -| `data` | CRUD / query operations | -| `action` | Side-effect actions (send email, create record) | -| `flow` | Trigger a visual flow | -| `integration` | External API / webhook calls | -| `vector_search` | RAG / vector search | -| `analytics` | Aggregation & reporting | -| `utility` | Formatters, parsers, helpers | +To gate what a tool can do, gate the underlying action +(`action.requiredPermissions`, ADR-0066) or the objects it touches; to withdraw +a tool, remove it from the skills/agents that reference it. Categorization, if +you need it, belongs on the action side (`action.ai.category` — a live, +enforced surface). > **Tool metadata is a read-only projection — not an execution entry point.** > `ToolSchema` has no `handler` / `implementation` field, and no framework diff --git a/skills/objectstack-ai/references/_index.md b/skills/objectstack-ai/references/_index.md index af1ab30d44..71e60b93f8 100644 --- a/skills/objectstack-ai/references/_index.md +++ b/skills/objectstack-ai/references/_index.md @@ -17,7 +17,7 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/ai/mcp.zod.ts` — Model Context Protocol (MCP) — Reference & Binding Primitives - `node_modules/@objectstack/spec/src/ai/model-registry.zod.ts` — AI Model Registry Protocol - `node_modules/@objectstack/spec/src/ai/skill.zod.ts` — Skill Trigger Condition Schema -- `node_modules/@objectstack/spec/src/ai/tool.zod.ts` — Tool Category +- `node_modules/@objectstack/spec/src/ai/tool.zod.ts` — Retired `ToolSchema` keys — the rejection carries the upgrade prescription, - `node_modules/@objectstack/spec/src/ai/usage.zod.ts` — AI Usage Primitives ## Transitive dependencies From 8720dfc5858c0b2aa867ca932cdbfc0cc88e219d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 02:32:59 +0000 Subject: [PATCH 5/6] chore(spec,i18n): api-surface + form-translation bundles catch up with the tool key removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Type Check job is a serial gauntlet — each fix revealed the next stale projection. This closes the remaining two, verified by running EVERY sub-check of the job locally this time: api-surface.json drops the removed ToolCategory/ToolCategorySchema exports (breaking is intentional, the major changeset is in this PR), and the four metadata-forms translation bundles drop the retired tool-form fields' strings (merge mode — pure deletions, no existing translation touched). check:api-surface, check:react-blocks, check:skill-examples, check:i18n, check:i18n-coverage all green locally. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QuViRSR1j6GJjf9qGbnqFX --- .../en.metadata-forms.generated.ts | 20 ------------------- .../es-ES.metadata-forms.generated.ts | 20 ------------------- .../ja-JP.metadata-forms.generated.ts | 20 ------------------- .../zh-CN.metadata-forms.generated.ts | 20 ------------------- packages/spec/api-surface.json | 2 -- 5 files changed, 82 deletions(-) diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index f38f2e326a..7aaa9723eb 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -1650,10 +1650,6 @@ export const enMetadataForms: NonNullable = { schemas: { label: "Schemas", description: "Inputs the tool accepts and the shape of its output." - }, - declarative_metadata_not_enforced: { - label: "Declarative metadata (not enforced)", - description: "Recorded on the tool definition but read by no execution path — see the per-field notes for where the real gates live." } }, fields: { @@ -1669,22 +1665,10 @@ export const enMetadataForms: NonNullable = { label: "Description", helpText: "Tell AI when to use this tool — be specific!" }, - category: { - label: "Category", - helpText: "Tool category (data, action, flow, integration, etc.)" - }, objectName: { label: "Object Name", helpText: "Related object (if this tool operates on a specific object)" }, - active: { - label: "Active", - helpText: "Enable/disable this tool" - }, - builtIn: { - label: "Built In", - helpText: "Platform built-in tool (vs user-defined)" - }, parameters: { label: "Parameters", helpText: "Input parameters — define properties like: {name: {type: \"string\", description: \"...\"}}" @@ -1692,10 +1676,6 @@ export const enMetadataForms: NonNullable = { outputSchema: { label: "Output Schema", helpText: "Output schema for validation (optional)" - }, - permissions: { - label: "Permissions", - helpText: "Required permissions to use this tool" } } }, diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 343854d713..cb7ea495b1 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -1650,10 +1650,6 @@ export const esESMetadataForms: NonNullable = schemas: { label: "Esquemas", description: "Entradas que acepta la herramienta y forma de su salida." - }, - declarative_metadata_not_enforced: { - label: "Declarative metadata (not enforced)", - description: "Recorded on the tool definition but read by no execution path — see the per-field notes for where the real gates live." } }, fields: { @@ -1669,22 +1665,10 @@ export const esESMetadataForms: NonNullable = label: "Descripción", helpText: "Indica a IA cuándo usar esta herramienta — sé específico." }, - category: { - label: "Categoría", - helpText: "Categoría de herramienta (data, action, flow, integration, etc.)" - }, objectName: { label: "Nombre de objeto", helpText: "Objeto relacionado (si esta herramienta opera sobre un objeto específico)" }, - active: { - label: "Activo", - helpText: "Activa/desactiva esta herramienta" - }, - builtIn: { - label: "Integrado", - helpText: "Herramienta integrada de la plataforma (frente a definida por usuario)" - }, parameters: { label: "Parámetros", helpText: "Parámetros de entrada — define propiedades como: {name: {type: \"string\", description: \"...\"}}" @@ -1692,10 +1676,6 @@ export const esESMetadataForms: NonNullable = outputSchema: { label: "Esquema de salida", helpText: "Esquema de salida para validación (opcional)" - }, - permissions: { - label: "Permisos", - helpText: "Permisos necesarios para usar esta herramienta" } } }, diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 62371bb6cc..0a8189b86f 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -1650,10 +1650,6 @@ export const jaJPMetadataForms: NonNullable = schemas: { label: "スキーマ", description: "ツールが受け取る入力と出力の形状。" - }, - declarative_metadata_not_enforced: { - label: "Declarative metadata (not enforced)", - description: "Recorded on the tool definition but read by no execution path — see the per-field notes for where the real gates live." } }, fields: { @@ -1669,22 +1665,10 @@ export const jaJPMetadataForms: NonNullable = label: "説明", helpText: "AI にこのツールの使用タイミングを指示 — 具体的に!" }, - category: { - label: "カテゴリ", - helpText: "ツールカテゴリ(data, action, flow, integration など)" - }, objectName: { label: "オブジェクト名", helpText: "関連オブジェクト(このツールが特定オブジェクトを扱う場合)" }, - active: { - label: "有効", - helpText: "このツールの有効/無効" - }, - builtIn: { - label: "組み込み", - helpText: "プラットフォーム組み込みツール(ユーザー定義との対比)" - }, parameters: { label: "パラメーター", helpText: "入力パラメーター — 次のようなプロパティを定義: {name: {type: \"string\", description: \"...\"}}" @@ -1692,10 +1676,6 @@ export const jaJPMetadataForms: NonNullable = outputSchema: { label: "出力スキーマ", helpText: "検証用出力スキーマ(任意)" - }, - permissions: { - label: "権限", - helpText: "このツールの使用に必要な権限" } } }, diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index d95aa19e65..0cf9568d0e 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -1650,10 +1650,6 @@ export const zhCNMetadataForms: NonNullable = schemas: { label: "参数 / 返回结构", description: "工具接收的输入与输出形态" - }, - declarative_metadata_not_enforced: { - label: "Declarative metadata (not enforced)", - description: "Recorded on the tool definition but read by no execution path — see the per-field notes for where the real gates live." } }, fields: { @@ -1669,22 +1665,10 @@ export const zhCNMetadataForms: NonNullable = label: "描述", helpText: "告诉 AI 何时使用此工具——请尽量具体!" }, - category: { - label: "分类", - helpText: "工具类别(data、action、flow、integration 等)" - }, objectName: { label: "对象名称", helpText: "相关对象(若工具针对特定对象)" }, - active: { - label: "启用", - helpText: "启用或禁用此工具" - }, - builtIn: { - label: "内置", - helpText: "平台内置工具(区别于用户自定义)" - }, parameters: { label: "参数", helpText: "输入参数——定义形如:{name: {type: \"string\", description: \"...\"}}" @@ -1692,10 +1676,6 @@ export const zhCNMetadataForms: NonNullable = outputSchema: { label: "输出 Schema", helpText: "用于校验的输出结构(可选)" - }, - permissions: { - label: "权限", - helpText: "使用此工具所需的权限" } } }, diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 130073214e..1c28cd6982 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -1962,8 +1962,6 @@ "Tool (type)", "ToolCall (type)", "ToolCallSchema (const)", - "ToolCategory (type)", - "ToolCategorySchema (const)", "ToolSchema (const)", "TransformPipelineStep (type)", "TransformPipelineStepSchema (const)", From 7bca08336e2877c75455abefe13dcd52d48afa2e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 02:46:05 +0000 Subject: [PATCH 6/6] test(cli): the tool.permissions authorWarn expectation retires with its ledger entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test Core caught the one consumer of the deleted ledger entry: the CLI authorWarn lint is ledger-driven, and a test pinned the warning for authored tool.permissions. Enforcement moved a layer down in this PR — the strict parse rejects the key with its prescription before any lint could run — so the test now pins the absence (same shape as tenancy #2763 and contextVariables ADR-0105 D11, which sit in the same test). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QuViRSR1j6GJjf9qGbnqFX --- packages/cli/src/utils/lint-liveness-properties.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/utils/lint-liveness-properties.test.ts b/packages/cli/src/utils/lint-liveness-properties.test.ts index 713450f592..1701287557 100644 --- a/packages/cli/src/utils/lint-liveness-properties.test.ts +++ b/packages/cli/src/utils/lint-liveness-properties.test.ts @@ -111,15 +111,20 @@ describe('lintLivenessProperties', () => { expect(paths(findings).some((m) => m.includes('`undoable`'))).toBe(false); }); - it('warns on the security-shaped dead props (tool.permissions)', () => { + it('retired props leave the warn list with their ledger entries', () => { // tenancy.strategy/crossTenantAccess left this list after spec 15.0 (#2763): // the schema now REJECTS them (strict tenancy block), so the ledger entries // are gone and the live tenancy knobs must not warn. const tenancy = lintLivenessProperties(objStack({ tenancy: { enabled: true, tenantField: 'org_id' } })); expect(paths(tenancy).some((m) => m.includes('tenancy'))).toBe(false); + // tool.permissions left with the #3896 close-out: the key was REMOVED from + // ToolSchema (with category/active/builtIn), so enforcement moved a layer + // down — the strict parse now rejects it with its prescription before any + // lint could run, and the ledger entry this warn keyed on is gone. The + // advisory warn's job is done by a hard error; warning again would be noise. const tool = lintLivenessProperties({ tools: [{ name: 't1', permissions: ['crm.admin'] }] }); - expect(paths(tool).some((m) => m.includes('`permissions`'))).toBe(true); + expect(paths(tool).some((m) => m.includes('`permissions`'))).toBe(false); // permission.contextVariables left this list with ADR-0105 D11: the prop was // REMOVED outright (enforce-or-remove), so its ledger entry is gone and the