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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/32768-upload-link-readonly-when-at-issue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/objectql': patch
'@objectstack/service-storage': patch
'@objectstack/mcp': patch
---

fix(service-storage)!: an upload link is refused at issue for a file field whose `readonlyWhen` holds now (#32768)

Clause-②: yes (narrowing) — `create_upload_link` (`createUploadLinkAs`) issues no link for a file field whose `readonlyWhen` holds on the record as it stands, where it issued one and the upload was refused when it arrived. The engine gains one write-free judgement, `ObjectQL#judgeFieldWriteLock` (widening).
Metadata-protocol: none

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no stored shape changes. The refusal is the existing FIELD_NOT_ACCESSIBLE 403 the link door already answers for a static readonly field, so there is nothing for a migration to rewrite and no tombstone. -->

**BREAKING** for a caller that minted an upload link onto a file field while its `readonlyWhen` held.

FROM: `create_upload_link` answered a link, and the upload through it was refused when it arrived (`403 FIELD_NOT_ACCESSIBLE`, "Nothing was attached: …", since #32734), after the person had picked and sent the file.
TO: the call itself is refused, `403 FIELD_NOT_ACCESSIBLE`, `You cannot attach a file to OBJECT.FIELD: the field is read-only in the target record's current state (a TRUE \`readonlyWhen\` predicate).`, and no link is written. The fix for a caller: mint the link once the record's state unlocks the field, or use the record's attachments, which carry no field lock and are unchanged.

The verdict is the engine's own: `ObjectQL#judgeFieldWriteLock(object, id, field, { context })` runs the update's `readonlyWhen` strip over the update's stored-row read and master-detail header, without writing, and answers the reason the update reports the field under (`readonly_when`) with the words its strict refusal uses. A field with no `readonlyWhen` costs no read. A static `readonly` field and a record a pending approval locks are refused at issue as before.
10 changes: 9 additions & 1 deletion content/docs/ai/connect-mcp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,15 @@ record means no file**, refused with the same `RECORD_NOT_FOUND`.
`401`, and a second use, an expired link, an unknown link and another user's
link all get the same `404`. The upload is stored as that user, with their
access checked again when the file arrives, and it meets the storage
service's maximum upload size before the link is spent. Sending a file's
service's maximum upload size before the link is spent. A field that cannot
take the upload now gets no link: a `readonly` field, or one whose
`readonlyWhen` holds on this record as it stands (refused `403`
`FIELD_NOT_ACCESSIBLE`, naming the lock in the words the engine update's
strict refusal (`strictReadonlyWrites`) uses for it; `update_record` reports
the same lock under `droppedFields` with reason `readonly_when`), and a
record a pending approval locks (`409`
`RECORD_LOCKED`). A field that becomes read-only after the link was made
refuses the upload when it arrives, attaching nothing. Sending a file's
bytes through the MCP protocol itself is not supported yet.

Reading a PDF or Word document's text runs on a pool of worker threads, so the
Expand Down
3 changes: 2 additions & 1 deletion packages/mcp/src/mcp-http-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3362,7 +3362,8 @@ export function registerFileTools(
'as an attachment (attachments: true). Give the link to the user to open in a browser where they are ' +
'signed in to this deployment as themselves; it works only for them, once, for this record and field ' +
'only, and expires (see expiresAt). The upload is stored as the user, so they must be able to edit the ' +
'record (and the field): a record a pending approval locks, or a read-only field, gets no link, and an ' +
'record (and the field): a record a pending approval locks, a read-only field, or a field read-only on ' +
'this record in its current state (readonlyWhen), gets no link, and an ' +
'upload the field cannot take when it arrives is refused with the reason, attaching nothing.',
inputSchema: strictToolInput(
{
Expand Down
174 changes: 174 additions & 0 deletions packages/objectql/src/engine-field-write-lock.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// #32768 — `ObjectQL#judgeFieldWriteLock`: the update's own `readonlyWhen`
// verdict on one field of one record, asked without writing. A door that hands
// out a promise to write a field later (the upload link) asks it first.
//
// Pinned against the write itself: for every row below, the judgement says
// LOCKED exactly when an update of the field reports it dropped under
// `readonly_when`, and nothing the judgement does reaches the driver's write
// verbs.

import { describe, it, expect, beforeEach } from 'vitest';
import type { DroppedFieldsEvent } from '@objectstack/spec/data';
import { ObjectQL } from './engine.js';

function makeDriver() {
const stores = new Map<string, Map<string, any>>();
const writes: string[] = [];
const storeFor = (o: string) => {
let s = stores.get(o);
if (!s) { s = new Map(); stores.set(o, s); }
return s;
};
const matches = (row: any, where: any): boolean => {
if (!where || typeof where !== 'object') return true;
return Object.entries(where).every(([k, v]: [string, any]) => {
if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`);
return row?.[k] === v;
});
};
const driver: any = {
name: 'memory', version: '0.0.0', supports: {},
async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; },
async find(object: string, ast: any) {
const rows = Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where));
return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows;
},
async findOne(object: string, ast: any) {
for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return r;
return null;
},
async create(object: string, data: Record<string, unknown>) {
writes.push(`create ${object}`);
const row = { ...data, id: data.id as string };
storeFor(object).set(row.id, row);
return row;
},
async update(object: string, id: string, data: Record<string, unknown>) {
writes.push(`update ${object}`);
const s = storeFor(object);
const row = { ...s.get(id), ...data, id };
s.set(id, row);
return row;
},
async updateMany() { writes.push('updateMany'); return 0; },
async delete() { writes.push('delete'); return true; },
async count() { return 0; },
async bulkCreate() { writes.push('bulkCreate'); return []; },
async bulkUpdate() { writes.push('bulkUpdate'); return []; }, async bulkDelete() { writes.push('bulkDelete'); },
async beginTransaction() { return { __trx: true, commit: async () => {}, rollback: async () => {} }; },
async commit() {}, async rollback() {},
};
return { driver, storeFor, writes };
}

const PHRASE = "read-only in the target record's current state (a TRUE `readonlyWhen` predicate)";

describe('judgeFieldWriteLock: the update\'s readonlyWhen verdict, without the write (#32768)', () => {
let engine: ObjectQL;
let d: ReturnType<typeof makeDriver>;
let warns: string[];

beforeEach(async () => {
warns = [];
const logger: any = {
warn: (m: string) => warns.push(String(m)),
debug() {}, info() {}, error() {}, trace() {}, fatal() {},
child() { return logger; },
};
engine = new ObjectQL({ logger });
d = makeDriver();
engine.registerDriver(d.driver, true);
await engine.init();

engine.registry.registerObject({
name: 'expense_claim',
fields: {
name: { type: 'text' },
status: { type: 'text' },
invoices: { type: 'file', multiple: true, readonlyWhen: "record.status == 'approved'" },
note: { type: 'text' },
broken: { type: 'text', readonlyWhen: 'record.no_such_field == 1' },
},
} as any, 'test');
engine.registry.registerObject({
name: 'expense_report',
fields: { name: { type: 'text' }, status: { type: 'text' } },
} as any, 'test');
engine.registry.registerObject({
name: 'expense_line',
fields: {
report: { type: 'master_detail', reference: 'expense_report', required: true },
receipt: { type: 'file', readonlyWhen: "parent.status == 'paid'" },
},
} as any, 'test');

d.storeFor('expense_claim').set('c_draft', { id: 'c_draft', name: 'Lyon', status: 'draft', invoices: null, note: null, broken: null });
d.storeFor('expense_claim').set('c_approved', { id: 'c_approved', name: 'Rome', status: 'approved', invoices: null, note: null, broken: null });
d.storeFor('expense_report').set('r_paid', { id: 'r_paid', name: 'Q3', status: 'paid' });
d.storeFor('expense_report').set('r_open', { id: 'r_open', name: 'Q4', status: 'open' });
d.storeFor('expense_line').set('l_paid', { id: 'l_paid', report: 'r_paid', receipt: null });
d.storeFor('expense_line').set('l_open', { id: 'l_open', report: 'r_open', receipt: null });
});

/** What the update itself reports for `field` on `id`: dropped under `readonly_when`, or kept. */
async function updateDrops(object: string, id: string, field: string, value: unknown = 'f_new'): Promise<boolean> {
const events: DroppedFieldsEvent[] = [];
await engine.update(object, { [field]: value }, {
where: { id },
context: { isSystem: true },
onFieldsDropped: (e: DroppedFieldsEvent) => { events.push(e); },
} as any);
return events.some((e) => e.reason === 'readonly_when' && e.fields.includes(field));
}

it('a field whose readonlyWhen holds now is locked, with the update\'s reason and its strict refusal\'s words', async () => {
const lock = await engine.judgeFieldWriteLock('expense_claim', 'c_approved', 'invoices', { context: { isSystem: true } as any });
expect(lock).toEqual({ reason: 'readonly_when', phrase: PHRASE });
expect(d.writes).toEqual([]);
expect(await updateDrops('expense_claim', 'c_approved', 'invoices', ['f_new'])).toBe(true);
});

it('the same field while the condition is false is writable', async () => {
expect(await engine.judgeFieldWriteLock('expense_claim', 'c_draft', 'invoices', { context: { isSystem: true } as any })).toBeNull();
expect(d.writes).toEqual([]);
expect(await updateDrops('expense_claim', 'c_draft', 'invoices', ['f_new'])).toBe(false);
});

it('a parent-scoped lock reads the master-detail header, as the update does', async () => {
expect(await engine.judgeFieldWriteLock('expense_line', 'l_paid', 'receipt', { context: { isSystem: true } as any }))
.toEqual({ reason: 'readonly_when', phrase: PHRASE });
expect(await engine.judgeFieldWriteLock('expense_line', 'l_open', 'receipt', { context: { isSystem: true } as any })).toBeNull();
expect(d.writes).toEqual([]);
expect(await updateDrops('expense_line', 'l_paid', 'receipt')).toBe(true);
expect(await updateDrops('expense_line', 'l_open', 'receipt')).toBe(false);
});

it('a field that declares no readonlyWhen is writable and costs no read', async () => {
const findOne = d.driver.findOne;
let reads = 0;
d.driver.findOne = async (...a: any[]) => { reads += 1; return findOne(...a); };
expect(await engine.judgeFieldWriteLock('expense_claim', 'c_approved', 'note', { context: { isSystem: true } as any })).toBeNull();
expect(reads).toBe(0);
});

it('a predicate that faults refuses as the write refuses it, and writes nothing', async () => {
const err = await engine.judgeFieldWriteLock('expense_claim', 'c_draft', 'broken', { context: { isSystem: true } as any })
.then(() => null, (e: unknown) => e as { code?: string; name?: string });
expect(err).not.toBeNull();
expect(err?.name).toBe('ValidationError');
expect(d.writes).toEqual([]);
});

it('a record that is not there answers as the by-id update answers it', async () => {
const err = await engine.judgeFieldWriteLock('expense_claim', 'c_missing', 'invoices', { context: { isSystem: true } as any })
.then(() => null, (e: unknown) => e as { code?: string; status?: number });
expect(err).toMatchObject({ code: 'RECORD_NOT_FOUND', status: 404 });
});

it('logs no strip line: nothing was written, so "ignoring incoming change" is not true', async () => {
await engine.judgeFieldWriteLock('expense_claim', 'c_approved', 'invoices', { context: { isSystem: true } as any });
expect(warns.filter((w) => w.includes('ignoring incoming change'))).toEqual([]);
});
});
109 changes: 109 additions & 0 deletions packages/objectql/src/engine-field-write-lock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#32768] The body of `ObjectQL#judgeFieldWriteLock`: is `field` writable on
* the record `id` names, NOW, as far as its `readonlyWhen` lock goes — asked
* without writing anything.
*
* ## Why it exists
*
* A door that hands out a promise to write a field later (the upload link
* `create_upload_link` mints) needs the update's verdict on that field before
* the write happens. The update preview (`validate`) names `readonlyWhen` as a
* limit it does not run (`ValidateDataResponse`), and the field-access read
* (`getRecordFieldAccess`) leaves conditional read-only out. So the door could
* only learn the lock by attempting the write. This answers it without one.
*
* ## The same evaluation, not a second one
*
* The verdict is the update's own conditional strip
* ({@link stripReadonlyWhenFields}), run over the inputs the by-id update
* hands it: the stored row as `previous`, read by the update's own prior-row
* read ({@link ObjectQL.readUpdatePriorRow}); the master-detail header as
* `parent`, resolved by the update's own resolver and under the same gate
* ({@link hasParentScopedReadonlyWhenInPayload}); and a payload carrying the
* field. The field is LOCKED when that strip would take it back out. A
* predicate that faults refuses here as it refuses the write (the
* `ValidationError` the strip throws), and an unbound root holds the lock, as
* on the write. No logger is handed to the strip: nothing is written, so no
* "ignoring incoming change" line is true.
*
* ## What it judges the field with
*
* The payload carries the value the field holds NOW (the stored row's), so a
* predicate reads the record exactly as it stands: `record` is the stored row,
* `previous` is the stored row. A lock whose predicate reads the INCOMING
* value of its own field cannot be judged before that value exists; it is
* judged here against the value already there.
*
* ## Named limits
*
* - The update's `beforeUpdate` hooks are not run. A hook that rewrites a
* field a predicate reads moves the write's verdict and not this one.
* - Static `readonly` is not judged here: it is a property of the field's
* definition, readable off the schema without the record.
* - Neither are the update's other refusals (permissions, an approval's
* record lock, validation rules): they belong to their own doors.
*
* ## Reads
*
* None for a field that declares no `readonlyWhen`. Otherwise: the READ DOOR
* first, under the caller's own context — a row the caller cannot read is a
* row that does not exist, answered `recordNotFoundError` as the by-id update
* answers it — then the stored row, then the header when a predicate on the
* field reads `parent`.
*/

import { recordNotFoundError } from '@objectstack/core';
import type { EngineQueryOptions } from '@objectstack/spec/data';
import type { ExecutionContext } from '@objectstack/spec/kernel';
import type { ObjectQL } from './engine.js';
import { readonlyDropReasonPhrase } from './readonly-strict-errors.js';
import { hasParentScopedReadonlyWhenInPayload, stripReadonlyWhenFields } from './validation/rule-validator.js';

/**
* A lock that keeps a field from taking a caller's write now. `reason` is the
* `DroppedFieldsEvent['reason']` the update reports the field under
* (`onFieldsDropped`, `droppedFields`); `phrase` is how the update's strict
* refusal (`ReadonlyFieldRejectedError`) names that reason.
*/
export interface FieldWriteLock {
readonly reason: 'readonly_when';
readonly phrase: string;
}

/** The members of `ObjectQL` the judgement reads. */
export interface FieldWriteLockHost {
resolveObjectName: ObjectQL['resolveObjectName'];
readonly _registry: ObjectQL['_registry'];
findOne: ObjectQL['findOne'];
readUpdatePriorRow: ObjectQL['readUpdatePriorRow'];
resolveMasterDetailParent: ObjectQL['resolveMasterDetailParent'];
}

/** The body of `ObjectQL#judgeFieldWriteLock`; the method's docblock there is its contract. */
export async function judgeFieldWriteLock(
this: FieldWriteLockHost,
objectName: string,
id: unknown,
field: string,
options?: { context?: ExecutionContext },
): Promise<FieldWriteLock | null> {
const object = this.resolveObjectName(objectName);
const schema = this._registry.getObject(object) as { fields?: Record<string, { readonlyWhen?: unknown }> } | undefined;
const def = schema?.fields?.[field];
if (!def || !def.readonlyWhen) return null;

const context = options?.context;
const visible = await this.findOne(object, { where: { id }, context } as EngineQueryOptions);
const prior = visible && typeof visible === 'object' ? await this.readUpdatePriorRow(object, id, context) : null;
if (!prior) throw recordNotFoundError(object, id as string);

const payload: Record<string, unknown> = { [field]: Object.prototype.hasOwnProperty.call(prior, field) ? prior[field] : null };
const parent = hasParentScopedReadonlyWhenInPayload(schema as never, payload)
? await this.resolveMasterDetailParent(schema, payload, prior, context)
: undefined;
const kept = stripReadonlyWhenFields(schema as never, payload, prior, undefined, parent);
if (kept && Object.prototype.hasOwnProperty.call(kept, field)) return null;
return { reason: 'readonly_when', phrase: readonlyDropReasonPhrase('readonly_when') };
}
Loading
Loading