Skip to content

Commit b3682d4

Browse files
committed
fix(objectql): spare only the platform's injected organization_id; an authored read-only one keeps the strip
Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
1 parent c5a1f5f commit b3682d4

5 files changed

Lines changed: 81 additions & 3 deletions

File tree

‎.changeset/24822-wall-judged-organization-stored.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,5 @@ Clause-②: yes (widening) — `PostHookWriteImageCheck` gains the optional publ
3232
- **A member with no active organization.** Under `group`, such a member with a non-empty membership set can now insert a row naming one of those organizations: it is stored there. It used to be refused `403 PERMISSION_DENIED` under ADR-0123 D2, because the value was taken and the row then named no organization. A row naming none is still refused under D2, and one naming an organization outside the set is still refused by the wall.
3333
- **An on-behalf-of insert (ADR-0090 D10).** Take a platform operator who crosses the wall and acts for a walled delegator. The named organization is held to the delegator's wall: stored as sent where that wall admits it, refused `403` where it does not. Before, an admitted value was taken and the active organization stored.
3434
- **An explicit empty value in an array insert.** A row naming `organization_id: null` or `''` is no longer taken, reported, or refused under strict. It is still stored as the active organization, because the driver's stamp fills an empty value.
35+
- **Only the platform's injected column is spared.** On an object whose author declares its own `organization_id` with `readonly: true`, a caller's value is still taken, reported, and refused under strict, exactly as before. The wall still judges that column. The author's declaration is what keeps the strip, and the injected column is told apart by the registry's provenance reader (`resolveInjectedColumnProvenance`).
3536
- **Where no wall judges the column, nothing changes.** The value is still taken and reported, and strict still refuses it. That includes `single`, a composition without the security plugin, a tenancy-disabled object, and a platform operator crossing the wall on its own behalf.

‎packages/objectql/src/engine-insert-wall-judged-organization.test.ts‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414
// What this file holds the engine to:
1515
// - the member SET: the value is stored as sent, shown to `beforeInsert`,
1616
// handed to the seam, not reported, and not refused under
17-
// `strictReadonlyWrites`. Every OTHER read-only key is still taken;
17+
// `strictReadonlyWrites`. Every OTHER read-only key is still taken, and so
18+
// is an AUTHORED read-only `organization_id`: only the platform's injected
19+
// column is spared;
1820
// - the member ABSENT or `false` on an installed seam, and no seam at all:
1921
// the strip takes the value exactly as before (reported, withheld from the
2022
// hook, refused under strict). Seam presence is not the signal: a business
@@ -92,6 +94,7 @@ const OBJECT = 'qa_ledger';
9294
const ACTIVE = 'org_a';
9395
const NAMED = 'org_c';
9496
const SESSION = { userId: 'usr_member', tenantId: ACTIVE };
97+
const AUTHORED = 'qa_authored';
9598

