Skip to content

Commit d42f40d

Browse files
committed
feat(spec,sharing): ADR-0056 D1 — canonical OWD vocabulary on object.sharingModel
Add the canonical OWD names (public_read / public_read_write) to the object.sharingModel enum alongside the legacy read/read_write/full aliases (non-breaking), and map them in the sharing runtime onto the three enforced behaviours. Unknown values stay enum-rejected (authoring-time fail-closed). Showcase announcement switched to canonical `public_read`, proven end-to-end. sharing-service 32 tests (+4 D1); OWD dogfood proofs 8/8; liveness green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx
1 parent 37e9acb commit d42f40d

5 files changed

Lines changed: 68 additions & 9 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/plugin-sharing": patch
4+
"@objectstack/example-showcase": patch
5+
---
6+
7+
feat(spec,sharing): canonical OWD vocabulary on `object.sharingModel` (ADR-0056 D1)
8+
9+
Reconciles the Org-Wide-Default naming so authors use ONE vocabulary. `object.sharingModel`
10+
now accepts the canonical OWD names — `private` | `public_read` | `public_read_write` |
11+
`controlled_by_parent` — alongside the legacy `read` / `read_write` / `full` aliases (kept,
12+
non-breaking). The sharing runtime maps them onto the three enforced behaviours
13+
(`public_read` ≡ legacy `read` = everyone reads / owner writes; `public_read_write` =
14+
unscoped). Unknown values remain rejected by the enum (authoring-time, fail-closed). The
15+
showcase announcement now declares the canonical `public_read`, exercised end-to-end by the
16+
public-read dogfood proof.

