Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/32865-object-ai-read-exposure.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/spec': minor
---

feat(spec): `enable.aiReadable` — a system object's author can declare its records readable by the AI read tools, never writable

Clause-②: yes (widening)
Metadata-protocol: added
Skills-docs: follow-up owed — skills/objectstack-data/SKILL.md (the `enable` capability table has no `aiReadable` row; it should say the key is refused on any object whose name does not start with `sys_`, so an app author never writes it)

- **`ObjectCapabilities.aiReadable`** (`enable.aiReadable`, optional boolean, absent by default) is the per-object, read-only AI exposure of a system object, the read counterpart of an action's `ai.exposed` (ADR-0011). `true` declares that the AI read tools (query, get one, aggregate, describe, list) may reach the object's records, with every gate after admission unchanged: the caller's permissions, row-level security, field masking and the object's API exposure. It never opens a write; a write on a system object stays on its actions, each opened by its own `ai.exposed`. No deployment switch replaces the declaration.
- **Refused at parse** wherever an object is parsed (`ObjectSchema.create`, `defineStack`, `os validate`, the object save door): on any object whose name does not start with `sys_`, where the AI read tools already read records as the caller, so the key would change nothing and a `false` would read as a lock that locks nothing; and on the stored-metadata family (`STORED_METADATA_BODY_OBJECTS`: `sys_metadata`, `sys_metadata_history`), which no declaration opens. Any declared value is judged, `false` included. The key is new, so no stored object carries it.
- **No write arm.** `enable` declares no other AI key. `aiExposed`, `aiWritable`, `aiWrite`, `aiEditable` and `ai` inside `enable` are refused with the read-only prescription; `aiRead`, `aiReadEnabled`, `aiReadExposed` and `mcpReadable` are answered with a rename to `aiReadable`. A value other than a boolean is refused.
- **`isAiReadExposed(schema)`** and **`aiReadExposureRefusal(objectName, enable)`** are exported from `@objectstack/spec/data`. `isAiReadExposed` is the one reading a surface that admits an object to the AI read tools asks: it answers `false` for the stored-metadata family before it reads the key, `false` off a `sys_*` object, and `true` only for the boolean `true`.

