Skip to content

Commit 1272f0a

Browse files
Sam Altmanclaude
andauthored
Promote resolveRecordOrganizationField to the shared platform-row resolver: approvals + automation runs stamp the SUBJECT record's organization (cloud#1395 Option A) (#11311)
* services+plugins: promote resolveRecordOrganizationField to the shared platform-row resolver (#10101) Implements the cloud#1395 Option A ruling: a platform row's organization is the SUBJECT record's organization; actor context is the fallback, never the primary. The resolver moves from plugin-audit to @objectstack/metadata-core (the {spec, zod}-only common home all three sanctioned writers can import with no new cycle); plugin-audit re-exports from its original paths, the approval-request writer stamps subject-first in openNodeRequest, and the automation-run store resolves the trigger record's organization on both its write paths (paused serialize() and terminal recordTerminal()). WIP: tests, i18n bundle regeneration and changeset follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4 * test+i18n+changeset: pins, promoted cloud#1395 assertion, bundle regen (#10101) - metadata-core: unit pins for the four-limb precedence, the sys_api_key divergence, and the memoized resolver factory (12 tests) - service-automation: the PINNED defect test is PROMOTED per its own instruction; subject-beats-actor, fallback directions, Option-C veto, sys_api_key divergence, paused/terminal agreement, and an engine-to-store end-to-end handoff pin - plugin-approvals: openNodeRequest attribution pins (row + action + approver index move together, both fallback directions, sys_api_key divergence and the ADR-0066 limb-1 non-stamp) - i18n: reworded organization_id help extracted to the en bundle; zh-CN / ja-JP / es-ES hand-translated in the same pass (check-i18n-bundles OK, 9/9) - changeset for the four packages Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4 * config: resolve metadata-core to source in the two plugins' tsc + vitest programs (#10101) check:test-source-alias and check:type-source-resolution both red on the new @objectstack/metadata-core edges: without these entries plugin-audit's and plugin-approvals' typecheck and test verdicts would be about metadata-core's dist build state rather than about the source in the checkout. paths + anchored vitest aliases per the gates' own prescription; rootDir widened as the packages/rest #9960 precedent records. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bbf1167 commit 1272f0a

23 files changed

Lines changed: 1099 additions & 317 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/metadata-core": minor
3+
"@objectstack/plugin-approvals": patch
4+
"@objectstack/service-automation": patch
5+
"@objectstack/plugin-audit": patch
6+
---
7+
8+
Promote `resolveRecordOrganizationField` to the shared platform-row organization resolver (the cloud#1395 Option A ruling): a platform row's organization is the SUBJECT record's organization; actor context is the fallback, never the primary.
9+
10+
- `@objectstack/metadata-core` now owns the resolver (`resolveRecordOrganizationField`, `createFieldPresenceProbe`, and the new memoized `createRecordOrganizationResolver` factory) so all three sanctioned writers share one precedence.
11+
- `@objectstack/plugin-approvals`: `openNodeRequest` stamps `sys_approval_request`, `sys_approval_action` and the `sys_approval_approver` index from the subject record's organization (acting context as fallback). Fixes the measured defect where every schedule / time-relative / api triggered approval persisted `organization_id = NULL` — locking the record it was about while being invisible in every inbox, its owner's included.
12+
- `@objectstack/service-automation`: `sys_automation_run` rows (paused and terminal) resolve their organization from the trigger-record snapshot, with the acting tenant as fallback. Terminal rows previously never carried an organization at all.
13+
- `@objectstack/plugin-audit`: the resolver moved out; the package re-exports it from the original paths, behavior unchanged.
14+
15+
The `sys_api_key` divergence is preserved and pinned: `tenancy.organizationField` (who a row is ABOUT) still wins over the tenant wall answer, and the credential table stays unwalled.

‎packages/metadata-core/src/index.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,15 @@ export * from './item-key-discriminators.js';
8181
// situation this package exists to resolve. `runtime` imports it from here now,
8282
// so its behaviour is unchanged and there is no second copy to drift.
8383
export * from './meta-write-org-scope.js';
84+
85+
// [#8707 / #10101] The shared platform-row organization resolver — sunk here
86+
// from `@objectstack/plugin-audit` per the maintainer ruling recorded on
87+
// cloud#1395 ("promoted to a shared resolver used by all three platform-row
88+
// writers"). The three sanctioned consumers — audit stamping, the approval-row
89+
// writer, the automation-run recorder — live in `plugin-audit`,
90+
// `plugin-approvals` and `service-automation`, which share no other common
91+
// home; this package's `{ @objectstack/spec, zod }`-only contract lets all
92+
// three import ONE precedence instead of drifting a copy each. `plugin-audit`
93+
// re-exports `createFieldPresenceProbe` from its original path, so its public
94+
// surface is unchanged.
95+
export * from './record-organization.js';
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#10101] Unit pins for the SHARED platform-row organization resolver — the
5+
* cloud#1395 Option A ruling's artifact ("A platform row's organization is the
6+
* SUBJECT record's organization; actor context is the fallback, never the
7+
* primary"), promoted here from plugin-audit so audit stamping, the
8+
* approval-row writer and the automation-run recorder share ONE precedence.
9+
*
10+
* The four-limb precedence is pinned per limb, and the `sys_api_key`
11+
* divergence is pinned by name: `tenancy.organizationField` answers "which
12+
* column says who this row is ABOUT", `tenantField`/`organization_id` answers
13+
* "what is this object WALLED by", and the two DELIBERATELY diverge for
14+
* credential tables (#8287). Flattening that divergence — resolving the stamp
15+
* from the wall, or walling from the stamp — is the two-tables-disagree
16+
* pathology this promotion exists to end.
17+
*/
18+
19+
import { describe, it, expect, vi } from 'vitest';
20+
21+
import {
22+
createFieldPresenceProbe,
23+
createRecordOrganizationResolver,
24+
resolveRecordOrganizationField,
25+
} from './record-organization.js';
26+
27+
/** Minimal engine double: `getSchema` over a name → definition map. */
28+
function engineOf(defs: Record<string, any>) {
29+
return {
30+
getSchema: vi.fn((name: string) => defs[name]),
31+
};
32+
}
33+
34+
const hasFieldOf = (def: any) => (field: string) =>
35+
def?.fields != null && Object.prototype.hasOwnProperty.call(def.fields, field);
36+
37+
describe('resolveRecordOrganizationField — the four-limb precedence', () => {
38+
it('limb 0: a declared `tenancy.organizationField` wins over everything, the ADR-0066 opt-out included (sys_api_key)', () => {
39+
// The shipped divergent case: an UNWALLED credential table
40+
// (`enabled: false`) whose rows are still ABOUT one organization, under a
41+
// column that deliberately is NOT the tenant column.
42+
const def = {
43+
name: 'sys_api_key',
44+
tenancy: { enabled: false, organizationField: 'active_organization_id' },
45+
fields: { id: {}, name: {}, user_id: {}, active_organization_id: {}, revoked: {} },
46+
};
47+
expect(resolveRecordOrganizationField(def, hasFieldOf(def))).toBe('active_organization_id');
48+
});
49+
50+
it('limb 0 guard (#5315): a declared organizationField naming a MISSING column falls through, never resolves to nothing', () => {
51+
// Missing column + disabled tenancy → limb 1 answers null (not the
52+
// phantom name, and not organization_id either).
53+
const def = {
54+
name: 'sys_api_key',
55+
tenancy: { enabled: false, organizationField: 'active_organization_id' },
56+
fields: { id: {}, organization_id: {} },
57+
};
58+
expect(resolveRecordOrganizationField(def, hasFieldOf(def))).toBeNull();
59+
});
60+
61+
it('limb 1: `tenancy.enabled === false` WITHOUT an organizationField resolves null even when an org FK exists (ADR-0066)', () => {
62+
// The sys_sso_provider shape: platform-global, keeps an optional org FK,
63+
// explicitly not tenant-scoped. Stamping from the FK would hide a global
64+
// object's platform rows from the platform admin who acted.
65+
const def = {
66+
name: 'sys_sso_provider',
67+
tenancy: { enabled: false },
68+
fields: { id: {}, organization_id: {} },
69+
};
70+
expect(resolveRecordOrganizationField(def, hasFieldOf(def))).toBeNull();
71+
});
72+
73+
it('limb 2: a declared `tenancy.tenantField` answers when present', () => {
74+
const def = {
75+
name: 'ws_doc',
76+
tenancy: { enabled: true, tenantField: 'workspace_id' },
77+
fields: { id: {}, workspace_id: {}, organization_id: {} },
78+
};
79+
expect(resolveRecordOrganizationField(def, hasFieldOf(def))).toBe('workspace_id');
80+
});
81+
82+
it('limb 3: the canonical injected `organization_id` when nothing is declared', () => {
83+
const def = { name: 'crm_deal', fields: { id: {}, organization_id: {} } };
84+
expect(resolveRecordOrganizationField(def, hasFieldOf(def))).toBe('organization_id');
85+
});
86+
87+
it('limb 4: no organization of its own → null (single-tenant shape)', () => {
88+
const def = { name: 'crm_deal', fields: { id: {}, amount: {} } };
89+
expect(resolveRecordOrganizationField(def, hasFieldOf(def))).toBeNull();
90+
expect(resolveRecordOrganizationField(undefined, () => true)).toBeNull();
91+
expect(resolveRecordOrganizationField(null, () => true)).toBeNull();
92+
});
93+
});
94+
95+
describe('createFieldPresenceProbe', () => {
96+
it('answers from the registered schema, map and array field shapes alike, memoized per object', () => {
97+
const engine = engineOf({
98+
map_obj: { fields: { id: {}, organization_id: {} } },
99+
arr_obj: { fields: [{ name: 'id' }, { name: 'organization_id' }] },
100+
});
101+
const has = createFieldPresenceProbe(engine);
102+
expect(has('map_obj', 'organization_id')).toBe(true);
103+
expect(has('arr_obj', 'organization_id')).toBe(true);
104+
expect(has('map_obj', 'missing')).toBe(false);
105+
expect(has('nowhere', 'organization_id')).toBe(false);
106+
has('map_obj', 'id');
107+
// one getSchema per object, not per question
108+
expect(engine.getSchema.mock.calls.filter(([n]) => n === 'map_obj')).toHaveLength(1);
109+
});
110+
111+
it('an engine with no getSchema reports every field absent (skip-the-stamp posture, never a throw)', () => {
112+
const has = createFieldPresenceProbe({});
113+
expect(has('anything', 'organization_id')).toBe(false);
114+
});
115+
});
116+
117+
describe('createRecordOrganizationResolver — the writers’ memoized face', () => {
118+
it('organizationOf reads the resolved column off the first candidate record that carries a non-empty value', () => {
119+
const engine = engineOf({ crm_deal: { fields: { id: {}, organization_id: {} } } });
120+
const r = createRecordOrganizationResolver(engine);
121+
expect(r.organizationFieldFor('crm_deal')).toBe('organization_id');
122+
expect(r.organizationOf('crm_deal', { id: 'd1', organization_id: 'org_A' })).toBe('org_A');
123+
// precedence across candidates: first non-empty wins (live record before
124+
// trigger snapshot, result before prior state — the callers' order)
125+
expect(
126+
r.organizationOf('crm_deal', { id: 'd1', organization_id: '' }, { id: 'd1', organization_id: 'org_B' }),
127+
).toBe('org_B');
128+
expect(r.organizationOf('crm_deal', undefined, null, { id: 'd1' })).toBeNull();
129+
});
130+
131+
it('pins the sys_api_key divergence end to end: the stamp column is active_organization_id, never the wall', () => {
132+
const engine = engineOf({
133+
sys_api_key: {
134+
tenancy: { enabled: false, organizationField: 'active_organization_id' },
135+
fields: { id: {}, name: {}, user_id: {}, active_organization_id: {}, revoked: {} },
136+
},
137+
});
138+
const r = createRecordOrganizationResolver(engine);
139+
expect(r.organizationFieldFor('sys_api_key')).toBe('active_organization_id');
140+
expect(
141+
r.organizationOf('sys_api_key', { id: 'k1', active_organization_id: 'org_key' }),
142+
).toBe('org_key');
143+
// A record carrying an `organization_id` VALUE anyway (defensive noise)
144+
// still stamps from the DECLARED column, not the canonical spelling.
145+
expect(
146+
r.organizationOf('sys_api_key', { id: 'k1', organization_id: 'org_wrong', active_organization_id: 'org_key' }),
147+
).toBe('org_key');
148+
});
149+
150+
it('degrades to null — the acting-context fallback signal — on a getSchema-less double, a throwing getSchema, and an unknown object', () => {
151+
expect(createRecordOrganizationResolver({}).organizationOf('crm_deal', { organization_id: 'org_A' })).toBeNull();
152+
const throwing = { getSchema: () => { throw new Error('not booted'); } };
153+
expect(createRecordOrganizationResolver(throwing).organizationOf('crm_deal', { organization_id: 'org_A' })).toBeNull();
154+
const empty = engineOf({});
155+
expect(createRecordOrganizationResolver(empty).organizationOf('crm_deal', { organization_id: 'org_A' })).toBeNull();
156+
});
157+
158+
it('memoizes the column per object (one schema read for N writes)', () => {
159+
const engine = engineOf({ crm_deal: { fields: { id: {}, organization_id: {} } } });
160+
const r = createRecordOrganizationResolver(engine);
161+
r.organizationOf('crm_deal', { organization_id: 'a' });
162+
r.organizationOf('crm_deal', { organization_id: 'b' });
163+
r.organizationFieldFor('crm_deal');
164+
// one call from the probe's field-set read + one from the column
165+
// resolution — and no growth with further questions
166+
const calls = engine.getSchema.mock.calls.filter(([n]) => n === 'crm_deal').length;
167+
r.organizationOf('crm_deal', { organization_id: 'c' });
168+
expect(engine.getSchema.mock.calls.filter(([n]) => n === 'crm_deal').length).toBe(calls);
169+
expect(calls).toBeLessThanOrEqual(2);
170+
});
171+
});

0 commit comments

Comments
 (0)