examples/app-showcase/src/objects/announcement.object.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data';
55
/**
66
* Team Announcement — the canonical PUBLIC-READ object (ADR-0056 OWD).
77
*
8-
* Declares `sharingModel: 'read'`: every member can READ every announcement,
8+
* Declares `sharingModel: 'public_read'`: every member can READ every announcement,
99
* but only the OWNER may edit or delete it (the engine derives "everyone reads,
1010
* owner writes" from the OWD baseline + the auto-stamped `owner_id`). No RLS
1111
* policy is authored. This is the sibling of `showcase_private_note`
@@ -20,8 +20,9 @@ export const Announcement = ObjectSchema.create({
2020
icon: 'megaphone',
2121
description: 'A team announcement everyone can read but only its owner can edit — `read` OWD (ADR-0056).',
2222

23-
// Everyone reads; owner writes. No RLS authored.
24-
sharingModel: 'read',
23+
// Everyone reads; owner writes. Canonical OWD name (ADR-0056 D1); `read` is
24+
// the legacy alias. No RLS authored.
25+
sharingModel: 'public_read',
2526

2627
fields: {
2728
title: Field.text({ label: 'Title', required: true, searchable: true, maxLength: 160 }),

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,18 @@ const PUBLIC_SCHEMA = {
100100
fields: { id: {}, name: {}, owner_id: {} },
101101
};
102102

103+
// ADR-0056 D1 — canonical OWD vocabulary maps onto the same enforced behaviours.
104+
const CANON_PUBLIC_READ_SCHEMA = {
105+
name: 'kbarticle',
106+
sharingModel: 'public_read', // canonical alias of legacy `read`
107+
fields: { id: {}, name: {}, owner_id: {} },
108+
};
109+
const CANON_PUBLIC_RW_SCHEMA = {
110+
name: 'whiteboard',
111+
sharingModel: 'public_read_write', // canonical alias of "public" (no record filter)
112+
fields: { id: {}, name: {}, owner_id: {} },
113+
};
114+
103115
const ORPHAN_SCHEMA = {
104116
name: 'note',
105117
sharingModel: 'private',
@@ -118,6 +130,8 @@ describe('SharingService.buildReadFilter', () => {
118130
lead: LEAD_SCHEMA,
119131
task: PUBLIC_SCHEMA,
120132
note: ORPHAN_SCHEMA,
133+
kbarticle: CANON_PUBLIC_READ_SCHEMA,
134+
whiteboard: CANON_PUBLIC_RW_SCHEMA,
121135
sys_record_share: { name: 'sys_record_share' },
122136
});
123137
svc = new SharingService({ engine });
@@ -139,6 +153,14 @@ describe('SharingService.buildReadFilter', () => {
139153
expect(await svc.buildReadFilter('lead', { userId: 'u1' })).toBeNull();
140154
});
141155

156+
it('canonical public_read reads like `read` (everyone reads → no filter) [ADR-0056 D1]', async () => {
157+
expect(await svc.buildReadFilter('kbarticle', { userId: 'u1' })).toBeNull();
158+
});
159+
160+
it('canonical public_read_write is unscoped on read [ADR-0056 D1]', async () => {
161+
expect(await svc.buildReadFilter('whiteboard', { userId: 'u1' })).toBeNull();
162+
});
163+
142164
it('returns null for objects without owner_id even when private', async () => {
143165
expect(await svc.buildReadFilter('note', { userId: 'u1' })).toBeNull();
144166
});
@@ -171,6 +193,8 @@ describe('SharingService.canEdit', () => {
171193
account: ACCOUNT_SCHEMA,
172194
lead: LEAD_SCHEMA,
173195
task: PUBLIC_SCHEMA,
196+
kbarticle: CANON_PUBLIC_READ_SCHEMA,
197+
whiteboard: CANON_PUBLIC_RW_SCHEMA,
174198
sys_record_share: { name: 'sys_record_share' },
175199
});
176200
svc = new SharingService({ engine });
@@ -181,6 +205,9 @@ describe('SharingService.canEdit', () => {
181205
engine._tables.lead = [
182206
{ id: 'l1', name: 'Lead1', owner_id: 'alice' },
183207
];
208+
engine._tables.kbarticle = [
209+
{ id: 'k1', name: 'KB1', owner_id: 'alice' },
210+
];
184211
});
185212

186213
it('returns true for system context', async () => {
@@ -213,6 +240,15 @@ describe('SharingService.canEdit', () => {
213240
expect(await svc.canEdit('lead', 'l1', { userId: 'alice' })).toBe(true);
214241
expect(await svc.canEdit('lead', 'l1', { userId: 'bob' })).toBe(false);
215242
});
243+
244+
it('canonical public_read gates writes to the owner [ADR-0056 D1]', async () => {
245+
expect(await svc.canEdit('kbarticle', 'k1', { userId: 'alice' })).toBe(true);
246+
expect(await svc.canEdit('kbarticle', 'k1', { userId: 'bob' })).toBe(false);
247+
});
248+
249+
it('canonical public_read_write lets anyone write [ADR-0056 D1]', async () => {
250+
expect(await svc.canEdit('whiteboard', 'anything', { userId: 'bob' })).toBe(true);
251+
});
216252
});
217253

218254
describe('SharingService.grant / listShares / revoke', () => {

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

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,15 +45,21 @@ const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const;
4545
const OWNER_FIELD = 'owner_id';
4646

4747
/**
48-
* Effective sharing model. Anything other than `private` / `read` is
49-
* treated as public — that includes objects that don't declare
50-
* `sharingModel` at all, so existing CRM behaviour is preserved
51-
* until an admin opts an object in.
48+
* Effective sharing model — collapses the authorable OWD vocabulary onto the
49+
* three behaviours this service enforces (ADR-0056 D1):
50+
* - `private` → owner-only read + write
51+
* - `public_read` / legacy `read` → everyone reads, owner writes
52+
* - everything else → public (no record-level filter)
53+
*
54+
* "Everything else" covers the canonical `public_read_write`, the legacy
55+
* `read_write` / `full` aliases, `controlled_by_parent` (scoped separately by
56+
* the security plugin), and objects that declare no `sharingModel` at all — so
57+
* existing behaviour is preserved until an admin opts an object in.
5258
*/
5359
function effectiveSharingModel(schema: any): 'private' | 'read' | 'public' {
5460
const m = schema?.sharingModel ?? schema?.security?.sharingModel;
5561
if (m === 'private') return 'private';
56-
if (m === 'read') return 'read';
62+
if (m === 'read' || m === 'public_read') return 'read';
5763
return 'public';
5864
}
5965

packages/spec/src/data/object.zod.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -598,7 +598,7 @@ const ObjectSchemaBase = z.object({
598598
* ids)` on reads and requires master edit-access on by-id writes. No RLS policy
599599
* is authored — the inheritance is derived from the relationship.
600600
*/
601-
sharingModel: z.enum(['private', 'read', 'read_write', 'full', 'controlled_by_parent']).optional().describe('Default sharing model'),
601+
sharingModel: z.enum(['private', 'public_read', 'public_read_write', 'controlled_by_parent', 'read', 'read_write', 'full']).optional().describe('Org-Wide Default record visibility (OWD). Canonical: private (owner-only) | public_read (everyone reads, owner writes) | public_read_write (everyone reads+writes) | controlled_by_parent (derived from the master record). Legacy aliases: read=public_read, read_write/full=public_read_write. ADR-0056 D1.'),
602602

603603
/**
604604
* Public Share-Link Policy

0 commit comments

Comments
 (0)