Skip to content

Commit 61c2ee8

Browse files
committed
feat(plugin-sharing): sys_sharing_rule provenance + seed-not-clobber (#2909 P0/T1)
sys_sharing_rule is record-authoritative (ADR-0094 addendum): declared rules are a boot seed, the row is the authority — but every boot re-ran a clobbering upsert, so an admin's active:false on an over-sharing rule was silently resurrected on redeploy. Add readonly managed_by (A4 tri-state) + customized provenance columns, put defineRule in seed mode when the bootstrap passes managedBy:'package' (pristine/legacy rows adopted and updated; admin-authored or customized rows untouched), and stamp customized via a beforeUpdate hook on any non-system edit of a seeded rule. No write gate on purpose — sharing rules stay a first-class admin authoring surface; edits are remembered, not blocked. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bJWtKmZ2mFpRFAmmmoeqe
1 parent 0ec4f28 commit 61c2ee8

9 files changed

Lines changed: 392 additions & 0 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@objectstack/plugin-sharing': minor
3+
'@objectstack/spec': patch
4+
---
5+
6+
feat(plugin-sharing): sys_sharing_rule provenance + seed-not-clobber (#2909 P0/T1). The object gains readonly `managed_by` (unified A4 tri-state platform/package/admin) and `customized` columns; declared rules seed with `managed_by: 'package'`. defineRule in seed mode adopts pristine/legacy rows (package upgrades stay deliverable) but never overwrites admin-authored or customized rows — an admin's `active: false` on an over-sharing rule now survives redeploys instead of being resurrected at boot. A beforeUpdate hook stamps `customized` on any non-system edit of a seeded rule. Deliberately NO write gate: sharing rules remain a first-class admin authoring surface (ADR-0094 addendum tradeoff).

packages/plugins/plugin-sharing/src/bootstrap-declared-sharing-rules.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,10 @@ export async function bootstrapDeclaredSharingRules(
122122
recipientId: String(r.sharedWith.value),
123123
accessLevel: (r.accessLevel ?? 'read') as ShareAccessLevel,
124124
active: r.active !== false,
125+
// [#2909 P0] Declared rules ship with the app/package → seed mode:
126+
// pristine rows keep receiving declared updates; admin-authored or
127+
// customized rows are never clobbered (defineRule seed-not-clobber).
128+
managedBy: 'package',
125129
} as any, SYSTEM_CTX as any);
126130
seeded += 1;
127131
} catch (err: any) {

packages/plugins/plugin-sharing/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,15 @@ export {
3131
export { TeamGraphService, expandPrincipal, type TeamGraphOptions } from './team-graph.js';
3232
export { BusinessUnitGraphService, type BusinessUnitGraphOptions } from './business-unit-graph.js';
3333
export { bindRuleHooks, unbindAllRuleHooks, SHARING_RULE_HOOK_PACKAGE } from './rule-hooks.js';
34+
export {
35+
bindRuleProvenanceStamp,
36+
unbindRuleProvenanceStamp,
37+
SHARING_RULE_PROVENANCE_PACKAGE,
38+
} from './sharing-rule-provenance.js';
3439
export {
3540
SharingServicePlugin,
3641
buildSharingMiddleware,
42+
backfillRuleGrants,
3743
type SharingPluginOptions,
3844
} from './sharing-plugin.js';
3945
export type {

packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,42 @@ export const SysSharingRule = ObjectSchema.create({
188188
group: 'Lifecycle',
189189
}),
190190

191+
// ── Provenance (#2909 P0 — record-authoritative seed-not-clobber) ──
192+
// Unified A4 (#2920) tri-state, shared verbatim with sys_position /
193+
// sys_capability / sys_permission_set. Both columns are `readonly`:
194+
// the engine strips them from non-system payloads (forge/clear-proof),
195+
// while the seeder and the provenance stamp hook write with isSystem.
196+
// NOTE deliberately NOT in SYSTEM_ROW_PROVENANCE (no write gate):
197+
// sharing rules are a first-class admin authoring/tuning surface —
198+
// admins may edit or deactivate package rules; the seeder simply stops
199+
// overwriting rows once `customized` is stamped (ADR-0094 addendum).
200+
managed_by: Field.select({
201+
label: 'Managed By',
202+
required: false,
203+
readonly: true,
204+
defaultValue: 'admin',
205+
description:
206+
'Record provenance (unified tri-state, A4 #2920): platform = framework built-in / ' +
207+
'package = app/package-declared (boot-seeded) / admin = tenant-created in Setup.',
208+
options: [
209+
{ value: 'platform', label: 'Platform' },
210+
{ value: 'package', label: 'Package' },
211+
{ value: 'admin', label: 'Admin' },
212+
],
213+
group: 'System',
214+
}),
215+
216+
customized: Field.boolean({
217+
label: 'Customized',
218+
required: false,
219+
readonly: true,
220+
defaultValue: false,
221+
description:
222+
'Set when an admin edits a package-declared rule; boot seeding will no longer ' +
223+
'overwrite the row (deactivations survive redeploys). Meaningless on admin rows.',
224+
group: 'System',
225+
}),
226+
191227
created_at: Field.datetime({
192228
label: 'Created At',
193229
required: true,

packages/plugins/plugin-sharing/src/sharing-plugin.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { SharingRuleService } from './sharing-rule-service.js';
1111
import { ShareLinkService } from './share-link-service.js';
1212
import { registerShareLinkRoutes } from './share-link-routes.js';
1313
import { bindRuleHooks, unbindAllRuleHooks, RULE_REBIND_TRIGGER_PACKAGE } from './rule-hooks.js';
14+
import { bindRuleProvenanceStamp, unbindRuleProvenanceStamp } from './sharing-rule-provenance.js';
1415
import { bindPrimaryBuHooks, backfillPrimaryBu } from './primary-bu-projection.js';
1516
import { bootstrapDeclaredSharingRules } from './bootstrap-declared-sharing-rules.js';
1617

@@ -301,6 +302,11 @@ export class SharingServicePlugin implements Plugin {
301302
bindRuleHooks(engine, this.ruleService, rules, ctx.logger as any);
302303
this.bindRuleRebindTriggers(engine, ctx);
303304

305+
// [#2909 T1] Stamp `customized` on admin edits of seeded rules so
306+
// the boot seeder stops overwriting them (seed-not-clobber).
307+
unbindRuleProvenanceStamp(engine);
308+
bindRuleProvenanceStamp(engine, ctx.logger as any);
309+
304310
await backfillRuleGrants(this.ruleService, rules, ctx.logger as any);
305311
} else {
306312
ctx.logger.warn('SharingServicePlugin: engine has no hook API — sharing rule auto-evaluation disabled');
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#2909 P0/T1] sys_sharing_rule provenance + seed-not-clobber.
5+
*
6+
* sys_sharing_rule is record-authoritative (ADR-0094 addendum): declared
7+
* rules are a boot seed, the row is the authority. The seed (defineRule with
8+
* managedBy package/platform):
9+
* - adopts pristine/legacy rows and keeps updating them (package upgrades
10+
* stay deliverable),
11+
* - NEVER overwrites a row the admin authored (managed_by admin) or
12+
* customized — most importantly an admin's `active: false` on an
13+
* over-sharing rule must survive redeploys (no resurrection),
14+
* - non-seed defineRule keeps its historical clobber semantics.
15+
* The `customized` stamp is applied by a beforeUpdate hook on any
16+
* non-system edit of a package/platform row.
17+
*/
18+
19+
import { describe, it, expect, beforeEach, vi } from 'vitest';
20+
import { SharingService } from './sharing-service.js';
21+
import { SharingRuleService } from './sharing-rule-service.js';
22+
import { bindRuleProvenanceStamp, SHARING_RULE_PROVENANCE_PACKAGE } from './sharing-rule-provenance.js';
23+
24+
interface Row { [k: string]: any }
25+
26+
const SYS = { isSystem: true } as any;
27+
28+
function makeEngine() {
29+
const tables: Record<string, Row[]> = {};
30+
const hooks: Array<{ event: string; handler: (ctx: any) => any; options: Row }> = [];
31+
const ensure = (n: string) => (tables[n] ??= []);
32+
function matches(row: Row, f: any): boolean {
33+
if (!f || typeof f !== 'object') return true;
34+
for (const [k, v] of Object.entries(f)) {
35+
if (row[k] !== v) return false;
36+
}
37+
return true;
38+
}
39+
return {
40+
_tables: tables,
41+
_hooks: hooks,
42+
getSchema() { return undefined; },
43+
async find(o: string, opts?: any) {
44+
const f = opts?.filter ?? opts?.where;
45+
return ensure(o).filter((r) => matches(r, f)).slice(0, opts?.limit ?? 10000);
46+
},
47+
async insert(o: string, data: any) { const row = { ...data }; ensure(o).push(row); return row; },
48+
async update(o: string, idOrData: any, dataOrOpts?: any) {
49+
const data = typeof idOrData === 'object' ? idOrData : dataOrOpts;
50+
const id = typeof idOrData === 'object' ? idOrData.id : idOrData;
51+
const t = ensure(o); const i = t.findIndex((r) => r.id === id);
52+
if (i >= 0) t[i] = { ...t[i], ...data };
53+
return t[i];
54+
},
55+
async delete(o: string, opts?: any) {
56+
const t = ensure(o); const where = opts?.where ?? {};
57+
for (let i = t.length - 1; i >= 0; i--) if (matches(t[i], where)) t.splice(i, 1);
58+
return { ok: true };
59+
},
60+
registerHook(event: string, handler: (ctx: any) => any, options: Row = {}) {
61+
hooks.push({ event, handler, options });
62+
},
63+
unregisterHooksByPackage(packageId: string) {
64+
let removed = 0;
65+
for (let i = hooks.length - 1; i >= 0; i--) {
66+
if (hooks[i].options.packageId === packageId) { hooks.splice(i, 1); removed++; }
67+
}
68+
return removed;
69+
},
70+
/** Test helper: simulate an engine update passing through beforeUpdate hooks. */
71+
async updateThroughHooks(o: string, id: string, data: Row, session: Row) {
72+
for (const h of hooks) {
73+
if (h.event === 'beforeUpdate' && h.options.object === o) {
74+
await h.handler({ session, input: { id, data } });
75+
}
76+
}
77+
return this.update(o, id, data);
78+
},
79+
};
80+
}
81+
82+
const DECLARED = {
83+
name: 'red_projects_to_exec', label: 'Red projects → exec', object: 'showcase_project',
84+
criteria: { health: 'red' },
85+
recipientType: 'position' as const, recipientId: 'exec', accessLevel: 'read' as const,
86+
managedBy: 'package' as const,
87+
};
88+
89+
describe('defineRule seed-not-clobber (#2909 P0/T1)', () => {
90+
let engine: ReturnType<typeof makeEngine>;
91+
let rules: SharingRuleService;
92+
93+
beforeEach(() => {
94+
engine = makeEngine();
95+
const sharing = new SharingService({ engine: engine as any });
96+
rules = new SharingRuleService({ engine: engine as any, sharing });
97+
});
98+
99+
it('seeds a new declared rule with managed_by:package and customized:false', async () => {
100+
const r = await rules.defineRule(DECLARED as any, SYS);
101+
expect(r.managed_by).toBe('package');
102+
expect(r.customized).toBe(false);
103+
expect(engine._tables.sys_sharing_rule[0]).toMatchObject({ managed_by: 'package', customized: false });
104+
});
105+
106+
it('keeps updating a PRISTINE seeded rule on re-seed (package upgrades deliverable)', async () => {
107+
await rules.defineRule(DECLARED as any, SYS);
108+
const r = await rules.defineRule({ ...DECLARED, label: 'Red projects → exec v2', accessLevel: 'edit' } as any, SYS);
109+
expect(r.label).toBe('Red projects → exec v2');
110+
expect(r.access_level).toBe('edit');
111+
expect(engine._tables.sys_sharing_rule).toHaveLength(1);
112+
});
113+
114+
it('ADOPTS a legacy row with no provenance (stamps managed_by on re-seed)', async () => {
115+
engine._tables.sys_sharing_rule = [{
116+
id: 'srule_legacy', name: DECLARED.name, label: 'old', object_name: 'showcase_project',
117+
criteria_json: null, recipient_type: 'position', recipient_id: 'exec',
118+
access_level: 'read', active: true,
119+
}];
120+
const r = await rules.defineRule(DECLARED as any, SYS);
121+
expect(r.managed_by).toBe('package');
122+
expect(engine._tables.sys_sharing_rule[0].managed_by).toBe('package');
123+
});
124+
125+
it('does NOT resurrect an admin-deactivated seeded rule (customized survives redeploys)', async () => {
126+
await rules.defineRule(DECLARED as any, SYS);
127+
// Admin deactivates the over-sharing rule (stamp applied by the hook — here direct).
128+
const row = engine._tables.sys_sharing_rule[0];
129+
await engine.update('sys_sharing_rule', { id: row.id, active: false, customized: true });
130+
// Next boot re-seeds with active:true…
131+
const r = await rules.defineRule(DECLARED as any, SYS);
132+
expect(r.active).toBe(false);
133+
expect(engine._tables.sys_sharing_rule[0].active).toBe(false);
134+
});
135+
136+
it('never touches an admin-authored rule that collides on name (admin row wins + warn)', async () => {
137+
const warn = vi.fn();
138+
const sharing = new SharingService({ engine: engine as any });
139+
const svc = new SharingRuleService({ engine: engine as any, sharing, logger: { warn } });
140+
await svc.defineRule({ ...DECLARED, managedBy: undefined, label: 'Admin authored' } as any, SYS);
141+
expect(engine._tables.sys_sharing_rule[0].managed_by).toBe('admin');
142+
const r = await svc.defineRule(DECLARED as any, SYS);
143+
expect(r.label).toBe('Admin authored');
144+
expect(engine._tables.sys_sharing_rule[0].label).toBe('Admin authored');
145+
expect(warn).toHaveBeenCalled();
146+
});
147+
148+
it('non-seed defineRule keeps clobber semantics and stamps admin provenance on insert', async () => {
149+
const first = await rules.defineRule({ ...DECLARED, managedBy: undefined } as any, SYS);
150+
expect(first.managed_by).toBe('admin');
151+
const r = await rules.defineRule({ ...DECLARED, managedBy: undefined, label: 'edited' } as any, SYS);
152+
expect(r.label).toBe('edited');
153+
// Programmatic re-define does not touch provenance columns.
154+
expect(engine._tables.sys_sharing_rule[0].managed_by).toBe('admin');
155+
});
156+
});
157+
158+
describe('provenance stamp hook (#2909 T1)', () => {
159+
let engine: ReturnType<typeof makeEngine>;
160+
let rules: SharingRuleService;
161+
162+
beforeEach(async () => {
163+
engine = makeEngine();
164+
const sharing = new SharingService({ engine: engine as any });
165+
rules = new SharingRuleService({ engine: engine as any, sharing });
166+
await rules.defineRule(DECLARED as any, SYS);
167+
bindRuleProvenanceStamp(engine as any);
168+
});
169+
170+
it('stamps customized:true when a non-system caller edits a package rule', async () => {
171+
const row = engine._tables.sys_sharing_rule[0];
172+
await engine.updateThroughHooks('sys_sharing_rule', row.id, { active: false }, { userId: 'admin1' });
173+
expect(engine._tables.sys_sharing_rule[0]).toMatchObject({ active: false, customized: true });
174+
});
175+
176+
it('does NOT stamp on isSystem writes (seeder/backfill are not customizations)', async () => {
177+
const row = engine._tables.sys_sharing_rule[0];
178+
await engine.updateThroughHooks('sys_sharing_rule', row.id, { label: 'reseed' }, { isSystem: true });
179+
expect(engine._tables.sys_sharing_rule[0].customized).toBe(false);
180+
});
181+
182+
it('does NOT stamp admin-authored rows (an env row IS the definition)', async () => {
183+
await rules.defineRule({ ...DECLARED, name: 'admin_rule', managedBy: undefined } as any, SYS);
184+
const adminRow = engine._tables.sys_sharing_rule.find((r) => r.name === 'admin_rule')!;
185+
await engine.updateThroughHooks('sys_sharing_rule', adminRow.id, { active: false }, { userId: 'admin1' });
186+
expect(engine._tables.sys_sharing_rule.find((r) => r.name === 'admin_rule')!.customized).toBe(false);
187+
});
188+
189+
it('ignores multi-row updates (no id) without crashing', async () => {
190+
const hook = engine._hooks.find((h) => h.options.packageId === SHARING_RULE_PROVENANCE_PACKAGE)!;
191+
await expect(hook.handler({ session: { userId: 'admin1' }, input: { data: { active: false } } })).resolves.toBeUndefined();
192+
});
193+
194+
it('end-to-end: admin edit through hooks → next seed does not clobber', async () => {
195+
const row = engine._tables.sys_sharing_rule[0];
196+
await engine.updateThroughHooks('sys_sharing_rule', row.id, { active: false }, { userId: 'admin1' });
197+
const r = await rules.defineRule(DECLARED as any, SYS);
198+
expect(r.active).toBe(false);
199+
});
200+
});
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#2909 T1] Provenance stamp for `sys_sharing_rule`.
5+
*
6+
* sys_sharing_rule is RECORD-AUTHORITATIVE (ADR-0094 addendum): declared
7+
* rules are a boot seed, and the row — including any admin tuning such as
8+
* deactivating an over-sharing rule — is the authority. The seeder
9+
* (defineRule in seed mode) skips rows marked `customized`, so this hook is
10+
* the half that DETECTS the admin edit: any non-system update touching a
11+
* package/platform-seeded row stamps `customized: true` onto the payload.
12+
*
13+
* Why a data hook (and not a write gate or the REST layer):
14+
* - admins edit rules through several doors (Setup UI generic data door,
15+
* scripts, console) — an engine hook covers them all;
16+
* - unlike sys_position/sys_capability there is deliberately NO
17+
* SYSTEM_ROW_PROVENANCE write gate here: sharing rules are a first-class
18+
* admin authoring surface, so edits are allowed — they just have to be
19+
* remembered;
20+
* - both provenance columns are `readonly`, and the engine's readonly strip
21+
* exempts isSystem callers while snapshotting supplied keys BEFORE hooks
22+
* run — so a caller can never forge/clear `customized`, while this hook's
23+
* stamp survives.
24+
*
25+
* Known boundary (recorded in the ADR): multi-row updates (no single
26+
* `input.id`) are not stamped — every rule-editing UI path updates by id.
27+
*/
28+
29+
interface MinimalEngine {
30+
find(object: string, opts?: any): Promise<any[]>;
31+
registerHook(event: string, handler: (ctx: any) => any, options?: Record<string, any>): void;
32+
unregisterHooksByPackage(packageId: string): number;
33+
}
34+
35+
interface MinimalLogger {
36+
info?: (msg: string, meta?: Record<string, any>) => void;
37+
warn?: (msg: string, meta?: Record<string, any>) => void;
38+
}
39+
40+
export const SHARING_RULE_PROVENANCE_PACKAGE = 'plugin-sharing:rule-provenance';
41+
42+
const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;
43+
44+
export function bindRuleProvenanceStamp(engine: MinimalEngine, logger?: MinimalLogger): void {
45+
engine.registerHook(
46+
'beforeUpdate',
47+
async (ctx: any) => {
48+
// Seeder / defineRule / boot reconcilers write with isSystem — those
49+
// are the package door, not an admin customization.
50+
if ((ctx?.session as any)?.isSystem) return;
51+
const id = ctx?.input?.id ?? (ctx?.input?.data as any)?.id;
52+
if (!id) return; // multi-row update — see boundary note above
53+
const data = ctx?.input?.data;
54+
if (!data || typeof data !== 'object') return;
55+
try {
56+
// `previous` is not resolved before beforeUpdate hooks run — read the
57+
// current row ourselves (system ctx: this is a provenance check, not
58+
// an authorization decision).
59+
const rows = await engine.find('sys_sharing_rule', {
60+
filter: { id },
61+
fields: ['id', 'managed_by', 'customized'],
62+
limit: 1,
63+
context: SYSTEM_CTX,
64+
});
65+
const row = Array.isArray(rows) ? rows[0] : undefined;
66+
if (!row) return;
67+
if ((row.managed_by === 'package' || row.managed_by === 'platform') && row.customized !== true) {
68+
(data as any).customized = true;
69+
}
70+
} catch (err: any) {
71+
logger?.warn?.('[sharing-rule] provenance stamp failed (edit proceeds unstamped)', {
72+
id,
73+
error: err?.message,
74+
});
75+
}
76+
},
77+
{ object: 'sys_sharing_rule', packageId: SHARING_RULE_PROVENANCE_PACKAGE, priority: 150 },
78+
);
79+
logger?.info?.('[sharing-rule] provenance stamp hook bound');
80+
}
81+
82+
export function unbindRuleProvenanceStamp(engine: MinimalEngine): number {
83+
return engine.unregisterHooksByPackage(SHARING_RULE_PROVENANCE_PACKAGE);
84+
}

0 commit comments

Comments
 (0)