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
15 changes: 15 additions & 0 deletions .changeset/shared-platform-row-org-resolver.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
---
"@objectstack/metadata-core": minor
"@objectstack/plugin-approvals": patch
"@objectstack/service-automation": patch
"@objectstack/plugin-audit": patch
---

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.

- `@objectstack/metadata-core` now owns the resolver (`resolveRecordOrganizationField`, `createFieldPresenceProbe`, and the new memoized `createRecordOrganizationResolver` factory) so all three sanctioned writers share one precedence.
- `@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.
- `@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.
- `@objectstack/plugin-audit`: the resolver moved out; the package re-exports it from the original paths, behavior unchanged.

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.
12 changes: 12 additions & 0 deletions packages/metadata-core/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -81,3 +81,15 @@ export * from './item-key-discriminators.js';
// situation this package exists to resolve. `runtime` imports it from here now,
// so its behaviour is unchanged and there is no second copy to drift.
export * from './meta-write-org-scope.js';

// [#8707 / #10101] The shared platform-row organization resolver — sunk here
// from `@objectstack/plugin-audit` per the maintainer ruling recorded on
// cloud#1395 ("promoted to a shared resolver used by all three platform-row
// writers"). The three sanctioned consumers — audit stamping, the approval-row
// writer, the automation-run recorder — live in `plugin-audit`,
// `plugin-approvals` and `service-automation`, which share no other common
// home; this package's `{ @objectstack/spec, zod }`-only contract lets all
// three import ONE precedence instead of drifting a copy each. `plugin-audit`
// re-exports `createFieldPresenceProbe` from its original path, so its public
// surface is unchanged.
export * from './record-organization.js';
171 changes: 171 additions & 0 deletions packages/metadata-core/src/record-organization.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#10101] Unit pins for the SHARED platform-row organization resolver — the
* cloud#1395 Option A ruling's artifact ("A platform row's organization is the
* SUBJECT record's organization; actor context is the fallback, never the
* primary"), promoted here from plugin-audit so audit stamping, the
* approval-row writer and the automation-run recorder share ONE precedence.
*
* The four-limb precedence is pinned per limb, and the `sys_api_key`
* divergence is pinned by name: `tenancy.organizationField` answers "which
* column says who this row is ABOUT", `tenantField`/`organization_id` answers
* "what is this object WALLED by", and the two DELIBERATELY diverge for
* credential tables (#8287). Flattening that divergence — resolving the stamp
* from the wall, or walling from the stamp — is the two-tables-disagree
* pathology this promotion exists to end.
*/

import { describe, it, expect, vi } from 'vitest';

import {
createFieldPresenceProbe,
createRecordOrganizationResolver,
resolveRecordOrganizationField,
} from './record-organization.js';

/** Minimal engine double: `getSchema` over a name → definition map. */
function engineOf(defs: Record<string, any>) {
return {
getSchema: vi.fn((name: string) => defs[name]),
};
}

const hasFieldOf = (def: any) => (field: string) =>
def?.fields != null && Object.prototype.hasOwnProperty.call(def.fields, field);

describe('resolveRecordOrganizationField — the four-limb precedence', () => {
it('limb 0: a declared `tenancy.organizationField` wins over everything, the ADR-0066 opt-out included (sys_api_key)', () => {
// The shipped divergent case: an UNWALLED credential table
// (`enabled: false`) whose rows are still ABOUT one organization, under a
// column that deliberately is NOT the tenant column.
const def = {
name: 'sys_api_key',
tenancy: { enabled: false, organizationField: 'active_organization_id' },
fields: { id: {}, name: {}, user_id: {}, active_organization_id: {}, revoked: {} },
};
expect(resolveRecordOrganizationField(def, hasFieldOf(def))).toBe('active_organization_id');
});

it('limb 0 guard (#5315): a declared organizationField naming a MISSING column falls through, never resolves to nothing', () => {
// Missing column + disabled tenancy → limb 1 answers null (not the
// phantom name, and not organization_id either).
const def = {
name: 'sys_api_key',
tenancy: { enabled: false, organizationField: 'active_organization_id' },
fields: { id: {}, organization_id: {} },
};
expect(resolveRecordOrganizationField(def, hasFieldOf(def))).toBeNull();
});

it('limb 1: `tenancy.enabled === false` WITHOUT an organizationField resolves null even when an org FK exists (ADR-0066)', () => {
// The sys_sso_provider shape: platform-global, keeps an optional org FK,
// explicitly not tenant-scoped. Stamping from the FK would hide a global
// object's platform rows from the platform admin who acted.
const def = {
name: 'sys_sso_provider',
tenancy: { enabled: false },
fields: { id: {}, organization_id: {} },
};
expect(resolveRecordOrganizationField(def, hasFieldOf(def))).toBeNull();
});

it('limb 2: a declared `tenancy.tenantField` answers when present', () => {
const def = {
name: 'ws_doc',
tenancy: { enabled: true, tenantField: 'workspace_id' },
fields: { id: {}, workspace_id: {}, organization_id: {} },
};
expect(resolveRecordOrganizationField(def, hasFieldOf(def))).toBe('workspace_id');
});

it('limb 3: the canonical injected `organization_id` when nothing is declared', () => {
const def = { name: 'crm_deal', fields: { id: {}, organization_id: {} } };
expect(resolveRecordOrganizationField(def, hasFieldOf(def))).toBe('organization_id');
});

it('limb 4: no organization of its own → null (single-tenant shape)', () => {
const def = { name: 'crm_deal', fields: { id: {}, amount: {} } };
expect(resolveRecordOrganizationField(def, hasFieldOf(def))).toBeNull();
expect(resolveRecordOrganizationField(undefined, () => true)).toBeNull();
expect(resolveRecordOrganizationField(null, () => true)).toBeNull();
});
});

describe('createFieldPresenceProbe', () => {
it('answers from the registered schema, map and array field shapes alike, memoized per object', () => {
const engine = engineOf({
map_obj: { fields: { id: {}, organization_id: {} } },
arr_obj: { fields: [{ name: 'id' }, { name: 'organization_id' }] },
});
const has = createFieldPresenceProbe(engine);
expect(has('map_obj', 'organization_id')).toBe(true);
expect(has('arr_obj', 'organization_id')).toBe(true);
expect(has('map_obj', 'missing')).toBe(false);
expect(has('nowhere', 'organization_id')).toBe(false);
has('map_obj', 'id');
// one getSchema per object, not per question
expect(engine.getSchema.mock.calls.filter(([n]) => n === 'map_obj')).toHaveLength(1);
});

it('an engine with no getSchema reports every field absent (skip-the-stamp posture, never a throw)', () => {
const has = createFieldPresenceProbe({});
expect(has('anything', 'organization_id')).toBe(false);
});
});

describe('createRecordOrganizationResolver — the writers’ memoized face', () => {
it('organizationOf reads the resolved column off the first candidate record that carries a non-empty value', () => {
const engine = engineOf({ crm_deal: { fields: { id: {}, organization_id: {} } } });
const r = createRecordOrganizationResolver(engine);
expect(r.organizationFieldFor('crm_deal')).toBe('organization_id');
expect(r.organizationOf('crm_deal', { id: 'd1', organization_id: 'org_A' })).toBe('org_A');
// precedence across candidates: first non-empty wins (live record before
// trigger snapshot, result before prior state — the callers' order)
expect(
r.organizationOf('crm_deal', { id: 'd1', organization_id: '' }, { id: 'd1', organization_id: 'org_B' }),
).toBe('org_B');
expect(r.organizationOf('crm_deal', undefined, null, { id: 'd1' })).toBeNull();
});

it('pins the sys_api_key divergence end to end: the stamp column is active_organization_id, never the wall', () => {
const engine = engineOf({
sys_api_key: {
tenancy: { enabled: false, organizationField: 'active_organization_id' },
fields: { id: {}, name: {}, user_id: {}, active_organization_id: {}, revoked: {} },
},
});
const r = createRecordOrganizationResolver(engine);
expect(r.organizationFieldFor('sys_api_key')).toBe('active_organization_id');
expect(
r.organizationOf('sys_api_key', { id: 'k1', active_organization_id: 'org_key' }),
).toBe('org_key');
// A record carrying an `organization_id` VALUE anyway (defensive noise)
// still stamps from the DECLARED column, not the canonical spelling.
expect(
r.organizationOf('sys_api_key', { id: 'k1', organization_id: 'org_wrong', active_organization_id: 'org_key' }),
).toBe('org_key');
});

it('degrades to null — the acting-context fallback signal — on a getSchema-less double, a throwing getSchema, and an unknown object', () => {
expect(createRecordOrganizationResolver({}).organizationOf('crm_deal', { organization_id: 'org_A' })).toBeNull();
const throwing = { getSchema: () => { throw new Error('not booted'); } };
expect(createRecordOrganizationResolver(throwing).organizationOf('crm_deal', { organization_id: 'org_A' })).toBeNull();
const empty = engineOf({});
expect(createRecordOrganizationResolver(empty).organizationOf('crm_deal', { organization_id: 'org_A' })).toBeNull();
});

it('memoizes the column per object (one schema read for N writes)', () => {
const engine = engineOf({ crm_deal: { fields: { id: {}, organization_id: {} } } });
const r = createRecordOrganizationResolver(engine);
r.organizationOf('crm_deal', { organization_id: 'a' });
r.organizationOf('crm_deal', { organization_id: 'b' });
r.organizationFieldFor('crm_deal');
// one call from the probe's field-set read + one from the column
// resolution — and no growth with further questions
const calls = engine.getSchema.mock.calls.filter(([n]) => n === 'crm_deal').length;
r.organizationOf('crm_deal', { organization_id: 'c' });
expect(engine.getSchema.mock.calls.filter(([n]) => n === 'crm_deal').length).toBe(calls);
expect(calls).toBeLessThanOrEqual(2);
});
});
Loading
Loading