Nothing reads the key at runtime yet: the MCP read tools still refuse every `sys_*` object by name, and the liveness ledger records the key as `planned`. The server half (the MCP bridge attesting the object through `isAiReadExposed`, the MCP object guards honouring the attestation, and `sys_approval_request` declaring the key) lands separately. The Studio object editor does not offer the key, because the objects it edits are the ones the parse refuses it on.
2 changes: 2 additions & 0 deletions content/docs/references/data/object.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,7 @@ const result = ApiMethod.parse(data);
| **searchable** | `boolean` | optional (default: `true`) | Index records for global search |
| **apiEnabled** | `boolean` | optional (default: `true`) | Expose object via automatic APIs |
| **apiMethods** | `Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]` | optional | Whitelist of allowed API operations (six primitives; undefined = all, [] = none) |
| **aiReadable** | `boolean` | optional | Read-only AI exposure of a system (sys_) object, opt-in: true lets the AI read tools (query, get one, aggregate, describe, list) reach its records, under the caller's permissions and row-level security as before. Never opens a write: writes stay on actions declared with ai.exposed. Refused on any object whose name does not start with sys_, and on the stored-metadata family (sys_metadata, sys_metadata_history) |
| **files** | `boolean` | optional (default: `false`) | Generic record Attachments panel (sys_attachment). Opt-in: true surfaces the panel and permits attachments to target this object; otherwise any write that makes an attachment target it is rejected (403 FILES_DISABLED) — a create and an update that re-points an existing attachment alike. Field.file/Field.image are independent |
| **feeds** | `boolean` | optional (default: `true`) | Record comments/collaboration feed. Default on; explicit false hides the feed UI and rejects any write that makes a comment target this object (403 FEEDS_DISABLED) — a new comment and an update that re-threads an existing one alike |
| **activities** | `boolean` | optional (default: `true`) | Record activity timeline (sys_activity mirror of CRUD). Default on; explicit false stops mirroring and hides the timeline |
Expand Down Expand Up @@ -572,6 +573,7 @@ const result = ApiMethod.parse(data);
| **searchable** | `boolean` | optional (default: `true`) | Index records for global search |
| **apiEnabled** | `boolean` | optional (default: `true`) | Expose object via automatic APIs |
| **apiMethods** | `Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]` | optional | Whitelist of allowed API operations (six primitives; undefined = all, [] = none) |
| **aiReadable** | `boolean` | optional | Read-only AI exposure of a system (sys_) object, opt-in: true lets the AI read tools (query, get one, aggregate, describe, list) reach its records, under the caller's permissions and row-level security as before. Never opens a write: writes stay on actions declared with ai.exposed. Refused on any object whose name does not start with sys_, and on the stored-metadata family (sys_metadata, sys_metadata_history) |
| **files** | `boolean` | optional (default: `false`) | Generic record Attachments panel (sys_attachment). Opt-in: true surfaces the panel and permits attachments to target this object; otherwise any write that makes an attachment target it is rejected (403 FILES_DISABLED) — a create and an update that re-points an existing attachment alike. Field.file/Field.image are independent |
| **feeds** | `boolean` | optional (default: `true`) | Record comments/collaboration feed. Default on; explicit false hides the feed UI and rejects any write that makes a comment target this object (403 FEEDS_DISABLED) — a new comment and an update that re-threads an existing one alike |
| **activities** | `boolean` | optional (default: `true`) | Record activity timeline (sys_activity mirror of CRUD). Default on; explicit false stops mirroring and hides the timeline |
Expand Down
3 changes: 3 additions & 0 deletions packages/spec/api-surface/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"AggregationRow (interface)",
"AggregationStage (type)",
"AggregationStageSchema (const)",
"AiReadExposureRefusal (interface)",
"AnalyticsDateRange (type)",
"AnalyticsDateRangePreset (type)",
"AnalyticsDateRangePresetSchema (const)",
Expand Down Expand Up @@ -879,6 +880,7 @@
"ValueRoundTripColumn (type)",
"ValueShapeFieldDef (interface)",
"ZeroLimitConformanceCase (interface)",
"aiReadExposureRefusal (function)",
"analyticsDateRangeRefusalMessage (function)",
"apiExposureDenialReason (function)",
"asciiCaseInsensitiveContains (function)",
Expand Down Expand Up @@ -958,6 +960,7 @@
"injectedSystemColumnDefs (function)",
"isAcceptedFilterComparand (function)",
"isAggregateCompatibleWithFieldType (function)",
"isAiReadExposed (function)",
"isAnalyticsDateRangeRefusalIssue (function)",
"isApiOperationAllowed (function)",
"isApiPrimitive (function)",
Expand Down
1 change: 1 addition & 0 deletions packages/spec/authorable-surface/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,7 @@
"data/Object:validations",
"data/ObjectAccessConfig:default",
"data/ObjectCapabilities:activities",
"data/ObjectCapabilities:aiReadable",
"data/ObjectCapabilities:apiEnabled",
"data/ObjectCapabilities:apiMethods",
"data/ObjectCapabilities:approvalsVisibleToReaders",
Expand Down
3 changes: 3 additions & 0 deletions packages/spec/export-origins/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"AggregationRow": "src/data/aggregation-conformance.ts#AggregationRow (interface)",
"AggregationStage": "src/data/driver-nosql.zod.ts#AggregationStage (type)",
"AggregationStageSchema": "src/data/driver-nosql.zod.ts#AggregationStageSchema (const)",
"AiReadExposureRefusal": "src/data/ai-read-exposure.ts#AiReadExposureRefusal (interface)",
"AnalyticsDateRange": "src/data/analytics.zod.ts#AnalyticsDateRange (type)",
"AnalyticsDateRangePreset": "src/data/analytics.zod.ts#AnalyticsDateRangePreset (type)",
"AnalyticsDateRangePresetSchema": "src/data/analytics.zod.ts#AnalyticsDateRangePresetSchema (const)",
Expand Down Expand Up @@ -865,6 +866,7 @@
"ValueRoundTripColumn": "src/data/value-roundtrip-conformance.ts#ValueRoundTripColumn (type)",
"ValueShapeFieldDef": "src/data/field-value.zod.ts#ValueShapeFieldDef (interface)",
"ZeroLimitConformanceCase": "src/data/pagination-conformance.ts#ZeroLimitConformanceCase (interface)",
"aiReadExposureRefusal": "src/data/ai-read-exposure.ts#aiReadExposureRefusal (function)",
"analyticsDateRangeRefusalMessage": "src/data/analytics.zod.ts#analyticsDateRangeRefusalMessage (function)",
"apiExposureDenialReason": "src/data/api-derivation.ts#apiExposureDenialReason (function)",
"asciiCaseInsensitiveContains": "src/data/filter.zod.ts#asciiCaseInsensitiveContains (function)",
Expand Down Expand Up @@ -944,6 +946,7 @@
"injectedSystemColumnDefs": "src/data/injected-system-column-provenance.ts#injectedSystemColumnDefs (function)",
"isAcceptedFilterComparand": "src/data/filter-comparand-type.ts#isAcceptedFilterComparand (function)",
"isAggregateCompatibleWithFieldType": "src/data/aggregate-field-type-compatibility.ts#isAggregateCompatibleWithFieldType (function)",
"isAiReadExposed": "src/data/ai-read-exposure.ts#isAiReadExposed (function)",
"isAnalyticsDateRangeRefusalIssue": "src/data/analytics.zod.ts#isAnalyticsDateRangeRefusalIssue (function)",
"isApiOperationAllowed": "src/data/api-derivation.ts#isApiOperationAllowed (function)",
"isApiPrimitive": "src/data/api-derivation.ts#isApiPrimitive (function)",
Expand Down
5 changes: 5 additions & 0 deletions packages/spec/liveness/object.json
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,11 @@
"evidence": "packages/spec/src/data/api-derivation.ts, packages/rest/src/rest-server.ts, packages/runtime/src/api-exposure.ts",
"note": "three-state whitelist over the six primitives (get/list/create/update/delete/bulk): undefined = unrestricted, [] = deny-all (fully closed), subset = the derived closure; a present-but-non-array value fails CLOSED (#3545). resolveEffectiveApiMethods (the single derivation table) is consumed by the REST gate (405 outside the effective set) and the runtime dispatcher gate. Since #3543 the enum IS the six primitives; the legacy 8 values (upsert/aggregate/history/search/restore/purge/import/export) are DERIVED effective operations (ApiOperation vocabulary) — a stored legacy value is stripped at parse (stripLegacyApiMethods, canonicalize-and-warn, permanent tolerance). Read points for the `sys_api_key` single-record-write exemption pinned in packages/spec/src/data/api-methods-batch-conformance.test.ts (moved out of spec source, #23434), re-read at objectui @b9814b334 (2026-10-14): objectui @b9814b334 packages/plugin-grid/src/ObjectGrid.tsx#selectionMode — with neither `selection` nor `selectable` authored, multi-select is enabled only by objectui @b9814b334 packages/plugin-grid/src/ObjectGrid.tsx#hasBulkActions, and the one implicit bulk action, bulk-delete, is gated on the resolved delete affordance (objectui @b9814b334 packages/plugin-grid/src/ObjectGrid.tsx#rowDeleteVerdict); a list-item action named in `bulkActions` is promoted to `operation: 'custom'` + `actionDef` and dispatched through objectui @b9814b334 packages/plugin-grid/src/ObjectGrid.tsx#dispatchBulkAction; objectui @b9814b334 packages/plugin-grid/src/hooks/useBulkExecutor.ts#useBulkExecutor — a promoted custom def fans out per row through the action runner, and only an `update`/`delete` bulk def reaches the data-plane `bulk` primitive."
},
"aiReadable": {
"status": "planned",
"verifiedAt": "2026-11-13",
"note": "PLANNED RECORD (ruling A on the read half of #32442, spec card #32865): the per-object, read-only AI exposure of a sys_* object. Its REFUSALS are live at parse: ObjectSchema refuses it on any object whose name does not start with sys_ and on the stored-metadata family, judged before the key is read (packages/spec/src/data/ai-read-exposure.ts#aiReadExposureRefusal). Its one reading is packages/spec/src/data/ai-read-exposure.ts#isAiReadExposed, which no runtime reads yet: the MCP read tools still refuse every sys_* object by name. It turns live when the server half (#32442) lands: the bridge attests the object through isAiReadExposed, the three MCP guards honour the attestation, and sys_approval_request declares it."
},
"trackHistory": {
"status": "live",
"evidence": "objectui app-shell RecordDetailView gates the record's field changes on enable.trackHistory === true (historyEnabled memo): on the synthesized record page they join the one timeline stream (RecordFieldChangesContext, its Changes filter), with no History tab; pairs with per-field trackHistory (plugin-audit renderTrackedChangeSummary, packages/plugins/plugin-audit/src/audit-writers.ts) which selects the field diffs. Audit CAPTURE into sys_audit_log is deliberately unconditional (compliance ledger) — the flag gates the UI surface, not the ledger (#2707)."
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/liveness/state-counts/object.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them.

| Type | live | exp | elsewhere | dead | planned | classified |
|---|---|---|---|---|---|---|
| `object` | 78 | 0 | 0 | 1 | 0 | 79 |
| `object` | 78 | 0 | 0 | 1 | 1 | 80 |
129 changes: 129 additions & 0 deletions packages/spec/src/data/ai-read-exposure.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { ObjectSchema, ObjectCapabilities } from './object.zod';
import { aiReadExposureRefusal, isAiReadExposed } from './ai-read-exposure';
import { STORED_METADATA_BODY_OBJECTS } from '../kernel/stored-metadata-body-objects';

/**
* [ruling A on the read half of #32442] `enable.aiReadable` — the per-object,
* read-only AI exposure of a system object. Four rules, each pinned here: the
* key is accepted on a `sys_*` object; refused on any other object; refused on
* the stored-metadata family, which its one reading judges before the key is
* read; and it has no write arm.
*/

function objectWith(name: string, enable: Record<string, unknown>) {
return { name, label: 'Probe', fields: { title: { type: 'text', label: 'Title' } }, enable };
}

function aiReadableIssues(result: ReturnType<typeof ObjectSchema.safeParse>) {
return result.success
? []
: result.error.issues.filter((i) => i.path.join('.') === 'enable.aiReadable');
}

describe('enable.aiReadable — accepted on a system object', () => {
it('parses on a sys_* object, and its one reading answers true for true only', () => {
const declared = ObjectSchema.safeParse(objectWith('sys_approval_request', { aiReadable: true }));
expect(declared.success).toBe(true);
expect(declared.success ? declared.data.enable?.aiReadable : undefined).toBe(true);
expect(isAiReadExposed(declared.success ? declared.data : undefined)).toBe(true);

// `false` on a system object restates the default and is accepted.
const closed = ObjectSchema.safeParse(objectWith('sys_approval_request', { aiReadable: false }));
expect(closed.success).toBe(true);
expect(isAiReadExposed(closed.success ? closed.data : undefined)).toBe(false);

// Absent: nothing declared, nothing opened, no default written.
const absent = ObjectSchema.safeParse(objectWith('sys_approval_request', {}));
expect(absent.success).toBe(true);
expect(absent.success ? absent.data.enable?.aiReadable : 'parse failed').toBeUndefined();
expect(isAiReadExposed(absent.success ? absent.data : undefined)).toBe(false);
});
});

describe('enable.aiReadable — refused on a non-system object', () => {
it.each([true, false])('refuses aiReadable: %s on a business object, located at the key', (value) => {
const result = ObjectSchema.safeParse(objectWith('crm_account', { aiReadable: value }));
expect(result.success).toBe(false);
const issues = aiReadableIssues(result);
expect(issues).toHaveLength(1);
expect(issues[0]?.code).toBe('custom');
});

it('its one reading never answers true off a system object, even on input no parse judged', () => {
expect(isAiReadExposed({ name: 'crm_account', enable: { aiReadable: true } })).toBe(false);
});
});

describe('enable.aiReadable — refused on the stored-metadata family, before the key is read', () => {
const family = [...STORED_METADATA_BODY_OBJECTS];

it('reads the family from its one list', () => {
expect(family.length).toBeGreaterThan(0);
expect(family).toContain('sys_metadata');
});

it.each(family)('refuses aiReadable on %s, true or false', (name) => {
for (const value of [true, false]) {
const result = ObjectSchema.safeParse(objectWith(name, { aiReadable: value }));
expect(result.success).toBe(false);
const issues = aiReadableIssues(result);
expect(issues).toHaveLength(1);
expect(issues[0]?.code).toBe('custom');
}
});

it.each(family)('its one reading answers false on %s without reading the key', (name) => {
let reads = 0;
const enable = {
get aiReadable() {
reads += 1;
return true;
},
};
expect(isAiReadExposed({ name, enable })).toBe(false);
expect(reads).toBe(0);
// Control leg: the same probe on a system object outside the family is read.
expect(isAiReadExposed({ name: 'sys_approval_request', enable })).toBe(true);
expect(reads).toBe(1);
});

it('the refusal of a family member is not the business-object refusal', () => {
const family0 = aiReadExposureRefusal(family[0], { aiReadable: true });
const business = aiReadExposureRefusal('crm_account', { aiReadable: true });
expect(family0).toBeDefined();
expect(business).toBeDefined();
expect(family0?.message).not.toBe(business?.message);
});
});

describe('enable.aiReadable — no write arm', () => {
it('the enable block declares exactly one AI key, and it is the read one', () => {
const aiKeys = Object.keys(ObjectCapabilities.shape).filter((k) => /^(ai|mcp)/i.test(k));
expect(aiKeys).toEqual(['aiReadable']);
});

it.each(['aiWritable', 'aiWrite', 'aiEditable', 'aiExposed'])('refuses enable.%s as an undeclared key', (key) => {
const result = ObjectCapabilities.safeParse({ [key]: true });
expect(result.success).toBe(false);
expect(result.success ? undefined : result.error.issues[0]?.code).toBe('unrecognized_keys');
});

it('takes a boolean only: no value names a write scope', () => {
for (const value of ['write', 'read_write', 'all', 'true', 1]) {
const result = ObjectCapabilities.safeParse({ aiReadable: value });
expect(result.success).toBe(false);
expect(result.success ? undefined : result.error.issues[0]?.code).toBe('invalid_type');
}
});

it('its one reading opens on the boolean true only', () => {
for (const value of ['true', 1, {}, 'read']) {
expect(isAiReadExposed({ name: 'sys_approval_request', enable: { aiReadable: value } })).toBe(false);
}
expect(isAiReadExposed(undefined)).toBe(false);
expect(isAiReadExposed({ name: 'sys_approval_request' })).toBe(false);
});
});
Loading
Loading