diff --git a/.changeset/21771-write-door-unreadable-is-not-found.md b/.changeset/21771-write-door-unreadable-is-not-found.md
new file mode 100644
index 00000000000..6c3cd615fd5
--- /dev/null
+++ b/.changeset/21771-write-door-unreadable-is-not-found.md
@@ -0,0 +1,27 @@
+---
+'@objectstack/plugin-security': minor
+"@objectstack/spec": patch
+---
+
+fix(plugin-security)!: on the write doors, a row the caller cannot read answers what a nonexistent id answers
+
+Clause-②: no (narrowing)
+
+
+
+**BREAKING**: a by-id update or delete of a row the caller cannot read now answers `404 RECORD_NOT_FOUND`, with exactly the body an id that names no row gets, for every principal class. On the write doors, "hidden" and "gone" are now one answer to a caller who cannot read the row. It ships as `minor` under the launch-window convention for accept-set narrowings. No export is added or removed, and no error code is new.
+
+**What changed.** The answer used to depend on which gate saw the row first. Where a write-class row filter binds the caller, the by-id write pre-image check answered `403 PERMISSION_DENIED`. Where none binds it, a later gate answered with its own 403: `FORBIDDEN` from record sharing, or a parent-derived gate's code on attachments and comments. Meanwhile a nonexistent id answered `404`. So the write door could tell a hidden row apart from a missing one. The pre-image check now asks the read door's own question first, for the by-id write the caller addressed: a by-id read in the caller's context, every data middleware's visibility included. A row that read does not return gets the read door's not-found producer. A store fault propagates as raised, and a read-time policy refusal is not treated as absence.
+
+**What is refused now that was not.** A principal that no write-class row filter binds could have its by-id write admitted on a row the read door hides from it. One example is the uploader of an attachment, or the author of a comment, whose parent record they can no longer read. That write is now refused with the not-found answer, as it already was for every principal a row filter binds.
+
+**FROM → TO.** A by-id update or delete of a row hidden from the caller: FROM a `403` (`PERMISSION_DENIED`, `FORBIDDEN`, or a parent-derived gate's code) → TO `404 RECORD_NOT_FOUND`, the body a nonexistent id gets.
+
+**If you are affected.** A client that read a by-id write's `403` as "the row exists, but you may not change it" should read `404 RECORD_NOT_FOUND` the way the read door means it: no row you can see has this id.
+
+**Unchanged.**
+- A caller who can read the row but may not write it keeps its 403. They already see the row.
+- By-id writes the platform issues under the caller's context keep their previous answer, because the caller never named their target: the engine's cascade delete of a dependent row, a hook's write, and the referential clear of a lookup.
+- Writes that are not routed by id are unchanged.
+
+`security/explain` follows enforcement. Its record verdict for an update or delete of a record the principal cannot read is now the missing-record shape: `visible: false`, with no decider.
diff --git a/content/docs/permissions/attachments-access.mdx b/content/docs/permissions/attachments-access.mdx
index 7b44a7a79ab..bc92cc38414 100644
--- a/content/docs/permissions/attachments-access.mdx
+++ b/content/docs/permissions/attachments-access.mdx
@@ -71,11 +71,12 @@ editable by design). A multi-delete requires *every* matched row to pass.
| Code | Status | When |
| --- | --- | --- |
| `ATTACHMENT_DELETE_DENIED` | 403 | The caller can read the attachment, but is neither the uploader nor able to edit the parent record |
-| `PERMISSION_DENIED` | 403 | The caller cannot read the parent record, so the attachment is not visible to them. This is the platform's not-visible refusal whichever layer gives it — the row-level write check that runs before the gate, or the gate itself for a caller that check does not cover — and it names neither the parent nor the attachment's link to it |
+| `RECORD_NOT_FOUND` | 404 | A delete or update **by id** of an attachment the caller cannot read — its parent record is not readable to them. On the write doors a row the caller cannot read is a row that does not exist: the answer is exactly what an id that names no attachment gets, for every caller, so a hidden attachment and a missing one cannot be told apart. It applies even to the uploader once the parent is out of their sight, because the read door no longer returns the attachment to them either |
+| `PERMISSION_DENIED` | 403 | The gate's own not-visible refusal, where the gate answers before that check. It names neither the parent nor the attachment's link to it |
An update of another user's attachment follows the same rule — the uploader or
-a parent editor — and a caller who cannot read the parent gets the same
-not-visible refusal.
+a parent editor — and a by-id update of an attachment the caller cannot read
+gets the same not-found answer.
The platform baseline also ships a parent-blind row-level delete floor
(`owner_only_deletes`: you may delete only the rows you created, for members
diff --git a/content/docs/permissions/permissions-matrix.mdx b/content/docs/permissions/permissions-matrix.mdx
index 8cb920ad1a4..857b96665a1 100644
--- a/content/docs/permissions/permissions-matrix.mdx
+++ b/content/docs/permissions/permissions-matrix.mdx
@@ -360,7 +360,7 @@ flowchart TD
| 7 | **Field-Level Security** | Which fields is the user allowed to see/edit? |
-**Performance:** For reads, steps 2–6 are compiled into a query filter (owner-match ∪ materialized shares, AND-ed with RLS) at query time, not evaluated record-by-record. By-id writes are verified with a pre-image check: the target row is re-read through the write-scope filter before the mutation. This keeps security checks efficient even on tables with millions of rows.
+**Performance:** For reads, steps 2–6 are compiled into a query filter (owner-match ∪ materialized shares, AND-ed with RLS) at query time, not evaluated record-by-record. By-id writes are verified with a pre-image check: the target row is re-read through the write-scope filter before the mutation, and a row the caller cannot read answers exactly what a missing id answers (`404 RECORD_NOT_FOUND`). This keeps security checks efficient even on tables with millions of rows.
## See also
diff --git a/content/docs/protocol/kernel/error-handling.mdx b/content/docs/protocol/kernel/error-handling.mdx
index e22695d79f9..19d80c88255 100644
--- a/content/docs/protocol/kernel/error-handling.mdx
+++ b/content/docs/protocol/kernel/error-handling.mdx
@@ -907,6 +907,12 @@ Content-Type: application/json
}
```
+⚠️ **This is the answer for a row the caller can read.** A by-id update or delete of a row
+the caller **cannot read** answers exactly what an id that names no row answers —
+`404 RECORD_NOT_FOUND` — whichever rule hides the row, so the write door never tells
+"hidden" apart from "gone". A caller who can read the row but may not write it gets the 403
+shown here.
+
⚠️ **`FORBIDDEN`, not `PERMISSION_DENIED`.** A by-id write the sharing rules refuse carries
`FORBIDDEN`; `PERMISSION_DENIED` is what the capability and identity guards carry. Both are
403s in the same envelope on this door, so branch on either — ⛔ but do not assume one code
diff --git a/packages/plugins/plugin-audit/src/comment-access-hooks.ts b/packages/plugins/plugin-audit/src/comment-access-hooks.ts
index e32cf4bb8db..b9af9e19816 100644
--- a/packages/plugins/plugin-audit/src/comment-access-hooks.ts
+++ b/packages/plugins/plugin-audit/src/comment-access-hooks.ts
@@ -200,6 +200,12 @@ function forbid(message: string, object?: string): never {
* The code of the platform's not-visible refusal: the one plugin-security's
* by-id write pre-image check throws when the caller's own read visibility
* does not reach the target row (`PermissionDeniedError`, 403).
+ *
+ * [#21771] No longer the pre-image check's answer for a row the caller cannot
+ * read: under the write doors' ruling A that check asks the caller's read
+ * visibility for every principal, and answers the by-id update or delete the
+ * caller addressed with what a nonexistent id answers, before this gate runs.
+ * This refusal stays the gate's own, for a write the gate answers first.
*/
const NOT_VISIBLE_CODE: StandardErrorCode = 'PERMISSION_DENIED';
const NOT_VISIBLE_STATUS = 403;
diff --git a/packages/plugins/plugin-auth/src/sys-user-self-service-route.test.ts b/packages/plugins/plugin-auth/src/sys-user-self-service-route.test.ts
index 6c8410439a8..ebceb9539fe 100644
--- a/packages/plugins/plugin-auth/src/sys-user-self-service-route.test.ts
+++ b/packages/plugins/plugin-auth/src/sys-user-self-service-route.test.ts
@@ -350,7 +350,9 @@ describe('sys_user self-service — the four pins, each attributed to a layer',
// with `refusedBy === 'object-gate'` — the object bit was false, so the row
// scope never ran and this pin proved nothing about it. Now the pre-image
// re-read HAPPENED, was scoped to the caller, and came back empty.
- expect(r.preImageWheres).toEqual([{ $and: [{ id: PEER }, { id: ME }] }]);
+ // …and, the row being refused, the read question that keeps the 403: the
+ // member reads the peer's row, so it is not answered as a missing one.
+ expect(r.preImageWheres).toEqual([{ $and: [{ id: PEER }, { id: ME }] }, { id: PEER }]);
expect(r.error?.name).toBe('PermissionDeniedError');
expect(r.error?.code).toBe('PERMISSION_DENIED');
});
diff --git a/packages/plugins/plugin-security/src/authz-matrix-gate.test.ts b/packages/plugins/plugin-security/src/authz-matrix-gate.test.ts
index 0a44069c3c4..9e89a968819 100644
--- a/packages/plugins/plugin-security/src/authz-matrix-gate.test.ts
+++ b/packages/plugins/plugin-security/src/authz-matrix-gate.test.ts
@@ -205,7 +205,14 @@ async function readFilter(cell: any, roleCtx: any): Promise {
*/
async function writeFilter(cell: any, roleCtx: any): Promise {
const plugin = new SecurityPlugin();
- const h = makeHarness({ ...cell, orgScoping: cell.orgScoping ?? true, findOneImpl: () => null });
+ // The write-class re-read finds nothing; [#21771] the addressed write's
+ // read question (a plain by-id read) finds the row, so the matrix keeps
+ // measuring the WRITE filter alone.
+ const h = makeHarness({
+ ...cell,
+ orgScoping: cell.orgScoping ?? true,
+ findOneImpl: (q: any) => (q?.where?.$and ? null : { id: 'r1' }),
+ });
await plugin.init(h.ctx); await plugin.start(h.ctx);
const opCtx: any = {
object: cell.objectName, operation: 'update',
@@ -213,10 +220,11 @@ async function writeFilter(cell: any, roleCtx: any): Promise {
};
let threw: any = null;
try { await h.run(opCtx); } catch (e: any) { threw = e; }
- if (h.findOne.mock.calls.length === 0) {
+ const reRead = h.findOne.mock.calls.find((call: any[]) => call[1]?.where?.$and);
+ if (!reRead) {
return threw ? `CRUD_DENY:${threw?.name ?? 'err'}` : 'BYPASS(no-write-filter)';
}
- return h.findOne.mock.calls[0][1].where.$and.slice(1);
+ return reRead[1].where.$and.slice(1);
}
/**
diff --git a/packages/plugins/plugin-security/src/by-id-write-unreadable-not-found.test.ts b/packages/plugins/plugin-security/src/by-id-write-unreadable-not-found.test.ts
new file mode 100644
index 00000000000..9f24cd14ae2
--- /dev/null
+++ b/packages/plugins/plugin-security/src/by-id-write-unreadable-not-found.test.ts
@@ -0,0 +1,251 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * On the write doors, a row the caller cannot READ is a row that does not
+ * exist — measured through a real `ObjectQL` over a real SQL driver, with the
+ * real `SecurityPlugin` middleware in front of it.
+ *
+ * ## What is pinned
+ *
+ * 1. The ADDRESSED by-id update and delete of a row the caller cannot read
+ * answer exactly what the same verbs answer for an id that exists nowhere:
+ * the read door's not-found, the same code, status and sentence (the one
+ * difference is the id the caller supplied).
+ * 2. A caller who can read the row but may not write it keeps `403
+ * PERMISSION_DENIED` with the record-level sentence: they already see it.
+ * 3. A writable row the caller can read is still written.
+ * 4. By-id writes the platform issues UNDER the caller's context — the
+ * engine's cascade delete of a dependent row, and a hook's `ctx.api`
+ * write — are not addressed by the caller, so they keep the answer they
+ * had before: the record-level 403, which names nothing. A not-found there
+ * would carry an id the caller never supplied.
+ *
+ * The rig is one permission set over two objects: a public parent, and a
+ * child whose rows a caller reads when it owns them or they are flagged
+ * shared, and writes only when it owns them.
+ */
+
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { ObjectQL } from '@objectstack/objectql';
+import { SqlDriver } from '@objectstack/driver-sql';
+import { PermissionSetSchema } from '@objectstack/spec/security';
+import { BUILTIN_OPERATION_MESSAGES } from '@objectstack/spec/system';
+import { recordNotFoundError } from '@objectstack/core';
+
+import { SecurityPlugin } from './security-plugin.js';
+import { defaultPermissionSets } from './objects/default-permission-sets.js';
+
+const SYS = { context: { isSystem: true } } as never;
+const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_default')!;
+const RECORD_SENTENCE = BUILTIN_OPERATION_MESSAGES.en.record_access_denied!;
+
+const PARENT = 'qa_nf_parent';
+const CHILD = 'qa_nf_child';
+const ME = 'usr_nf_member';
+const OTHER = 'usr_nf_other';
+const CALLER = { userId: ME, positions: ['qa_pos'], permissions: ['qa_nf_guard'], posture: 'MEMBER' };
+
+interface Refusal { code?: string; status?: number; message: string; name?: string }
+type Outcome = { kind: 'landed' } | ({ kind: 'refused' } & Refusal);
+
+interface Rig {
+ engine: ObjectQL;
+ update: (object: string, id: string, data?: Record) => Promise;
+ remove: (object: string, id: string) => Promise;
+ stored: (object: string, id: string) => Promise | null>;
+ teardown: () => Promise;
+}
+const rigs: Rig[] = [];
+afterEach(async () => {
+ for (const rig of rigs.splice(0)) await rig.teardown();
+});
+
+async function boot(): Promise {
+ const engine = new ObjectQL();
+ engine.registerDriver(
+ new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) as never,
+ true,
+ );
+ await engine.init();
+ engine.registerApp({
+ id: 'com.objectstack.qa.by-id-write-unreadable-not-found',
+ name: 'By-id write: an unreadable row is a missing row',
+ version: '1.0.0',
+ type: 'plugin',
+ scope: 'system',
+ objects: [
+ {
+ name: PARENT,
+ label: 'Parent',
+ sharingModel: 'public_read_write',
+ fields: {
+ id: { name: 'id', type: 'text', primaryKey: true },
+ name: { name: 'name', type: 'text' },
+ },
+ },
+ {
+ name: CHILD,
+ label: 'Child',
+ sharingModel: 'public_read_write',
+ fields: {
+ id: { name: 'id', type: 'text', primaryKey: true },
+ name: { name: 'name', type: 'text' },
+ owner: { name: 'owner', type: 'text' },
+ shared: { name: 'shared', type: 'boolean' },
+ parent: { name: 'parent', type: 'lookup', reference: PARENT, deleteBehavior: 'cascade' },
+ },
+ },
+ ],
+ } as never);
+ await engine.syncSchemas();
+ await engine.insert(PARENT, [
+ { id: 'p_plain', name: 'plain' },
+ { id: 'p_cascade', name: 'has a hidden child' },
+ { id: 'p_hook', name: 'hooked' },
+ ], SYS);
+ await engine.insert(CHILD, [
+ { id: 'c_own', name: 'mine', owner: ME, shared: false },
+ { id: 'c_hidden', name: 'theirs, private', owner: OTHER, shared: false },
+ { id: 'c_shared', name: 'theirs, shared', owner: OTHER, shared: true },
+ { id: 'c_kid', name: 'theirs, under the cascade parent', owner: OTHER, shared: false, parent: 'p_cascade' },
+ ], SYS);
+
+ const set = PermissionSetSchema.parse({
+ name: 'qa_nf_guard',
+ objects: {
+ [PARENT]: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },
+ [CHILD]: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },
+ },
+ rowLevelSecurity: [
+ { name: 'child_read', object: CHILD, operation: 'select', using: 'record.owner == current_user.id || record.shared == true' },
+ { name: 'child_update', object: CHILD, operation: 'update', using: 'record.owner == current_user.id' },
+ { name: 'child_delete', object: CHILD, operation: 'delete', using: 'record.owner == current_user.id' },
+ ],
+ });
+ const services: Record = {
+ manifest: { register: vi.fn() },
+ objectql: engine,
+ metadata: {
+ get: async (_type: string, name: string) => engine.getSchema(name) ?? null,
+ list: async () => [MEMBER_DEFAULT, set],
+ },
+ };
+ const ctx = {
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
+ registerService: vi.fn(),
+ hook: () => undefined,
+ getService: (name: string) => {
+ if (!(name in services)) throw new Error(`service not registered: ${name}`);
+ return services[name];
+ },
+ };
+ const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
+ await plugin.init(ctx as never);
+ await plugin.start(ctx as never);
+
+ const rig: Rig = {
+ engine,
+ update: (object: string, id: string, data: Record = { name: 'renamed' }) =>
+ outcome(engine.update(object, data, { where: { id }, context: { ...CALLER } } as never)),
+ remove: (object: string, id: string) =>
+ outcome(engine.delete(object, { where: { id }, context: { ...CALLER } } as never)),
+ stored: async (object: string, id: string) =>
+ ((await engine.findOne(object, { where: { id }, context: { isSystem: true } } as never)) ?? null) as Record | null,
+ teardown: async () => { try { await engine.destroy(); } catch { /* noop */ } },
+ };
+ rigs.push(rig);
+ return rig;
+}
+
+function outcome(p: Promise): Promise {
+ return p.then(
+ () => ({ kind: 'landed' as const }),
+ (e: any) => ({
+ kind: 'refused' as const,
+ code: e?.code,
+ status: e?.statusCode ?? e?.status,
+ message: String(e?.message),
+ name: e?.name,
+ }),
+ );
+}
+
+/** The whole refusal with the caller-supplied id written out of the sentence. */
+function withoutId(o: Outcome, id: string): Outcome {
+ return o.kind === 'refused' ? { ...o, message: o.message.split(id).join('ID') } : o;
+}
+
+describe('a by-id write to a row the caller cannot read answers what a nonexistent id answers', () => {
+ for (const verb of ['update', 'delete'] as const) {
+ it(`${verb}: the hidden row and the missing id get one answer, the read door's not-found`, async () => {
+ const r = await boot();
+ // The precondition, read off the read door: the caller does not see it.
+ expect(await r.engine.findOne(CHILD, { where: { id: 'c_hidden' }, context: { ...CALLER } } as never)).toBeNull();
+
+ const hidden = verb === 'update' ? await r.update(CHILD, 'c_hidden') : await r.remove(CHILD, 'c_hidden');
+ const missing = verb === 'update' ? await r.update(CHILD, 'c_nowhere') : await r.remove(CHILD, 'c_nowhere');
+
+ expect(hidden).toMatchObject({ kind: 'refused', code: 'RECORD_NOT_FOUND', status: 404 });
+ expect(hidden).toMatchObject({ message: (recordNotFoundError(CHILD, 'c_hidden') as Error).message });
+ expect(withoutId(hidden, 'c_hidden')).toEqual(withoutId(missing, 'c_nowhere'));
+ // Refused means untouched.
+ expect((await r.stored(CHILD, 'c_hidden'))?.name).toBe('theirs, private');
+ });
+
+ it(`${verb}: a caller who can read the row but may not write it keeps 403 PERMISSION_DENIED`, async () => {
+ const r = await boot();
+ expect(await r.engine.findOne(CHILD, { where: { id: 'c_shared' }, context: { ...CALLER } } as never)).toBeTruthy();
+
+ const got = verb === 'update' ? await r.update(CHILD, 'c_shared') : await r.remove(CHILD, 'c_shared');
+
+ expect(got).toMatchObject({ kind: 'refused', code: 'PERMISSION_DENIED', status: 403, message: RECORD_SENTENCE });
+ expect((await r.stored(CHILD, 'c_shared'))?.name).toBe('theirs, shared');
+ });
+
+ it(`${verb}: a row the caller reads and may write is still written`, async () => {
+ const r = await boot();
+ const got = verb === 'update' ? await r.update(CHILD, 'c_own') : await r.remove(CHILD, 'c_own');
+ expect(got).toEqual({ kind: 'landed' });
+ const after = await r.stored(CHILD, 'c_own');
+ if (verb === 'update') expect(after?.name).toBe('renamed');
+ else expect(after).toBeNull();
+ });
+ }
+});
+
+describe('a by-id write the platform issues under the caller\'s context keeps the answer it had', () => {
+ it('the cascade delete of a dependent row the caller cannot read refuses with the record-level 403, naming nothing', async () => {
+ const r = await boot();
+ const got = await r.remove(PARENT, 'p_cascade');
+
+ expect(got).toMatchObject({ kind: 'refused', code: 'PERMISSION_DENIED', status: 403, message: RECORD_SENTENCE });
+ expect(got.kind === 'refused' && got.message).not.toContain('c_kid');
+ // One unit of work: neither the dependent nor the parent went.
+ expect(await r.stored(CHILD, 'c_kid')).toBeTruthy();
+ expect(await r.stored(PARENT, 'p_cascade')).toBeTruthy();
+ });
+
+ it('a hook\'s by-id write to a row the caller cannot read refuses with the record-level 403, not the not-found', async () => {
+ const r = await boot();
+ r.engine.registerHook(
+ 'beforeUpdate',
+ async (hookCtx: any) => {
+ await hookCtx.api.object(CHILD).updateById('c_hidden', { name: 'written by the hook' });
+ },
+ { object: PARENT, packageId: 'com.objectstack.qa.by-id-write-unreadable-not-found' },
+ );
+
+ const got = await r.update(PARENT, 'p_hook');
+
+ expect(got).toMatchObject({ kind: 'refused', code: 'PERMISSION_DENIED', status: 403, message: RECORD_SENTENCE });
+ expect(got.kind === 'refused' && got.message).not.toContain('c_hidden');
+ expect((await r.stored(CHILD, 'c_hidden'))?.name).toBe('theirs, private');
+ expect((await r.stored(PARENT, 'p_hook'))?.name).toBe('hooked');
+ });
+
+ it('the same caller addressing that row itself gets the not-found — the line is who addressed it', async () => {
+ const r = await boot();
+ expect(await r.update(CHILD, 'c_hidden')).toMatchObject({ kind: 'refused', code: 'RECORD_NOT_FOUND', status: 404 });
+ expect(await r.remove(CHILD, 'c_kid')).toMatchObject({ kind: 'refused', code: 'RECORD_NOT_FOUND', status: 404 });
+ });
+});
diff --git a/packages/plugins/plugin-security/src/controlled-by-parent-sharing.test.ts b/packages/plugins/plugin-security/src/controlled-by-parent-sharing.test.ts
index e86204c24d4..848ebab99d6 100644
--- a/packages/plugins/plugin-security/src/controlled-by-parent-sharing.test.ts
+++ b/packages/plugins/plugin-security/src/controlled-by-parent-sharing.test.ts
@@ -35,6 +35,7 @@
// nothing), and a master the caller owns.
import { describe, it, expect, vi } from 'vitest';
+import { recordNotFoundError } from '@objectstack/core';
import { SecurityPlugin } from './security-plugin.js';
import { SharingService, type SharingEngine } from '@objectstack/plugin-sharing';
import { matchesFilterCondition } from '@objectstack/formula';
@@ -784,7 +785,10 @@ describe('[#7474] the six refusal legs answer with six envelopes, not one', () =
const err = await refusalOf(h.updateContact('ct_deleted_concurrently'));
expect(err.code).toBe('RECORD_NOT_FOUND');
expect(err.status).toBe(404);
- expect(err.statusCode).toBe(404);
+ // [#21771] The addressed by-id write now asks the read door first, so a
+ // missing id gets the READ door's own not-found producer — which spells
+ // the status once — rather than the master gate's detail copy.
+ expect(err.message).toBe(recordNotFoundError('crm_contact', 'ct_deleted_concurrently').message);
expect(err.message).toContain('ct_deleted_concurrently');
expect(err.message).not.toContain('requires edit access to its master record');
});
diff --git a/packages/plugins/plugin-security/src/explain-cross-class-refusal.test.ts b/packages/plugins/plugin-security/src/explain-cross-class-refusal.test.ts
index d180214881d..cb6fb20016d 100644
--- a/packages/plugins/plugin-security/src/explain-cross-class-refusal.test.ts
+++ b/packages/plugins/plugin-security/src/explain-cross-class-refusal.test.ts
@@ -159,6 +159,8 @@ async function boot(makeDriver: () => Driver, predicate: string) {
type Envelope = { code: string; status: number };
const DENIED: Envelope = { code: 'PERMISSION_DENIED', status: 403 };
const INVALID: Envelope = { code: 'INVALID_FILTER', status: 400 };
+/** [#21771] The read door's answer for an id it does not return. */
+const NOT_FOUND: Envelope = { code: 'RECORD_NOT_FOUND', status: 404 };
const envelopeOf = (e: unknown): Envelope => {
const x = e as { code?: string; status?: number; statusCode?: number };
@@ -267,8 +269,10 @@ for (const [driverName, makeDriver, available] of DRIVERS) {
expect(await outcome(w.request('update', 'r1'))).toBe('admitted');
expect((await w.explain('update', 'r1')).record).toEqual({ recordId: 'r1', visible: true, decidedBy: 'rls' });
- expect(await outcome(w.request('update', 'r2'))).toEqual(DENIED);
- expect((await w.explain('update', 'r2')).record).toEqual({ recordId: 'r2', visible: false, decidedBy: 'rls' });
+ // [#21771] r2 is a row the caller cannot read: the write door answers
+ // what a nonexistent id answers, and explain reports the missing shape.
+ expect(await outcome(w.request('update', 'r2'))).toEqual(NOT_FOUND);
+ expect((await w.explain('update', 'r2')).record).toEqual({ recordId: 'r2', visible: false });
});
});
}
diff --git a/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts b/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts
index 002ce6a91bf..62568039c0d 100644
--- a/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts
+++ b/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts
@@ -656,6 +656,8 @@ const withPrincipal = (source: PostureSource, f: (rig: PrincipalRig) => Promise<
const REFUSED_INVALID: Enforced = { kind: 'refused', ...INVALID };
const REFUSED_DENIED: Enforced = { kind: 'refused', ...DENIED };
+/** [#21771] The read door's answer for an id it does not return, which a by-id write now gives for a row its caller cannot read. */
+const REFUSED_NOT_FOUND: Enforced = { kind: 'refused', code: 'RECORD_NOT_FOUND', status: 404 };
/** Rows over one row-level policy: every verdict position, for a predicate the find refuses and for the control. */
function rlsRows(card: string, label: string, predicate: string, refused: boolean): Row[] {
@@ -706,7 +708,9 @@ function rlsRows(card: string, label: string, predicate: string, refused: boolea
},
{
card, shape: `${label}: record r2, update`, position: 'record.visible',
- enforced: REFUSED_DENIED,
+ // r2 is outside the predicate, so the caller cannot read it (#21771);
+ // a predicate the find refuses still refuses before that question.
+ enforced: refused ? REFUSED_DENIED : REFUSED_NOT_FOUND,
run: withRls(predicate, async (r) => ({ explain: await r.explain('update', 'r2'), enforce: await r.update('r2') })),
},
{
@@ -853,8 +857,10 @@ const TABLE: Row[] = [
},
{
card: '#19963 control', shape: 'private OWD, an `own` writer, a row owned by someone else, update', position: 'record.visible',
- // The sharing middleware's by-id write refusal.
- enforced: { kind: 'refused', code: 'FORBIDDEN', status: 403 },
+ // [#21771] The `own` writer cannot READ a row owned by someone else on a
+ // private object, so the by-id write answers the read door's not-found
+ // before the sharing middleware's write refusal is reached.
+ enforced: REFUSED_NOT_FOUND,
run: withSharing({}, async (r) => ({
explain: await r.explain(SHARING_OWN, 'update', 'l_other'), enforce: await r.update(SHARING_OWN, 'l_other'),
})),
diff --git a/packages/plugins/plugin-security/src/explain-engine.ts b/packages/plugins/plugin-security/src/explain-engine.ts
index 5a4b02f89a2..8384bb16609 100644
--- a/packages/plugins/plugin-security/src/explain-engine.ts
+++ b/packages/plugins/plugin-security/src/explain-engine.ts
@@ -364,6 +364,14 @@ export interface ExplainEngineDeps {
* explanation for a `delete` must consult this rather than the update gate.
*/
canDeleteRecord?: (object: string, recordId: string, context: any) => Promise;
+ /**
+ * [#21771] Enforcement's own read question for an addressed by-id write:
+ * would the read door, asked by this principal, NOT return the record?
+ * `true` only for absence; a read-time policy refusal answers `false`, and a
+ * store fault propagates — exactly as the write path asks it, so the
+ * explanation cannot drift from the answer the write gets.
+ */
+ recordAbsentToCaller?: (object: string, recordId: string, context: any) => Promise;
}
export interface ExplainInput {
@@ -2048,6 +2056,25 @@ export async function explainAccess(deps: ExplainEngineDeps, input: ExplainInput
});
recordVerdict = out.record;
posture = out.posture;
+ // [#21771] Ruling A on the write doors: a row the principal cannot READ is
+ // a row that does not exist, so an update or delete of it answers what a
+ // nonexistent id answers. The record verdict says so in its own vocabulary
+ // — the missing-record shape, `visible: false` with no decider — wherever
+ // enforcement reaches that question: past the capability and object CRUD
+ // gates (which answer first, as they do here), for a principal with an
+ // identity, on a record that exists. (A system principal reads every row,
+ // so the question cannot change its verdict.)
+ if (
+ (operation === 'update' || operation === 'delete') &&
+ deps.recordAbsentToCaller &&
+ context?.userId &&
+ recordVerdict.decidedBy !== 'required_permissions' &&
+ recordVerdict.decidedBy !== 'object_crud' &&
+ !(recordVerdict.visible === false && recordVerdict.decidedBy === undefined) &&
+ (await deps.recordAbsentToCaller(object, input.recordId, context))
+ ) {
+ recordVerdict = { recordId: input.recordId, visible: false };
+ }
}
const decision: ExplainDecision = {
diff --git a/packages/plugins/plugin-security/src/explain-json-column-refusal.test.ts b/packages/plugins/plugin-security/src/explain-json-column-refusal.test.ts
index 37385d34d64..5279666eb5b 100644
--- a/packages/plugins/plugin-security/src/explain-json-column-refusal.test.ts
+++ b/packages/plugins/plugin-security/src/explain-json-column-refusal.test.ts
@@ -318,7 +318,10 @@ for (const [driverName, makeDriver, available] of DRIVERS) {
for (const { id } of c.rows as Array<{ id: string }>) {
const visible = c.visible.includes(id);
expect((await w.explain('read', id)).record, `read ${id}`).toEqual({ recordId: id, visible, decidedBy: 'rls' });
- expect((await w.explain('update', id)).record, `update ${id}`).toEqual({ recordId: id, visible, decidedBy: 'rls' });
+ // [#21771] A row the caller cannot read answers, on the write side,
+ // what a nonexistent id answers: the missing-record shape.
+ expect((await w.explain('update', id)).record, `update ${id}`)
+ .toEqual(visible ? { recordId: id, visible, decidedBy: 'rls' } : { recordId: id, visible: false });
}
});
}
diff --git a/packages/plugins/plugin-security/src/get-writable-fields.test.ts b/packages/plugins/plugin-security/src/get-writable-fields.test.ts
index bf461203ba1..65131294670 100644
--- a/packages/plugins/plugin-security/src/get-writable-fields.test.ts
+++ b/packages/plugins/plugin-security/src/get-writable-fields.test.ts
@@ -76,7 +76,11 @@ async function boot(sets: PermissionSet[], opts: { noBaseline?: boolean } = {})
registerMiddleware: (mw: any) => middlewares.push(mw),
getSchema: (name: string) => SCHEMAS[name],
findOne: vi.fn(async (_object: string, query: any) =>
- (query?.where?.id === LIVE_DELEGATOR ? { id: LIVE_DELEGATOR, email: 'boss@example.test' } : null)),
+ (query?.where?.id === LIVE_DELEGATOR
+ ? { id: LIVE_DELEGATOR, email: 'boss@example.test' }
+ // [#21771] An addressed by-id update asks the read door for its row;
+ // the double holds the one row the update names.
+ : query?.where?.id === PAYLOAD_VALUE.id ? { id: PAYLOAD_VALUE.id, title: 'x' } : null)),
},
metadata: {
get: async (_type: string, name: string) => SCHEMAS[name],
diff --git a/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts b/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts
index 652b9da0bfc..a93c653cd83 100644
--- a/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts
+++ b/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts
@@ -619,9 +619,14 @@ describe("walled posture, two organizations — the predicate reads the WRITER's
));
const foreign = envelopeOf(foreignErr);
const nowhere = envelopeOf(nowhereErr);
- expect([foreign.code, foreign.status], position).toEqual(['PERMISSION_DENIED', 403]);
- expect([nowhere.code, nowhere.status], position).toEqual(['PERMISSION_DENIED', 403]);
- expect(resolveThrownHttpError(foreignErr).message, position).toBe(resolveThrownHttpError(nowhereErr).message);
+ // [#21771] Ruling A: a row the writer cannot read answers what a
+ // nonexistent id answers — the read door's not-found, for both ids.
+ expect([foreign.code, foreign.status], position).toEqual(['RECORD_NOT_FOUND', 404]);
+ expect([nowhere.code, nowhere.status], position).toEqual(['RECORD_NOT_FOUND', 404]);
+ // The one difference the not-found sentence carries is the id the
+ // writer itself supplied.
+ expect(resolveThrownHttpError(foreignErr).message.replace('upb_foreign', 'ID'), position)
+ .toBe(resolveThrownHttpError(nowhereErr).message.replace('up_nowhere', 'ID'));
}
const [row] = await h.engine.find('sys_user_position', { where: { id: 'upb_foreign' }, context: SYS });
expect(row?.position).toBe('qa_b_only');
diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts
index 8027f814948..87634f7e8f2 100644
--- a/packages/plugins/plugin-security/src/security-plugin.test.ts
+++ b/packages/plugins/plugin-security/src/security-plugin.test.ts
@@ -2,6 +2,7 @@
import { describe, it, expect, vi } from 'vitest';
import { assertEngineUpdateDispatch, assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core';
+import { recordNotFoundError } from '@objectstack/core';
import { SecurityPlugin } from './security-plugin.js';
import { PermissionEvaluator, crudBucketForOperation } from './permission-evaluator.js';
import { FieldMasker } from './field-masker.js';
@@ -502,12 +503,14 @@ describe('SecurityPlugin', () => {
});
it('update without owner_id in the change-set is untouched by the guard', async () => {
- const harness = await boot([memberSet]);
+ // [#21771] The addressed by-id update now asks the read door whether the
+ // caller can read `t1`, so the double answers that read with the row.
+ const harness = await boot([memberSet], () => ({ id: 't1', owner_id: 'u1' }));
const opCtx: any = {
object: 'task', operation: 'update', data: { id: 't1', name: 'renamed' },
context: memberCtx(),
};
- await harness.run(opCtx); // must not throw, no pre-image read needed
+ await harness.run(opCtx); // must not throw — the guard reads no owner here
});
it('update disowning via owner_id:undefined is denied (mongo $set null hazard)', async () => {
@@ -548,7 +551,8 @@ describe('SecurityPlugin', () => {
});
it('a prototype-chain owner_id (not own property) does not trip the guard', async () => {
- const harness = await boot([memberSet]);
+ // [#21771] The double answers the addressed write's read question with the row.
+ const harness = await boot([memberSet], () => ({ id: 't1', owner_id: 'u1' }));
const proto = { owner_id: 'attacker' };
const data: any = Object.create(proto);
data.id = 't1';
@@ -873,7 +877,9 @@ describe('SecurityPlugin', () => {
const harness = makeMiddlewareCtx({
permissionSets: [ownerPolicySet],
objectFields: ownerFields,
- findOneImpl: () => null, // row exists but filtered out by created_by → not visible
+ // The owner policies are WRITE-only (`update` / `delete`): the row is
+ // readable, and filtered out only by `created_by` on the write re-read.
+ findOneImpl: (q: any) => (q?.where?.$and ? null : { id: 'r1', created_by: 'u2', name: 'theirs' }),
});
await plugin.init(harness.ctx);
await plugin.start(harness.ctx);
@@ -883,10 +889,36 @@ describe('SecurityPlugin', () => {
context: memberCtx,
};
await expect(harness.run(opCtx)).rejects.toMatchObject({ name: 'PermissionDeniedError' });
- expect(harness.findOne).toHaveBeenCalledTimes(1);
+ // [#21771] Two reads: the write re-read, then — the row being refused —
+ // the read question that keeps this caller's 403 (they can read it).
+ expect(harness.findOne).toHaveBeenCalledTimes(2);
// the re-read ANDs the row id with the owner write filter
const [, query] = harness.findOne.mock.calls[0];
expect(query.where.$and[0]).toEqual({ id: 'r1' });
+ expect(harness.findOne.mock.calls[1][1].where).toEqual({ id: 'r1' });
+ });
+
+ it('[#21771] a row the caller cannot READ answers what a nonexistent id answers, not 403', async () => {
+ // Every read of `r1` under the caller's context comes back empty: the
+ // row is hidden from this caller, so the write door says what the read
+ // door says — the shared not-found producer, never the 403.
+ for (const operation of ['update', 'delete'] as const) {
+ const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
+ const harness = makeMiddlewareCtx({ permissionSets: [ownerPolicySet], objectFields: ownerFields, findOneImpl: () => null });
+ await plugin.init(harness.ctx);
+ await plugin.start(harness.ctx);
+ const opCtx: any = {
+ object: 'task', operation,
+ ...(operation === 'update' ? { data: { id: 'r1', name: 'hijack' } } : {}),
+ options: { where: { id: 'r1' } },
+ context: memberCtx,
+ };
+ const err: any = await harness.run(opCtx).then(() => null, (e: unknown) => e);
+ const missing: any = recordNotFoundError('task', 'r1');
+ expect({ code: err?.code, status: err?.status, message: err?.message }, operation)
+ .toEqual({ code: 'RECORD_NOT_FOUND', status: 404, message: missing.message });
+ expect(err?.name, operation).not.toBe('PermissionDeniedError');
+ }
});
it('ALLOWS an update when the target row IS visible under the write filter (the owner)', async () => {
@@ -941,14 +973,18 @@ describe('SecurityPlugin', () => {
expect(harness.findOne).toHaveBeenCalledTimes(0);
});
- it('SKIPS the check when no RLS policy applies (e.g. modifyAllRecords / admin) — no extra read', async () => {
+ it('SKIPS the write-class re-read when no RLS policy applies (e.g. modifyAllRecords / admin) — the read question only', async () => {
const adminSet: PermissionSet = {
name: 'admin_full_access', label: 'Admin',
objects: { '*': { allowRead: true, allowEdit: true, allowDelete: true, modifyAllRecords: true, viewAllRecords: true } },
// no rowLevelSecurity
} as any;
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'admin_full_access' });
- const harness = makeMiddlewareCtx({ permissionSets: [adminSet], objectFields: ownerFields });
+ const harness = makeMiddlewareCtx({
+ permissionSets: [adminSet],
+ objectFields: ownerFields,
+ findOneImpl: (q: any) => (q?.where?.$and ? null : { id: 'r1', name: 'x' }),
+ });
await plugin.init(harness.ctx);
await plugin.start(harness.ctx);
const opCtx: any = {
@@ -957,7 +993,10 @@ describe('SecurityPlugin', () => {
context: { userId: 'admin', roles: ['admin_full_access'], permissions: [] },
};
await expect(harness.run(opCtx)).resolves.toBeDefined();
- expect(harness.findOne).not.toHaveBeenCalled();
+ // [#21771] Ruling A asks every principal class whether it can read the
+ // addressed row — one plain by-id read, and still no write-class re-read.
+ expect(harness.findOne).toHaveBeenCalledTimes(1);
+ expect(harness.findOne.mock.calls[0][1].where).toEqual({ id: 'r1' });
});
it('SKIPS the check for a multi-row predicate id ({$in}) — only single-id by-pk writes are guarded', async () => {
@@ -1243,7 +1282,9 @@ describe('SecurityPlugin', () => {
objectFields: ['id', 'organization_id', 'signed_token'],
schemaExtra: { access: { default: 'private' }, requiredPermissions: ['manage_platform_settings'] },
orgScoping: true,
- findOneImpl: () => null, // would DENY if the pre-image check ran
+ // The write-class re-read would DENY if it ran; the read question
+ // (#21771) finds the row the admin reads.
+ findOneImpl: (q: any) => (q?.where?.$and ? null : { id: 'r1', signed_token: 'old' }),
});
await plugin.init(harness.ctx);
await plugin.start(harness.ctx);
@@ -1253,7 +1294,8 @@ describe('SecurityPlugin', () => {
context: { userId: 'admin', tenantId: 'org-1', roles: ['admin_full_access'], permissions: [] },
};
await expect(harness.run(opCtx)).resolves.toBeDefined();
- expect(harness.findOne).not.toHaveBeenCalled();
+ expect(harness.findOne).toHaveBeenCalledTimes(1);
+ expect(harness.findOne.mock.calls[0][1].where).toEqual({ id: 'r1' });
});
// ADR-0135 D4 / cloud#551 — `managedBy: 'better-auth'` identity tables get the
diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts
index bf796ff10c8..59831b78fbd 100644
--- a/packages/plugins/plugin-security/src/security-plugin.ts
+++ b/packages/plugins/plugin-security/src/security-plugin.ts
@@ -1,6 +1,8 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
-import { Plugin, PluginContext, POSTURE_LADDER, isRowActive, buildEffectiveObjectPermissions } from '@objectstack/core';
+import { AsyncLocalStorage } from 'node:async_hooks';
+import { declaredHttpStatus } from '@objectstack/types';
+import { Plugin, PluginContext, POSTURE_LADDER, isRowActive, buildEffectiveObjectPermissions, recordNotFoundError } from '@objectstack/core';
import type { EffectiveObjectPermission, PermissionSet, RowLevelSecurityPolicy, TenantLayer0Verdict } from '@objectstack/spec/security';
import { describeHighPrivilegeBits, describeAnchorForbiddenBits, PUBLIC_FORM_SERVER_MANAGED_FIELDS } from '@objectstack/spec/security';
import type { AnchorBindingContext } from '@objectstack/spec/security';
@@ -15,7 +17,7 @@ import type { NumberComparandDoorFieldMeta } from '@objectstack/spec/data';
import { renderOperationMessage } from '@objectstack/spec/system';
// [#19989] The engine's own update-dispatch predicate, asked rather than
// re-derived: step 3.6 needs to know which row the ENGINE will write.
-import { resolveEngineUpdateDispatch, type EngineUpdateDispatchData } from '@objectstack/metadata-core';
+import { resolveEngineDeleteDispatch, resolveEngineUpdateDispatch, type EngineDeleteDispatchInput, type EngineUpdateDispatchData } from '@objectstack/metadata-core';
import {
localWriteEpochSource,
resolveWriteEpochSource,
@@ -581,6 +583,37 @@ function callerHasOrganizationScope(context: any, posture: TenancyPosture): bool
* (`organization_id = …` / `organization_id $in […]`) or `null`, so no legitimate
* wall can collide with this test.
*/
+/**
+ * [#21771] Ruling A's read question, judged on the answer of a caller-context
+ * by-id read: is the row ABSENT to this caller?
+ *
+ * Three outcomes of the read, and only one is absence (the #7505 rule):
+ *
+ * - no row — absent: the row does not exist, or the read door hides it from
+ * this caller (row-level security, record sharing, a data middleware's
+ * visibility). Those two are the same answer by ruling, so they are one
+ * `true` here;
+ * - a declared 4xx refusal — the read itself was REFUSED (the object's read
+ * grant withheld, a predicate the driver will not compile). Not a hidden
+ * row, so `false`: the write keeps the answer it has today;
+ * - anything else — a store fault, which propagates as raised: an outage is
+ * neither absence nor a refusal, and reporting it as either would relabel it.
+ *
+ * Shared by the write path (step 2.7) and `security/explain`, so the two ask
+ * one question one way.
+ */
+async function absentUnderCallerRead(read: () => Promise): Promise {
+ let row: unknown;
+ try {
+ row = await read();
+ } catch (e) {
+ const status = declaredHttpStatus(e);
+ if (status !== undefined && status < 500) return false;
+ throw e;
+ }
+ return row == null;
+}
+
function isTenantWallDenial(filter: Record | null | undefined): boolean {
if (!filter) return false;
const keys = Object.keys(filter);
@@ -1224,6 +1257,27 @@ export class SecurityPlugin implements Plugin {
* that fallback, so the memo is never keyed on `undefined`.
*/
private epoch: WriteEpochSource = localWriteEpochSource();
+ /**
+ * [#21771] The engine operations in flight through this plugin's middleware,
+ * as an async scope: the middleware runs the rest of the chain (the engine's
+ * hooks and driver call included) inside it, so an operation that starts
+ * while another is still in that chain reads `true` here.
+ *
+ * That is the line ruling A draws between the by-id write a caller ADDRESSED
+ * at a door and the by-id writes the platform issues on the caller's behalf
+ * under the caller's context: the engine's cascade delete of each dependent
+ * row, and a hook's `ctx.api` write. Only the addressed write is asked the
+ * read question ({@link addressedByIdWriteId}). A nested one keeps today's
+ * behaviour exactly: its target is a row the caller never named, so a
+ * not-found answer would carry an id the caller never supplied and misstate
+ * what happened to the write they did address.
+ *
+ * Owned here rather than stamped on the context: a context is shared by
+ * reference and copied by spread across those very sub-writes (the cascade's
+ * transaction context is a spread), so a stamp would leak in both directions.
+ * An async scope cannot outlive the chain it wraps.
+ */
+ private readonly engineOperationScope = new AsyncLocalStorage();
/**
* This plugin's report sink. Console-backed until a host injects one — see
* {@link SecurityReportSink} and {@link CONSOLE_SECURITY_SINK} for the ruling
@@ -2150,7 +2204,14 @@ export class SecurityPlugin implements Plugin {
const writesExecutedByDataDoor = new WeakSet