9699
/** How the stand-in installer behaves on this engine's next insert. */
97100
type SeamMode = 'judges' | 'judges-false' | 'no-member' | 'none';
@@ -122,6 +125,12 @@ describe('[#24822] engine.insert spares organization_id only where the seam decl
122125
engine.registerDriver(d.driver, true);
123126
await engine.init();
124127
engine.registry.registerObject({ name: OBJECT, fields: { name: { type: 'text' } } } as any);
128+
// [#24822] F4: an app object whose AUTHOR declares its own read-only
129+
// organization_id. The member alone does not spare it.
130+
engine.registry.registerObject({
131+
name: AUTHORED,
132+
fields: { name: { type: 'text' }, organization_id: { type: 'text', readonly: true } },
133+
} as any);
125134

126135
engine.registerHook('beforeInsert', async (ctx: any) => {
127136
const data = (ctx.input?.data ?? {}) as Record<string, unknown>;
@@ -189,6 +198,26 @@ describe('[#24822] engine.insert spares organization_id only where the seam decl
189198
expect([...storeFor(OBJECT).values()][0]?.created_by, 'the forged audit value is not stored').not.toBe('usr_forged');
190199
});
191200

201+
it('SET, but an AUTHORED read-only organization_id: the author\'s declaration keeps the strip, and strict refuses it', async () => {
202+
const dropped: Array<{ fields: string[]; reason: string }> = [];
203+
await engine.insert(AUTHORED, { name: 'one', organization_id: NAMED } as any, {
204+
context: SESSION,
205+
onFieldsDropped: (e: any) => dropped.push({ fields: e.fields, reason: e.reason }),
206+
} as any);
207+
208+
expect([...storeFor(AUTHORED).values()].map((r) => r.organization_id), 'the caller\'s value is not stored').toEqual([undefined]);
209+
expect(dropped).toEqual([{ fields: ['organization_id'], reason: 'readonly' }]);
210+
211+
let thrown: { code?: unknown; fields?: unknown } | undefined;
212+
try {
213+
await engine.insert(AUTHORED, { name: 'two', organization_id: NAMED } as any, { context: SESSION, strictReadonlyWrites: true } as any);
214+
} catch (e) {
215+
thrown = e as typeof thrown;
216+
}
217+
expect(thrown?.code).toBe('ERR_READONLY_FIELD_REJECTED');
218+
expect(thrown?.fields).toEqual(['organization_id']);
219+
});
220+
192221
it('SET: strictReadonlyWrites does not refuse it, because strict refuses only what the strip takes', async () => {
193222
await insertReporting({ name: 'one', organization_id: NAMED }, { strictReadonlyWrites: true });
194223

‎packages/objectql/src/engine.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import type { ValidateDataIssue, ValidateDataResponse } from '@objectstack/spec/
2929
// [#22646] The ADR-0112 catalog — `rejectCredentialAggregation`'s refusal
3030
// envelope carries `INVALID_FIELD` from it rather than a bare `Error`.
3131
import { StandardErrorCode } from '@objectstack/spec/api';
32-
import { parseAutonumberFormat, renderAutonumber, resolveAutonumberFormat, readAutonumberCounter, missingFieldValues, isTenancyDisabled, FILE_REFERENCE_TYPES, REFERENCE_VALUE_TYPES, referenceTargetOf, referenceCarrierOf, isFileIdToken, RAW_FILE_VALUES_CONTEXT_KEY, isCurrentUserDefaultToken, isNowDefaultToken, isMultiValueField, driverSupportsTransactions, AUDIT_PROVENANCE_FIELDS } from '@objectstack/spec/data';
32+
import { parseAutonumberFormat, renderAutonumber, resolveAutonumberFormat, readAutonumberCounter, missingFieldValues, isTenancyDisabled, FILE_REFERENCE_TYPES, REFERENCE_VALUE_TYPES, referenceTargetOf, referenceCarrierOf, isFileIdToken, RAW_FILE_VALUES_CONTEXT_KEY, isCurrentUserDefaultToken, isNowDefaultToken, isMultiValueField, driverSupportsTransactions, AUDIT_PROVENANCE_FIELDS, resolveInjectedColumnProvenance } from '@objectstack/spec/data';
3333
import type { FileRefusedValue } from '@objectstack/spec/data';
3434
// [#5158] Door 2's lowering sink — the SAME pair the protocol face (Door 1)
3535
// runs, so `FilterArray` has exactly one lowering in the product.
@@ -2391,7 +2391,8 @@ function callerSuppliedRow(row: unknown, sent: ReadonlySet<string> | undefined):
23912391
/**
23922392
* [#24822] The fields ONE insert's static-`readonly` passes may judge: the
23932393
* {@link staticReadonlyInsertSubject} view, without `organization_id` when the
2394-
* Layer 0 wall judges that column on this insert's stored rows.
2394+
* Layer 0 wall judges that column on this insert's stored rows AND the column
2395+
* is the platform's injected tenant column.
23952396
*
23962397
* The wall says so itself, on the seam it installs
23972398
* (`PostHookWriteImageCheck.judgesOrganization`). The caller passes that
@@ -2417,6 +2418,12 @@ function insertStaticReadonlySubject(
24172418
const subject = staticReadonlyInsertSubject(schema as any);
24182419
if (!subject || !wallJudgesOrganization) return subject;
24192420
if (!Object.prototype.hasOwnProperty.call(subject.fields, SystemFieldName.ORGANIZATION_ID)) return subject;
2421+
// An AUTHORED `organization_id` declared `readonly: true` is the author's
2422+
// own declaration, and declaration is enforcement: it keeps the strip. Only
2423+
// the platform's injected tenant column is spared, the one
2424+
// `TENANT_SCOPE_FIELD_DEF` declares read-only on the platform's behalf. Which
2425+
// one this is, is the registry's own provenance reader, never a second copy.
2426+
if (resolveInjectedColumnProvenance(schema, SystemFieldName.ORGANIZATION_ID) === 'author') return subject;
24202427
const fields = { ...subject.fields };
24212428
delete fields[SystemFieldName.ORGANIZATION_ID];
24222429
return Object.keys(fields).length > 0 ? { name: subject.name, fields } : null;
@@ -3752,6 +3759,8 @@ export interface PostHookWriteImageCheck {
37523759
* (the platform's tenant column declares `readonly: true`). Set, the value
37533760
* is stored as sent where the wall admits it and refused where it does not.
37543761
* It is never silently replaced by the active organization (ADR-0105 D5).
3762+
* Only the platform's injected column is spared. An AUTHORED
3763+
* `organization_id` declared `readonly: true` keeps the strip.
37553764
* Absent or `false`, the strip runs exactly as before. The engine never
37563765
* sets it, and never derives it from the posture or the context: that
37573766
* would be a mirror of the wall, and a mirror is wider than the wall

‎packages/objectql/src/federated-injected-column-readers.test.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,12 @@ const READERS: Record<string, Row> = {
350350
},
351351
// [#24822] The insert's static-`readonly` subject, without the tenant column
352352
// where the Layer 0 wall's seam declares it judges the insert's organization.
353+
'engine.ts#insertStaticReadonlySubject :: resolveInjectedColumnProvenance()': {
354+
disposition: 'not-a-read',
355+
why:
356+
"asks whether the insert's organization_id is the platform's injected column or the author's; " +
357+
'it reads a registered definition, never storage',
358+
},
353359
'engine.ts#insertStaticReadonlySubject :: organization_id': {
354360
disposition: 'not-a-read',
355361
why:

‎packages/plugins/organizations/src/create-explicit-organization-wall.test.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ const DECLARED = 'qa_assignment';
7777
const INJECTED = 'qa_ledger';
7878
const PRIVATE = 'qa_vault';
7979
const UNWALLED = 'qa_catalog';
80+
const AUTHORED = 'qa_authored';
8081

8182
const OBJECTS = [
8283
{
@@ -108,6 +109,18 @@ const OBJECTS = [
108109
name: { name: 'name', type: 'text' },
109110
},
110111
},
112+
// [#24822] A tenancy-enabled app object whose AUTHOR declares its own
113+
// read-only `organization_id`. The wall still judges it, and the author's
114+
// declaration keeps the static-`readonly` strip.
115+
{
116+
name: AUTHORED,
117+
label: 'Authored',
118+
fields: {
119+
id: { name: 'id', type: 'text', primaryKey: true },
120+
name: { name: 'name', type: 'text' },
121+
organization_id: { name: 'organization_id', type: 'text', readonly: true },
122+
},
123+
},
111124
// [#24822] Tenancy switched off, and an AUTHORED read-only `organization_id`:
112125
// Layer 0 composes nothing on it, so no wall judges the column.
113126
{
@@ -629,6 +642,26 @@ describe('[#24822] an explicit organization_id the wall judges is stored as sent
629642
});
630643
});
631644

645+
describe('an AUTHORED read-only organization_id on a walled object (F4)', () => {
646+
// [#24822] F4: the wall judges this column too (the object stays walled on
647+
// an authored `organization_id`), and the seam declares it. But the
648+
// author declared the column read-only, and declaration is enforcement:
649+
// only the platform's injected column is spared.
650+
it('an AUTHORED read-only organization_id on a walled object keeps the strip: taken, reported, the active organization stored, and refused under strict', async () => {
651+
const b = await boot('group');
652+
653+
const { outcome, dropped } = await createReporting(b, AUTHORED, { id: 'r2', name: 'new', organization_id: SISTER_ORG }, GROUP_CTX);
654+
expect(outcome.ok, outcome.message).toBe(true);
655+
expect(await b.table(AUTHORED), 'the author\'s read-only column is not written by the caller').toEqual([{ id: 'r2', organization_id: OWN_ORG }]);
656+
expect(dropped).toEqual(READONLY_DROP(AUTHORED));
657+
expect(organizationWarnings(b)).toHaveLength(1);
658+
659+
await expectStrictRefusal(b, AUTHORED, { id: 'r3', name: 'strict', organization_id: SISTER_ORG }, GROUP_CTX);
660+
expect(await b.table(AUTHORED)).toEqual([{ id: 'r2', organization_id: OWN_ORG }]);
661+
expect(b.seams, 'the wall still judges an authored column and declares it').toEqual([...WALL_JUDGES, ...WALL_JUDGES]);
662+
});
663+
});
664+
632665
// [#24822 · ADR-0123 D2] F1: a member with a membership set and NO active
633666
// organization. Before this change the strip took the named organization, the
634667
// stored row named none, and the D2 arm refused it. The arm's scope is a row

0 commit comments

Comments
 (0)