Skip to content

Commit f81c00c

Browse files
committed
feat(app-showcase): bind persona positions on kernel:listening + announcements; document isDefault dual-track (#2926 ②⑤)
A fresh deploy booted with ZERO position↔permission-set bindings — every persona silently degraded to the everyone baseline. Bindings are record-authoritative (ADR-0090/0094) and cannot be a declarative seed (the seed loader runs before the security bootstrap creates sys_position / sys_permission_set, so name references can't resolve). They are ensured imperatively by registerShowcasePositionBindings on kernel:listening — the phase that fires only after every kernel:ready handler (incl. the security bootstrap) has settled, so the referenced rows exist. everyone→member_default is bound here too: the framework only auto-binds an app's isDefault set to everyone when it is application-owned; the showcase ships as a package, so its default lands in sys_audience_binding_suggestion (pending admin confirmation) and is not live until confirmed. Also seeds showcase_announcement demo rows and documents the isDefault dual-track (app-level auto-bind vs package-level suggestion) in the spec. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bJWtKmZ2mFpRFAmmmoeqe
1 parent 55c3021 commit f81c00c

5 files changed

Lines changed: 169 additions & 6 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
docs(spec): rewrite the `isDefault` permission-set docs to describe the actual dual-track behavior (#2926 ②): app-level `isDefault` sets are resolved as the SecurityPlugin's fallback and idempotently auto-bound to the `everyone` anchor at boot (guarded by the high-privilege-bits check), while package-level sets are never auto-bound and instead materialize a `sys_audience_binding_suggestion` an admin confirms. The previous "never auto-bound" wording contradicted the shipped app-level track.

examples/app-showcase/objectstack.config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { ShowcaseExternalDatasource } from './src/system/datasources/showcase-ex
1616
import { ExternalCustomer, ExternalOrder } from './src/data/objects/external/index.js';
1717
import { setupShowcaseExternalDatasource } from './src/system/datasources/external-fixture.js';
1818
import { registerRecalcEndpoint } from './src/system/server/recalc-endpoint.js';
19+
import { registerShowcasePositionBindings } from './src/security/bind-position-sets.js';
1920
import { TaskViews, ProjectViews, InquiryViews, BusinessUnitViews } from './src/ui/views/index.js';
2021
import { ShowcaseApp } from './src/ui/apps/index.js';
2122
import { ChartGalleryDashboard, OpsDashboard } from './src/ui/dashboards/index.js';
@@ -218,4 +219,7 @@ export const onEnable = async (ctx: unknown): Promise<void> => {
218219
await setupShowcaseExternalDatasource(ctx as Parameters<typeof setupShowcaseExternalDatasource>[0]);
219220
// Mount the custom REST endpoint behind the `showcase_recalc_estimate` api action.
220221
registerRecalcEndpoint(ctx as Parameters<typeof registerRecalcEndpoint>[0]);
222+
// [#2926 ②] Ensure the persona position↔permission-set bindings exist after
223+
// the security bootstraps (cannot be a seed — see bind-position-sets.ts).
224+
registerShowcasePositionBindings(ctx as Parameters<typeof registerShowcasePositionBindings>[0]);
221225
};

examples/app-showcase/src/data/seed/index.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { Product, Invoice, InvoiceLine } from '../objects/invoice.object.js';
1313
import { Contact } from '../objects/contact.object.js';
1414
import { Inquiry } from '../objects/inquiry.object.js';
1515
import { FieldZoo } from '../objects/field-zoo.object.js';
16+
import { Announcement } from '../objects/announcement.object.js';
1617

1718
/**
1819
* Seed data sized to "feed every view": every Kanban column is populated,
@@ -161,6 +162,13 @@ const orgUnits = SeedSchema.parse({
161162
],
162163
});
163164

165+
// [#2926 ②] Position ↔ permission-set bindings are NOT seeded here: the seed
166+
// loader runs before the security bootstrap creates the sys_position /
167+
// sys_permission_set rows, so the required name references cannot resolve.
168+
// They are ensured imperatively on kernel:listening instead (after every
169+
// kernel:ready handler, incl. the security bootstrap, has settled) — see
170+
// `src/security/bind-position-sets.ts` (wired via `onEnable`).
171+
164172
const teams = defineSeed(Team, {
165173
mode: 'upsert',
166174
externalId: 'name',
@@ -286,4 +294,20 @@ const preferences = defineSeed(Preference, {
286294
],
287295
});
288296

289-
export const ShowcaseSeedData = [accounts, contacts, inquiries, products, projects, tasks, categories, businessUnits, orgUnits, teams, memberships, fieldZoo, invoices, invoiceLines, preferences];
297+
/**
298+
* [#2926 ⑤] Announcements — the read-visibility demo object finally ships
299+
* with data, so its assertions stop dry-running on a fresh DB. The object has
300+
* NO `name` field (display name derives from `title`); `owner_id` stays
301+
* unset — users can't be seeded, and creation rights are deliberately narrow
302+
* (only `showcase_ops` may create; everyone else is read-only by design).
303+
*/
304+
const announcements = defineSeed(Announcement, {
305+
mode: 'upsert',
306+
externalId: 'title',
307+
records: [
308+
{ title: 'Welcome to the Showcase workspace', body: 'This demo org exercises the full permission model: positions, permission sets, sharing rules and field-level security. Log in as different personas to compare what each can see and edit.' },
309+
{ title: 'Q3 field-ops rollout', body: 'Field Operations onboards the new inquiry intake flow this quarter. New public inquiries are shared automatically with the Field Ops subtree.' },
310+
],
311+
});
312+
313+
export const ShowcaseSeedData = [accounts, contacts, inquiries, products, projects, tasks, categories, businessUnits, orgUnits, teams, memberships, fieldZoo, invoices, invoiceLines, preferences, announcements];
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#2926 ②] Position ↔ permission-set bindings for the showcase personas.
5+
*
6+
* The permission model is record-authoritative (ADR-0090/0094): bindings live
7+
* only as `sys_position_permission_set` rows. A fresh deploy used to boot with
8+
* ZERO bindings — every persona silently degraded to the `everyone` baseline
9+
* until an admin hand-assigned all sets.
10+
*
11+
* This cannot be a declarative SEED: the seed loader runs before the security
12+
* bootstrap creates the `sys_position` / `sys_permission_set` rows, so the name
13+
* references cannot resolve and the required lookups fail validation. So we play
14+
* the admin's part imperatively — inserting each missing binding idempotently
15+
* (dedup by position+set pair, stable ids).
16+
*
17+
* Timing matters. `kernel:ready` handlers run SEQUENTIALLY, each awaited, in
18+
* registration order (`kernel.ts` `trigger`). The showcase AppPlugin starts
19+
* BEFORE the Security plugin, so an app hook on `kernel:ready` runs *before*
20+
* the security bootstrap has created the position/set rows — the rows never
21+
* appear from inside that hook. We therefore bind on **`kernel:listening`**,
22+
* the phase the kernel fires only AFTER every `kernel:ready` handler has
23+
* completed (`kernel.ts` Phase 4 / `lite-kernel.ts`), so the bootstrap rows are
24+
* guaranteed present.
25+
*
26+
* `everyone → showcase_member_default` IS bound here. The security plugin only
27+
* auto-binds an app's `isDefault` set to `everyone` when that set is
28+
* application-owned; the showcase ships as a package, so its default lands in
29+
* `sys_audience_binding_suggestion` (pending admin confirmation) and is NOT
30+
* live until confirmed. Binding it here keeps the demo's baseline working out
31+
* of the box, idempotently and alongside the suggestion.
32+
*/
33+
34+
const BINDINGS: ReadonlyArray<readonly [position: string, permissionSet: string]> = [
35+
['everyone', 'showcase_member_default'],
36+
['contributor', 'showcase_contributor'],
37+
['manager', 'showcase_manager'],
38+
['exec', 'showcase_executive'],
39+
['auditor', 'showcase_auditor'],
40+
['ops', 'showcase_ops'],
41+
['field_ops_delegate', 'showcase_field_ops_delegate'],
42+
['client_portal_user', 'showcase_guest_portal'],
43+
];
44+
45+
const SYS = { isSystem: true } as const;
46+
47+
interface BindHostContext {
48+
ql: {
49+
find: (object: string, query: unknown, options?: unknown) => Promise<unknown>;
50+
insert: (object: string, data: Record<string, unknown>, options?: unknown) => Promise<unknown>;
51+
};
52+
logger?: { info?: (...a: unknown[]) => void; warn?: (...a: unknown[]) => void };
53+
hook?: (event: string, handler: () => Promise<void> | void) => void;
54+
}
55+
56+
/** Find one row by `name`, passing the system context the way the engine's own
57+
* read path expects it (merged from `query.context`; see objectql `find`). */
58+
async function findOneByName(ctx: BindHostContext, object: string, name: string): Promise<{ id?: string } | undefined> {
59+
try {
60+
const rows = (await ctx.ql.find(object, { where: { name }, limit: 1, context: SYS })) as
61+
| Array<{ id?: string }>
62+
| { records?: Array<{ id?: string }> };
63+
if (Array.isArray(rows)) return rows[0];
64+
return rows?.records?.[0];
65+
} catch (err) {
66+
ctx.logger?.warn?.('[showcase] position binding lookup failed', {
67+
object,
68+
name,
69+
error: err instanceof Error ? err.message : String(err),
70+
});
71+
return undefined;
72+
}
73+
}
74+
75+
export function registerShowcasePositionBindings(ctx: BindHostContext): void {
76+
const run = async (): Promise<void> => {
77+
let created = 0;
78+
for (const [positionName, setName] of BINDINGS) {
79+
const position = await findOneByName(ctx, 'sys_position', positionName);
80+
const set = await findOneByName(ctx, 'sys_permission_set', setName);
81+
if (!position?.id || !set?.id) {
82+
ctx.logger?.warn?.('[showcase] position binding skipped (row missing)', { position: positionName, set: setName });
83+
continue;
84+
}
85+
const existing = (await ctx.ql.find(
86+
'sys_position_permission_set',
87+
{ where: { position_id: position.id, permission_set_id: set.id }, limit: 1, context: SYS },
88+
)) as unknown;
89+
const hit = Array.isArray(existing) ? existing[0] : (existing as { records?: unknown[] })?.records?.[0];
90+
if (hit) continue;
91+
try {
92+
await ctx.ql.insert(
93+
'sys_position_permission_set',
94+
{ id: `ppsb_showcase_${positionName}`, position_id: position.id, permission_set_id: set.id },
95+
{ context: SYS },
96+
);
97+
created += 1;
98+
} catch (err) {
99+
ctx.logger?.warn?.('[showcase] position binding insert failed', {
100+
position: positionName,
101+
set: setName,
102+
error: err instanceof Error ? err.message : String(err),
103+
});
104+
}
105+
}
106+
ctx.logger?.info?.('[showcase] position bindings ensured', { created, total: BINDINGS.length });
107+
};
108+
109+
// Bind on `kernel:listening` — the phase that fires only after every
110+
// `kernel:ready` handler (incl. the security bootstrap that seeds the
111+
// position/set rows) has completed. Fall back to a deferred immediate run
112+
// if the host context somehow omits the hook registrar.
113+
if (typeof ctx.hook === 'function') {
114+
ctx.hook('kernel:listening', run);
115+
} else {
116+
setTimeout(() => void run(), 0);
117+
}
118+
}

packages/spec/src/security/permission.zod.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -179,12 +179,24 @@ export const PermissionSetSchema = lazySchema(() => z.object({
179179
.describe('[ADR-0086 D3] Record provenance: package (upgrade-owned metadata) vs platform/user (env config)'),
180180

181181
/**
182-
* [ADR-0090 D5] Package SUGGESTION: on install the admin is prompted to bind
183-
* this set to the built-in `everyone` position (default grants for
184-
* authenticated users). Never auto-bound; carries no runtime semantics of
185-
* its own. (The former `isProfile` flag was removed by ADR-0090 D2.)
182+
* [ADR-0090 D5] Marks this set as the app's baseline for the built-in
183+
* `everyone` position (default grants for authenticated users). Two tracks
184+
* consume it (#2926 ②):
185+
*
186+
* - **App-level** (the set is declared by the served app itself): the CLI
187+
* resolves the first `isDefault` set as the SecurityPlugin's
188+
* `fallbackPermissionSet`, and the plugin IDEMPOTENTLY AUTO-BINDS it to
189+
* `everyone` at boot — after a high-privilege-bits check refuses
190+
* dangerous sets. Without this a fresh deploy boots with zero bindings
191+
* and every persona silently degrades.
192+
* - **Package-level** (the set ships in an installed package with a
193+
* `packageId`): never auto-bound — it materializes a pending
194+
* `sys_audience_binding_suggestion` row that an admin confirms in Setup.
195+
*
196+
* Carries no runtime semantics of its own beyond these boot-time effects.
197+
* (The former `isProfile` flag was removed by ADR-0090 D2.)
186198
*/
187-
isDefault: z.boolean().default(false).describe('[ADR-0090 D5] Install-time suggestion to bind this set to the everyone position (admin confirms; never auto-bound)'),
199+
isDefault: z.boolean().default(false).describe('[ADR-0090 D5] App baseline for the everyone position: app-level sets are auto-bound at boot (guarded, idempotent); package-level sets become install-time suggestions an admin confirms'),
188200

189201
/** Object Permissions Map: <entity_name> -> permissions */
190202
objects: z.record(z.string(), ObjectPermissionSchema).describe('Entity permissions'),

0 commit comments

Comments
 (0)