From 7daf0fac7beb5b5670c8278c1d5dd9247d1c0214 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:44:28 +0800 Subject: [PATCH 1/2] feat(showcase): make v16 approvals demonstrable out of the box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v16 marquee approval features (M-of-N quorum + finance∧legal 会签, server-computed progress, metadata-driven decision actions, ?request= deep links) could not be exercised in the showcase on a fresh boot: - the approval flows route to `finance`/`legal` positions that were never defined (only manager/exec/... existed) — a dangling reference that made the flows unroutable even when triggered manually; - no user held any approver position (assignments are runtime admin actions, users can't be seeded), so every request resolved to an empty slate; - the seed loader suppresses record-change flows (#2661), so seeding a `sent` invoice never opened a request; and `sys_approval_request` is engine-owned (ADR-0103), so a request can't be inserted through the data API either. Fix, mirroring `bind-position-sets.ts` (imperative, on `kernel:bootstrapped`): - define the `finance` + `legal` approval-routing positions; - assign the dev-seeded admin to manager/finance/legal (`sys_user_position`) so they resolve as an approver and can act in the inbox — org resolved from `sys_member` (the admin's `sys_user.organization_id` is null), so the org-scoped approver resolution + `getRequest` both match; - provision a phone-based demo user so the "phone sign-in surfaces" show a real number in the All Users list + detail; - seed a high-value (`$8,900`) submitted `EXP-DEMO` report and launch the Invoice Dual Sign-off (会签) and High-Value Committee Quorum (2-of-3) flows through the real automation engine, so two genuine, resumable pending requests land in the inbox on first boot; - add a "Submit for Sign-off" record action on invoices so the flow can be re-triggered on demand from the UI. Idempotent throughout (persistent DB keeps the rows; `openNodeRequest` rejects duplicate pending requests, which we swallow). Verified: on a fresh `objectstack dev --seed-admin`, the Approval Center shows both requests (待我审批 = 2) with the server-computed progress ("Approvals — 0 of 1", the 2→1 quorum clamp) and the metadata-driven Approve/Reject/Reassign/Send-back/ Request-info bar. `os validate` + `tsc --noEmit` pass. Found during the #3358 v16.0 verification sweep. Co-Authored-By: Claude Opus 4.8 --- examples/app-showcase/objectstack.config.ts | 6 + examples/app-showcase/src/data/seed/index.ts | 10 + .../app-showcase/src/security/positions.ts | 26 ++ .../src/security/seed-approval-demo.ts | 269 ++++++++++++++++++ examples/app-showcase/src/ui/actions/index.ts | 35 +++ 5 files changed, 346 insertions(+) create mode 100644 examples/app-showcase/src/security/seed-approval-demo.ts diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index efe0095d70..8338a4770a 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -19,6 +19,7 @@ import { ExternalCustomer, ExternalOrder } from './src/data/objects/external/ind import { setupShowcaseExternalDatasource } from './src/system/datasources/external-fixture.js'; import { registerRecalcEndpoint } from './src/system/server/recalc-endpoint.js'; import { registerShowcasePositionBindings } from './src/security/bind-position-sets.js'; +import { registerShowcaseApprovalDemo } from './src/security/seed-approval-demo.js'; import { TaskViews, ProjectViews, InquiryViews, BusinessUnitViews } from './src/ui/views/index.js'; import { ShowcaseApp } from './src/ui/apps/index.js'; import { ChartGalleryDashboard, OpsDashboard, RevenuePulseDashboard } from './src/ui/dashboards/index.js'; @@ -248,4 +249,9 @@ export const onEnable = async (ctx: unknown): Promise => { // [#2926 ②] Ensure the persona position↔permission-set bindings exist after // the security bootstraps (cannot be a seed — see bind-position-sets.ts). registerShowcasePositionBindings(ctx as Parameters[0]); + // Make the v16 approval features (会签 / quorum) demonstrable on a fresh boot: + // assign the dev admin the approver positions and launch the signoff flows so + // real pending requests land in the inbox (cannot be a seed — see + // seed-approval-demo.ts). + registerShowcaseApprovalDemo(ctx as Parameters[0]); }; diff --git a/examples/app-showcase/src/data/seed/index.ts b/examples/app-showcase/src/data/seed/index.ts index dd226b4640..6118551ff1 100644 --- a/examples/app-showcase/src/data/seed/index.ts +++ b/examples/app-showcase/src/data/seed/index.ts @@ -314,6 +314,11 @@ const expenseReports = defineSeed(ExpenseReport, { { name: 'EXP-2001', employee: 'Ada Lovelace', status: 'submitted', submitted_on: cel`daysAgo(5)` }, { name: 'EXP-2002', employee: 'Linus Torvalds', status: 'approved', submitted_on: cel`daysAgo(12)` }, { name: 'EXP-2003', employee: 'Grace Hopper', status: 'draft' }, + // High-value (> $5000) submitted report — the trigger record for the + // High-Value Committee Quorum (2-of-3) flow, launched on boot by + // src/security/seed-approval-demo.ts so a real pending request lands in the + // inbox out of the box. + { name: 'EXP-DEMO', employee: 'Grace Hopper', status: 'submitted', submitted_on: cel`daysAgo(1)` }, ], }); @@ -333,6 +338,11 @@ const expenseLines = defineSeed(ExpenseLine, { // EXP-2003 (draft) → total 225.75 · approved 0 · reimbursable 0 · rejected 0 · over$500 0 { merchant: 'Hilton Garden Inn', expense_report: 'EXP-2003', category: 'lodging', amount: 210, billable: false, status: 'submitted', incurred_on: cel`daysAgo(3)` }, { merchant: 'Starbucks', expense_report: 'EXP-2003', category: 'meals', amount: 15.75, billable: false, status: 'submitted', incurred_on: cel`daysAgo(2)` }, + // EXP-DEMO → total 8900 (≥ $5000, trips the committee-quorum threshold) + { merchant: 'Dreamforce Conference', expense_report: 'EXP-DEMO', category: 'other', amount: 3200, billable: true, status: 'submitted', incurred_on: cel`daysAgo(4)` }, + { merchant: 'Lufthansa', expense_report: 'EXP-DEMO', category: 'travel', amount: 2400, billable: true, status: 'submitted', incurred_on: cel`daysAgo(4)` }, + { merchant: 'Grand Hyatt', expense_report: 'EXP-DEMO', category: 'lodging', amount: 1800, billable: true, status: 'submitted', incurred_on: cel`daysAgo(3)` }, + { merchant: 'Apple Store', expense_report: 'EXP-DEMO', category: 'software', amount: 1500, billable: true, status: 'submitted', incurred_on: cel`daysAgo(3)` }, ], }); diff --git a/examples/app-showcase/src/security/positions.ts b/examples/app-showcase/src/security/positions.ts index c9228e2e6b..79cea727aa 100644 --- a/examples/app-showcase/src/security/positions.ts +++ b/examples/app-showcase/src/security/positions.ts @@ -77,6 +77,30 @@ export const ClientPortalUserPosition = definePosition({ description: 'External client admitted to the Client Portal.', }); +/** + * Approval-routing positions (会签 / quorum demos). The `approval` flow nodes + * in src/automation/flows route to `{ type: 'position', value: 'finance' | 'legal' }` + * (Invoice Dual Sign-off → finance AND legal; High-Value Committee Quorum → + * manager + finance + legal, 2-of-3). Without these declared — and without a + * holder assigned (see src/security/seed-approval-demo.ts) — those requests + * resolve to an empty approver slate and wait forever, so the marquee v16 + * approval features could not be demonstrated out of the box. + * + * They carry no permission-set binding: they exist purely to route approvals, + * so a holder gets no extra data access from holding one. + */ +export const FinancePosition = definePosition({ + name: 'finance', + label: 'Finance', + description: 'Finance sign-off authority on invoices and high-value expenses (approval routing only).', +}); + +export const LegalPosition = definePosition({ + name: 'legal', + label: 'Legal', + description: 'Legal sign-off authority on invoices and high-value expenses (approval routing only).', +}); + export const allPositions = [ ContributorPosition, ManagerPosition, @@ -85,4 +109,6 @@ export const allPositions = [ OpsPosition, FieldOpsDelegatePosition, ClientPortalUserPosition, + FinancePosition, + LegalPosition, ]; diff --git a/examples/app-showcase/src/security/seed-approval-demo.ts b/examples/app-showcase/src/security/seed-approval-demo.ts new file mode 100644 index 0000000000..778d2a1996 --- /dev/null +++ b/examples/app-showcase/src/security/seed-approval-demo.ts @@ -0,0 +1,269 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Approval demo bootstrap — makes the marquee v16 approval features + * (M-of-N quorum + per-group 会签, server-computed progress, decision + * attachments, `?request=` deep links, viewer gating, the reassign picker) + * demonstrable on a FRESH boot, with no manual setup. + * + * Why this exists (and can't be a seed): + * - The `approval` flow nodes route to `{ type: 'position', value: 'finance' | + * 'legal' | 'manager' }`. Approver resolution reads `sys_user_position` + * (ADR-0090 D3), but users can't be seeded (they sign up) and position + * assignments are runtime admin actions — so out of the box NO ONE holds + * those positions and every request resolves to an empty slate and waits + * forever. + * - The seed loader SUPPRESSES record-change flows (#2661), so seeding an + * invoice as `sent` (or an expense as `submitted`) never opens a request. + * - `sys_approval_request` is engine-owned (ADR-0103: get/list only), so a + * request can't be inserted through the generic data API either. + * + * So we play the admin's part imperatively, exactly like `bind-position-sets.ts`: + * on `kernel:bootstrapped` (after the security bootstrap has created the + * position/permission rows and the automation engine is wired) we + * 1. assign the dev-seeded admin to `manager` / `finance` / `legal` so they + * are a resolvable approver on every demo request (and can act in the + * inbox); + * 2. provision a phone-based demo user so the "phone sign-in surfaces" show + * a real number in the All Users list + record detail; + * 3. launch the Invoice Dual Sign-off (finance ∧ legal — 会签) and the + * High-Value Committee Quorum (2-of-3) flows through the real automation + * engine, so genuine, resumable pending requests land in the inbox. + * + * Everything is idempotent: a persistent DB keeps the assignments/requests, and + * `openNodeRequest` rejects a duplicate pending request per (object, record), + * which we swallow. + */ + +const SYS = { isSystem: true } as const; + +const ADMIN_EMAIL = 'admin@objectos.ai'; + +/** Positions the admin is granted so they resolve as an approver on the demos. */ +const ADMIN_APPROVAL_POSITIONS = ['manager', 'finance', 'legal'] as const; + +/** A phone-based demo persona (§6 "phone sign-in surfaces"). */ +const PHONE_DEMO_USER = { + id: 'usr_showcase_phone_demo', + name: 'Mei Phone (demo)', + email: 'phone.demo@example.com', + phone_number: '+8613800138000', +} as const; + +interface ApprovalDemoContext { + ql: { + find: (object: string, query: unknown, options?: unknown) => Promise; + insert: (object: string, data: Record, options?: unknown) => Promise; + }; + getService?: (name: string) => Promise; + logger?: { info?: (...a: unknown[]) => void; warn?: (...a: unknown[]) => void }; + hook?: (event: string, handler: () => Promise | void) => void; +} + +/** Minimal shape of the automation engine we drive (see service-automation). */ +interface AutomationEngineLike { + execute: ( + flowName: string, + context?: { record?: unknown; previous?: unknown; object?: string; organizationId?: string | null; [k: string]: unknown }, + ) => Promise<{ success?: boolean; error?: string; output?: unknown } | unknown>; +} + +function asRows(res: unknown): Array> { + if (Array.isArray(res)) return res as Array>; + const r = res as { records?: unknown[] } | null; + return (r?.records as Array>) ?? []; +} + +async function findOne( + ctx: ApprovalDemoContext, + object: string, + where: Record, +): Promise | undefined> { + try { + const rows = asRows(await ctx.ql.find(object, { where, limit: 1, context: SYS })); + return rows[0]; + } catch (err) { + ctx.logger?.warn?.('[showcase] approval-demo lookup failed', { + object, + error: err instanceof Error ? err.message : String(err), + }); + return undefined; + } +} + +/** Grant the admin the approval-routing positions (idempotent by stable id). */ +async function assignAdminPositions( + ctx: ApprovalDemoContext, + adminId: string, + organizationId: string | null, +): Promise { + for (const position of ADMIN_APPROVAL_POSITIONS) { + const existing = await findOne(ctx, 'sys_user_position', { + user_id: adminId, + position, + ...(organizationId ? { organization_id: organizationId } : {}), + }); + if (existing) continue; + try { + await ctx.ql.insert( + 'sys_user_position', + { + id: `usp_showcase_admin_${position}`, + user_id: adminId, + position, + ...(organizationId ? { organization_id: organizationId } : {}), + reason: 'Showcase approval demo — admin holds every approver position so requests are actionable.', + }, + { context: SYS }, + ); + } catch (err) { + ctx.logger?.warn?.('[showcase] approval-demo position assign failed', { + position, + error: err instanceof Error ? err.message : String(err), + }); + } + } +} + +/** Provision a phone-based demo user (best-effort; renders the phone surfaces). */ +async function ensurePhoneDemoUser(ctx: ApprovalDemoContext, organizationId: string | null): Promise { + const existing = await findOne(ctx, 'sys_user', { email: PHONE_DEMO_USER.email }); + if (existing) return; + try { + await ctx.ql.insert( + 'sys_user', + { + ...PHONE_DEMO_USER, + ...(organizationId ? { organization_id: organizationId } : {}), + }, + { context: SYS }, + ); + ctx.logger?.info?.('[showcase] approval-demo phone user provisioned', { email: PHONE_DEMO_USER.email }); + } catch (err) { + // Non-fatal: sign-in still needs a better-auth account; this row just makes + // the phone number visible in the All Users list + record detail. + ctx.logger?.warn?.('[showcase] approval-demo phone user insert failed (surfaces only)', { + error: err instanceof Error ? err.message : String(err), + }); + } +} + +/** + * Launch a signoff flow on a record through the real automation engine, unless + * a pending request already exists for it. + */ +async function launchSignoff( + ctx: ApprovalDemoContext, + engine: AutomationEngineLike, + flowName: string, + objectName: string, + record: Record, + organizationId: string | null, + /** + * The record's status BEFORE it entered the trigger state, supplied as + * `context.previous` so the start-node transition gate — e.g. + * `status == "sent" && previous.status != "sent"` — is satisfied. The engine + * only binds `previous` when the caller provides it (engine.ts), and a + * record-change trigger normally would; an explicit launch must too, or the + * start condition silently evaluates false and no request opens. + */ + previousStatus: string, +): Promise { + const recordId = String(record.id ?? ''); + if (!recordId) return; + const pending = await findOne(ctx, 'sys_approval_request', { + object_name: objectName, + record_id: recordId, + status: 'pending', + }); + if (pending) { + ctx.logger?.info?.('[showcase] approval-demo request already pending', { flow: flowName, record: recordId }); + return; + } + try { + // The `object` + `organizationId` on the context are what a record-change + // trigger supplies; the approval node reads `context.object` for its target + // (approval-node.ts) and stamps the request's org from `context.organizationId`. + const result = (await engine.execute(flowName, { + record, + previous: { ...record, status: previousStatus }, + object: objectName, + organizationId, + })) as { success?: boolean; error?: string; output?: { skipped?: boolean; reason?: string } }; + if (result?.success === false) { + ctx.logger?.warn?.('[showcase] approval-demo flow returned an error', { flow: flowName, error: result.error }); + } else if (result?.output?.skipped) { + ctx.logger?.warn?.('[showcase] approval-demo flow skipped (start condition not met)', { + flow: flowName, reason: result.output.reason, + }); + } else { + ctx.logger?.info?.('[showcase] approval-demo launched', { flow: flowName, object: objectName, record: recordId }); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes('DUPLICATE_REQUEST')) return; // raced another launcher — fine + ctx.logger?.warn?.('[showcase] approval-demo flow launch failed', { flow: flowName, error: msg }); + } +} + +export function registerShowcaseApprovalDemo(ctx: ApprovalDemoContext): void { + const run = async (): Promise => { + const admin = await findOne(ctx, 'sys_user', { email: ADMIN_EMAIL }); + if (!admin?.id) { + // No dev-seeded admin (e.g. a real deployment) — nothing to demo against. + ctx.logger?.info?.('[showcase] approval-demo skipped (no dev admin)'); + return; + } + const adminId = String(admin.id); + // The active org lives on the better-auth membership (`sys_member`), NOT on + // `sys_user.organization_id` (which is null for the dev admin). Both the + // position rows AND the requests must carry this org, or the org-scoped + // approver resolution (`sys_user_position` filtered by org) and `getRequest` + // (org-scoped read that the inbox drawer uses) silently return nothing. + let organizationId = (admin.organization_id as string | undefined) ?? null; + if (!organizationId) { + const ownerMember = await findOne(ctx, 'sys_member', { user_id: adminId, role: 'owner' }); + const anyMember = ownerMember ?? (await findOne(ctx, 'sys_member', { user_id: adminId })); + organizationId = (anyMember?.organization_id as string | undefined) ?? null; + } + + await assignAdminPositions(ctx, adminId, organizationId); + await ensurePhoneDemoUser(ctx, organizationId); + + let engine: AutomationEngineLike | undefined; + try { + engine = await ctx.getService?.('automation'); + } catch { + engine = undefined; + } + if (!engine || typeof engine.execute !== 'function') { + ctx.logger?.warn?.('[showcase] approval-demo: automation engine unavailable — requests not opened'); + return; + } + + // 会签 (per_group): Invoice Dual Sign-off needs a `sent` invoice; the start + // gate is `status == "sent" && previous.status != "sent"`, so it entered + // from `draft`. + const sentInvoice = await findOne(ctx, 'showcase_invoice', { status: 'sent' }); + if (sentInvoice) { + await launchSignoff(ctx, engine, 'showcase_invoice_signoff', 'showcase_invoice', sentInvoice, organizationId, 'draft'); + } + + // Quorum (2-of-3): High-Value Committee needs a `submitted` report ≥ $5000; + // the start gate is `status == "submitted" && previous.status != "submitted" + // && total_amount >= 5000`, so it entered from `draft`. + const demoExpense = await findOne(ctx, 'showcase_expense_report', { name: 'EXP-DEMO' }); + if (demoExpense) { + await launchSignoff(ctx, engine, 'showcase_committee_quorum', 'showcase_expense_report', demoExpense, organizationId, 'draft'); + } + }; + + if (typeof ctx.hook === 'function') { + // `kernel:bootstrapped` — after every `kernel:ready` handler (the security + // bootstrap that seeds positions, and the automation engine wiring) has + // settled, so lookups resolve and the engine is ready. + ctx.hook('kernel:bootstrapped', run); + } else { + setTimeout(() => void run(), 0); + } +} diff --git a/examples/app-showcase/src/ui/actions/index.ts b/examples/app-showcase/src/ui/actions/index.ts index f9cf1ddd98..9507627303 100644 --- a/examples/app-showcase/src/ui/actions/index.ts +++ b/examples/app-showcase/src/ui/actions/index.ts @@ -3,6 +3,7 @@ import { defineAction } from '@objectstack/spec/ui'; const task = 'showcase_task'; +const invoice = 'showcase_invoice'; /** * Action matrix — covers every `ActionType` (script / url / flow / modal / @@ -138,6 +139,39 @@ export const NewTaskAction = defineAction({ refreshAfter: true, }); +/** + * script — Submit an invoice for finance + legal sign-off (§1 demo entry point). + * + * Flipping `status` to `sent` is exactly the transition the `showcase_invoice_signoff` + * flow's start gate watches (`status == "sent" && previous.status != "sent"`), so + * this button opens a fresh 会签 (finance ∧ legal) approval request from the record + * header — the same request the boot-time demo seeds (src/security/seed-approval-demo.ts), + * but on demand. The sandboxed body's write fires the record-change trigger like any + * user edit. Gated to draft invoices so it disappears once submitted. + */ +export const SubmitForSignoffAction = defineAction({ + name: 'showcase_submit_signoff', + label: 'Submit for Sign-off', + icon: 'send', + objectName: invoice, + type: 'script', + body: { + language: 'js', + source: + "var id = ctx.recordId || (ctx.record && ctx.record.id) || input.recordId;" + + "if (!id) throw new Error('No invoice to submit');" + + "await ctx.api.object('showcase_invoice').update({ id: id, status: 'sent' });" + + "return { ok: true, id: id };", + capabilities: ['api.write'], + }, + successMessage: 'Invoice submitted for finance + legal sign-off.', + // Only on invoices not yet sent. `record.`-prefixed single comparison, per the + // ActionEngine's fail-closed CEL evaluation (see MarkDoneAction's note). + visible: "record.status != 'sent'", + locations: ['list_item', 'record_header'], + refreshAfter: true, +}); + export const allActions = [ MarkDoneAction, OpenDocsAction, @@ -146,4 +180,5 @@ export const allActions = [ RecalcEstimateAction, LogTimeAction, NewTaskAction, + SubmitForSignoffAction, ]; From 61908f09edc40750ba0147ac704585327128da40 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:20:31 +0800 Subject: [PATCH 2/2] test(showcase): expect 9 positions after finance/legal added MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v16 approval-demo change added `finance` + `legal` positions to positions.ts (7 → 9), but seed.test.ts still asserted 7, failing Test Core. Co-Authored-By: Claude Opus 4.8 --- examples/app-showcase/test/seed.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/app-showcase/test/seed.test.ts b/examples/app-showcase/test/seed.test.ts index b4432841fb..6e08679cfd 100644 --- a/examples/app-showcase/test/seed.test.ts +++ b/examples/app-showcase/test/seed.test.ts @@ -25,11 +25,11 @@ describe('showcase stack', () => { // leaving 3 dataset-bound analytics reports. expect((stack.reports ?? []).length).toBe(3); expect((stack.flows ?? []).length).toBeGreaterThan(0); - // Seven flat positions (contributor/manager/exec/auditor/ops/ - // field_ops_delegate/client_portal_user) — the ADR-0090 distribution - // layer; `everyone` and `guest` are built-in anchors and never declared - // by the app. - expect((stack.positions ?? []).length).toBe(7); + // Nine flat positions (contributor/manager/exec/auditor/ops/ + // field_ops_delegate/client_portal_user, plus finance/legal for the v16 + // approval sign-off flows) — the ADR-0090 distribution layer; `everyone` + // and `guest` are built-in anchors and never declared by the app. + expect((stack.positions ?? []).length).toBe(9); expect((stack.agents ?? []).length).toBe(0); // AI agents are an enterprise (service-ai) feature; the open showcase ships none }); });