From c2ad84e4dd3f4e78d6c085d511027b9a6e4f655f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 01:36:17 +0000 Subject: [PATCH 1/8] test(dogfood): measure a row-conditioned field lock against the write gate on one record Claude-Session: https://claude.ai/code/session_01WGufzfXsZBPnnuWWQGXm48 Co-authored-by: Claude --- .../fixtures/record-field-access-fixture.ts | 97 +++++++++++++++++++ .../record-field-access-when.dogfood.test.ts | 53 ++++++++++ 2 files changed, 150 insertions(+) create mode 100644 packages/qa/dogfood/test/fixtures/record-field-access-fixture.ts create mode 100644 packages/qa/dogfood/test/record-field-access-when.dogfood.test.ts diff --git a/packages/qa/dogfood/test/fixtures/record-field-access-fixture.ts b/packages/qa/dogfood/test/fixtures/record-field-access-fixture.ts new file mode 100644 index 00000000000..688ec034e4a --- /dev/null +++ b/packages/qa/dogfood/test/fixtures/record-field-access-fixture.ts @@ -0,0 +1,97 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// A row-conditioned field lock, as a bootable app: a contract whose legal +// summary is locked only from `approved` onward (`FieldPermissionSchema.when`). +// The shape the downstream contract app ships for its finance persona — "fill +// it on the draft you launch, never touch it once approved". +// +// Two objects: +// +// - `rfa_contract` is wide open at the row level (`public_read_write`), so the +// only gate in play on it is FIELD-level security — the one this fixture is +// about. Its `summary` carries the conditioned lock; `title` carries none +// and is the positive control; `ref_no` carries an unconditioned lock, so a +// row-blind lock is shown to stay a lock on every row. +// - `rfa_memo` is `private`: a member reads only the rows they own. It is the +// row the caller cannot read, for the existence-leak leg. + +import { defineStack } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { PermissionSetSchema, type PermissionSet } from '@objectstack/spec/security'; +import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; + +export const RecordFieldAccessContract = ObjectSchema.create({ + name: 'rfa_contract', + // [ADR-0090 D1] grandfather stamp: row-level sharing is wide open so a row + // refusal can never be mistaken for the field-level answer under test. + sharingModel: 'public_read_write', + label: 'RFA Contract', + pluralLabel: 'RFA Contracts', + fields: { + title: Field.text({ label: 'Title', required: true }), + status: Field.select({ + label: 'Status', + options: [ + { label: 'Draft', value: 'draft' }, + { label: 'Approved', value: 'approved' }, + ], + }), + /** Locked only on approved rows — the conditioned lock. */ + summary: Field.text({ label: 'Summary' }), + /** Locked on every row — the unconditioned control. */ + ref_no: Field.text({ label: 'Reference number' }), + }, +}); + +export const RecordFieldAccessMemo = ObjectSchema.create({ + name: 'rfa_memo', + sharingModel: 'private', + label: 'RFA Memo', + pluralLabel: 'RFA Memos', + fields: { + title: Field.text({ label: 'Title', required: true }), + summary: Field.text({ label: 'Summary' }), + }, +}); + +export const recordFieldAccessFixtureStack = defineStack({ + manifest: { + id: 'com.dogfood.record-field-access-fixture', + namespace: 'rfa', + version: '0.0.0', + type: 'app', + name: 'Record Field Access Fixture', + description: 'A field lock that holds only from a stage onward (field permission `when`).', + }, + objects: [RecordFieldAccessContract, RecordFieldAccessMemo], +} as any); + +/** The `when` the conditioned entry carries, as authored. */ +export const APPROVED_ONLY = "status in ['approved']"; + +/** + * The persona: full CRUD on both objects; `rfa_contract.summary` locked on + * approved rows only, `rfa_contract.ref_no` locked everywhere, and + * `rfa_memo.summary` locked on approved-titled rows (a condition on a row the + * caller may not read at all). + */ +export const recordFieldAccessMemberSet: PermissionSet = PermissionSetSchema.parse({ + name: 'rfa_fixture_member', + label: 'RFA Fixture Member — the summary locks from approval on', + objects: { + rfa_contract: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }, + rfa_memo: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }, + }, + fields: { + 'rfa_contract.summary': { readable: true, editable: false, when: APPROVED_ONLY }, + 'rfa_contract.ref_no': { readable: true, editable: false }, + 'rfa_memo.summary': { readable: true, editable: false, when: "title == 'approved'" }, + }, +}); + +export function recordFieldAccessFixtureSecurity(): SecurityPlugin { + return new SecurityPlugin({ + defaultPermissionSets: [...securityDefaultPermissionSets, recordFieldAccessMemberSet], + fallbackPermissionSet: recordFieldAccessMemberSet.name, + }); +} diff --git a/packages/qa/dogfood/test/record-field-access-when.dogfood.test.ts b/packages/qa/dogfood/test/record-field-access-when.dogfood.test.ts new file mode 100644 index 00000000000..71e598f1bdd --- /dev/null +++ b/packages/qa/dogfood/test/record-field-access-when.dogfood.test.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// A row-conditioned field lock (`FieldPermissionSchema.when`), end to end +// through the real HTTP + security stack: what the console is told about a +// field on ONE record, against what the write gate does with a write of it. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { + recordFieldAccessFixtureStack, + recordFieldAccessFixtureSecurity, +} from './fixtures/record-field-access-fixture.js'; + +const SYS = { isSystem: true } as const; +const MEMBER = 'record-field-access@verify.test'; + +describe('a row-conditioned field lock, on the record the form edits', () => { + let stack: VerifyStack; + let ql: any; + let memberTok: string; + let memberId: string; + let draftId: string; + let approvedId: string; + + beforeAll(async () => { + stack = await bootStack(recordFieldAccessFixtureStack, { security: recordFieldAccessFixtureSecurity() }); + await stack.signIn(); + memberTok = await stack.signUp(MEMBER); + ql = await stack.kernel.getServiceAsync('objectql'); + memberId = (await ql.findOne('sys_user', { where: { email: MEMBER }, context: SYS }))?.id; + expect(memberId, 'member provisioned').toBeTruthy(); + draftId = (await ql.insert('rfa_contract', { title: 'D', status: 'draft' }, { context: SYS })).id; + approvedId = (await ql.insert('rfa_contract', { title: 'A', status: 'approved' }, { context: SYS })).id; + }, 180_000); + + afterAll(async () => { + await stack?.stop?.(); + }); + + it('measure — the row-less reader reports the conditioned entry as its lock', async () => { + const res = await stack.apiAs(memberTok, 'GET', '/auth/me/permissions'); + expect(res.status).toBe(200); + const body: any = await res.json(); + expect(body.fields['rfa_contract.summary']).toEqual({ readable: true, editable: false }); + }, 60_000); + + it('measure — the write gate admits the field on the draft and refuses it on the approved row', async () => { + const onDraft = await stack.apiAs(memberTok, 'PATCH', `/data/rfa_contract/${draftId}`, { summary: 'filled' }); + expect(onDraft.status, await onDraft.clone().text()).toBe(200); + const onApproved = await stack.apiAs(memberTok, 'PATCH', `/data/rfa_contract/${approvedId}`, { summary: 'filled' }); + expect(onApproved.status).toBe(403); + }, 60_000); +}); From 243bd1a1df33d07c91df82872c2af344281a1f18 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 01:40:34 +0000 Subject: [PATCH 2/8] wip(spec,plugin-security,rest,client): record-scoped field projection over the write gate's own verdict Claude-Session: https://claude.ai/code/session_01WGufzfXsZBPnnuWWQGXm48 Co-authored-by: Claude --- packages/client/src/index.ts | 21 +++++ ...tered-security-service-members.pin.test.ts | 1 + .../src/security-plugin-field-readers.ts | 23 +++++- .../src/security-plugin-security-service.ts | 9 ++- .../src/security-plugin.test.ts | 71 ++++++++++++++++ .../plugin-security/src/security-plugin.ts | 64 ++++++++++++++- packages/rest/src/data-action-routes.ts | 81 ++++++++++++++++++- packages/rest/src/rest-route-ledger.ts | 3 + packages/spec/src/api/protocol.zod.ts | 21 +++++ .../spec/src/contracts/security-service.ts | 42 ++++++++++ packages/spec/src/security/permission.zod.ts | 7 +- 11 files changed, 336 insertions(+), 7 deletions(-) diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index e553346c4fe..67cbe73cec0 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -4,6 +4,7 @@ import { QueryAST, SortNode, AggregationNode, isFilterAST, type DroppedFieldsEve import { BatchUpdateRequest, BatchUpdateResponse, + GetRecordFieldAccessResponse, UpdateManyRequest, DeleteManyRequest, BatchOptions, @@ -7400,6 +7401,25 @@ export class ObjectStackClient { return this.unwrapResponse>(res); }, + /** + * [#26594] The field projection of ONE record — + * `GET /data/:object/:id/field-access`, declared by + * `GetRecordFieldAccessResponseSchema` (`@objectstack/spec/api`): the + * fields the caller may read on `object`, and the fields the write gate + * admits on an update of THIS stored row. A record's edit form reads it, so + * a field permission whose `when` names only some rows disables the field + * only on those rows; the row-less `/auth/me/permissions` read keeps + * reporting such an entry as its lock. A row that is absent or that the + * caller cannot read rejects with the record read's 404. + */ + fieldAccess: async (object: string, id: string): Promise => { + const route = this.getRoute('data'); + const res = await this.fetch( + `${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(id)}/field-access`, + ); + return this.unwrapResponse(res); + }, + /** * Streaming export (M10.9): CSV / JSON / XLSX file download. Returns the * raw `Response` — the body is a file stream (`Content-Disposition` @@ -8338,6 +8358,7 @@ export type { CheckPermissionResponse, GetObjectPermissionsResponse, GetEffectivePermissionsResponse, + GetRecordFieldAccessResponse, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeSubscribeRequest, diff --git a/packages/plugins/plugin-security/src/registered-security-service-members.pin.test.ts b/packages/plugins/plugin-security/src/registered-security-service-members.pin.test.ts index 4d3073d3b27..852adee7891 100644 --- a/packages/plugins/plugin-security/src/registered-security-service-members.pin.test.ts +++ b/packages/plugins/plugin-security/src/registered-security-service-members.pin.test.ts @@ -62,6 +62,7 @@ const DECLARED_MEMBERS = { getMetadataReadableFields: 'optional', getQueryableFields: 'optional', getWritableFields: 'optional', + getRecordFieldAccess: 'optional', resolvePermissionSetNames: 'required', resolvePermissionSetsForContext: 'optional', getEffectiveObjectPermissions: 'optional', diff --git a/packages/plugins/plugin-security/src/security-plugin-field-readers.ts b/packages/plugins/plugin-security/src/security-plugin-field-readers.ts index e5bca44c9e9..8b40b33dbb6 100644 --- a/packages/plugins/plugin-security/src/security-plugin-field-readers.ts +++ b/packages/plugins/plugin-security/src/security-plugin-field-readers.ts @@ -82,6 +82,7 @@ export interface FieldReadersHost { readonly permissionEvaluator: SecurityPlugin['permissionEvaluator']; readonly ql: SecurityPlugin['ql']; readonly tenancyPosture: SecurityPlugin['tenancyPosture']; + computeForbiddenFieldWrites: SecurityPlugin['computeForbiddenFieldWrites']; computeQueryGuardFieldPerms: SecurityPlugin['computeQueryGuardFieldPerms']; computeReadPartialMaskRules: SecurityPlugin['computeReadPartialMaskRules']; foldFieldRequiredPermissions: SecurityPlugin['foldFieldRequiredPermissions']; @@ -133,7 +134,7 @@ export async function computeReadableFields( /** The body of `SecurityPlugin#resolveProjectionFieldMask`; the method's docblock there is its contract. */ export async function resolveProjectionFieldMask( - this: Pick, + this: Pick, object: string, context: any, options: { fallbackOnEmptySets: boolean }, @@ -146,6 +147,11 @@ export async function resolveProjectionFieldMask( readPartialMaskRules: () => Record; /** [#20935] The middleware's query-guard map for these sets (computeQueryGuardFieldPerms). */ readQueryGuardPerms: () => Record; + /** + * [#26594] The write gate's verdict (computeForbiddenFieldWrites) on an + * update of `row` naming every field: the fields it refuses there. + */ + readRowForbiddenWrites: (row: Record) => Promise; } > { const objectName = String(object ?? ''); @@ -225,6 +231,21 @@ export async function resolveProjectionFieldMask( readQueryGuardPerms: () => this.computeQueryGuardFieldPerms( objectName, secMeta, permissionSets, delegatorSets, ), + // [#26594 / ruling of record 6457467702] The record-scoped projection's + // edit half is the step 2.5 gate ITSELF, asked over these sets, this + // posture and this delegator's sets, as an update by id of `row` (the + // pre-image, read as the caller reads it) naming every field. The gate + // refuses per field, so the fields it names are exactly the ones a write + // of that row may not touch — a row-conditioned entry judged on `row`, + // the rest as `fieldPerms` above reads them. + readRowForbiddenWrites: (row) => this.computeForbiddenFieldWrites( + objectName, + Object.fromEntries(allFields.map((f) => [f, null])), + secMeta, + permissionSets, + delegatorSets, + { operation: 'update', context, preImage: async () => row }, + ), }; } diff --git a/packages/plugins/plugin-security/src/security-plugin-security-service.ts b/packages/plugins/plugin-security/src/security-plugin-security-service.ts index 9cbdf0f0026..f56b659f4dd 100644 --- a/packages/plugins/plugin-security/src/security-plugin-security-service.ts +++ b/packages/plugins/plugin-security/src/security-plugin-security-service.ts @@ -109,13 +109,14 @@ export interface SecurityServiceHost { getQueryableFields: SecurityPlugin['getQueryableFields']; getReadFilter: SecurityPlugin['getReadFilter']; getReadableFields: SecurityPlugin['getReadableFields']; + getRecordFieldAccess: SecurityPlugin['getRecordFieldAccess']; getWritableFields: SecurityPlugin['getWritableFields']; resolvePermissionSetsForContext: SecurityPlugin['resolvePermissionSetsForContext']; } /** The `security` service registration `SecurityPlugin#start` runs; the comment above its call there is its contract. */ export function registerSecurityService( - this: Pick, + this: Pick, ctx: PluginContext, ql: IObjectQLEngine, metadata: IMetadataService, @@ -154,6 +155,12 @@ export function registerSecurityService( // [#18386] Its write-side twin: the fields step 2.5 would not refuse. // The import template narrows its columns by it. See getWritableFields. getWritableFields: (object: string, context?: any) => this.getWritableFields(object, context), + // [#26594] The field projection of ONE record: its edit half is step + // 2.5's own verdict on the stored row, so a row-conditioned lock (`when`) + // disables a field only on the rows it names. A record's edit form reads + // it. See getRecordFieldAccess. + getRecordFieldAccess: (object: string, recordId: string, context?: any) => + this.getRecordFieldAccess(object, recordId, context), // [#20935] Its query-side twin: the fields steps 2.5b and 2.9 would not // refuse as a group key, an aggregate input, a filter or a sort key. A // field served MASKED is readable and NOT queryable, so a door that diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index 59b1c2e8c18..39c882736e0 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -2119,6 +2119,77 @@ describe('SecurityPlugin', () => { await expect(plugin.canWriteObject('task', 'update', whenCtx, { name: 'renamed' })).resolves.toBe(true); }); + // [#26594 / ruling of record 6457467702] The record-scoped field projection: + // its edit half is the step 2.5 gate's own verdict on the stored row, so it + // must agree with a by-id update of that row, both ways, field by field. + describe('[#26594] getRecordFieldAccess — the field projection of one record', () => { + const rowOf = (status: string) => ({ id: 'r1', owner_id: 'u1', name: 'A', amount: 1, status, assigned_to: null }); + const gateAdmits = async (harness: any, data: Record) => { + const opCtx: any = { object: 'task', operation: 'update', context: whenCtx, data, options: { where: { id: 'r1' } } }; + return harness.run(opCtx).then(() => true, (e: any) => { + if (e?.details?.forbiddenFields) return false; + throw e; + }); + }; + + it('a row the condition does not name: the conditioned field is editable, and the gate admits its write', async () => { + const { plugin, harness } = await bootWhen(APPROVED_LOCK, { findOneImpl: () => rowOf('draft') }); + const access = await plugin.getRecordFieldAccess('task', 'r1', whenCtx); + expect(access).toEqual({ object: 'task', recordId: 'r1', readable: whenFields, editable: whenFields }); + await expect(gateAdmits(harness, { amount: 2 })).resolves.toBe(true); + // Read as the CALLER, by id — the gate's own pre-image reader. + expect(harness.findOne).toHaveBeenCalledWith('task', expect.objectContaining({ where: { id: 'r1' }, context: whenCtx })); + }); + + it('a row the condition names: the field is locked, and the gate refuses its write', async () => { + const { plugin, harness } = await bootWhen(APPROVED_LOCK, { findOneImpl: () => rowOf('approved') }); + const access = await plugin.getRecordFieldAccess('task', 'r1', whenCtx); + expect(access?.editable).toEqual(whenFields.filter((f) => f !== 'amount')); + expect(access?.readable).toEqual(whenFields); + await expect(gateAdmits(harness, { amount: 2 })).resolves.toBe(false); + }); + + it('equals the gate verdict for every field, on a row each side of the condition', async () => { + for (const status of ['draft', 'approved']) { + const { plugin, harness } = await bootWhen(APPROVED_LOCK, { findOneImpl: () => rowOf(status) }); + const access = await plugin.getRecordFieldAccess('task', 'r1', whenCtx); + for (const field of whenFields) { + const admitted = await gateAdmits(harness, { [field]: 'x' }); + expect(access!.editable.includes(field), `${status}.${field}`).toBe(admitted); + } + } + }); + + it('a row that is absent or that the caller cannot read answers null — no projection is served for it', async () => { + const { plugin } = await bootWhen(APPROVED_LOCK, { findOneImpl: () => null }); + await expect(plugin.getRecordFieldAccess('task', 'r1', whenCtx)).resolves.toBeNull(); + }); + + it('the row-less projection is unchanged: it still reports the conditioned entry as its lock', async () => { + const { plugin, harness } = await bootWhen(APPROVED_LOCK, { findOneImpl: () => rowOf('draft') }); + await expect(plugin.getWritableFields('task', whenCtx)).resolves.toEqual(whenFields.filter((f) => f !== 'amount')); + // …and it reads no row to say so. + expect(harness.findOne).not.toHaveBeenCalled(); + }); + + it('an unresolvable schema is "no answer", and a system context reads every field', async () => { + const { plugin } = await bootWhen(APPROVED_LOCK, { findOneImpl: () => rowOf('approved') }); + await expect(plugin.getRecordFieldAccess('', 'r1', whenCtx)).resolves.toBeUndefined(); + await expect(plugin.getRecordFieldAccess('task', 'r1', { isSystem: true })).resolves.toEqual({ + object: 'task', recordId: 'r1', readable: whenFields, editable: whenFields, + }); + }); + + it('is the method the registered security service answers from', async () => { + const { plugin, harness } = await bootWhen(APPROVED_LOCK, { findOneImpl: () => rowOf('approved') }); + const svc = harness.ctx.registerService.mock.calls.find((c: any[]) => c[0] === 'security')?.[1]; + expect(typeof svc?.getRecordFieldAccess).toBe('function'); + await expect(svc.getRecordFieldAccess('task', 'r1', whenCtx)).resolves.toEqual( + await plugin.getRecordFieldAccess('task', 'r1', whenCtx), + ); + }); + }); + it('[#2982 follow-up] bulk-write predicate guard inspects the CALLER predicate, not injected owner/RLS filters', async () => { // The anti-oracle guard (2.9) must reject a caller filtering on an // FLS-hidden field, but must NOT reject an owner_id predicate a sibling diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index b4f24a58621..ca19e11ae47 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -2095,7 +2095,7 @@ export class SecurityPlugin implements Plugin { const readPermissionEvaluator = () => this.permissionEvaluator; const readQl = () => this.ql; const readTenancyPosture = () => this.tenancyPosture; - const host: Pick = { + const host: Pick = { acceptedDefaultPermissionSets: this.acceptedDefaultPermissionSets, canExport: (...a) => this.canExport(...a), canReadObject: (...a) => this.canReadObject(...a), @@ -2111,6 +2111,7 @@ export class SecurityPlugin implements Plugin { getQueryableFields: (...a) => this.getQueryableFields(...a), getReadFilter: (...a) => this.getReadFilter(...a), getReadableFields: (...a) => this.getReadableFields(...a), + getRecordFieldAccess: (...a) => this.getRecordFieldAccess(...a), getWritableFields: (...a) => this.getWritableFields(...a), get logger() { return readLogger(); }, ownershipFloorAlternates: this.ownershipFloorAlternates, @@ -2969,6 +2970,62 @@ export class SecurityPlugin implements Plugin { return mask.allFields.filter((f) => !nonEditable.has(f)); } + /** + * [#26594 / ruling of record 6457467702] Query surface: the field projection + * of ONE record — which fields the caller may read on `object`, and which it + * may write on the stored row `recordId`. The record-scoped twin of + * {@link getReadableFields} and {@link getWritableFields}, for the one reader + * that holds a row: a record's edit form. + * + * One judge. `editable` is the step 2.5 write gate's own verdict + * ({@link computeForbiddenFieldWrites}) on an update by id of that row + * naming every field, over the same sets, posture and delegator + * intersection the gate takes ({@link resolveProjectionFieldMask}), with the + * row read as the CALLER reads it — the reader the gate's own pre-image is + * read by ({@link getCallerPreImage} → {@link readRowById}). So a + * row-conditioned entry (`FieldPermissionSchema.when`) locks a field only on + * the rows its condition names, and a field is in `editable` iff a by-id + * update of that row naming it passes step 2.5. No second evaluator of + * `when` exists for this answer to drift from. + * + * `readable` is {@link getReadableFields}' answer from the same mask: reads + * are judged row-blind. + * + * Never wider than the read path: `null` for a row that is absent or that the + * caller cannot read — the same no-row read, deliberately indistinguishable + * — and a read the middleware refuses outright (no read grant) rejects as it + * does there. `undefined` when the schema cannot be resolved, as on the + * object-wide projections. A system context reads every field. + */ + async getRecordFieldAccess( + object: string, + recordId: string, + context?: any, + ): Promise<{ object: string; recordId: string; readable: string[]; editable: string[] } | null | undefined> { + const objectName = String(object ?? ''); + const id = String(recordId ?? ''); + const mask = await this.resolveProjectionFieldMask(objectName, context, { fallbackOnEmptySets: false }); + if (mask.kind === 'answer' && mask.fields === undefined) return undefined; + if (!id) return null; + const row = await this.readRowById(objectName, id, context); + if (!row) return null; + // The read half from the SAME mask, through the read projection's own body. + const readable = await computeReadableFields.call( + { resolveProjectionFieldMask: async () => mask }, + objectName, context, { fallbackOnEmptySets: false }, + ); + if (mask.kind === 'answer') { + return { object: objectName, recordId: id, readable: readable ?? [], editable: mask.fields ?? [] }; + } + const forbidden = new Set(await mask.readRowForbiddenWrites(row)); + return { + object: objectName, + recordId: id, + readable: readable ?? [], + editable: mask.allFields.filter((f) => !forbidden.has(f)), + }; + } + /** * [#20935] Query surface: the field names the caller may QUERY ON in * `object` — filter, sort, group or aggregate by — the query-side twin of @@ -3023,6 +3080,8 @@ export class SecurityPlugin implements Plugin { readPartialMaskRules: () => Record; /** [#20935] The middleware's query-guard map for these sets (computeQueryGuardFieldPerms). */ readQueryGuardPerms: () => Record; + /** [#26594] The write gate's verdict on an update of `row` naming every field (computeForbiddenFieldWrites). */ + readRowForbiddenWrites: (row: Record) => Promise; } > { // [#25223 cut 9] The body moved to `security-plugin-field-readers.ts` (see `FieldReadersHost`). @@ -3030,7 +3089,8 @@ export class SecurityPlugin implements Plugin { const readPermissionEvaluator = () => this.permissionEvaluator; const readQl = () => this.ql; const readTenancyPosture = () => this.tenancyPosture; - const host: Pick = { + const host: Pick = { + computeForbiddenFieldWrites: (...a) => this.computeForbiddenFieldWrites(...a), computeQueryGuardFieldPerms: (...a) => this.computeQueryGuardFieldPerms(...a), computeReadPartialMaskRules: (...a) => this.computeReadPartialMaskRules(...a), foldFieldRequiredPermissions: (...a) => this.foldFieldRequiredPermissions(...a), diff --git a/packages/rest/src/data-action-routes.ts b/packages/rest/src/data-action-routes.ts index 989eca22bde..11546c5099b 100644 --- a/packages/rest/src/data-action-routes.ts +++ b/packages/rest/src/data-action-routes.ts @@ -47,6 +47,7 @@ import type { CreateDataRequest, UpdateDataRequest, DeleteDataRequest, + GetDataRequest, } from '@objectstack/spec/api'; // [#20061] The DECLARED request schema the import-job list door parses its // numeric parameters through — see `readDeclaredQueryNumber` in `rest-server.ts`. @@ -75,7 +76,10 @@ import { handleRouteError, logUnexpectedRouteError, mapDataError, nestedRefusalE // [#24348] The shared ADR-0112 nested envelope for the import doors' control // opt-out refusal — the flat sibling-`code` dialect the doors' older answers // use is ratcheted down by `check:route-envelope`. -import { sendError as sendEnvelopeError } from '@objectstack/types'; +import { sendError as sendEnvelopeError, sendOk } from '@objectstack/types'; +// [#26594] The read path's ONE no-row answer, for a row that left the caller's +// sight between the record read and the projection. +import { recordNotFoundError } from '@objectstack/core'; import { logError } from './log.js'; import { DATA_EXPORT_PARAMS, @@ -179,6 +183,16 @@ function pickExportKeys(row: Record, fields: readonly string[]) return out; } +/** + * [#26594] The closed query-parameter set of + * `GET {basePath}/data/:object/:id/field-access`: EMPTY. Measured from the + * handler's reads — the object and the record are path parameters, and neither + * the record read it makes (`getData`, called with no `select` / `expand`) nor + * the projection reads `req.query` — so any name is refused with a located 400 + * rather than dropped. + */ +export const RECORD_FIELD_ACCESS_PARAMS: readonly string[] = []; + /** * The `RestServer` members {@link registerDataActionEndpoints} reads — every * one of them, and no other. Each is typed AS the class member (an indexed @@ -326,6 +340,71 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath: }, }); + // GET /data/:object/:id/field-access — the field projection of ONE record + // (`GetRecordFieldAccessResponseSchema`): the fields the caller may read, + // and the fields it may write on this stored row as the write gate judges + // an update of it. The answer a record's edit form reads, so a field + // permission whose `when` names only some rows disables the field only on + // those rows. `/auth/me/permissions` holds no row and stays as it is. + // + // Never wider than the read path: the record is read first exactly as + // `GET /data/:object/:id` reads it (`getData`, same gates, same mapped + // refusals), so a row that is absent or that the caller cannot read + // answers that route's 404, and no projection discloses its existence. + this.routeManager.register({ + method: 'GET', + path: `${dataPath}/:object/:id/field-access`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + const p = await this.resolveProtocol(environmentId, req); + const context = await this.resolveExecCtx(environmentId, req); + if (this.enforceAuth(req, res, context)) return; + if (await this.enforceApiAccess(req, res, p, environmentId, 'get')) return; + // Closed and empty: the record is the path, and nothing else is read. + if (refuseUnknownQueryParams(req, res, RECORD_FIELD_ACCESS_PARAMS)) return; + const object = String(req.params.object); + const id = String(req.params.id); + // The read path's own answer for this row, refusals included. + await p.getData({ + object, + id, + ...(environmentId ? { environmentId } : {}), + ...(context ? { context } : {}), + } satisfies ServerScopedDataRequest); + const security = await this.resolveSecurityService(environmentId, req); + if (typeof security?.getRecordFieldAccess !== 'function') { + sendEnvelopeError( + res, 501, 'NOT_IMPLEMENTED', + 'The record field projection is not available on this deployment (no security service with ' + + 'getRecordFieldAccess); read the row-less one at /auth/me/permissions instead.', + ); + return; + } + const access = await security.getRecordFieldAccess(object, id, context); + // The row went away (or out of the caller's sight) after the + // read above: the read path's own no-row answer. + if (access === null) throw recordNotFoundError(object, id); + if (access === undefined) { + sendEnvelopeError( + res, 501, 'NOT_IMPLEMENTED', + `The record field projection has no answer for '${object}' (its schema could not be resolved).`, + ); + return; + } + sendOk(res, access); + } catch (error: any) { + const mapped = mapDataError(error, req.params?.object); + logUnexpectedRouteError(error, mapped); + res.status(mapped.status).json(mapped.body); + } + }, + metadata: { + summary: 'Field projection of one record: readable fields, and the fields the write gate admits on this row', + tags: ['data', 'security'], + }, + }); + // POST /data/:object/import — bulk CSV/JSON ingestion (M10.9) // // Body shapes: diff --git a/packages/rest/src/rest-route-ledger.ts b/packages/rest/src/rest-route-ledger.ts index b6dd2271294..fd237507b52 100644 --- a/packages/rest/src/rest-route-ledger.ts +++ b/packages/rest/src/rest-route-ledger.ts @@ -361,6 +361,9 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [ { route: 'POST /api/v1/data/:object/:id/clone', family: 'data-actions', source: 'route-manager', disposition: 'sdk', client: 'data.clone', responseSchema: 'CloneDataResponseSchema', note: 'Answers BARE with 201 (res.status(201).json(result), no envelope), so the named schema is the whole body. Filled with its conformance coverage: search-clone-route-schema-conformance.test.ts drives this mount, and metadata-protocol\'s search-clone-schema-conformance.test.ts parses the real cloneData producer' }, + { route: 'GET /api/v1/data/:object/:id/field-access', family: 'data-actions', source: 'route-manager', disposition: 'sdk', client: 'data.fieldAccess', + responseSchema: 'GetRecordFieldAccessResponseSchema', + note: 'The field projection of ONE record, for its edit form: `editable` is the write gate\'s own verdict (SecurityPlugin computeForbiddenFieldWrites) on an update of the stored row, so a row-conditioned field permission (`when`) locks only the rows it names; `/auth/me/permissions` stays row-less. Reads the record through getData first, so an absent or unreadable row answers the record read\'s 404. Enveloped ({ success, data }); record-field-access-when.dogfood.test.ts parses `data` against the named schema on the real mount' }, { route: 'POST /api/v1/data/:object/import', family: 'data-actions', source: 'route-manager', disposition: 'sdk', client: 'data.import' }, { route: 'POST /api/v1/data/:object/import/jobs', family: 'data-actions', source: 'route-manager', disposition: 'sdk', client: 'data.createImportJob' }, { route: 'POST /api/v1/data/import/jobs/:jobId/cancel', family: 'data-actions', source: 'route-manager', disposition: 'sdk', client: 'data.cancelImportJob' }, diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 84b9fb2c301..40f0c665b30 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -3102,6 +3102,26 @@ export const GetEffectivePermissionsResponseSchema = lazySchema(() => z.object({ systemPermissions: z.array(z.string()).describe('Effective system-level permissions'), })); +/** + * The field projection of ONE record (`GET /data/{object}/{id}/field-access`): + * the answer a record's edit form reads. `editable` is judged on the stored row + * by the write gate itself, so a field permission whose `when` names only some + * rows locks the field only on those rows; `/auth/me/permissions`, which holds + * no row, keeps reporting such an entry as its lock. + */ +export const GetRecordFieldAccessResponseSchema = lazySchema(() => z.object({ + object: z.string().describe('Object name'), + recordId: z.string().describe('The record the projection was judged on'), + readable: z.array(z.string()).describe( + 'Fields the caller may read on this object. Reads are judged row-blind, so this equals the object-wide read projection', + ), + editable: z.array(z.string()).describe( + 'Fields the caller may write on this record, as field-level security judges an update of the stored row: a ' + + 'row-conditioned field permission locks a field only on the rows its condition names. A field absent here is ' + + 'refused by the write gate. The object grant and a field\'s own rules (readonly, computed types) are not part of it', + ), +})); + // ========================================== // Workflow Operations — REMOVED (#4451, v17) // ========================================== @@ -3915,6 +3935,7 @@ export type GetObjectPermissionsResponse = z.input; export type GetEffectivePermissionsRequest = z.input; export type GetEffectivePermissionsResponse = z.input; +export type GetRecordFieldAccessResponse = z.input; // Workflow Types — removed with the schemas (#4451, v17); see the block comment // at "Workflow Operations — REMOVED" above. diff --git a/packages/spec/src/contracts/security-service.ts b/packages/spec/src/contracts/security-service.ts index ef3704921a9..7297c6b0750 100644 --- a/packages/spec/src/contracts/security-service.ts +++ b/packages/spec/src/contracts/security-service.ts @@ -61,6 +61,7 @@ * defensively and keep a fallback path — rather than assume registration. */ +import type { GetRecordFieldAccessResponse } from '../api/protocol.zod.js'; import type { FilterCondition } from '../data/filter.zod.js'; import type { ExecutionContext } from '../kernel/execution-context.zod.js'; import type { ExplainDecision, ExplainOperation } from '../security/explain.zod.js'; @@ -604,6 +605,47 @@ export interface ISecurityService { */ getWritableFields?(object: string, context?: SecurityContext): Promise; + /** + * The field projection of ONE record: which fields `context` may read on + * `object`, and which it may write on the stored row `recordId` — the + * record-scoped twin of {@link getReadableFields} and + * {@link getWritableFields}, for a reader that holds the row (a record's + * edit form). + * + * **`editable` is the write gate's own verdict on that row, ⛔ never a + * second evaluator.** It is the complement of the fields the field-level + * write gate refuses on an update of `recordId` naming every field, judged + * on the stored row read as the caller reads it — the same pre-image an + * update by id is judged on. So a row-conditioned field permission + * (`FieldPermissionSchema.when`) contributes its lock only on the rows its + * condition names, and a field is in `editable` iff a by-id update of that + * row naming it passes field-level security. Like {@link getWritableFields}, + * neither the object grant nor a field's own rules (`readonly`, a computed + * type) are part of the answer. + * + * **`readable` is {@link getReadableFields}' answer**, unchanged: reads are + * judged row-blind (a `when` reaches the edit vote only). + * + * **Never wider than the read path.** `null` means there is no row for this + * caller — absent, or one the caller cannot read — and the two are + * deliberately the same answer, so the projection discloses no existence the + * read refuses. `undefined` is "no answer — use your own fallback" (the + * object schema could not be resolved), as on the object-wide projections. + * A system context bypasses field-level security and is answered every + * field, for a row that exists. + * + * **OPTIONAL, and absence is a defined state.** A security service that + * predates it omits it; consumers feature-detect + * (`typeof svc.getRecordFieldAccess === 'function'`) and keep the row-less + * reader, which applies every row-conditioned entry as its lock and so is + * never wider than the gate. + */ + getRecordFieldAccess?( + object: string, + recordId: string, + context?: SecurityContext, + ): Promise; + /** * The effective permission-set NAMES for `context` — positions expanded and * the additive baseline applied, i.e. the same set the middleware enforces diff --git a/packages/spec/src/security/permission.zod.ts b/packages/spec/src/security/permission.zod.ts index 834a29de59c..2dccf577365 100644 --- a/packages/spec/src/security/permission.zod.ts +++ b/packages/spec/src/security/permission.zod.ts @@ -992,8 +992,11 @@ export const FieldPermissionSchema = lazySchema(() => strictObject( + 'request) the entry is judged as its lock: it contributes no edit grant on that write. Validated like an RLS ' + 'predicate at authoring and publish time: the fields it names must exist on the entry\'s object and it may ' + 'not reach across a relation; a condition that cannot be lowered is refused there, never dropped at runtime. ' - + 'Readers that hold no row (`/auth/me/permissions`, the field projections, the write preview on an update) ' - + 'report a row-conditioned entry as its lock, so a client never advertises an edit the gate refuses.', + + 'Readers that hold no row (`/auth/me/permissions`, the object-wide field projections, the write preview on an ' + + 'update) report a row-conditioned entry as its lock, so a client never advertises an edit the gate refuses. ' + + 'The record-scoped field projection (`GET /data/{object}/{id}/field-access`, the answer a record\'s edit form ' + + 'reads) holds the row: it judges the condition on the stored row exactly as the write gate does on an update ' + + 'of that row, so a lock that applies only from a stage onward disables the field only on the rows it names.', ), }).refine( // The cross-key rule: a condition reaches the edit vote only, so an entry From e8f2903eec1a98d6795ae8e5ecf03757dc7a98e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 01:48:23 +0000 Subject: [PATCH 3/8] wip: rest route test, client test, docs, changesets Claude-Session: https://claude.ai/code/session_01WGufzfXsZBPnnuWWQGXm48 Co-authored-by: Claude --- .../26594-record-field-access-client.md | 10 ++ .changeset/26594-record-field-access-rest.md | 10 ++ .../26594-record-field-access-security.md | 10 ++ .changeset/26594-record-field-access-spec.md | 12 ++ .../docs/permissions/field-level-security.mdx | 17 ++- packages/client/src/client.test.ts | 9 ++ .../src/security-plugin.test.ts | 4 +- .../record-field-access-when.dogfood.test.ts | 83 ++++++++++- .../src/record-field-access-route.test.ts | 139 ++++++++++++++++++ packages/spec/authorable-surface/api.json | 4 + packages/spec/json-schema.manifest/api.json | 1 + 11 files changed, 291 insertions(+), 8 deletions(-) create mode 100644 .changeset/26594-record-field-access-client.md create mode 100644 .changeset/26594-record-field-access-rest.md create mode 100644 .changeset/26594-record-field-access-security.md create mode 100644 .changeset/26594-record-field-access-spec.md create mode 100644 packages/rest/src/record-field-access-route.test.ts diff --git a/.changeset/26594-record-field-access-client.md b/.changeset/26594-record-field-access-client.md new file mode 100644 index 00000000000..8a058bdecd3 --- /dev/null +++ b/.changeset/26594-record-field-access-client.md @@ -0,0 +1,10 @@ +--- +'@objectstack/client': minor +--- + +feat(client): `data.fieldAccess(object, id)` reads the field projection of one record + +Clause-②: yes (widening) +Metadata-protocol: added + +`client.data.fieldAccess(object, id)` calls `GET /data/:object/:id/field-access` and resolves to `GetRecordFieldAccessResponse`: the fields the caller may read, and the fields the write gate admits on an update of that stored row. A field permission whose `when` names only some rows is unlocked on the rows it does not name. A row that is absent or unreadable rejects with the record read's 404. diff --git a/.changeset/26594-record-field-access-rest.md b/.changeset/26594-record-field-access-rest.md new file mode 100644 index 00000000000..893ee65ad13 --- /dev/null +++ b/.changeset/26594-record-field-access-rest.md @@ -0,0 +1,10 @@ +--- +'@objectstack/rest': minor +--- + +feat(rest): `GET /data/:object/:id/field-access` serves the field projection of one record + +Clause-②: yes (widening) +Metadata-protocol: added + +A record's edit form can ask which fields it may write on THAT record: the route answers `{ success: true, data: { object, recordId, readable, editable } }` (`GetRecordFieldAccessResponseSchema`), with `editable` judged on the stored row by the write gate through the security service's `getRecordFieldAccess`. The record is read first exactly as `GET /data/:object/:id` reads it, so an absent row or one the caller cannot read answers that route's 404 and the projection discloses nothing more. The route takes no query parameter (a located 400 names any). Without a security service that offers the method it answers 501, and `/auth/me/permissions` remains the row-less reader. diff --git a/.changeset/26594-record-field-access-security.md b/.changeset/26594-record-field-access-security.md new file mode 100644 index 00000000000..c88a344d067 --- /dev/null +++ b/.changeset/26594-record-field-access-security.md @@ -0,0 +1,10 @@ +--- +'@objectstack/plugin-security': minor +--- + +feat(plugin-security): `getRecordFieldAccess` — a record's field projection from the write gate's own verdict + +Clause-②: yes (widening) +Metadata-protocol: added + +The `security` service answers `getRecordFieldAccess(object, recordId, context)`. It reads the row as the caller reads it (the reader the write gate's pre-image comes from) and asks the field-level write gate itself, `computeForbiddenFieldWrites`, for an update of that row naming every field, over the same permission sets, posture and delegator intersection the gate takes. A field permission whose `when` names only some rows therefore locks the field only on those rows, and the answer cannot disagree with a write of that row. A row that is absent or unreadable answers `null`. `getWritableFields` and every other row-less reader are unchanged and still apply each row-conditioned entry as its lock. diff --git a/.changeset/26594-record-field-access-spec.md b/.changeset/26594-record-field-access-spec.md new file mode 100644 index 00000000000..70271f99153 --- /dev/null +++ b/.changeset/26594-record-field-access-spec.md @@ -0,0 +1,12 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec): a record-scoped field projection, judged on the stored row by the write gate + +Clause-②: yes (widening) +Metadata-protocol: added + +`GetRecordFieldAccessResponseSchema` (`@objectstack/spec/api`) declares the answer of `GET /api/v1/data/{object}/{id}/field-access`: `{ object, recordId, readable, editable }`, the field projection of ONE record. `ISecurityService` declares its source as the optional `getRecordFieldAccess(object, recordId, context)`: `editable` is the write gate's own verdict on an update of the stored row, so a field permission whose `when` names only some rows locks the field only on those rows, and `readable` is the row-blind read projection. `null` means the caller has no row there (absent or unreadable, deliberately the same answer); `undefined` means no answer. + +The `FieldPermissionSchema.when` description now names that reader: the row-less readers (`/auth/me/permissions`, the object-wide field projections, the write preview on an update) keep reporting a row-conditioned entry as its lock, and the record-scoped projection judges it on the row. Nothing an author writes changes meaning. diff --git a/content/docs/permissions/field-level-security.mdx b/content/docs/permissions/field-level-security.mdx index e59541c88bc..4e54f02fe9d 100644 --- a/content/docs/permissions/field-level-security.mdx +++ b/content/docs/permissions/field-level-security.mdx @@ -52,10 +52,19 @@ fields: { address, a stored row the caller cannot read, a condition that does not resolve for the request — the entry is applied as its **lock**: no edit grant on that write, and a WARN line says why. -- **Readers that hold no row** (`/auth/me/permissions`, the writable-field - projections, `os explain`) report a row-conditioned entry as its lock, so - a client never advertises an edit the gate refuses. `os explain` names - each such entry as a row-conditioned lock in its `fls` layer. +- **Readers that hold no row** (`/auth/me/permissions`, the object-wide + writable-field projections, `os explain`) report a row-conditioned entry + as its lock, so a client never advertises an edit the gate refuses. + `os explain` names each such entry as a row-conditioned lock in its `fls` + layer. +- **The record-scoped projection holds the row.** + `GET /api/v1/data/{object}/{id}/field-access` (`client.data.fieldAccess`) + answers the fields the caller may read and the fields it may write on + **that** record, and its `editable` list is the write gate's own verdict on + an update of the stored row. So a lock that applies from a stage onward + disables the field only on the rows its condition names — this is the + answer a record's edit form reads. A row that is absent or that the caller + cannot read answers the record read's `404`. - **Union without deny is unchanged.** A matched conditioned lock is still out-voted by another set's unconditioned `editable: true`; an entry without `when` votes on every row, as before (ADR-0066 ⑧, note of diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts index 07e3fad6813..5a68ab1601c 100644 --- a/packages/client/src/client.test.ts +++ b/packages/client/src/client.test.ts @@ -542,6 +542,15 @@ describe('Data actions, email, dataset query, external datasources (#3587 gap cl expect(JSON.parse(init.body)).toEqual({ overrides: { name: 'Copy of Acme' } }); }); + it('[#26594] data.fieldAccess pins GET /data/:object/:id/field-access and unwraps the envelope', async () => { + const access = { object: 'lead', recordId: 'rec 1', readable: ['name'], editable: [] }; + const { client, fetchMock } = createMockClient({ success: true, data: access }); + await expect(client.data.fieldAccess('lead', 'rec 1')).resolves.toEqual(access); + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toBe('http://localhost:3000/api/v1/data/lead/rec%201/field-access'); + expect(init?.method ?? 'GET').toBe('GET'); + }); + it('data.export pins GET /data/:object/export and returns the raw Response', async () => { const { client, fetchMock } = createMockClient({}); const res = await client.data.export('lead', { format: 'xlsx', limit: 100, filter: { status: 'open' }, orderby: 'name:asc', header: false }); diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index 39c882736e0..bf0d540e1bb 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -2154,7 +2154,9 @@ describe('SecurityPlugin', () => { const { plugin, harness } = await bootWhen(APPROVED_LOCK, { findOneImpl: () => rowOf(status) }); const access = await plugin.getRecordFieldAccess('task', 'r1', whenCtx); for (const field of whenFields) { - const admitted = await gateAdmits(harness, { [field]: 'x' }); + // The stored value written back, so no other gate (an ownership + // change, a move across the condition) answers instead of 2.5. + const admitted = await gateAdmits(harness, { [field]: (rowOf(status) as any)[field] ?? 'x' }); expect(access!.editable.includes(field), `${status}.${field}`).toBe(admitted); } } diff --git a/packages/qa/dogfood/test/record-field-access-when.dogfood.test.ts b/packages/qa/dogfood/test/record-field-access-when.dogfood.test.ts index 71e598f1bdd..0370ce16f93 100644 --- a/packages/qa/dogfood/test/record-field-access-when.dogfood.test.ts +++ b/packages/qa/dogfood/test/record-field-access-when.dogfood.test.ts @@ -1,11 +1,20 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // -// A row-conditioned field lock (`FieldPermissionSchema.when`), end to end -// through the real HTTP + security stack: what the console is told about a -// field on ONE record, against what the write gate does with a write of it. +// [#26594 / ruling of record 6457467702] A row-conditioned field lock +// (`FieldPermissionSchema.when`), end to end through the real HTTP + security +// stack: what the console is told about a field on ONE record, against what the +// write gate does with a write of it. +// +// The two `measure` legs are the card's symptom and stay as its record: the +// row-less reader (`/auth/me/permissions`) reports the conditioned entry as its +// lock on every row, while the write gate admits the field on a draft. The rest +// pin the record-scoped projection (`GET /data/:object/:id/field-access`): it +// answers what the gate does, row by row and field by field, and never more +// than the record read discloses. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { GetRecordFieldAccessResponseSchema } from '@objectstack/spec/api'; import { recordFieldAccessFixtureStack, recordFieldAccessFixtureSecurity, @@ -21,6 +30,7 @@ describe('a row-conditioned field lock, on the record the form edits', () => { let memberId: string; let draftId: string; let approvedId: string; + let hiddenMemoId: string; beforeAll(async () => { stack = await bootStack(recordFieldAccessFixtureStack, { security: recordFieldAccessFixtureSecurity() }); @@ -31,6 +41,8 @@ describe('a row-conditioned field lock, on the record the form edits', () => { expect(memberId, 'member provisioned').toBeTruthy(); draftId = (await ql.insert('rfa_contract', { title: 'D', status: 'draft' }, { context: SYS })).id; approvedId = (await ql.insert('rfa_contract', { title: 'A', status: 'approved' }, { context: SYS })).id; + // A private row the member does not own, so cannot read. + hiddenMemoId = (await ql.insert('rfa_memo', { title: 'approved' }, { context: SYS })).id; }, 180_000); afterAll(async () => { @@ -50,4 +62,69 @@ describe('a row-conditioned field lock, on the record the form edits', () => { const onApproved = await stack.apiAs(memberTok, 'PATCH', `/data/rfa_contract/${approvedId}`, { summary: 'filled' }); expect(onApproved.status).toBe(403); }, 60_000); + + const fieldAccess = async (object: string, id: string, query = '') => { + const res = await stack.apiAs(memberTok, 'GET', `/data/${object}/${id}/field-access${query}`); + return { status: res.status, body: (await res.json().catch(() => undefined)) as any }; + }; + const stored = async (id: string) => ql.findOne('rfa_contract', { where: { id }, context: SYS }); + const FIELDS = ['title', 'status', 'summary', 'ref_no'] as const; + + it('the record-scoped projection unlocks the conditioned field on a row the condition does not name', async () => { + const { status, body } = await fieldAccess('rfa_contract', draftId); + expect(status).toBe(200); + expect(body.success).toBe(true); + const access = GetRecordFieldAccessResponseSchema.parse(body.data); + expect(access.object).toBe('rfa_contract'); + expect(access.recordId).toBe(draftId); + expect(access.editable).toEqual(expect.arrayContaining(['title', 'status', 'summary'])); + // The unconditioned lock stays a lock on every row. + expect(access.editable).not.toContain('ref_no'); + expect(access.readable).toEqual(expect.arrayContaining([...FIELDS])); + }, 60_000); + + it('and locks it on a row the condition names', async () => { + const { status, body } = await fieldAccess('rfa_contract', approvedId); + expect(status).toBe(200); + const access = GetRecordFieldAccessResponseSchema.parse(body.data); + expect(access.editable).not.toContain('summary'); + expect(access.editable).not.toContain('ref_no'); + expect(access.editable).toEqual(expect.arrayContaining(['title', 'status'])); + }, 60_000); + + it('equals the write gate, field by field, on a row each side of the condition (allowed and refused)', async () => { + for (const id of [draftId, approvedId]) { + const access = GetRecordFieldAccessResponseSchema.parse((await fieldAccess('rfa_contract', id)).body.data); + const row: any = await stored(id); + for (const field of FIELDS) { + // Write the stored value back (a placeholder where none is stored), so + // no write moves the row across its own condition. + const value = row[field] ?? 'x'; + const res = await stack.apiAs(memberTok, 'PATCH', `/data/rfa_contract/${id}`, { [field]: value }); + const admitted = res.status === 200; + if (!admitted) expect(res.status, `${row.status}.${field}: ${await res.clone().text()}`).toBe(403); + expect(access.editable.includes(field), `${row.status}.${field}`).toBe(admitted); + } + } + }, 120_000); + + it('a record the caller cannot read answers exactly as the record read does — no projection leaks it', async () => { + for (const id of [hiddenMemoId, 'rfa_no_such_row']) { + const read = await stack.apiAs(memberTok, 'GET', `/data/rfa_memo/${id}`); + const projected = await fieldAccess('rfa_memo', id); + expect(read.status).toBe(404); + expect(projected.status).toBe(read.status); + expect(projected.body).toEqual(await read.json()); + } + }, 60_000); + + it('the row-less reader still answers as before, and the route takes no query parameter', async () => { + const res = await stack.apiAs(memberTok, 'GET', '/auth/me/permissions'); + const body: any = await res.json(); + expect(body.fields['rfa_contract.summary']).toEqual({ readable: true, editable: false }); + expect(body.fields['rfa_contract.ref_no']).toEqual({ readable: true, editable: false }); + const refused = await fieldAccess('rfa_contract', draftId, '?fields=summary'); + expect(refused.status).toBe(400); + expect(refused.body?.error?.code).toBe('VALIDATION_ERROR'); + }, 60_000); }); diff --git a/packages/rest/src/record-field-access-route.test.ts b/packages/rest/src/record-field-access-route.test.ts new file mode 100644 index 00000000000..cef83eaaa2d --- /dev/null +++ b/packages/rest/src/record-field-access-route.test.ts @@ -0,0 +1,139 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#26594 / ruling of record 6457467702] `GET /data/:object/:id/field-access` + * — the field projection of ONE record, the answer a record's edit form reads. + * + * What the route owes, each pinned below: + * + * - the record is read FIRST, exactly as `GET /data/:object/:id` reads it, so + * a refusal of that read (404 for an absent or unreadable row, 403) is this + * route's answer too and the projection is never asked; + * - the answer is the security service's `getRecordFieldAccess`, relayed in + * the shared envelope, asked with the caller's object, record and context; + * - a row that leaves the caller's sight between the two reads answers the + * read path's own 404, never an empty projection; + * - no service with the method → a loud 501, never an invented projection; + * - a CLOSED, empty query-parameter set: any name is a located 400 and the + * service is never called. + * + * The end-to-end half — the real security stack judging a `when` on a row each + * side of its condition — is `record-field-access-when.dogfood.test.ts`. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { RestServer } from './rest-server.js'; +import { RECORD_FIELD_ACCESS_PARAMS } from './data-action-routes.js'; +import { unknownQueryParamMessage } from './query-allowlist.js'; + +const TASK = { + name: 'task', + label: 'Task', + fields: { + id: { name: 'id', type: 'text', label: 'ID' }, + title: { name: 'title', type: 'text', label: 'Title' }, + amount: { name: 'amount', type: 'number', label: 'Amount' }, + }, +}; +const CTX = { userId: 'u1', permissions: ['member_default'] }; +const ACCESS = { object: 'task', recordId: 'r1', readable: ['id', 'title', 'amount'], editable: ['title', 'amount'] }; + +function mockServer() { + const noop = () => { /* routes are driven directly, never through the adapter */ }; + return { + get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, + listen: async () => { /* never started */ }, + close: async () => { /* never started */ }, + }; +} + +function mockRes() { + const res: any = { + statusCode: 200, + header: vi.fn(function (this: any) { return this; }), + setHeader: vi.fn(function (this: any) { return this; }), + json: vi.fn(function (this: any, body: any) { this._body = body; return this; }), + status: vi.fn(function (this: any, code: number) { this.statusCode = code; return this; }), + }; + return res; +} + +function boot(opts: { getData?: () => Promise; security?: unknown } = {}) { + const getData = vi.fn(opts.getData ?? (async () => ({ object: 'task', id: 'r1', record: { id: 'r1' } }))); + const protocol: any = { + getDiscovery: vi.fn().mockResolvedValue({ + version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' }, + }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn().mockResolvedValue({ items: [TASK] }), + getMetaItem: vi.fn().mockResolvedValue({ type: 'object', name: 'task', item: TASK }), + getData, + }; + const rest = new RestServer(mockServer() as any, protocol as any, { api: { requireAuth: false } } as any); + (rest as any).resolveExecCtx = async () => CTX; + (rest as any).resolveSecurityService = async () => opts.security; + rest.registerRoutes(); + const route = (rest as any).getRoutes().find( + (r: any) => r.method === 'GET' && r.path === '/api/v1/data/:object/:id/field-access', + ); + const call = async (query: Record = {}, params = { object: 'task', id: 'r1' }) => { + if (!route) throw new Error('route not registered: GET /api/v1/data/:object/:id/field-access'); + const res = mockRes(); + await route.handler({ method: 'GET', params, query, headers: {}, body: {} } as any, res); + return { status: res.statusCode, body: res.json.mock.calls.at(-1)?.[0] }; + }; + return { getData, call }; +} + +const recordNotFound = () => Object.assign(new Error('Record r1 not found in task'), { + code: 'RECORD_NOT_FOUND', status: 404, object: 'task', +}); + +describe('[#26594] GET /data/:object/:id/field-access', () => { + it("relays the service's projection of this record, asked with the caller's object, record and context", async () => { + const getRecordFieldAccess = vi.fn().mockResolvedValue(ACCESS); + const { call, getData } = boot({ security: { getRecordFieldAccess } }); + const answer = await call(); + expect(answer).toEqual({ status: 200, body: { success: true, data: ACCESS } }); + expect(getRecordFieldAccess).toHaveBeenCalledWith('task', 'r1', CTX); + // The record read the GET record route makes, as that route makes it. + expect(getData).toHaveBeenCalledWith({ object: 'task', id: 'r1', context: CTX }); + }); + + it('a record the read path refuses answers that refusal, and the projection is never asked', async () => { + const getRecordFieldAccess = vi.fn().mockResolvedValue(ACCESS); + const { call } = boot({ getData: async () => { throw recordNotFound(); }, security: { getRecordFieldAccess } }); + const answer = await call(); + expect(answer.status).toBe(404); + expect(answer.body?.error?.code ?? answer.body?.code).toBe('RECORD_NOT_FOUND'); + expect(getRecordFieldAccess).not.toHaveBeenCalled(); + }); + + it("a row the service cannot see after the read answers the read path's own 404, not an empty projection", async () => { + const viaService = await boot({ security: { getRecordFieldAccess: vi.fn().mockResolvedValue(null) } }).call(); + const viaRead = await boot({ getData: async () => { throw recordNotFound(); }, security: {} }).call(); + expect(viaService.status).toBe(404); + expect(viaService).toEqual(viaRead); + }); + + it('no security service with the method answers a loud 501, never an invented projection', async () => { + for (const security of [undefined, {}]) { + const answer = await boot({ security }).call(); + expect(answer.status).toBe(501); + expect(answer.body?.success).toBe(false); + expect(answer.body?.error?.code).toBe('NOT_IMPLEMENTED'); + } + }); + + it('takes no query parameter: a name is a located 400 and the service is never called', async () => { + expect(RECORD_FIELD_ACCESS_PARAMS).toEqual([]); + const getRecordFieldAccess = vi.fn().mockResolvedValue(ACCESS); + const { call, getData } = boot({ security: { getRecordFieldAccess } }); + const answer = await call({ fields: 'amount' }); + expect(answer.status).toBe(400); + expect(answer.body?.error?.code).toBe('VALIDATION_ERROR'); + expect(answer.body?.error?.message).toBe(unknownQueryParamMessage(['fields'], [])); + expect(getData).not.toHaveBeenCalled(); + expect(getRecordFieldAccess).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 24f682d4281..29946991cba 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -821,6 +821,10 @@ "api/GetPresignedUrlRequest:mimeType", "api/GetPresignedUrlRequest:scope", "api/GetPresignedUrlRequest:size", + "api/GetRecordFieldAccessResponse:editable", + "api/GetRecordFieldAccessResponse:object", + "api/GetRecordFieldAccessResponse:readable", + "api/GetRecordFieldAccessResponse:recordId", "api/GetRunRequest:name", "api/GetRunRequest:runId", "api/GetRunResponse:data", diff --git a/packages/spec/json-schema.manifest/api.json b/packages/spec/json-schema.manifest/api.json index d0a1bee0ef2..65d06d966ff 100644 --- a/packages/spec/json-schema.manifest/api.json +++ b/packages/spec/json-schema.manifest/api.json @@ -200,6 +200,7 @@ "api/GetPresenceResponse", "api/GetPresignedUrlRequest", "api/GetPublishedMetaItemResponse", + "api/GetRecordFieldAccessResponse", "api/GetRunRequest", "api/GetRunResponse", "api/GetTranslationsRequest", From bbda2fbcc6e2a5f69af834b21b5d82d220dac392 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 01:52:48 +0000 Subject: [PATCH 4/8] chore(spec): regenerate artifacts for the record field projection Claude-Session: https://claude.ai/code/session_01WGufzfXsZBPnnuWWQGXm48 Co-authored-by: Claude --- content/docs/references/api/protocol.mdx | 22 +++++++++++++++---- content/docs/references/index.mdx | 10 ++++----- .../docs/references/security/permission.mdx | 4 ++-- .../api.md | 2 +- packages/spec/api-surface/api.json | 2 ++ packages/spec/declaration-map/api.json | 2 ++ packages/spec/export-origins/api.json | 2 ++ 7 files changed, 32 insertions(+), 12 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 266a340a994..08140046499 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1,7 +1,7 @@ --- title: Protocol schema — API Protocol reference navTitle: Protocol -description: "Protocol schemas of the ObjectStack API Protocol: AiAgentCapabilities and 137 more — each property with its type, default and a TypeScript example." +description: "Protocol schemas of the ObjectStack API Protocol: AiAgentCapabilities and 138 more — each property with its type, default and a TypeScript example." --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -13,8 +13,8 @@ description: "Protocol schemas of the ObjectStack API Protocol: AiAgentCapabilit ## TypeScript Usage ```typescript -import { AiAgentCapabilitiesSchema, AiAgentChatRequestSchema, AiAgentSummarySchema, AiAgentsResponseSchema, AiChatRequestSchema, AiChatResponseSchema, AiCompleteRequestSchema, AiConversationSchema, AiMessageSchema, AiModelsResponseSchema, AiPendingActionSchema, AiPendingActionStatusSchema, AiStreamChunkSchema, ApproveAiPendingActionResponseSchema, AuditMetaItemRequestSchema, AuditMetaItemResponseSchema, AutomationActionsResponseSchema, AutomationTriggerRequestSchema, AutomationTriggerResponseSchema, BatchDataRequestSchema, BatchDataResponseSchema, CheckPermissionRequestSchema, CheckPermissionResponseSchema, CloneDataResponseSchema, CreateAiConversationRequestSchema, CreateDataRequestSchema, CreateDataResponseSchema, CreateManyDataRequestSchema, CreateManyDataResponseSchema, DeleteDataRequestSchema, DeleteDataResponseSchema, DeleteManyDataRequestSchema, DeleteManyDataResponseSchema, DeleteMetaItemRequestSchema, DeleteMetaItemResponseSchema, DiffMetaItemResponseSchema, DisablePackageRequestSchema, DisablePackageResponseSchema, EnablePackageRequestSchema, EnablePackageResponseSchema, FindDataRequestSchema, FindDataResponseSchema, FindReferencesToMetaResponseSchema, FlowRegistrationSchema, FlowRegistrationRefusalSchema, GetDataRequestSchema, GetDataResponseSchema, GetDiscoveryRequestSchema, GetDiscoveryResponseSchema, GetEffectivePermissionsRequestSchema, GetEffectivePermissionsResponseSchema, GetFieldLabelsRequestSchema, GetFieldLabelsResponseSchema, GetLocalesRequestSchema, GetLocalesResponseSchema, GetMetaDiagnosticsResponseSchema, GetMetaItemCachedRequestSchema, GetMetaItemCachedResponseSchema, GetMetaItemLayeredRequestSchema, GetMetaItemLayeredResponseSchema, GetMetaItemRequestSchema, GetMetaItemResponseSchema, GetMetaItemsRequestSchema, GetMetaItemsResponseSchema, GetMetaTypesRequestSchema, GetMetaTypesResponseSchema, GetNotificationPreferencesRequestSchema, GetNotificationPreferencesResponseSchema, GetObjectPermissionsRequestSchema, GetObjectPermissionsResponseSchema, GetPackageRequestSchema, GetPackageResponseSchema, GetPresenceRequestSchema, GetPresenceResponseSchema, GetPublishedMetaItemResponseSchema, GetTranslationsRequestSchema, GetTranslationsResponseSchema, GetUiViewRequestSchema, GetUiViewResponseSchema, HistoryMetaItemRequestSchema, HistoryMetaItemResponseSchema, HttpFindQueryParamsSchema, InstallPackageRequestSchema, InstallPackageResponseSchema, ListAiConversationsRequestSchema, ListAiConversationsResponseSchema, ListAiPendingActionsRequestSchema, ListAiPendingActionsResponseSchema, ListDraftsResponseSchema, ListNotificationsRequestSchema, ListNotificationsResponseSchema, ListPackagesRequestSchema, ListPackagesResponseSchema, MarkAllNotificationsReadRequestSchema, MarkAllNotificationsReadResponseSchema, MarkNotificationsReadRequestSchema, MarkNotificationsReadResponseSchema, MetadataAuthoringSurfaceSchema, MetadataConflictErrorSchema, NotificationSchema, NotificationPreferencesSchema, PublishMetaItemRequestSchema, PublishMetaItemResponseSchema, PublishPackageDraftsResponseSchema, RealtimeConnectRequestSchema, RealtimeConnectResponseSchema, RealtimeDisconnectRequestSchema, RealtimeDisconnectResponseSchema, RealtimeSubscribeRequestSchema, RealtimeSubscribeResponseSchema, RealtimeUnsubscribeRequestSchema, RealtimeUnsubscribeResponseSchema, RegisterDeviceRequestSchema, RegisterDeviceResponseSchema, RejectAiPendingActionResponseSchema, RollbackMetaItemResponseSchema, RuntimeAuthoringIssueSchema, SaveMetaItemRequestSchema, SaveMetaItemResponseSchema, SearchAllHitSchema, SearchAllPageHitSchema, SearchAllResponseSchema, SetPresenceRequestSchema, SetPresenceResponseSchema, UninstallPackageRequestSchema, UninstallPackageResponseSchema, UnregisterDeviceRequestSchema, UnregisterDeviceResponseSchema, UpdateAiConversationRequestSchema, UpdateDataRequestSchema, UpdateDataResponseSchema, UpdateManyDataRequestSchema, UpdateManyDataResponseSchema, UpdateNotificationPreferencesRequestSchema, UpdateNotificationPreferencesResponseSchema, ValidateDataIssueSchema, ValidateDataRequestSchema, ValidateDataResponseSchema } from '@objectstack/spec/api'; -import type { AiAgentCapabilities, AiAgentChatRequest, AiAgentSummary, AiAgentsResponse, AiChatRequest, AiChatResponse, AiCompleteRequest, AiConversation, AiMessage, AiModelsResponse, AiPendingAction, AiPendingActionStatus, AiStreamChunk, ApproveAiPendingActionResponse, AuditMetaItemRequest, AuditMetaItemResponse, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CloneDataResponse, CreateAiConversationRequest, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DiffMetaItemResponse, DisablePackageRequest, DisablePackageResponse, EnablePackageRequest, EnablePackageResponse, FindDataRequest, FindDataResponse, FindReferencesToMetaResponse, FlowRegistration, FlowRegistrationRefusal, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaDiagnosticsResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemLayeredRequest, GetMetaItemLayeredResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPackageRequest, GetPackageResponse, GetPresenceRequest, GetPresenceResponse, GetPublishedMetaItemResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, HistoryMetaItemRequest, HistoryMetaItemResponse, InstallPackageRequest, InstallPackageResponse, ListAiConversationsRequest, ListAiConversationsResponse, ListAiPendingActionsRequest, ListAiPendingActionsResponse, ListDraftsResponse, ListNotificationsRequest, ListNotificationsResponse, ListPackagesRequest, ListPackagesResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, MetadataAuthoringSurface, MetadataConflictError, Notification, NotificationPreferences, PublishMetaItemRequest, PublishMetaItemResponse, PublishPackageDraftsResponse, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, RejectAiPendingActionResponse, RollbackMetaItemResponse, RuntimeAuthoringIssue, SaveMetaItemRequest, SaveMetaItemResponse, SearchAllHit, SearchAllPageHit, SearchAllResponse, SetPresenceRequest, SetPresenceResponse, UninstallPackageRequest, UninstallPackageResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateAiConversationRequest, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, ValidateDataIssue, ValidateDataRequest, ValidateDataResponse } from '@objectstack/spec/api'; +import { AiAgentCapabilitiesSchema, AiAgentChatRequestSchema, AiAgentSummarySchema, AiAgentsResponseSchema, AiChatRequestSchema, AiChatResponseSchema, AiCompleteRequestSchema, AiConversationSchema, AiMessageSchema, AiModelsResponseSchema, AiPendingActionSchema, AiPendingActionStatusSchema, AiStreamChunkSchema, ApproveAiPendingActionResponseSchema, AuditMetaItemRequestSchema, AuditMetaItemResponseSchema, AutomationActionsResponseSchema, AutomationTriggerRequestSchema, AutomationTriggerResponseSchema, BatchDataRequestSchema, BatchDataResponseSchema, CheckPermissionRequestSchema, CheckPermissionResponseSchema, CloneDataResponseSchema, CreateAiConversationRequestSchema, CreateDataRequestSchema, CreateDataResponseSchema, CreateManyDataRequestSchema, CreateManyDataResponseSchema, DeleteDataRequestSchema, DeleteDataResponseSchema, DeleteManyDataRequestSchema, DeleteManyDataResponseSchema, DeleteMetaItemRequestSchema, DeleteMetaItemResponseSchema, DiffMetaItemResponseSchema, DisablePackageRequestSchema, DisablePackageResponseSchema, EnablePackageRequestSchema, EnablePackageResponseSchema, FindDataRequestSchema, FindDataResponseSchema, FindReferencesToMetaResponseSchema, FlowRegistrationSchema, FlowRegistrationRefusalSchema, GetDataRequestSchema, GetDataResponseSchema, GetDiscoveryRequestSchema, GetDiscoveryResponseSchema, GetEffectivePermissionsRequestSchema, GetEffectivePermissionsResponseSchema, GetFieldLabelsRequestSchema, GetFieldLabelsResponseSchema, GetLocalesRequestSchema, GetLocalesResponseSchema, GetMetaDiagnosticsResponseSchema, GetMetaItemCachedRequestSchema, GetMetaItemCachedResponseSchema, GetMetaItemLayeredRequestSchema, GetMetaItemLayeredResponseSchema, GetMetaItemRequestSchema, GetMetaItemResponseSchema, GetMetaItemsRequestSchema, GetMetaItemsResponseSchema, GetMetaTypesRequestSchema, GetMetaTypesResponseSchema, GetNotificationPreferencesRequestSchema, GetNotificationPreferencesResponseSchema, GetObjectPermissionsRequestSchema, GetObjectPermissionsResponseSchema, GetPackageRequestSchema, GetPackageResponseSchema, GetPresenceRequestSchema, GetPresenceResponseSchema, GetPublishedMetaItemResponseSchema, GetRecordFieldAccessResponseSchema, GetTranslationsRequestSchema, GetTranslationsResponseSchema, GetUiViewRequestSchema, GetUiViewResponseSchema, HistoryMetaItemRequestSchema, HistoryMetaItemResponseSchema, HttpFindQueryParamsSchema, InstallPackageRequestSchema, InstallPackageResponseSchema, ListAiConversationsRequestSchema, ListAiConversationsResponseSchema, ListAiPendingActionsRequestSchema, ListAiPendingActionsResponseSchema, ListDraftsResponseSchema, ListNotificationsRequestSchema, ListNotificationsResponseSchema, ListPackagesRequestSchema, ListPackagesResponseSchema, MarkAllNotificationsReadRequestSchema, MarkAllNotificationsReadResponseSchema, MarkNotificationsReadRequestSchema, MarkNotificationsReadResponseSchema, MetadataAuthoringSurfaceSchema, MetadataConflictErrorSchema, NotificationSchema, NotificationPreferencesSchema, PublishMetaItemRequestSchema, PublishMetaItemResponseSchema, PublishPackageDraftsResponseSchema, RealtimeConnectRequestSchema, RealtimeConnectResponseSchema, RealtimeDisconnectRequestSchema, RealtimeDisconnectResponseSchema, RealtimeSubscribeRequestSchema, RealtimeSubscribeResponseSchema, RealtimeUnsubscribeRequestSchema, RealtimeUnsubscribeResponseSchema, RegisterDeviceRequestSchema, RegisterDeviceResponseSchema, RejectAiPendingActionResponseSchema, RollbackMetaItemResponseSchema, RuntimeAuthoringIssueSchema, SaveMetaItemRequestSchema, SaveMetaItemResponseSchema, SearchAllHitSchema, SearchAllPageHitSchema, SearchAllResponseSchema, SetPresenceRequestSchema, SetPresenceResponseSchema, UninstallPackageRequestSchema, UninstallPackageResponseSchema, UnregisterDeviceRequestSchema, UnregisterDeviceResponseSchema, UpdateAiConversationRequestSchema, UpdateDataRequestSchema, UpdateDataResponseSchema, UpdateManyDataRequestSchema, UpdateManyDataResponseSchema, UpdateNotificationPreferencesRequestSchema, UpdateNotificationPreferencesResponseSchema, ValidateDataIssueSchema, ValidateDataRequestSchema, ValidateDataResponseSchema } from '@objectstack/spec/api'; +import type { AiAgentCapabilities, AiAgentChatRequest, AiAgentSummary, AiAgentsResponse, AiChatRequest, AiChatResponse, AiCompleteRequest, AiConversation, AiMessage, AiModelsResponse, AiPendingAction, AiPendingActionStatus, AiStreamChunk, ApproveAiPendingActionResponse, AuditMetaItemRequest, AuditMetaItemResponse, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CloneDataResponse, CreateAiConversationRequest, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DiffMetaItemResponse, DisablePackageRequest, DisablePackageResponse, EnablePackageRequest, EnablePackageResponse, FindDataRequest, FindDataResponse, FindReferencesToMetaResponse, FlowRegistration, FlowRegistrationRefusal, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaDiagnosticsResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemLayeredRequest, GetMetaItemLayeredResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPackageRequest, GetPackageResponse, GetPresenceRequest, GetPresenceResponse, GetPublishedMetaItemResponse, GetRecordFieldAccessResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, HistoryMetaItemRequest, HistoryMetaItemResponse, InstallPackageRequest, InstallPackageResponse, ListAiConversationsRequest, ListAiConversationsResponse, ListAiPendingActionsRequest, ListAiPendingActionsResponse, ListDraftsResponse, ListNotificationsRequest, ListNotificationsResponse, ListPackagesRequest, ListPackagesResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, MetadataAuthoringSurface, MetadataConflictError, Notification, NotificationPreferences, PublishMetaItemRequest, PublishMetaItemResponse, PublishPackageDraftsResponse, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, RejectAiPendingActionResponse, RollbackMetaItemResponse, RuntimeAuthoringIssue, SaveMetaItemRequest, SaveMetaItemResponse, SearchAllHit, SearchAllPageHit, SearchAllResponse, SetPresenceRequest, SetPresenceResponse, UninstallPackageRequest, UninstallPackageResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateAiConversationRequest, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, ValidateDataIssue, ValidateDataRequest, ValidateDataResponse } from '@objectstack/spec/api'; // Validate data const result = AiAgentCapabilitiesSchema.parse(data); @@ -1659,7 +1659,7 @@ One changed key path of the pending draft, and the last write that changed it. | :--- | :--- | :--- | :--- | | **readable** | `boolean` | optional (default: `true`) | Field read access | | **editable** | `boolean` | optional (default: `false`) | Field edit access | -| **when** | `string` | optional | Row condition narrowing where this entry votes, authored in canonical CEL like `rowLevelSecurity[].using` (a field of this object compared with a literal or a `current_user.*` value using `==`, `!=`, `<`, `<=`, `>`, `>=` or `in`, combined with `&&` / `\|\|`; e.g. status in ["approved", "signing", "active"]). Absent, the entry votes on every row. Present, the entry votes as declared on the rows the condition matches and is ABSENT on every other row, so the most-permissive merge across the caller's sets and the rule that only declaring sets vote are unchanged: a set that locks a field only from a stage onward does not lock the rows before it, and a co-held set that says nothing about the field still lifts nothing. The write gate judges the condition on the stored row BEFORE an update (so a lock on `status in [...]` holds on an approved row even when the write changes `status`) and on the incoming row of an insert, as the caller sent it. It reaches the EDIT vote only — reads are judged row-blind — so `readable: false` beside a `when` is refused at parse time: keep `readable: true` (the default) on a row-conditioned entry. Where no row can be judged (a predicate update with no row address, a stored row the caller cannot read, a condition that does not resolve for the request) the entry is judged as its lock: it contributes no edit grant on that write. Validated like an RLS predicate at authoring and publish time: the fields it names must exist on the entry's object and it may not reach across a relation; a condition that cannot be lowered is refused there, never dropped at runtime. Readers that hold no row (`/auth/me/permissions`, the field projections, the write preview on an update) report a row-conditioned entry as its lock, so a client never advertises an edit the gate refuses. | +| **when** | `string` | optional | Row condition narrowing where this entry votes, authored in canonical CEL like `rowLevelSecurity[].using` (a field of this object compared with a literal or a `current_user.*` value using `==`, `!=`, `<`, `<=`, `>`, `>=` or `in`, combined with `&&` / `\|\|`; e.g. status in ["approved", "signing", "active"]). Absent, the entry votes on every row. Present, the entry votes as declared on the rows the condition matches and is ABSENT on every other row, so the most-permissive merge across the caller's sets and the rule that only declaring sets vote are unchanged: a set that locks a field only from a stage onward does not lock the rows before it, and a co-held set that says nothing about the field still lifts nothing. The write gate judges the condition on the stored row BEFORE an update (so a lock on `status in [...]` holds on an approved row even when the write changes `status`) and on the incoming row of an insert, as the caller sent it. It reaches the EDIT vote only — reads are judged row-blind — so `readable: false` beside a `when` is refused at parse time: keep `readable: true` (the default) on a row-conditioned entry. Where no row can be judged (a predicate update with no row address, a stored row the caller cannot read, a condition that does not resolve for the request) the entry is judged as its lock: it contributes no edit grant on that write. Validated like an RLS predicate at authoring and publish time: the fields it names must exist on the entry's object and it may not reach across a relation; a condition that cannot be lowered is refused there, never dropped at runtime. Readers that hold no row (`/auth/me/permissions`, the object-wide field projections, the write preview on an update) report a row-conditioned entry as its lock, so a client never advertises an edit the gate refuses. The record-scoped field projection (`GET /data/{object}/{id}/field-access`, the answer a record's edit form reads) holds the row: it judges the condition on the stored row exactly as the write gate does on an update of that row, so a lock that applies only from a stage onward disables the field only on the rows it names. | --- @@ -1745,6 +1745,20 @@ Get package response The published metadata item body, opaque by ruling (1C). Shape is the item's own metadata-type schema, resolved at read time — never frozen into this contract. +--- + +## GetRecordFieldAccessResponse + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **object** | `string` | ✅ | Object name | +| **recordId** | `string` | ✅ | The record the projection was judged on | +| **readable** | `string[]` | ✅ | Fields the caller may read on this object. Reads are judged row-blind, so this equals the object-wide read projection | +| **editable** | `string[]` | ✅ | Fields the caller may write on this record, as field-level security judges an update of the stored row: a row-conditioned field permission locks a field only on the rows its condition names. A field absent here is refused by the write gate. The object grant and a field's own rules (readonly, computed types) are not part of it | + + --- ## GetTranslationsRequest diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index d862cc1d3da..52b1120e2ac 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1556 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1557 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -21,7 +21,7 @@ counts are sums of the rows they head. Regenerate with | Module | Pages | Schemas | Description | | :--- | ---: | ---: | :--- | | [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | -| [API Protocol](/docs/references/api) | 32 | 438 | REST contracts, endpoints, routing, realtime, batch, discovery. | +| [API Protocol](/docs/references/api) | 32 | 439 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 15 | 77 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Data Protocol](/docs/references/data) | 31 | 184 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | @@ -34,7 +34,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 37 | 287 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 171 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **202** | **1556** | 14 protocol modules | +| **Total** | **202** | **1557** | 14 protocol modules | --- @@ -63,7 +63,7 @@ Agents, tools, skills, RAG and knowledge sources, model registry, conversations. ## API Protocol -**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **32 pages, 438 schemas** +**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **32 pages, 439 schemas** REST contracts, endpoints, routing, realtime, batch, discovery. @@ -91,7 +91,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery. | [`package-api-assembled.zod.ts`](/docs/references/api/package-api-assembled) | `AssembledInstalledPackage`, `GetInstalledPackageResponse`, `InstalledPackageAtEitherStage`, `ListInstalledPackagesResponse` | | [`package-lifecycle.zod.ts`](/docs/references/api/package-lifecycle) | `DiscardPackageDraftsResponse`, `DuplicatePackageResponse`, `ImportUnmanagedPackageRequest`, `ImportUnmanagedPackageResponse`, `ListPackageCommitsResponse`, `PackageExportManifest`, `PackagePublishResult`, `ReassignOrphanedMetadataResponse`, `RevertPackageCommitResponse`, `RollbackToPackageCommitResponse` | | [`plugin-rest-api.zod.ts`](/docs/references/api/plugin-rest-api) | `ErrorHandlingConfig`, `OpenApiGenerationConfig`, `RequestValidationConfig`, `ResponseEnvelopeConfig`, `RestApiEndpoint`, `RestApiPluginConfig`, `RestApiRouteCategory`, `RestApiRouteRegistration`, `ValidationMode` | -| [`protocol.zod.ts`](/docs/references/api/protocol) | `AiAgentCapabilities`, `AiAgentChatRequest`, `AiAgentSummary`, `AiAgentsResponse`, `AiChatRequest`, `AiChatResponse`, `AiCompleteRequest`, `AiConversation`, `AiMessage`, `AiModelsResponse`, `AiPendingAction`, `AiPendingActionStatus`, `AiStreamChunk`, `ApproveAiPendingActionResponse`, `AuditMetaItemRequest`, `AuditMetaItemResponse`, `AutomationActionsResponse`, `AutomationTriggerRequest`, `AutomationTriggerResponse`, `BatchDataRequest`, `BatchDataResponse`, `CheckPermissionRequest`, `CheckPermissionResponse`, `CloneDataResponse`, `CreateAiConversationRequest`, `CreateDataRequest`, `CreateDataResponse`, `CreateManyDataRequest`, `CreateManyDataResponse`, `DeleteDataRequest`, `DeleteDataResponse`, `DeleteManyDataRequest`, `DeleteManyDataResponse`, `DeleteMetaItemRequest`, `DeleteMetaItemResponse`, `DiffMetaItemResponse`, `DisablePackageRequest`, `DisablePackageResponse`, `EnablePackageRequest`, `EnablePackageResponse`, `FindDataRequest`, `FindDataResponse`, `FindReferencesToMetaResponse`, `FlowRegistration`, `FlowRegistrationRefusal`, `GetDataRequest`, `GetDataResponse`, `GetDiscoveryRequest`, `GetDiscoveryResponse`, `GetEffectivePermissionsRequest`, `GetEffectivePermissionsResponse`, `GetFieldLabelsRequest`, `GetFieldLabelsResponse`, `GetLocalesRequest`, `GetLocalesResponse`, `GetMetaDiagnosticsResponse`, `GetMetaItemCachedRequest`, `GetMetaItemCachedResponse`, `GetMetaItemLayeredRequest`, `GetMetaItemLayeredResponse`, `GetMetaItemRequest`, `GetMetaItemResponse`, `GetMetaItemsRequest`, `GetMetaItemsResponse`, `GetMetaTypesRequest`, `GetMetaTypesResponse`, `GetNotificationPreferencesRequest`, `GetNotificationPreferencesResponse`, `GetObjectPermissionsRequest`, `GetObjectPermissionsResponse`, `GetPackageRequest`, `GetPackageResponse`, `GetPresenceRequest`, `GetPresenceResponse`, `GetPublishedMetaItemResponse`, `GetTranslationsRequest`, `GetTranslationsResponse`, `GetUiViewRequest`, `GetUiViewResponse`, `HistoryMetaItemRequest`, `HistoryMetaItemResponse`, `HttpFindQueryParams`, `InstallPackageRequest`, `InstallPackageResponse`, `ListAiConversationsRequest`, `ListAiConversationsResponse`, `ListAiPendingActionsRequest`, `ListAiPendingActionsResponse`, `ListDraftsResponse`, `ListNotificationsRequest`, `ListNotificationsResponse`, `ListPackagesRequest`, `ListPackagesResponse`, `MarkAllNotificationsReadRequest`, `MarkAllNotificationsReadResponse`, `MarkNotificationsReadRequest`, `MarkNotificationsReadResponse`, `MetadataAuthoringSurface`, `MetadataConflictError`, `Notification`, `NotificationPreferences`, `PublishMetaItemRequest`, `PublishMetaItemResponse`, `PublishPackageDraftsResponse`, `RealtimeConnectRequest`, `RealtimeConnectResponse`, `RealtimeDisconnectRequest`, `RealtimeDisconnectResponse`, `RealtimeSubscribeRequest`, `RealtimeSubscribeResponse`, `RealtimeUnsubscribeRequest`, `RealtimeUnsubscribeResponse`, `RegisterDeviceRequest`, `RegisterDeviceResponse`, `RejectAiPendingActionResponse`, `RollbackMetaItemResponse`, `RuntimeAuthoringIssue`, `SaveMetaItemRequest`, `SaveMetaItemResponse`, `SearchAllHit`, `SearchAllPageHit`, `SearchAllResponse`, `SetPresenceRequest`, `SetPresenceResponse`, `UninstallPackageRequest`, `UninstallPackageResponse`, `UnregisterDeviceRequest`, `UnregisterDeviceResponse`, `UpdateAiConversationRequest`, `UpdateDataRequest`, `UpdateDataResponse`, `UpdateManyDataRequest`, `UpdateManyDataResponse`, `UpdateNotificationPreferencesRequest`, `UpdateNotificationPreferencesResponse`, `ValidateDataIssue`, `ValidateDataRequest`, `ValidateDataResponse` | +| [`protocol.zod.ts`](/docs/references/api/protocol) | `AiAgentCapabilities`, `AiAgentChatRequest`, `AiAgentSummary`, `AiAgentsResponse`, `AiChatRequest`, `AiChatResponse`, `AiCompleteRequest`, `AiConversation`, `AiMessage`, `AiModelsResponse`, `AiPendingAction`, `AiPendingActionStatus`, `AiStreamChunk`, `ApproveAiPendingActionResponse`, `AuditMetaItemRequest`, `AuditMetaItemResponse`, `AutomationActionsResponse`, `AutomationTriggerRequest`, `AutomationTriggerResponse`, `BatchDataRequest`, `BatchDataResponse`, `CheckPermissionRequest`, `CheckPermissionResponse`, `CloneDataResponse`, `CreateAiConversationRequest`, `CreateDataRequest`, `CreateDataResponse`, `CreateManyDataRequest`, `CreateManyDataResponse`, `DeleteDataRequest`, `DeleteDataResponse`, `DeleteManyDataRequest`, `DeleteManyDataResponse`, `DeleteMetaItemRequest`, `DeleteMetaItemResponse`, `DiffMetaItemResponse`, `DisablePackageRequest`, `DisablePackageResponse`, `EnablePackageRequest`, `EnablePackageResponse`, `FindDataRequest`, `FindDataResponse`, `FindReferencesToMetaResponse`, `FlowRegistration`, `FlowRegistrationRefusal`, `GetDataRequest`, `GetDataResponse`, `GetDiscoveryRequest`, `GetDiscoveryResponse`, `GetEffectivePermissionsRequest`, `GetEffectivePermissionsResponse`, `GetFieldLabelsRequest`, `GetFieldLabelsResponse`, `GetLocalesRequest`, `GetLocalesResponse`, `GetMetaDiagnosticsResponse`, `GetMetaItemCachedRequest`, `GetMetaItemCachedResponse`, `GetMetaItemLayeredRequest`, `GetMetaItemLayeredResponse`, `GetMetaItemRequest`, `GetMetaItemResponse`, `GetMetaItemsRequest`, `GetMetaItemsResponse`, `GetMetaTypesRequest`, `GetMetaTypesResponse`, `GetNotificationPreferencesRequest`, `GetNotificationPreferencesResponse`, `GetObjectPermissionsRequest`, `GetObjectPermissionsResponse`, `GetPackageRequest`, `GetPackageResponse`, `GetPresenceRequest`, `GetPresenceResponse`, `GetPublishedMetaItemResponse`, `GetRecordFieldAccessResponse`, `GetTranslationsRequest`, `GetTranslationsResponse`, `GetUiViewRequest`, `GetUiViewResponse`, `HistoryMetaItemRequest`, `HistoryMetaItemResponse`, `HttpFindQueryParams`, `InstallPackageRequest`, `InstallPackageResponse`, `ListAiConversationsRequest`, `ListAiConversationsResponse`, `ListAiPendingActionsRequest`, `ListAiPendingActionsResponse`, `ListDraftsResponse`, `ListNotificationsRequest`, `ListNotificationsResponse`, `ListPackagesRequest`, `ListPackagesResponse`, `MarkAllNotificationsReadRequest`, `MarkAllNotificationsReadResponse`, `MarkNotificationsReadRequest`, `MarkNotificationsReadResponse`, `MetadataAuthoringSurface`, `MetadataConflictError`, `Notification`, `NotificationPreferences`, `PublishMetaItemRequest`, `PublishMetaItemResponse`, `PublishPackageDraftsResponse`, `RealtimeConnectRequest`, `RealtimeConnectResponse`, `RealtimeDisconnectRequest`, `RealtimeDisconnectResponse`, `RealtimeSubscribeRequest`, `RealtimeSubscribeResponse`, `RealtimeUnsubscribeRequest`, `RealtimeUnsubscribeResponse`, `RegisterDeviceRequest`, `RegisterDeviceResponse`, `RejectAiPendingActionResponse`, `RollbackMetaItemResponse`, `RuntimeAuthoringIssue`, `SaveMetaItemRequest`, `SaveMetaItemResponse`, `SearchAllHit`, `SearchAllPageHit`, `SearchAllResponse`, `SetPresenceRequest`, `SetPresenceResponse`, `UninstallPackageRequest`, `UninstallPackageResponse`, `UnregisterDeviceRequest`, `UnregisterDeviceResponse`, `UpdateAiConversationRequest`, `UpdateDataRequest`, `UpdateDataResponse`, `UpdateManyDataRequest`, `UpdateManyDataResponse`, `UpdateNotificationPreferencesRequest`, `UpdateNotificationPreferencesResponse`, `ValidateDataIssue`, `ValidateDataRequest`, `ValidateDataResponse` | | [`query-adapter.zod.ts`](/docs/references/api/query-adapter) | `ODataQueryAdapter`, `OperatorMapping`, `QueryAdapterConfig`, `QueryAdapterTarget`, `RestQueryAdapter` | | [`realtime.zod.ts`](/docs/references/api/realtime) | `RealtimeConfig`, `RealtimeEvent`, `RealtimeEventType`, `RealtimePresence`, `Subscription`, `SubscriptionEvent`, `TransportProtocol` | | [`realtime-shared.zod.ts`](/docs/references/api/realtime-shared) | `BasePresence`, `PresenceStatus`, `RealtimeRecordAction` | diff --git a/content/docs/references/security/permission.mdx b/content/docs/references/security/permission.mdx index ccf7054e6c4..ea851226052 100644 --- a/content/docs/references/security/permission.mdx +++ b/content/docs/references/security/permission.mdx @@ -69,7 +69,7 @@ const result = AdminScopeSchema.parse(data); | :--- | :--- | :--- | :--- | | **readable** | `boolean` | optional (default: `true`) | Field read access | | **editable** | `boolean` | optional (default: `false`) | Field edit access | -| **when** | `string` | optional | Row condition narrowing where this entry votes, authored in canonical CEL like `rowLevelSecurity[].using` (a field of this object compared with a literal or a `current_user.*` value using `==`, `!=`, `<`, `<=`, `>`, `>=` or `in`, combined with `&&` / `\|\|`; e.g. status in ["approved", "signing", "active"]). Absent, the entry votes on every row. Present, the entry votes as declared on the rows the condition matches and is ABSENT on every other row, so the most-permissive merge across the caller's sets and the rule that only declaring sets vote are unchanged: a set that locks a field only from a stage onward does not lock the rows before it, and a co-held set that says nothing about the field still lifts nothing. The write gate judges the condition on the stored row BEFORE an update (so a lock on `status in [...]` holds on an approved row even when the write changes `status`) and on the incoming row of an insert, as the caller sent it. It reaches the EDIT vote only — reads are judged row-blind — so `readable: false` beside a `when` is refused at parse time: keep `readable: true` (the default) on a row-conditioned entry. Where no row can be judged (a predicate update with no row address, a stored row the caller cannot read, a condition that does not resolve for the request) the entry is judged as its lock: it contributes no edit grant on that write. Validated like an RLS predicate at authoring and publish time: the fields it names must exist on the entry's object and it may not reach across a relation; a condition that cannot be lowered is refused there, never dropped at runtime. Readers that hold no row (`/auth/me/permissions`, the field projections, the write preview on an update) report a row-conditioned entry as its lock, so a client never advertises an edit the gate refuses. | +| **when** | `string` | optional | Row condition narrowing where this entry votes, authored in canonical CEL like `rowLevelSecurity[].using` (a field of this object compared with a literal or a `current_user.*` value using `==`, `!=`, `<`, `<=`, `>`, `>=` or `in`, combined with `&&` / `\|\|`; e.g. status in ["approved", "signing", "active"]). Absent, the entry votes on every row. Present, the entry votes as declared on the rows the condition matches and is ABSENT on every other row, so the most-permissive merge across the caller's sets and the rule that only declaring sets vote are unchanged: a set that locks a field only from a stage onward does not lock the rows before it, and a co-held set that says nothing about the field still lifts nothing. The write gate judges the condition on the stored row BEFORE an update (so a lock on `status in [...]` holds on an approved row even when the write changes `status`) and on the incoming row of an insert, as the caller sent it. It reaches the EDIT vote only — reads are judged row-blind — so `readable: false` beside a `when` is refused at parse time: keep `readable: true` (the default) on a row-conditioned entry. Where no row can be judged (a predicate update with no row address, a stored row the caller cannot read, a condition that does not resolve for the request) the entry is judged as its lock: it contributes no edit grant on that write. Validated like an RLS predicate at authoring and publish time: the fields it names must exist on the entry's object and it may not reach across a relation; a condition that cannot be lowered is refused there, never dropped at runtime. Readers that hold no row (`/auth/me/permissions`, the object-wide field projections, the write preview on an update) report a row-conditioned entry as its lock, so a client never advertises an edit the gate refuses. The record-scoped field projection (`GET /data/{object}/{id}/field-access`, the answer a record's edit form reads) holds the row: it judges the condition on the stored row exactly as the write gate does on an update of that row, so a lock that applies only from a stage onward disables the field only on the rows it names. | --- @@ -159,7 +159,7 @@ const result = AdminScopeSchema.parse(data); | :--- | :--- | :--- | :--- | | **readable** | `boolean` | optional (default: `true`) | Field read access | | **editable** | `boolean` | optional (default: `false`) | Field edit access | -| **when** | `string` | optional | Row condition narrowing where this entry votes, authored in canonical CEL like `rowLevelSecurity[].using` (a field of this object compared with a literal or a `current_user.*` value using `==`, `!=`, `<`, `<=`, `>`, `>=` or `in`, combined with `&&` / `\|\|`; e.g. status in ["approved", "signing", "active"]). Absent, the entry votes on every row. Present, the entry votes as declared on the rows the condition matches and is ABSENT on every other row, so the most-permissive merge across the caller's sets and the rule that only declaring sets vote are unchanged: a set that locks a field only from a stage onward does not lock the rows before it, and a co-held set that says nothing about the field still lifts nothing. The write gate judges the condition on the stored row BEFORE an update (so a lock on `status in [...]` holds on an approved row even when the write changes `status`) and on the incoming row of an insert, as the caller sent it. It reaches the EDIT vote only — reads are judged row-blind — so `readable: false` beside a `when` is refused at parse time: keep `readable: true` (the default) on a row-conditioned entry. Where no row can be judged (a predicate update with no row address, a stored row the caller cannot read, a condition that does not resolve for the request) the entry is judged as its lock: it contributes no edit grant on that write. Validated like an RLS predicate at authoring and publish time: the fields it names must exist on the entry's object and it may not reach across a relation; a condition that cannot be lowered is refused there, never dropped at runtime. Readers that hold no row (`/auth/me/permissions`, the field projections, the write preview on an update) report a row-conditioned entry as its lock, so a client never advertises an edit the gate refuses. | +| **when** | `string` | optional | Row condition narrowing where this entry votes, authored in canonical CEL like `rowLevelSecurity[].using` (a field of this object compared with a literal or a `current_user.*` value using `==`, `!=`, `<`, `<=`, `>`, `>=` or `in`, combined with `&&` / `\|\|`; e.g. status in ["approved", "signing", "active"]). Absent, the entry votes on every row. Present, the entry votes as declared on the rows the condition matches and is ABSENT on every other row, so the most-permissive merge across the caller's sets and the rule that only declaring sets vote are unchanged: a set that locks a field only from a stage onward does not lock the rows before it, and a co-held set that says nothing about the field still lifts nothing. The write gate judges the condition on the stored row BEFORE an update (so a lock on `status in [...]` holds on an approved row even when the write changes `status`) and on the incoming row of an insert, as the caller sent it. It reaches the EDIT vote only — reads are judged row-blind — so `readable: false` beside a `when` is refused at parse time: keep `readable: true` (the default) on a row-conditioned entry. Where no row can be judged (a predicate update with no row address, a stored row the caller cannot read, a condition that does not resolve for the request) the entry is judged as its lock: it contributes no edit grant on that write. Validated like an RLS predicate at authoring and publish time: the fields it names must exist on the entry's object and it may not reach across a relation; a condition that cannot be lowered is refused there, never dropped at runtime. Readers that hold no row (`/auth/me/permissions`, the object-wide field projections, the write preview on an update) report a row-conditioned entry as its lock, so a client never advertises an edit the gate refuses. The record-scoped field projection (`GET /data/{object}/{id}/field-access`, the answer a record's edit form reads) holds the row: it judges the condition on the stored row exactly as the write gate does on an update of that row, so a lock that applies only from a stage onward disables the field only on the rows it names. | ### Nested Shape: `PermissionSet.rowLevelSecurity[number]` diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md index c85442a231d..7b162852b5c 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate. | Dir | Sites | |---|---| -| `api/` | 447 | +| `api/` | 448 | diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index ef27f11c6bd..0187f19fa0e 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -496,6 +496,8 @@ "GetPublishedMetaItemResponse (type)", "GetPublishedMetaItemResponseParsed (type)", "GetPublishedMetaItemResponseSchema (const)", + "GetRecordFieldAccessResponse (type)", + "GetRecordFieldAccessResponseSchema (const)", "GetRunRequest (type)", "GetRunRequestSchema (const)", "GetRunResponse (type)", diff --git a/packages/spec/declaration-map/api.json b/packages/spec/declaration-map/api.json index c59e0702c10..bea3663795c 100644 --- a/packages/spec/declaration-map/api.json +++ b/packages/spec/declaration-map/api.json @@ -368,6 +368,8 @@ "GetPresignedUrlRequestSchema": "api/GetPresignedUrlRequest", "GetPublishedMetaItemResponse": "api/GetPublishedMetaItemResponse", "GetPublishedMetaItemResponseSchema": "api/GetPublishedMetaItemResponse", + "GetRecordFieldAccessResponse": "api/GetRecordFieldAccessResponse", + "GetRecordFieldAccessResponseSchema": "api/GetRecordFieldAccessResponse", "GetRunRequest": "api/GetRunRequest", "GetRunRequestSchema": "api/GetRunRequest", "GetRunResponse": "api/GetRunResponse", diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json index 3852cf7e674..e7df6795366 100644 --- a/packages/spec/export-origins/api.json +++ b/packages/spec/export-origins/api.json @@ -476,6 +476,8 @@ "GetPublishedMetaItemResponse": "src/api/protocol.zod.ts#GetPublishedMetaItemResponse (type)", "GetPublishedMetaItemResponseParsed": "src/api/protocol.zod.ts#GetPublishedMetaItemResponseParsed (type)", "GetPublishedMetaItemResponseSchema": "src/api/protocol.zod.ts#GetPublishedMetaItemResponseSchema (const)", + "GetRecordFieldAccessResponse": "src/api/protocol.zod.ts#GetRecordFieldAccessResponse (type)", + "GetRecordFieldAccessResponseSchema": "src/api/protocol.zod.ts#GetRecordFieldAccessResponseSchema (const)", "GetRunRequest": "src/api/automation-api.zod.ts#GetRunRequest (type)", "GetRunRequestSchema": "src/api/automation-api.zod.ts#GetRunRequestSchema (const)", "GetRunResponse": "src/api/automation-api.zod.ts#GetRunResponse (type)", From e1ffeb8eee29a7730438579c9b94aec30e79f2ff Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 01:55:48 +0000 Subject: [PATCH 5/8] chore(spec): name the parsed state of the record field projection Claude-Session: https://claude.ai/code/session_01WGufzfXsZBPnnuWWQGXm48 Co-authored-by: Claude --- packages/spec/api-surface/api.json | 1 + packages/spec/export-origins/api.json | 1 + packages/spec/src/api/protocol.zod.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index 0187f19fa0e..d00865aeaf2 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -497,6 +497,7 @@ "GetPublishedMetaItemResponseParsed (type)", "GetPublishedMetaItemResponseSchema (const)", "GetRecordFieldAccessResponse (type)", + "GetRecordFieldAccessResponseParsed (type)", "GetRecordFieldAccessResponseSchema (const)", "GetRunRequest (type)", "GetRunRequestSchema (const)", diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json index e7df6795366..722c60b65de 100644 --- a/packages/spec/export-origins/api.json +++ b/packages/spec/export-origins/api.json @@ -477,6 +477,7 @@ "GetPublishedMetaItemResponseParsed": "src/api/protocol.zod.ts#GetPublishedMetaItemResponseParsed (type)", "GetPublishedMetaItemResponseSchema": "src/api/protocol.zod.ts#GetPublishedMetaItemResponseSchema (const)", "GetRecordFieldAccessResponse": "src/api/protocol.zod.ts#GetRecordFieldAccessResponse (type)", + "GetRecordFieldAccessResponseParsed": "src/api/protocol.zod.ts#GetRecordFieldAccessResponseParsed (type)", "GetRecordFieldAccessResponseSchema": "src/api/protocol.zod.ts#GetRecordFieldAccessResponseSchema (const)", "GetRunRequest": "src/api/automation-api.zod.ts#GetRunRequest (type)", "GetRunRequestSchema": "src/api/automation-api.zod.ts#GetRunRequestSchema (const)", diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 40f0c665b30..ac96d3e5463 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -3936,6 +3936,7 @@ export type GetObjectPermissionsResponseParsed = z.infer; export type GetEffectivePermissionsResponse = z.input; export type GetRecordFieldAccessResponse = z.input; +export type GetRecordFieldAccessResponseParsed = z.infer; // Workflow Types — removed with the schemas (#4451, v17); see the block comment // at "Workflow Operations — REMOVED" above. From 3af86eab0fc4e73acdae2cbb32a1bb2ed68369f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 02:41:44 +0000 Subject: [PATCH 6/8] chore: regenerate the references docs from the merged tree Claude-Session: https://claude.ai/code/session_01WGufzfXsZBPnnuWWQGXm48 Co-authored-by: Claude --- content/docs/references/api/protocol.mdx | 1 + content/docs/references/index.mdx | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 08140046499..e16c1e1e93e 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1796,6 +1796,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **reports** | `Record }>` | optional | Report translations keyed by report name | | **pages** | `Record` | optional | Page translations keyed by page name | | **flows** | `Record; … }>` | optional | Screen-flow translations keyed by flow name | +| **hooks** | `Record }>` | optional | Hook translations keyed by hook name | | **positions** | `Record` | optional | Position translations keyed by position name | | **permissions** | `Record` | optional | Permission-set translations keyed by permission-set name | | **books** | `Record }>` | optional | Doc-book translations keyed by book name | diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 52b1120e2ac..22ade5ba762 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1557 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1558 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -23,7 +23,7 @@ counts are sums of the rows they head. Regenerate with | [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | | [API Protocol](/docs/references/api) | 32 | 439 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 15 | 77 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | -| [Data Protocol](/docs/references/data) | 31 | 184 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | +| [Data Protocol](/docs/references/data) | 31 | 185 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | | [Integration Protocol](/docs/references/integration) | 1 | 12 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | | [Kernel Protocol](/docs/references/kernel) | 29 | 151 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. | @@ -34,7 +34,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 37 | 287 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 171 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **202** | **1557** | 14 protocol modules | +| **Total** | **202** | **1558** | 14 protocol modules | --- @@ -132,7 +132,7 @@ Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execu ## Data Protocol -**Source:** `packages/spec/src/data/` · **Import:** `@objectstack/spec/data` · **31 pages, 184 schemas** +**Source:** `packages/spec/src/data/` · **Import:** `@objectstack/spec/data` · **31 pages, 185 schemas** Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. @@ -159,7 +159,7 @@ Objects, fields, queries, filters, datasources and drivers — the ObjectQL laye | [`field.zod.ts`](/docs/references/data/field) | `CurrencyConfig`, `CurrencyValue`, `Field`, `FieldMaskingKeep`, `FieldMaskingRule`, `FieldType`, `InlineGridColumn`, `LocationCoordinates`, `SelectOption`, `UniqueScope` | | [`field-value.zod.ts`](/docs/references/data/field-value) | `Address`, `AddressValue`, `CalendarDateValue`, `ClockTimeValue`, `FileLikeValue`, `FileReferenceIdValue`, `FileRefusedValue`, `FileValue`, `InstantValue`, `LocationValue`, `ReferenceIdValue` | | [`filter.zod.ts`](/docs/references/data/filter) | `ComparisonOperator`, `EqualityOperator`, `FieldOperators`, `FieldReference`, `FilterArray`, `FilterCondition`, `NormalizedFilter`, `NowAnchor`, `QueryFilter`, `RangeOperator`, `SetOperator`, `SpecialOperator`, `StringOperator` | -| [`hook.zod.ts`](/docs/references/data/hook) | `Hook`, `HookContext`, `HookEvent` | +| [`hook.zod.ts`](/docs/references/data/hook) | `Hook`, `HookContext`, `HookEvent`, `HookRefusal` | | [`hook-body.zod.ts`](/docs/references/data/hook-body) | `ExpressionBody`, `HookBody`, `HookBodyCapability`, `ScriptBody` | | [`mapping.zod.ts`](/docs/references/data/mapping) | `ImportFieldMapping`, `Mapping`, `TransformType` | | [`object.zod.ts`](/docs/references/data/object) | `ApiMethod`, `ApiOperation`, `Index`, `Lifecycle`, `LifecycleClass`, `Object`, `ObjectAccessConfig`, `ObjectCapabilities`, `ObjectExtension`, `ObjectExternalBinding`, `ObjectFieldGroup`, `ObjectOwnershipEnum`, `ObjectRequiredPermissions`, `PerOperationRequiredPermissions`, `RowCrudActionOverride`, `TenancyConfig` | From c28f8a4a297d775dc866e3670e25e8ebce9d21c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 03:35:07 +0000 Subject: [PATCH 7/8] docs(security): name the record-scoped field projection beside the row-less readers of a when lock Claude-Session: https://claude.ai/code/session_01WGufzfXsZBPnnuWWQGXm48 Co-authored-by: Claude --- content/docs/protocol/objectql/security.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/protocol/objectql/security.mdx b/content/docs/protocol/objectql/security.mdx index ae39118374c..f274a0b49c7 100644 --- a/content/docs/protocol/objectql/security.mdx +++ b/content/docs/protocol/objectql/security.mdx @@ -261,7 +261,7 @@ fields: > There is no separate `create` flag for fields. `editable` governs both create-time and update-time writes; read stripping is governed by `readable`. -> `when` narrows **where** the entry votes: on a matching row it votes as declared, on every other row it is absent (as if the set had not declared the field). The write gate judges the stored row before an update and each incoming row of an insert, in process; a row it cannot judge (a predicate update, a stored row the caller cannot read, a condition that does not resolve for the request) applies the entry as its lock. `readable: false` beside a `when` is refused at parse time, and `objectstack validate` judges the condition like an RLS predicate. Readers that hold no row (`/auth/me/permissions`, `os explain`) report the entry as its lock. See [Field-level security](/docs/permissions/field-level-security#row-conditioned-edit-locks-when). +> `when` narrows **where** the entry votes: on a matching row it votes as declared, on every other row it is absent (as if the set had not declared the field). The write gate judges the stored row before an update and each incoming row of an insert, in process; a row it cannot judge (a predicate update, a stored row the caller cannot read, a condition that does not resolve for the request) applies the entry as its lock. `readable: false` beside a `when` is refused at parse time, and `objectstack validate` judges the condition like an RLS predicate. Readers that hold no row (`/auth/me/permissions`, `os explain`) report the entry as its lock; the record-scoped projection (`GET /data/{object}/{id}/field-access`, the answer a record's edit form reads) judges the stored row exactly as the write gate does. See [Field-level security](/docs/permissions/field-level-security#row-conditioned-edit-locks-when). ### Static `readonly` fields on the write path From 05abc10d7deb31e99a844c0861071851dde6cec3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 04:22:15 +0000 Subject: [PATCH 8/8] docs(spec): editable names the row-level write scope it leaves out, beside the object grant Claude-Session: https://claude.ai/code/session_01WGufzfXsZBPnnuWWQGXm48 Co-authored-by: Claude --- content/docs/references/api/protocol.mdx | 2 +- packages/spec/src/api/protocol.zod.ts | 3 ++- packages/spec/src/contracts/security-service.ts | 5 +++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index e16c1e1e93e..e5cbe81cb32 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1756,7 +1756,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **object** | `string` | ✅ | Object name | | **recordId** | `string` | ✅ | The record the projection was judged on | | **readable** | `string[]` | ✅ | Fields the caller may read on this object. Reads are judged row-blind, so this equals the object-wide read projection | -| **editable** | `string[]` | ✅ | Fields the caller may write on this record, as field-level security judges an update of the stored row: a row-conditioned field permission locks a field only on the rows its condition names. A field absent here is refused by the write gate. The object grant and a field's own rules (readonly, computed types) are not part of it | +| **editable** | `string[]` | ✅ | Fields the caller may write on this record, as field-level security judges an update of the stored row: a row-conditioned field permission locks a field only on the rows its condition names. A field absent here is refused by the write gate. The object grant, the record's row-level write scope (sharing, row-level security, ownership) and a field's own rules (readonly, computed types) are not part of it | --- diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index ac96d3e5463..c3ad70f35a9 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -3118,7 +3118,8 @@ export const GetRecordFieldAccessResponseSchema = lazySchema(() => z.object({ editable: z.array(z.string()).describe( 'Fields the caller may write on this record, as field-level security judges an update of the stored row: a ' + 'row-conditioned field permission locks a field only on the rows its condition names. A field absent here is ' - + 'refused by the write gate. The object grant and a field\'s own rules (readonly, computed types) are not part of it', + + 'refused by the write gate. The object grant, the record\'s row-level write scope (sharing, row-level security, ' + + 'ownership) and a field\'s own rules (readonly, computed types) are not part of it', ), })); diff --git a/packages/spec/src/contracts/security-service.ts b/packages/spec/src/contracts/security-service.ts index 7297c6b0750..f4d857fb176 100644 --- a/packages/spec/src/contracts/security-service.ts +++ b/packages/spec/src/contracts/security-service.ts @@ -620,8 +620,9 @@ export interface ISecurityService { * (`FieldPermissionSchema.when`) contributes its lock only on the rows its * condition names, and a field is in `editable` iff a by-id update of that * row naming it passes field-level security. Like {@link getWritableFields}, - * neither the object grant nor a field's own rules (`readonly`, a computed - * type) are part of the answer. + * neither the object grant, the record's row-level write scope (sharing, + * row-level security, ownership) nor a field's own rules (`readonly`, a + * computed type) are part of the answer: a reader ANDs it with those. * * **`readable` is {@link getReadableFields}' answer**, unchanged: reads are * judged row-blind (a `when` reaches the edit vote only).