@rytass/bpm-core-nestjs-module 0.16.2 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +415 -261
  3. package/package.json +2 -2
  4. package/src/lib/delegation/delegation-rule.entity.js +3 -1
  5. package/src/lib/delegation/delegation-rule.entity.js.map +1 -1
  6. package/src/lib/delegation/delegation.service.d.ts +4 -0
  7. package/src/lib/delegation/delegation.service.js +19 -3
  8. package/src/lib/delegation/delegation.service.js.map +1 -1
  9. package/src/lib/delegation/dto/delegation-rule.input.js +6 -2
  10. package/src/lib/delegation/dto/delegation-rule.input.js.map +1 -1
  11. package/src/lib/migrations/0000000000001-enable-postgres-extensions.js +6 -2
  12. package/src/lib/migrations/0000000000001-enable-postgres-extensions.js.map +1 -1
  13. package/src/lib/notification/notification-options.d.ts +5 -5
  14. package/src/lib/notification/notification.service.js +5 -2
  15. package/src/lib/notification/notification.service.js.map +1 -1
  16. package/src/lib/organization/index.d.ts +1 -0
  17. package/src/lib/organization/index.js +1 -0
  18. package/src/lib/organization/index.js.map +1 -1
  19. package/src/lib/organization/org-unit-retirement-impact.object.d.ts +18 -0
  20. package/src/lib/organization/org-unit-retirement-impact.object.js +34 -0
  21. package/src/lib/organization/org-unit-retirement-impact.object.js.map +1 -0
  22. package/src/lib/organization/organization.mutations.d.ts +1 -1
  23. package/src/lib/organization/organization.mutations.js +7 -4
  24. package/src/lib/organization/organization.mutations.js.map +1 -1
  25. package/src/lib/organization/organization.queries.d.ts +2 -0
  26. package/src/lib/organization/organization.queries.js +13 -0
  27. package/src/lib/organization/organization.queries.js.map +1 -1
  28. package/src/lib/organization/organization.service.d.ts +32 -1
  29. package/src/lib/organization/organization.service.js +157 -16
  30. package/src/lib/organization/organization.service.js.map +1 -1
  31. package/src/lib/template/template.service.js +91 -57
  32. package/src/lib/template/template.service.js.map +1 -1
  33. package/src/lib/template/workflow-definition.validator.js +11 -0
  34. package/src/lib/template/workflow-definition.validator.js.map +1 -1
  35. package/src/lib/workflow-engine/dto/reassign-member-tasks.input.d.ts +7 -0
  36. package/src/lib/workflow-engine/dto/reassign-member-tasks.input.js +34 -0
  37. package/src/lib/workflow-engine/dto/reassign-member-tasks.input.js.map +1 -0
  38. package/src/lib/workflow-engine/dto/reassign-task.input.d.ts +8 -0
  39. package/src/lib/workflow-engine/dto/reassign-task.input.js +39 -0
  40. package/src/lib/workflow-engine/dto/reassign-task.input.js.map +1 -0
  41. package/src/lib/workflow-engine/index.d.ts +3 -0
  42. package/src/lib/workflow-engine/index.js +3 -0
  43. package/src/lib/workflow-engine/index.js.map +1 -1
  44. package/src/lib/workflow-engine/task-reassignment.object.d.ts +26 -0
  45. package/src/lib/workflow-engine/task-reassignment.object.js +97 -0
  46. package/src/lib/workflow-engine/task-reassignment.object.js.map +1 -0
  47. package/src/lib/workflow-engine/workflow-engine.enums.d.ts +29 -0
  48. package/src/lib/workflow-engine/workflow-engine.enums.js +42 -1
  49. package/src/lib/workflow-engine/workflow-engine.enums.js.map +1 -1
  50. package/src/lib/workflow-engine/workflow-engine.mutations.d.ts +5 -0
  51. package/src/lib/workflow-engine/workflow-engine.mutations.js +48 -0
  52. package/src/lib/workflow-engine/workflow-engine.mutations.js.map +1 -1
  53. package/src/lib/workflow-engine/workflow-engine.queries.d.ts +2 -0
  54. package/src/lib/workflow-engine/workflow-engine.queries.js +15 -0
  55. package/src/lib/workflow-engine/workflow-engine.queries.js.map +1 -1
  56. package/src/lib/workflow-engine/workflow-engine.service.d.ts +150 -1
  57. package/src/lib/workflow-engine/workflow-engine.service.js +716 -45
  58. package/src/lib/workflow-engine/workflow-engine.service.js.map +1 -1
  59. package/src/lib/workflow-engine/workflow-engine.tokens.d.ts +2 -1
  60. package/src/lib/workflow-engine/workflow-engine.tokens.js.map +1 -1
package/README.md CHANGED
@@ -21,7 +21,9 @@ and keeps transport details and credentials out of form JSON.
21
21
 
22
22
  ## Package Status
23
23
 
24
- Current version: `0.1.10`
24
+ The released versions and their changes are listed in
25
+ [CHANGELOG.md](./CHANGELOG.md). Sections marked **unreleased** describe code
26
+ that is on `main` but not in any published version yet.
25
27
 
26
28
  The package is intended for NestJS backend hosts. It does not include the
27
29
  Next.js backoffice UI and does not provide a production auth system by itself.
@@ -68,7 +70,10 @@ Your host application must provide:
68
70
  - A configured TypeORM `DataSource` / `TypeOrmModule`.
69
71
  - Auth/session/JWT logic.
70
72
  - A `BPMAuthContext` bridge from the request execution context.
71
- - A `BPM_MEMBER_RESOLVER` provider for member metadata lookup.
73
+ - A member resolver for member metadata lookup (`memberResolverProvider`, or
74
+ the `BPM_MEMBER_RESOLVER` provider it registers). It is optional: without one,
75
+ BPM uses `DefaultBPMMemberResolver`, which shows raw member ids as names and
76
+ empty emails. That keeps a new host booting; it is not an identity system.
72
77
  - Attachment storage configuration when local storage is not acceptable.
73
78
  - A dedicated worker or host dispatcher when email/webhook/SLA work should not
74
79
  run inside API replicas.
@@ -128,7 +133,7 @@ import { HostSessionService } from './host-session.service';
128
133
  sortSchema: true,
129
134
  }),
130
135
  }),
131
- BPMRootModule.forRoot({
136
+ BPMRootModule.forRoot({
132
137
  imports: [HostAuthModule],
133
138
  authContextFactory: buildBPMAuthContextFromExecutionContext,
134
139
  memberResolverProvider: {
@@ -185,21 +190,13 @@ BPM also exports param decorators so host resolvers don't need to call
185
190
  `authContextFactory` directly:
186
191
 
187
192
  ```ts
188
- import {
189
- BPMAuthenticated,
190
- BPMCurrentAuthContext,
191
- BPMCurrentMemberId,
192
- type BPMAuthContext,
193
- } from '@rytass/bpm-core-nestjs-module';
193
+ import { BPMAuthenticated, BPMCurrentAuthContext, BPMCurrentMemberId, type BPMAuthContext } from '@rytass/bpm-core-nestjs-module';
194
194
 
195
195
  @Resolver()
196
196
  class HostResolver {
197
197
  @Query(() => HostSummary)
198
198
  @BPMAuthenticated()
199
- hostSummary(
200
- @BPMCurrentAuthContext() auth: BPMAuthContext,
201
- @BPMCurrentMemberId() memberId: string,
202
- ): HostSummary {
199
+ hostSummary(@BPMCurrentAuthContext() auth: BPMAuthContext, @BPMCurrentMemberId() memberId: string): HostSummary {
203
200
  return buildHostSummary(auth, memberId);
204
201
  }
205
202
  }
@@ -224,11 +221,11 @@ interface ApiMember {
224
221
 
225
222
  Status-code contract:
226
223
 
227
- | Endpoint | Success | Failure |
228
- | --------------------- | ----------- | ----------------------------------- |
229
- | `POST /auth/login` | `200` + `ApiMember` JSON | `401` (bad credentials) / `400` (validation) |
230
- | `GET /auth/me` | `200` + `ApiMember` JSON | `401` (no session — React client treats as anonymous, not error) |
231
- | `POST /auth/logout` | `204` (or `200` empty) | `401` ignored |
224
+ | Endpoint | Success | Failure |
225
+ | ------------------- | ------------------------ | ---------------------------------------------------------------- |
226
+ | `POST /auth/login` | `200` + `ApiMember` JSON | `401` (bad credentials) / `400` (validation) |
227
+ | `GET /auth/me` | `200` + `ApiMember` JSON | `401` (no session — React client treats as anonymous, not error) |
228
+ | `POST /auth/logout` | `204` (or `200` empty) | `401` ignored |
232
229
 
233
230
  Session lifetime is owned by the host. The React `<AuthProvider>`
234
231
  relies on `credentials: 'include'` plus an HTTP-only cookie issued by
@@ -284,10 +281,10 @@ and compute `expiresAt` from the token TTL.
284
281
  function projectMemberToApiMember(member: HostMember): ApiMember {
285
282
  return {
286
283
  memberId: member.id,
287
- email: member.email ?? '', // ApiMember.email is `string`, not `string | null`
284
+ email: member.email ?? '', // ApiMember.email is `string`, not `string | null`
288
285
  name: member.name,
289
286
  roles: projectHostRolesToBPMRoles(member.roles), // [] if no admin/designer claim
290
- permissions: [], // optional — return [] if not used
287
+ permissions: [], // optional — return [] if not used
291
288
  expiresAt: new Date(Date.now() + JWT_TTL_MS).toISOString(),
292
289
  };
293
290
  }
@@ -295,16 +292,19 @@ function projectMemberToApiMember(member: HostMember): ApiMember {
295
292
 
296
293
  Response status contract that BPM's React client expects:
297
294
 
298
- | Endpoint | Success | Auth fail | Validation fail |
299
- |---|---|---|---|
300
- | `POST /auth/login` | `200` + `ApiMember` JSON, set HTTP-only cookie | `401` | `400` |
301
- | `GET /auth/me` | `200` + `ApiMember` JSON | `401` (client treats as anonymous, **not error**) | n/a |
302
- | `POST /auth/logout` | `204` (or `200` with empty body) | `401` ignored — client clears local state anyway | n/a |
295
+ | Endpoint | Success | Auth fail | Validation fail |
296
+ | ------------------- | ---------------------------------------------- | ------------------------------------------------- | --------------- |
297
+ | `POST /auth/login` | `200` + `ApiMember` JSON, set HTTP-only cookie | `401` | `400` |
298
+ | `GET /auth/me` | `200` + `ApiMember` JSON | `401` (client treats as anonymous, **not error**) | n/a |
299
+ | `POST /auth/logout` | `204` (or `200` with empty body) | `401` ignored — client clears local state anyway | n/a |
303
300
 
304
301
  `roles` and `permissions` MUST be arrays (use `[]` for no claim), not
305
302
  `null` or `undefined` — the React client's `member.roles.includes(...)`
306
303
  checks would crash otherwise.
307
304
 
305
+ The table above is the contract the React client needs. The reference host
306
+ (`apps/api`) answers `POST /auth/logout` with `200` and `{ "ok": true }`.
307
+
308
308
  #### Pattern B — Run BPM on a separate auth host
309
309
 
310
310
  When BPM lives on its own subdomain (e.g. `bpm.example.com`) separate
@@ -389,11 +389,7 @@ registers one versioned catalog provider; a form stores only the source key,
389
389
  version, and field/constant bindings:
390
390
 
391
391
  ```ts
392
- import {
393
- BPM_FORM_DATA_SOURCE_REGISTRY,
394
- BPM_MEMBER_RESOLVER,
395
- BPMRootModule,
396
- } from '@rytass/bpm-core-nestjs-module';
392
+ import { BPM_FORM_DATA_SOURCE_REGISTRY, BPM_MEMBER_RESOLVER, BPMRootModule } from '@rytass/bpm-core-nestjs-module';
397
393
  import { HostFormDataSourceRegistry } from './host-form-data-source.registry';
398
394
 
399
395
  BPMRootModule.forRoot({
@@ -472,22 +468,20 @@ import { HostAuthProvider } from '@your-host/auth';
472
468
 
473
469
  export default function RootLayout({ children }) {
474
470
  return (
475
- <html><body>
476
- <HostAuthProvider>{children}</HostAuthProvider>
477
- </body></html>
471
+ <html>
472
+ <body>
473
+ <HostAuthProvider>{children}</HostAuthProvider>
474
+ </body>
475
+ </html>
478
476
  );
479
477
  }
480
478
 
481
479
  // app/operations/approval/layout.tsx — BPM sub-tree
482
- 'use client';
480
+ ('use client');
483
481
  import { BPMNextProviders } from '@rytass/bpm-core-react/next';
484
482
 
485
483
  export default function ApprovalLayout({ children }) {
486
- return (
487
- <BPMNextProviders loginPath="/login">
488
- {children}
489
- </BPMNextProviders>
490
- );
484
+ return <BPMNextProviders loginPath="/login">{children}</BPMNextProviders>;
491
485
  }
492
486
  ```
493
487
 
@@ -511,11 +505,11 @@ exact-string matching. The host must inject one of the following strings to
511
505
  clear admin / designer guards; otherwise BPM resolvers reject with
512
506
  `ForbiddenException`.
513
507
 
514
- | Tier | Accepted roles or permissions | Guards / decorators / helpers |
515
- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
516
- | Admin | `roles: ['BPM_ADMIN']`; or `permissions` contains one of `bpm:*`, `bpm:admin`, `bpm.admin`, `bpm:admin:*` | `BPMAdminGuard`, `@BPMAdminOnly()`, `isBPMAdmin(authContext)` |
517
- | Designer | Includes admin; or `roles: ['BPM_DESIGNER']`; or `permissions` contains one of `bpm:design`, `bpm.design`, `bpm.form.design`, `bpm.template.design`, `bpm:form:design`, `bpm:template:design` | `BPMDesignerGuard`, `@BPMDesignerOnly()`, `isBPMDesigner(authContext)` |
518
- | Authenticated | `BPMAuthContext.memberId` is non-empty | `BPMAuthenticatedGuard`, `@BPMAuthenticated()` |
508
+ | Tier | Accepted roles or permissions | Guards / decorators / helpers |
509
+ | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
510
+ | Admin | `roles: ['BPM_ADMIN']`; or `permissions` contains one of `bpm:*`, `bpm:admin`, `bpm.admin`, `bpm:admin:*` | `BPMAdminGuard`, `@BPMAdminOnly()`, `isBPMAdmin(authContext)` |
511
+ | Designer | Includes admin; or `roles: ['BPM_DESIGNER']`; or `permissions` contains one of `bpm:design`, `bpm.design`, `bpm.form.design`, `bpm.template.design`, `bpm:form:design`, `bpm:template:design` | `BPMDesignerGuard`, `@BPMDesignerOnly()`, `isBPMDesigner(authContext)` |
512
+ | Authenticated | `BPMAuthContext.memberId` is non-empty | `BPMAuthenticatedGuard`, `@BPMAuthenticated()` |
519
513
 
520
514
  `@BPMAdminOnly()` and `@BPMDesignerOnly()` already chain
521
515
  `BPMAuthenticatedGuard`. The host does not need to add `@BPMAuthenticated()`
@@ -555,9 +549,7 @@ export class BPMAuthContextFactory {
555
549
 
556
550
  // 2. Translate into BPM's exact-string literals. Unknown host roles
557
551
  // do nothing — BPM only checks for the documented strings.
558
- const bpmRoles = hostRoles
559
- .map((r) => HOST_TO_BPM_ROLES[r])
560
- .filter((r): r is string => Boolean(r));
552
+ const bpmRoles = hostRoles.map((r) => HOST_TO_BPM_ROLES[r]).filter((r): r is string => Boolean(r));
561
553
 
562
554
  return {
563
555
  memberId: member.id,
@@ -590,9 +582,7 @@ writes — and `@rytass/member-base-nestjs-module`'s `CasbinGuard` rejects any
590
582
  route without `@CheckPermission`:
591
583
 
592
584
  ```json
593
- { "errors": [{ "message": "Route has no permission metadata",
594
- "path": ["orgUnits"],
595
- "extensions": { "code": "FORBIDDEN" } }] }
585
+ { "errors": [{ "message": "Route has no permission metadata", "path": ["orgUnits"], "extensions": { "code": "FORBIDDEN" } }] }
596
586
  ```
597
587
 
598
588
  Every BPM query and mutation fails this way, before BPM's guards run at all.
@@ -615,7 +605,7 @@ const resolverMetadataFactory: BPMResolverMetadataFactory = ({ access }) => ({
615
605
  [PERMISSION_METADATA_KEY]: HOST_PERMISSIONS[access],
616
606
  });
617
607
 
618
- BPMRootModule.forRoot({ resolverMetadataFactory, /* ... */ });
608
+ BPMRootModule.forRoot({ resolverMetadataFactory /* ... */ });
619
609
  ```
620
610
 
621
611
  The factory is called once per handler and receives `{ access, resolverName,
@@ -641,7 +631,9 @@ console.table([...listBPMResolverHandlers()]);
641
631
  ## Member Resolver
642
632
 
643
633
  The package resolves display names, email addresses, and approver candidates
644
- through `BPM_MEMBER_RESOLVER`.
634
+ through `BPM_MEMBER_RESOLVER`. Registering one is optional: without it BPM
635
+ falls back to `DefaultBPMMemberResolver` (see Runtime Responsibilities), which
636
+ logs a warning outside `development` and `test`.
645
637
 
646
638
  For a member-base-like directory, prefer the package helper. It creates a Nest
647
639
  provider without forcing BPM to depend on the host identity package:
@@ -721,11 +713,11 @@ export interface BPMMemberResolver {
721
713
 
722
714
  BPM is the **sole authority** for four organization tables:
723
715
 
724
- | Table | Owns |
725
- |---|---|
726
- | `org_units` | Department / division / company nodes (typed by `OrgUnitType`) |
727
- | `positions` | Job titles, with optional org-unit scoping |
728
- | `memberships` | Member × org-unit × position relations (the join table that puts a member at a position inside a unit) |
716
+ | Table | Owns |
717
+ | --------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
718
+ | `org_units` | Department / division / company nodes (typed by `OrgUnitType`) |
719
+ | `positions` | Job titles, with optional org-unit scoping |
720
+ | `memberships` | Member × org-unit × position relations (the join table that puts a member at a position inside a unit) |
729
721
  | `manager_resolutions` | "Who is whose manager" rules (per-member, per-org-unit, or per-position scope), consumed by the `ORG_MANAGER` approver resolver |
730
722
 
731
723
  There is **no** host-injectable `OrgUnitResolver` / `PositionResolver` /
@@ -738,8 +730,9 @@ exist, and the rest of this section explains why and what to do instead.
738
730
  The BPM workflow engine reads the org graph on hot paths:
739
731
 
740
732
  - **Approver routing** — every running instance evaluates `UserTaskNode`
741
- candidates (resolved via `DIRECT`, `POSITION`, `ORG_MANAGER`, or
742
- `CANDIDATE_GROUP`) against the current org snapshot. A `ParallelGateway`
733
+ candidates (resolved via `DIRECT`, `POSITION`, `ORG_UNIT_MEMBER`,
734
+ `ORG_UNIT_POSITION`, `ORG_MANAGER`, `ORG_UNIT_MANAGER`, or
735
+ `ORG_MANAGER_CHAIN`) against the current org snapshot. A `ParallelGateway`
743
736
  fanning out to "all managers in the IT division" may touch dozens of
744
737
  membership rows in one step.
745
738
  - **Tree-diff commits** — `commitOrgUnitTreeDraft` writes batched moves
@@ -774,17 +767,58 @@ and observable:
774
767
  pagination payload. **For reconciliation, always key on `code`**
775
768
  (which IS returned). Treat `metadataJson` as a debugging/audit
776
769
  stash, not a live FK pointer.
777
- 3. **Soft delete via `deleteOrgUnit` / `deleteMembership` / `deleteManagerResolution`.** Deletes set
778
- `deletedAt` rather than removing rows. BPM's query layer hides
779
- soft-deleted rows automatically; reading `orgUnitCount()` will report
780
- the live count.
781
- 4. **Positions are intentionally not deletable.** There is no
782
- `deletePosition` mutation — positions are part of the historical
783
- record (every signed approval task references the assignee's position
784
- at sign time, so deleting a position would lose audit data). Retire a
785
- position by removing all active memberships referencing it; the
786
- position itself stays. Use `updatePosition({ id, name: 'retired-' + oldName, ... })`
787
- if you want a UI hint that a position is no longer in use.
770
+ 3. **`deleteOrgUnit` is a soft delete, with a hard-delete exception.** It sets `deletedAt`, and
771
+ BPM's query layer hides soft-deleted units automatically; reading
772
+ `orgUnitCount()` reports the live count. Retiring a unit with a successor
773
+ (see "Retiring an org unit", unreleased) also hard-deletes not-yet-started
774
+ memberships that duplicate a row in the successor, and not-yet-started
775
+ `ORG_UNIT` manager rules. `deleteMembership`,
776
+ `deleteManagerResolution` and `deletePosition` are **hard deletes** —
777
+ those entities have no `deletedAt` column, so the row and its history
778
+ are gone.
779
+ 4. **End a membership or manager rule with `effectiveTo`, don't delete
780
+ it.** To keep "who was in this unit / who managed whom, and when",
781
+ call `updateMembership` / `updateManagerResolution` with `effectiveTo`
782
+ set to the last day the row should still apply. `effectiveTo` is
783
+ inclusive, and "today" is read in `organizationTimeZone` (default
784
+ `Asia/Taipei`), so a row ending yesterday stops applying today. Keep
785
+ the delete methods for rows written by mistake.
786
+ 5. **Positions are deleted only when nothing points at them.**
787
+ `deletePosition` refuses while any membership or manager resolution
788
+ (ended ones included) references the position, or while a template or
789
+ a `PENDING` ad-hoc directive routes approvers through it. Since the
790
+ delete is permanent, retire a position by ending the memberships that
791
+ use it and leaving the position in place; rename it (for example
792
+ `updatePosition({ id, name: 'retired-' + oldName, ... })`) if you want
793
+ a UI hint that it is no longer in use.
794
+
795
+ ### Retiring an org unit (unreleased)
796
+
797
+ > Not in a published version yet. It landed after `v0.16.2`.
798
+
799
+ `deleteOrgUnit(id, { transferToOrgUnitId })` retires a unit. The GraphQL
800
+ mutation takes the same two values as arguments (`deleteOrgUnit(id,
801
+ transferToOrgUnitId)`), and the client takes `deleteOrgUnit(id, { transferToOrgUnitId })`.
802
+
803
+ - A unit with live child units is always refused.
804
+ - A unit with open memberships or open `ORG_UNIT` manager rules is refused,
805
+ unless `transferToOrgUnitId` names a different live unit.
806
+ - With a successor, one transaction runs:
807
+ - a membership that has already started is ended yesterday and continued in
808
+ the successor from today, so "who was in this unit" survives;
809
+ - a membership that starts today or later is moved to the successor as it
810
+ is, unless the successor already has that row, in which case it is
811
+ hard-deleted;
812
+ - an `ORG_UNIT` manager rule that has already started is ended yesterday;
813
+ one that has not started is hard-deleted. The successor keeps its own
814
+ managers.
815
+ - The unit is then soft-deleted. Ended memberships and manager rules never
816
+ block the delete.
817
+
818
+ Preview what a retirement would have to handle with the
819
+ `orgUnitRetirementImpact(id)` query, which returns `childUnitCount`,
820
+ `openMembershipCount` and `openManagerResolutionCount`. The client exposes the
821
+ same data as `readOrgUnitRetirementImpact(id)`.
788
822
 
789
823
  ### Worked example: idempotent sync
790
824
 
@@ -797,49 +831,39 @@ input shapes — no `{id, input: {...}}` wrapping):
797
831
  - `readOrganizationDashboard({ orgUnitPageSize, orgUnitSearchText, ... })`
798
832
  - `createOrgUnit({ code, name, type, parentId, metadataJson })`
799
833
  - `updateOrgUnit({ id, code, name, type, parentId, metadataJson })`
800
- - `deleteOrgUnit(id)` (soft-delete)
834
+ - `deleteOrgUnit(id)` (soft delete; refused while open memberships or ORG_UNIT manager rules remain, unless `transferToOrgUnitId` is given — unreleased, see "Retiring an org unit")
801
835
  - `commitOrgUnitTreeDraft({ moves: { id, parentId, baseUpdatedAt }[] })` (transactional batch moves — each entry's `baseUpdatedAt` is the row's last-known `updatedAt`, used by BPM for optimistic-locking conflict detection)
802
- - `createPosition({ code, name, level, metadataJson })` — `level` (1+) controls hierarchy weight (higher = more senior, used by manager-resolution priority sort); `metadataJson` is required (pass `'{}'` if unused)
836
+ - `createPosition({ code, name, level, metadataJson })` — `level` (1+) is the position grade; higher is more senior. The `ORG_MANAGER_CHAIN` resolver reads it: it walks up the manager chain, adds only managers whose grade is higher than the last one added, and stops at the first grade reaching `minPositionLevel`. Manager-rule `priority` does not use it. `metadataJson` is required (pass `'{}'` if unused)
803
837
  - `updatePosition({ id, code, name, level, metadataJson })` — every field except `id` is `T | null`; pass `null` to leave a field unchanged
804
- - `createMembership({ memberId, orgUnitId, positionId, isPrimary, effectiveFrom, effectiveTo })` — `positionId` and `effectiveTo` accept `null`; `effectiveFrom` is an ISO timestamp string. Uniqueness key is `(memberId, orgUnitId, positionId)`
805
- - `updateMembership({ id, orgUnitId, positionId, isPrimary, effectiveFrom, effectiveTo })` — every field except `id` is `T | null`
806
- - `createManagerResolution({ scopeType, scopeId, managerMemberId, priority, effectiveFrom, effectiveTo })` — `scopeType` is `'MEMBER' | 'ORG_UNIT' | 'POSITION'`; `priority` orders the resolver chain (lower number = checked first)
807
- - `createMembership` / `updateMembership` (unique by `(memberId, orgUnitId, positionId)`)
808
- - `createManagerResolution` / `updateManagerResolution`
838
+ - `createMembership({ memberId, orgUnitId, positionId, isPrimary, effectiveFrom, effectiveTo })` — `positionId` and `effectiveTo` accept `null`; `effectiveFrom` / `effectiveTo` are `YYYY-MM-DD` date strings (`effectiveTo` is inclusive). Uniqueness key is `(memberId, orgUnitId, positionId, effectiveFrom)`
839
+ - `updateMembership({ id, orgUnitId, positionId, isPrimary, effectiveFrom, effectiveTo })` — every field except `id` is `T | null`. Passing `null` leaves `orgUnitId`, `isPrimary` and `effectiveFrom` unchanged, but clears `positionId` and `effectiveTo` (an open-ended row)
840
+ - `createManagerResolution({ scopeType, scopeId, managerMemberId, priority, effectiveFrom, effectiveTo })` — `scopeType` is `'MEMBER' | 'ORG_UNIT' | 'POSITION'`; `priority` orders the rules: the highest number is checked first, and every rule that ties with the top priority and scope type applies. `manager_resolutions` has no unique constraint, so several rules for one scope can coexist
841
+ - `createMembership` / `updateMembership` (unique by `(memberId, orgUnitId, positionId, effectiveFrom)`)
842
+ - `createManagerResolution` / `updateManagerResolution` — `updateManagerResolution` follows the same `null` rule: `null` clears `effectiveTo` and keeps the other fields
809
843
 
810
844
  ```ts
811
- import {
812
- readOrganizationDashboard,
813
- createOrgUnit,
814
- updateOrgUnit,
815
- type OrgUnitRecord,
816
- } from '@rytass/bpm-core-client/organization';
845
+ import { readOrganizationDashboard, createOrgUnit, updateOrgUnit, type OrgUnitRecord } from '@rytass/bpm-core-client/organization';
817
846
  import type { OrgUnitType } from '@rytass/bpm-core-shared';
818
847
 
819
848
  interface HostOrgUnit {
820
- readonly hostId: string; // e.g. ERP primary key — your host-FK
821
- readonly code: string; // stable across sync runs
849
+ readonly hostId: string; // e.g. ERP primary key — your host-FK
850
+ readonly code: string; // stable across sync runs
822
851
  readonly name: string;
823
- readonly type: OrgUnitType; // 'COMPANY' | 'DIVISION' | 'DEPARTMENT' | 'TEAM'
852
+ readonly type: OrgUnitType; // 'COMPANY' | 'DIVISION' | 'DEPARTMENT' | 'TEAM'
824
853
  readonly parentCode: string | null;
825
854
  }
826
855
 
827
- async function syncOrgTree(
828
- hostNodes: readonly HostOrgUnit[],
829
- ): Promise<void> {
856
+ async function syncOrgTree(hostNodes: readonly HostOrgUnit[]): Promise<void> {
830
857
  // 1. Single round trip: load every existing BPM org unit, index by code.
831
858
  const dashboard = await readOrganizationDashboard({ orgUnitPageSize: null });
832
- const byCode = new Map<string, OrgUnitRecord>(
833
- dashboard.orgUnits.map((unit) => [unit.code, unit]),
834
- );
859
+ const byCode = new Map<string, OrgUnitRecord>(dashboard.orgUnits.map((unit) => [unit.code, unit]));
835
860
 
836
861
  // 2. Topo-sort by parent so a parent always exists before its child
837
862
  // is upserted (createOrgUnit needs the parentId to be valid).
838
863
  const ordered = topoSortByParent(hostNodes);
839
864
 
840
865
  for (const host of ordered) {
841
- const parentId =
842
- host.parentCode != null ? (byCode.get(host.parentCode)?.id ?? null) : null;
866
+ const parentId = host.parentCode != null ? (byCode.get(host.parentCode)?.id ?? null) : null;
843
867
  const metadataJson = JSON.stringify({ hostId: host.hostId });
844
868
  const existing = byCode.get(host.code);
845
869
 
@@ -866,8 +890,9 @@ async function syncOrgTree(
866
890
  ```
867
891
 
868
892
  The same pattern works for `Position` (key on `code`), `Membership`
869
- (unique by `memberId × orgUnitId × positionId`), and
870
- `ManagerResolution` (unique by scope shape + active flag). For bulk
893
+ (unique by `memberId × orgUnitId × positionId × effectiveFrom`), and
894
+ `ManagerResolution` (no database unique constraint: look up existing rules by
895
+ scope and manager before inserting). For bulk
871
896
  tree-shape changes (multi-node moves under a single parent),
872
897
  `commitOrgUnitTreeDraft` accepts the full draft in one transaction.
873
898
 
@@ -880,7 +905,7 @@ tree-shape changes (multi-node moves under a single parent),
880
905
  > **Programmatic base URL.** Server-side scripts that aren't running
881
906
  > under Next.js (cron workers, one-off seeds) cannot rely on
882
907
  > `NEXT_PUBLIC_API_URL` resolution — call `configureBPMClient({ baseUrl,
883
- > fetch, headers })` from `@rytass/bpm-core-client` once at startup to
908
+ fetch, headers })` from `@rytass/bpm-core-client` once at startup to
884
909
  > point the transport at the right host and inject a session cookie or
885
910
  > bearer token.
886
911
 
@@ -891,6 +916,29 @@ through `BPMMemberResolver` (see the previous section). Only the
891
916
  **org structure** (who reports where, what unit they sit in, who manages
892
917
  them) needs to live inside BPM.
893
918
 
919
+ ## Approver Fallback
920
+
921
+ `ORG_MANAGER`, `ORG_UNIT_MANAGER` and `ORG_MANAGER_CHAIN` approver resolvers
922
+ take an optional `fallback`. It supplies approvers when the org chart yields
923
+ none:
924
+
925
+ - `ORG_MANAGER` and `ORG_UNIT_MANAGER`: the fallback is used only when no manager
926
+ is found.
927
+ - `ORG_MANAGER_CHAIN`: the chain walk collects managers until one reaches
928
+ `minPositionLevel`. If the walk ends before that, the fallback's members are
929
+ added after the managers already collected. The chain is not replaced. If the
930
+ chain is empty, the fallback alone supplies the approver. With no fallback, or
931
+ `NONE`, a walk that ends below the level fails the whole step with a conflict
932
+ error; the managers it did find are not used on their own.
933
+
934
+ Fallback types:
935
+
936
+ | Type | Behavior |
937
+ | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
938
+ | `NONE` | No approver. The step raises a conflict error. An absent `fallback` behaves the same way. |
939
+ | `DIRECT` | A fixed `memberId`. If that member is the initiator, the step fails unless `allowInitiatorSelfApproval` is `true`. |
940
+ | `POSITION` | The current holders of `positionId`, minus the initiator unless `allowInitiatorSelfApproval` is `true`. If nobody remains, the step raises a conflict error. Not released yet (it landed after `v0.16.2`); older engines reject it at run time. |
941
+
894
942
  ## Root Module Configuration
895
943
 
896
944
  Use `BPMRootModule.forRoot()` for static configuration or
@@ -934,57 +982,75 @@ BPMRootModule.forRootAsync({
934
982
 
935
983
  ### Configuration Reference
936
984
 
937
- | Option | Default | Description |
938
- | ------------------------------------------------ | ------------------------------ | -------------------------------------------------------------- |
939
- | `authContextFactory` | `undefined` | Reads `BPMAuthContext` from NestJS `ExecutionContext`. |
940
- | `memberResolverProvider` | required | Provider for `BPM_MEMBER_RESOLVER`. |
941
- | `resolverMetadataFactory` | `undefined` | Stamps the host's own route metadata onto every BPM GraphQL handler. Required when the host installs a global metadata-driven guard — see "Coexisting with a host global guard". |
942
- | `attachmentStorageProvider` | local `.storage/attachments` | Host-provided `@rytass/storages` adapter. |
943
- | `workflowServiceTaskDispatcherProvider` | built-in `fetch` dispatcher | Host provider for executable workflow `WEBHOOK` service tasks. |
944
- | `businessCalendarProvider` | Monday–Friday calendar | Host provider for `BPM_BUSINESS_CALENDAR`, used by `BUSINESS_DAY` SLAs. BPMCore ships no holiday data. |
945
- | `formDataSourceRegistryProvider` | Empty catalog | Single host provider for versioned Select / AutoComplete / Radio / Checkbox DataSources. |
946
- | `attachmentRoutePrefix` | `/attachments` | Drives both the BPM signed URL path and the Nest controller mount path for `AttachmentController`. Must be set at module wiring time (see "Attachment Storage" below). |
947
- | `attachmentStorageProviderId` | `local` | Value stored on attachment metadata for the active adapter. |
948
- | `attachmentPublicBaseUrl` | requesting origin | Public base URL for signed attachment URLs. Unset, each URL uses the origin of the GraphQL request that asked for it (`X-Forwarded-Proto` / `X-Forwarded-Host`, else the request's protocol and `Host`), so a host behind a proxy needs no setting. |
949
- | `attachmentSignedUrlSecret` | random per process | HMAC secret for signed attachment download/preview tokens. Unset, each process generates one at boot and warns on stderr; a URL then only verifies on the process that signed it, so set a shared secret before running more than one API replica. |
950
- | `attachmentAllowInsecureSignedUrlSecret` | — | Deprecated and ignored: no signing secret ships in the package any more. |
951
- | `attachmentSignedUrlTtlSeconds` | `300` | Signed attachment URL lifetime in seconds. |
952
- | `identityMemberMetadataCacheTtlMs` | `300000` | Member metadata cache TTL. |
953
- | `identityRegisterResolvers` | `true` | Set to `false` when the host already publishes `member` / `members` / `memberCount` / `searchMembers` GraphQL queries. `IdentityService` stays available either way. |
954
- | `organizationTimeZone` | `Asia/Taipei` | IANA zone in which BPM reads "today" when it compares against the date-only `effectiveFrom` / `effectiveTo` of memberships and manager resolutions (manager chains, direct and org-unit managers, position levels, the initiator snapshot, SLA escalation, the "active only" organization views). Set `'UTC'` if your organization dates are kept in UTC. An unknown zone makes BPM fail at startup. Independent of `notificationSlaBusinessCalendarTimeZone`. Before this option the workflow engine read UTC while `OrganizationService` read `Asia/Taipei`; both now follow it. |
955
- | `notificationInAppEnabled` | `true` | Enables in-app notification records. |
956
- | `notificationEmailEnabled` | `auto` | Enables email when SMTP settings are complete. |
957
- | `notificationEmailSmtpHost` | `null` | SMTP host. |
958
- | `notificationEmailSmtpPort` | `null` | SMTP port. |
959
- | `notificationEmailSmtpSecure` | `false` | `true` for implicit TLS, `false` for STARTTLS. |
960
- | `notificationEmailSmtpUsername` | `null` | SMTP username. |
961
- | `notificationEmailSmtpPassword` | `null` | SMTP password or app password. |
962
- | `notificationEmailFrom` | `null` | Email sender address. |
963
- | `notificationWebhookEnabled` | `auto` | Enables webhook when URL and signing secret are complete. |
964
- | `notificationWebhookEndpointUrl` | `null` | Default webhook endpoint URL. |
965
- | `notificationWebhookSigningSecret` | `null` | HMAC secret for webhook payload signatures. |
966
- | `notificationDeliverySchedulerEnabled` | `false` | Runs pending email/webhook delivery loop in this process. |
967
- | `notificationDeliveryScanIntervalMs` | `30000` | Delivery scheduler interval. |
968
- | `notificationDeliveryBatchSize` | `25` | Maximum pending notifications per delivery scan. |
969
- | `notificationDeliveryMaxAttempts` | `3` | Attempts before a notification is marked failed. |
970
- | `notificationDeliveryRetryBaseDelayMs` | `60000` | Base retry delay multiplied by attempt count. |
971
- | `notificationSlaSchedulerEnabled` | `false` | Runs automatic SLA scan loop in this process. |
972
- | `notificationSlaScanIntervalMs` | `60000` | SLA scheduler interval. |
973
- | `notificationSlaBusinessCalendarTimeZone` | `UTC` | IANA zone for the built-in weekday calendar. Ignored when `businessCalendarProvider` is set. |
974
- | `notificationSlaTimeoutRemindEnabled` | `true` | Enables SLA timeout `REMIND`. |
975
- | `notificationSlaTimeoutAutoApproveEnabled` | `false` | Enables SLA timeout `AUTO_APPROVE`. |
976
- | `notificationSlaTimeoutEscalateEnabled` | `false` | Enables SLA timeout `ESCALATE`. |
977
- | `notificationSlaTimeoutTerminateInstanceEnabled` | `false` | Enables SLA timeout `TERMINATE_INSTANCE`. |
978
- | `notificationTemplateEngine` | `simple` | `simple` only — `handlebars` is reserved and currently rendered as `simple`. |
979
- | `notificationDefaultChannels` | `[IN_APP]` | Fallback channels when a workflow node has no channel list. |
980
- | `notificationDefaultEmailDigestMode` | `INSTANT` | Default digest mode for missing preferences. |
981
- | `notificationDefaultInAppPreferenceEnabled` | `true` | Default in-app preference for missing preferences. |
982
- | `notificationDefaultEmailPreferenceEnabled` | `true` | Default email preference for missing preferences. |
983
- | `notificationQuietHoursTimeZone` | registered calendar's zone | IANA zone the members' `quietHoursStart` / `quietHoursEnd` are read in. Falls back to `BPM_BUSINESS_CALENDAR.timeZone`, then `UTC`. |
984
- | `notificationEmailDigestHour` | `9` | Local hour a `DAILY` recipient's held email is flushed. |
985
- | `signatureCurrentKeyVersion` | `1` | Key version used for new signatures. |
986
- | `signatureKeyProvider` | local development key provider | Host key provider for signing and verification. |
987
- | `signatureTimestampProvider` | mock timestamp provider | Host timestamp token provider. |
985
+ Every option is optional, and `BPMRootModule.forRoot()` boots with the defaults
986
+ below.
987
+
988
+ Options that are Nest providers have a **runtime twin**: a ready instance on the
989
+ options object that a `forRootAsync` factory can return instead. Prefer the twin
990
+ when the value needs a secret, because the factory runs after secrets are
991
+ loaded. The twins are: `attachmentStorageProvider` / `attachmentStorage`,
992
+ `businessCalendarProvider` / `businessCalendar`,
993
+ `formDataSourceRegistryProvider` / `formDataSourceRegistry`,
994
+ `memberResolverProvider` / `memberResolver`,
995
+ `workflowServiceTaskDispatcherProvider` / `workflowServiceTaskDispatcher`, and
996
+ `workflowWebhookRegistryProvider` / `workflowWebhookRegistry`.
997
+ `resolverMetadataFactory`, `attachmentRoutePrefix` and `identityRegisterResolvers`
998
+ have no twin: Nest reads them while it builds routes and the schema, before any
999
+ factory runs.
1000
+
1001
+ | Option | Default | Description |
1002
+ | ------------------------------------------------ | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1003
+ | `authContextFactory` | `undefined` | Reads `BPMAuthContext` from NestJS `ExecutionContext`. |
1004
+ | `memberResolverProvider` | `undefined` | Optional. Provider for `BPM_MEMBER_RESOLVER`; without it `DefaultBPMMemberResolver` is used. Runtime twin: `memberResolver`. |
1005
+ | `resolverMetadataFactory` | `undefined` | Stamps the host's own route metadata onto every BPM GraphQL handler. Required when the host installs a global metadata-driven guard — see "Coexisting with a host global guard". |
1006
+ | `attachmentStorageProvider` | local `.storage/attachments` | Host-provided `@rytass/storages` adapter. |
1007
+ | `workflowServiceTaskDispatcherProvider` | built-in `fetch` dispatcher | Host provider for executable workflow `WEBHOOK` service tasks. |
1008
+ | `workflowWebhookRegistryProvider` | Empty catalog | Host catalog of NOTIFY webhook endpoints (`BPMWorkflowWebhookRegistry`). Runtime twin: `workflowWebhookRegistry`. See "NOTIFY webhooks". |
1009
+ | `businessCalendarProvider` | Monday–Friday calendar | Host provider for `BPM_BUSINESS_CALENDAR`, used by `BUSINESS_DAY` SLAs. BPMCore ships no holiday data. |
1010
+ | `formDataSourceRegistryProvider` | Empty catalog | Single host provider for versioned Select / AutoComplete / Radio / Checkbox DataSources. |
1011
+ | `attachmentRoutePrefix` | `/attachments` | Drives both the BPM signed URL path and the Nest controller mount path for `AttachmentController`. Must be set at module wiring time (see "Attachment Storage" below). |
1012
+ | `attachmentStorageProviderId` | `local` | Value stored on attachment metadata for the active adapter. |
1013
+ | `attachmentPublicBaseUrl` | requesting origin | Public base URL for signed attachment URLs. Unset, each URL uses the origin of the GraphQL request that asked for it (`X-Forwarded-Proto` / `X-Forwarded-Host`, else the request's protocol and `Host`), so a host behind a proxy needs no setting. |
1014
+ | `attachmentSignedUrlSecret` | random per process | HMAC secret for signed attachment download/preview tokens. Unset, each process generates one at boot and warns on stderr; a URL then only verifies on the process that signed it, so set a shared secret before running more than one API replica. |
1015
+ | `attachmentAllowInsecureSignedUrlSecret` | — | Deprecated and ignored: no signing secret ships in the package any more. |
1016
+ | `attachmentSignedUrlTtlSeconds` | `300` | Signed attachment URL lifetime in seconds. |
1017
+ | `identityMemberMetadataCacheTtlMs` | `300000` | Member metadata cache TTL. |
1018
+ | `identityRegisterResolvers` | `true` | Set to `false` when the host already publishes `member` / `members` / `memberCount` / `searchMembers` GraphQL queries. `IdentityService` stays available either way. |
1019
+ | `organizationTimeZone` | `Asia/Taipei` | IANA zone in which BPM reads "today" when it compares against the date-only `effectiveFrom` / `effectiveTo` of memberships and manager resolutions (manager chains, direct and org-unit managers, position levels, the initiator snapshot, SLA escalation, the "active only" organization views). Set `'UTC'` if your organization dates are kept in UTC. An unknown zone makes BPM fail at startup. Independent of `notificationSlaBusinessCalendarTimeZone`. Before this option the workflow engine read UTC while `OrganizationService` read `Asia/Taipei`; both now follow it. |
1020
+ | `notificationInAppEnabled` | `true` | Enables in-app notification records. |
1021
+ | `notificationEmailEnabled` | `auto` | Enables email when SMTP settings are complete. |
1022
+ | `notificationEmailSmtpHost` | `null` | SMTP host. |
1023
+ | `notificationEmailSmtpPort` | `null` | SMTP port. |
1024
+ | `notificationEmailSmtpSecure` | `false` | `true` for implicit TLS, `false` for STARTTLS. |
1025
+ | `notificationEmailSmtpUsername` | `null` | SMTP username. |
1026
+ | `notificationEmailSmtpPassword` | `null` | SMTP password or app password. |
1027
+ | `notificationEmailFrom` | `null` | Email sender address. |
1028
+ | `notificationWebhookEnabled` | `auto` | Enables webhook when URL and signing secret are complete. |
1029
+ | `notificationWebhookEndpointUrl` | `null` | Default webhook endpoint URL. |
1030
+ | `notificationWebhookSigningSecret` | `null` | HMAC secret for webhook payload signatures. |
1031
+ | `notificationDeliverySchedulerEnabled` | `false` | Runs pending email/webhook delivery loop in this process. |
1032
+ | `notificationDeliveryScanIntervalMs` | `30000` | Delivery scheduler interval. |
1033
+ | `notificationDeliveryBatchSize` | `25` | Maximum pending notifications per delivery scan. |
1034
+ | `notificationDeliveryMaxAttempts` | `3` | Attempts before a notification is marked failed. |
1035
+ | `notificationDeliveryRetryBaseDelayMs` | `60000` | Base retry delay multiplied by attempt count. |
1036
+ | `notificationDeliveryDispatchTimeoutMs` | `30000` | Maximum time for one delivery dispatch before it is recorded as a failed attempt. `0` waits indefinitely. |
1037
+ | `notificationSlaSchedulerEnabled` | `false` | Runs automatic SLA scan loop in this process. |
1038
+ | `notificationSlaScanIntervalMs` | `60000` | SLA scheduler interval. |
1039
+ | `notificationSlaBusinessCalendarTimeZone` | `UTC` | IANA zone for the built-in weekday calendar. Ignored when `businessCalendarProvider` is set. |
1040
+ | `notificationSlaTimeoutRemindEnabled` | `true` | Enables SLA timeout `REMIND`. |
1041
+ | `notificationSlaTimeoutAutoApproveEnabled` | `false` | Enables SLA timeout `AUTO_APPROVE`. |
1042
+ | `notificationSlaTimeoutEscalateEnabled` | `false` | Enables SLA timeout `ESCALATE`. |
1043
+ | `notificationSlaTimeoutTerminateInstanceEnabled` | `false` | Enables SLA timeout `TERMINATE_INSTANCE`. |
1044
+ | `notificationTemplateEngine` | `simple` | `simple` only — `handlebars` is reserved and currently rendered as `simple`. |
1045
+ | `notificationDefaultChannels` | `[IN_APP]` | Fallback channels when a workflow node has no channel list. |
1046
+ | `notificationDefaultEmailDigestMode` | `INSTANT` | Default digest mode for missing preferences. |
1047
+ | `notificationDefaultInAppPreferenceEnabled` | `true` | Default in-app preference for missing preferences. |
1048
+ | `notificationDefaultEmailPreferenceEnabled` | `true` | Default email preference for missing preferences. |
1049
+ | `notificationQuietHoursTimeZone` | registered calendar's zone | IANA zone the members' `quietHoursStart` / `quietHoursEnd` are read in. Falls back to `BPM_BUSINESS_CALENDAR.timeZone`, then `UTC`. |
1050
+ | `notificationEmailDigestHour` | `9` | Local hour a `DAILY` recipient's held email is flushed. |
1051
+ | `signatureCurrentKeyVersion` | `1` | Key version used for new signatures. |
1052
+ | `signatureKeyProvider` | built-in local development key provider | Host key provider for signing and verification. |
1053
+ | `signatureTimestampProvider` | built-in mock timestamp provider | Host timestamp token provider (RFC 3161-style). Replace the built-in mock in production. |
988
1054
 
989
1055
  Production deployments should override all local development secrets.
990
1056
 
@@ -1005,14 +1071,14 @@ import { OrganizationModule, OrganizationService } from '@rytass/bpm-core-nestjs
1005
1071
  export class HostOrganizationModule {}
1006
1072
  ```
1007
1073
 
1008
- | Module | Self-contained | Notes |
1009
- | -------------------- | -------------- | -------------------------------------------------------------------- |
1010
- | `OrganizationModule` | yes | Org units, positions, memberships, manager resolution. Mounted alone, `deletePosition` silently skips its approval-template and ad-hoc-directive reference checks (it logs when it does) — those tables are not in the schema. |
1011
- | `FormModule` | yes | Form definitions and versions. |
1012
- | `TemplateModule` | yes | Approval templates, versions, categories. |
1013
- | `ConditionModule` | yes | CEL lint and evaluation only. |
1014
- | `IdentityModule` | no | Needs `memberResolverProvider`; use `IdentityModule.forRoot(...)`. |
1015
- | `AttachmentModule` | no | Needs storage and signed-URL options; use `AttachmentModule.forRoot(...)`. |
1074
+ | Module | Self-contained | Notes |
1075
+ | -------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1076
+ | `OrganizationModule` | yes | Org units, positions, memberships, manager resolution. Mounted alone, `deletePosition` skips its approval-template and ad-hoc-directive reference checks, logging a warning each time it does — those tables are not in the schema. |
1077
+ | `FormModule` | yes | Form definitions and versions. |
1078
+ | `TemplateModule` | yes | Approval templates, versions, categories. |
1079
+ | `ConditionModule` | yes | CEL lint and evaluation only. |
1080
+ | `IdentityModule` | no | Takes an optional `memberResolverProvider` (defaults to `DefaultBPMMemberResolver`); use `IdentityModule.forRoot(...)`. |
1081
+ | `AttachmentModule` | no | Needs storage and signed-URL options; use `AttachmentModule.forRoot(...)`. |
1016
1082
 
1017
1083
  What you still owe them:
1018
1084
 
@@ -1040,7 +1106,7 @@ map `jest-resolve` cannot follow — lazily, on the first CEL expression it
1040
1106
  parses. Importing anything from the package root does not reach it, so a host
1041
1107
  jest suite needs no `moduleNameMapper` entry and no stub.
1042
1108
 
1043
- A suite that *does* exercise conditions (template validation, workflow
1109
+ A suite that _does_ exercise conditions (template validation, workflow
1044
1110
  transitions) will load `cel-js` for real. If your jest version cannot resolve
1045
1111
  it, map it to a stub of your own:
1046
1112
 
@@ -1053,18 +1119,22 @@ moduleNameMapper: { '^cel-js$': '<rootDir>/test/cel-js.stub.ts' },
1053
1119
 
1054
1120
  ### Database prerequisites
1055
1121
 
1056
- BPM's first migration enables two PostgreSQL extensions — `uuid-ossp` for
1057
- `uuid_generate_v4()` defaults and `ltree` for the org unit `path` column:
1122
+ BPM's first migration enables three PostgreSQL extensions — `uuid-ossp` for
1123
+ `uuid_generate_v4()` defaults, `ltree` for the org unit `path` column, and
1124
+ `pg_trgm` for the instance search indexes of
1125
+ `WorkflowQueryIndexes0000000013000` (which also creates it, for databases
1126
+ migrated before the first migration checked it):
1058
1127
 
1059
1128
  ```sql
1060
1129
  CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
1061
1130
  CREATE EXTENSION IF NOT EXISTS "ltree";
1131
+ CREATE EXTENSION IF NOT EXISTS "pg_trgm";
1062
1132
  ```
1063
1133
 
1064
1134
  `CREATE EXTENSION` needs the CREATE privilege **on the database**, which a
1065
1135
  least-privilege application role normally does not have — including on Rytass
1066
1136
  platforms, where that privilege is revoked by convention. Have a superuser,
1067
- the database owner, or `rds_superuser` run the two statements once before the
1137
+ the database owner, or `rds_superuser` run the three statements once before the
1068
1138
  first migration run.
1069
1139
 
1070
1140
  PostgreSQL itself returns early from `CREATE EXTENSION IF NOT EXISTS` when the
@@ -1148,47 +1218,37 @@ Required environment variables for direct Vault loading:
1148
1218
 
1149
1219
  ### Sharing a Postgres cluster with the host
1150
1220
 
1151
- Hosts that already run their own Postgres (and their own Vault path,
1152
- e.g. `shuttle/develop`) have three options for where BPM's 22 tables
1153
- live. Pick the one that matches your operational model:
1221
+ Hosts that already run their own Postgres (and their own Vault path, e.g.
1222
+ `shuttle/develop`) need to decide where BPM's 25 tables live.
1154
1223
 
1155
- | Option | When to use | Trade-off |
1156
- |---|---|---|
1157
- | **Same database, same schema (`public`)** | Tiny prototypes, monorepo dev | Table-name collision risk — BPM owns `org_units`, `positions`, etc., common names. Audit your host's tables before choosing. |
1158
- | **Same database, separate schema** (recommended) | Most production hosts | Clean isolation; pg_dump per-schema; one connection pool. Set `DB_SCHEMA=bpm_core` in BPM's Vault path. Host stays on its own schema (typically `public`). |
1159
- | **Separate database** | Hard multi-tenant or compliance boundary | Two pools; cross-schema joins impossible (BPM doesn't need them); migration runners stay completely independent. |
1224
+ BPM has no connection-name option. Its repositories bind to the **default**
1225
+ TypeORM DataSource of the Nest application, and `BPMRootModule` has no
1226
+ `typeormConnectionName`. A process has one default DataSource, so when BPM runs
1227
+ inside the host's Nest application, BPM and the host share that connection and
1228
+ its `schema` setting. Choose one of these layouts:
1160
1229
 
1161
- For option B (separate schema), the host's own TypeORM `forRoot()` and
1162
- BPM's `buildTypeOrmModuleOptions` create **two distinct connections**
1163
- to the same Postgres cluster with different `schema:` settings — that
1164
- is the cleanest separation Nest's TypeORM module supports.
1230
+ | Option | When to use | Trade-off |
1231
+ | ------------------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
1232
+ | **Same database, same schema (`public`)** | Tiny prototypes, monorepo dev | Table-name collision risk: BPM owns `org_units`, `positions`, and other common names. Audit the host's tables before choosing. |
1233
+ | **Shared DataSource with a dedicated schema** | Host and BPM in one Nest application | The one DataSource's `schema` applies to both, so the host's own entities must also resolve in the BPM schema. Only practical when the host's tables live in that schema too. |
1234
+ | **Dedicated BPM application** (needed for a separate schema or database) | Compliance or multi-tenant boundary, or a different database | BPM runs in its own Nest application with its own DataSource and Vault path. The host integrates through GraphQL, not through shared TypeORM repositories. Migrations run per application. |
1165
1235
 
1166
- Concrete wiring:
1236
+ A dedicated BPM application wires its DataSource from its own Vault path:
1167
1237
 
1168
1238
  ```ts
1169
- // apps/api/src/app/app.module.ts
1170
1239
  @Module({
1171
1240
  imports: [
1172
- // Host's existing TypeORM connection (default name, host's schema).
1173
1241
  TypeOrmModule.forRootAsync({
1174
- imports: [HostVaultModule],
1175
- inject: [HostVaultService],
1176
- useFactory: (vault) => buildHostDataSourceOptions(vault), // schema: 'public'
1177
- }),
1178
- // BPM's own connection (named, separate schema, separate Vault path).
1179
- TypeOrmModule.forRootAsync({
1180
- name: 'bpm', // <-- distinct connection name
1181
- imports: [BPMVaultModule],
1182
- inject: [BPMVaultService],
1183
- useFactory: buildTypeOrmModuleOptions, // reads DB_SCHEMA=bpm_core from Vault
1242
+ imports: [VaultModule],
1243
+ inject: [VaultService],
1244
+ useFactory: buildTypeOrmModuleOptions, // reads DB_SCHEMA=bpm_core from Vault
1184
1245
  }),
1185
- // Hand BPM the connection name so its repositories bind to the right pool.
1186
1246
  BPMRootModule.forRootAsync({
1187
- typeormConnectionName: 'bpm',
1188
1247
  // ...
1189
1248
  }),
1190
1249
  ],
1191
1250
  })
1251
+ export class BPMApplicationModule {}
1192
1252
  ```
1193
1253
 
1194
1254
  If the host runs only one Vault path (e.g. `shuttle/develop`) and you
@@ -1228,8 +1288,11 @@ import { BPM_CORE_MIGRATIONS } from '@rytass/bpm-core-nestjs-module/migrations';
1228
1288
  `TypeOrmModule` setup still keeps `migrationsRun: false`; run migrations
1229
1289
  explicitly during deploy.
1230
1290
 
1231
- The first migration attempts to create required PostgreSQL extensions
1232
- (`uuid-ossp`, `ltree`) if they do not exist. Hosts that cannot grant
1291
+ The first migration attempts to create the required PostgreSQL extensions
1292
+ (`uuid-ossp`, `ltree`, `pg_trgm`) if they do not exist. When one is missing and
1293
+ the role cannot create it, the migration fails with the SQL to run and who must
1294
+ run it (the `pg_trgm` check is unreleased; earlier versions surfaced a missing
1295
+ `pg_trgm` as a bare `42501` from migration `0000000013000`). Hosts that cannot grant
1233
1296
  `CREATE EXTENSION` should pre-provision those extensions in the target database
1234
1297
  before running BPM migrations. Schema selection comes from the TypeORM `schema`
1235
1298
  option, so multi-schema hosts should run the same migration list once per BPM
@@ -1308,23 +1371,22 @@ This means:
1308
1371
  `attachmentPublicBaseUrl` pointing at a CDN edge that enforces its own
1309
1372
  authz on top — BPM's signature is freshness, not authorization.
1310
1373
 
1311
- ### Disabling the `/auth/test-members` endpoint in production
1374
+ ### The demo `/auth/test-members` endpoint
1375
+
1376
+ `@rytass/bpm-core-nestjs-module` does not register `GET /auth/test-members`.
1377
+ The endpoint belongs to the wrapper host. BPMCore's demo host (`apps/api`)
1378
+ registers it **unconditionally**, with no `NODE_ENV` check, so the demo login
1379
+ form can list the fixture accounts seeded by `pnpm demo:reset` and
1380
+ `pnpm staging:reset`. Staging runs with `NODE_ENV=production` and depends on
1381
+ that endpoint.
1312
1382
 
1313
- Hosts wired with the demo seed (typical for `pnpm demo:reset` /
1314
- `pnpm staging:reset`) expose `GET /auth/test-members` so the login form
1315
- shows a clickable list of fixture accounts. **Do not ship this in
1316
- production.** Two ways to keep it out:
1383
+ Do not copy that wiring into a production host as it is. Either omit the
1384
+ controller that serves `/auth/test-members`, or guard it yourself, for example
1385
+ by returning 404 unless an explicit non-production setting is on.
1317
1386
 
1318
- 1. The endpoint is **owned by the wrapper host**, not by BPM —
1319
- `@rytass/bpm-core-nestjs-module` does not register it. Simply omit
1320
- the wrapper module / controller that serves
1321
- `/auth/test-members`. The reference implementation in BPMCore's
1322
- `apps/api` only exposes it when `NODE_ENV !== 'production'`; copy
1323
- that pattern in your own auth controller.
1324
- 2. The browser-side `listApiTestMembers()` client function tolerates a
1325
- `404` / `401` and returns an empty array, so removing the endpoint
1326
- degrades gracefully — the login form simply hides the demo-account
1327
- picker.
1387
+ If the endpoint is missing or fails, the browser client `listApiTestMembers()`
1388
+ rejects, and `LoginView` in `@rytass/bpm-core-react` catches the error and hides
1389
+ the demo-account picker. The login page still works.
1328
1390
 
1329
1391
  ## Notifications and SLA
1330
1392
 
@@ -1344,10 +1406,10 @@ If any one of those is missing, email stays off even on `auto`. Webhook
1344
1406
  `auto` requires both `notificationWebhookEndpointUrl` and
1345
1407
  `notificationWebhookSigningSecret`.
1346
1408
 
1347
- `notificationTemplateEngine: 'handlebars'` is currently a reserved value; the
1348
- runtime template renderer still uses the built-in `simple` renderer regardless
1349
- of this setting. Leave it as `simple` until the Handlebars implementation is
1350
- wired.
1409
+ `notificationTemplateEngine` defaults to `simple`, which only replaces
1410
+ `{{path}}` placeholders. `handlebars` renders titles and bodies with Handlebars
1411
+ (`noEscape: true`, `strict: false`), so helpers and blocks work and values are
1412
+ not HTML-escaped.
1351
1413
 
1352
1414
  Delivery and SLA schedulers are disabled by default. Enable them only in a
1353
1415
  dedicated worker process or in a single-replica host that intentionally owns
@@ -1389,7 +1451,8 @@ export class HostNotificationModule {}
1389
1451
  ```
1390
1452
 
1391
1453
  Workflow service tasks are a separate integration point from notification
1392
- delivery. `WEBHOOK` service-task nodes run inside the workflow engine and use
1454
+ delivery. Their `WEBHOOK` action and the webhooks attached to a `NOTIFY` action
1455
+ are different mechanisms; see "NOTIFY webhooks" below. `WEBHOOK` service-task nodes run inside the workflow engine and use
1393
1456
  `BPM_WORKFLOW_SERVICE_TASK_DISPATCHER`. The default dispatcher sends a JSON
1394
1457
  `POST` with `fetch`; wrapper apps can replace it when outbound workflow actions
1395
1458
  must be signed, queued, audited, or routed through an internal integration
@@ -1403,14 +1466,7 @@ shared integration bus instances. Providers do not have to live in a
1403
1466
 
1404
1467
  ```ts
1405
1468
  import { Injectable } from '@nestjs/common';
1406
- import {
1407
- BPMRootModule,
1408
- BPM_MEMBER_RESOLVER,
1409
- BPM_WORKFLOW_SERVICE_TASK_DISPATCHER,
1410
- BPMWorkflowServiceTaskDispatcher,
1411
- BPMWorkflowWebhookDispatchInput,
1412
- BPMWorkflowWebhookDispatchResult,
1413
- } from '@rytass/bpm-core-nestjs-module';
1469
+ import { BPMRootModule, BPM_MEMBER_RESOLVER, BPM_WORKFLOW_SERVICE_TASK_DISPATCHER, BPMWorkflowServiceTaskDispatcher, BPMWorkflowWebhookDispatchInput, BPMWorkflowWebhookDispatchResult } from '@rytass/bpm-core-nestjs-module';
1414
1470
 
1415
1471
  interface IntegrationBusResult {
1416
1472
  readonly accepted: boolean;
@@ -1418,19 +1474,14 @@ interface IntegrationBusResult {
1418
1474
  }
1419
1475
 
1420
1476
  interface IntegrationBus {
1421
- enqueue(
1422
- topic: string,
1423
- payload: Readonly<Record<string, unknown>>,
1424
- ): Promise<IntegrationBusResult>;
1477
+ enqueue(topic: string, payload: Readonly<Record<string, unknown>>): Promise<IntegrationBusResult>;
1425
1478
  }
1426
1479
 
1427
1480
  @Injectable()
1428
1481
  class HostWorkflowServiceTaskDispatcher implements BPMWorkflowServiceTaskDispatcher {
1429
1482
  constructor(private readonly integrationBus: IntegrationBus) {}
1430
1483
 
1431
- async dispatchWebhook(
1432
- input: BPMWorkflowWebhookDispatchInput,
1433
- ): Promise<BPMWorkflowWebhookDispatchResult> {
1484
+ async dispatchWebhook(input: BPMWorkflowWebhookDispatchInput): Promise<BPMWorkflowWebhookDispatchResult> {
1434
1485
  const result = await this.integrationBus.enqueue('bpm.workflow.webhook', {
1435
1486
  headers: input.headers ?? {},
1436
1487
  payload: input.payload,
@@ -1460,12 +1511,69 @@ BPMRootModule.forRoot({
1460
1511
 
1461
1512
  SLA timeout actions that change workflow state are disabled by default:
1462
1513
 
1463
- - `AUTO_APPROVE`
1464
- - `ESCALATE`
1465
- - `TERMINATE_INSTANCE`
1514
+ - `AUTO_APPROVE` decides the overdue task as its assignee (or as the first
1515
+ candidate when there is no assignee), with the comment `SLA timeout
1516
+ auto-approved this task.`. The decision, its signature and the `TASK_DECIDED`
1517
+ event are attributed to that member, not to the system. Only the comment and
1518
+ the `SLA_TRIGGERED` activity entry show that the decision was automatic, so
1519
+ audit reviews must read them.
1520
+ - `ESCALATE` is also disabled by default.
1521
+ - `TERMINATE_INSTANCE` ends the instance as `EXPIRED` (unreleased; earlier
1522
+ versions ended it as `CANCELLED`). The activity log records `INSTANCE_EXPIRED`
1523
+ with no actor, and the instance's node and task are the overdue ones. Ad-hoc
1524
+ completion notifications label it 逾期終止. An instance that an earlier version
1525
+ already ended by SLA remains `CANCELLED`.
1466
1526
 
1467
1527
  Enable them only after the host application's business policy is explicit.
1468
1528
 
1529
+ ### NOTIFY webhooks
1530
+
1531
+ A `NOTIFY` service action can call external endpoints through its `webhooks`
1532
+ list (`NotifyWebhookTarget`). A template stores only the endpoint reference
1533
+ (`key` and `version`) and parameter bindings, never a URL, header or secret. Each
1534
+ target has a stable `id`, which the delivery outbox uses for idempotency.
1535
+
1536
+ Endpoints come from the sources in `workflowWebhookTargetSources` (default
1537
+ `['REGISTRY']`):
1538
+
1539
+ - `REGISTRY`: endpoints that host code registers through
1540
+ `workflowWebhookRegistryProvider` (or the `workflowWebhookRegistry` twin). A
1541
+ `BPMWorkflowWebhookRegistry` exposes `get(key, version)` and `list()`. Each
1542
+ endpoint's `buildRequest(event)` returns the URL, method, headers and optional
1543
+ `signingSecret`. It runs once per delivery attempt, so a rotated credential
1544
+ applies to queued deliveries too. Without a registry, a template that
1545
+ references webhook endpoints cannot be published (`REGISTRY_MISSING`).
1546
+ - `DATABASE`: endpoints an administrator maintains in the database. The source
1547
+ stays off unless `workflowWebhookAllowedUrlPatterns` is non-empty and
1548
+ `workflowWebhookSecretEncryptionKey` is set. Header values and signing secrets
1549
+ are encrypted with that key.
1550
+
1551
+ The request body, when the endpoint does not build one, is the JSON of a
1552
+ `BPMWorkflowWebhookEvent`. It carries `deliveryId` (the receiver's idempotency
1553
+ key), `attempt`, `endpoint`, `instance`, `node`, `initiator`, `occurredAt`, and
1554
+ only the parameters the template bound.
1555
+
1556
+ Deliveries are written to `workflow_webhook_deliveries` and sent by a scheduler.
1557
+ The scheduler is enabled automatically once at least one endpoint is
1558
+ registered, and is always off under `NODE_ENV=test`. Options, all optional:
1559
+
1560
+ | Option | Default | Description |
1561
+ | -------------------------------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------- |
1562
+ | `workflowWebhookTargetSources` | `['REGISTRY']` | Endpoint sources to enable. |
1563
+ | `workflowWebhookAllowedUrlPatterns` | `[]` | URL patterns such as `https://*.example.com/hooks/*`. An invalid pattern fails boot. Empty does not mean "allow all". |
1564
+ | `workflowWebhookEnforceAllowlistForRegistry` | `false` | Also check `REGISTRY` endpoint URLs against the allowlist. |
1565
+ | `workflowWebhookSecretEncryptionKey` | `null` | Encryption key for database-managed endpoints: 32 bytes, hex or base64. |
1566
+ | `workflowWebhookDeliverySchedulerEnabled` | auto | Runs the retry and release scan in this process. |
1567
+ | `workflowWebhookDeliveryScanIntervalMs` | `15000` | Scheduler scan interval. |
1568
+ | `workflowWebhookDeliveryBatchSize` | `25` | Deliveries claimed per scan. |
1569
+ | `workflowWebhookDeliveryMaxAttempts` | `6` | Attempts before a retryable failure becomes `FAILED`. |
1570
+ | `workflowWebhookDeliveryRetryBaseDelayMs` | `30000` | First retry delay. Each further attempt doubles it, with ±20 % jitter. |
1571
+ | `workflowWebhookDeliveryMaxRetryDelayMs` | `3600000` | Ceiling for one retry delay. |
1572
+ | `workflowWebhookDeliveryDefaultTimeoutMs` | `10000` | Request timeout when an endpoint sets none. An endpoint's own timeout is capped at 30 000 ms. |
1573
+
1574
+ The WEBHOOK service-task dispatcher (`workflowServiceTaskDispatcherProvider`)
1575
+ is separate from these options and from the notification dispatcher.
1576
+
1469
1577
  ## Signatures
1470
1578
 
1471
1579
  Decision signatures use HMAC-SHA256 and are chained through the previous
@@ -1497,11 +1605,73 @@ or a `Promise`; the example above uses async because production KMS / TSA
1497
1605
  clients are typically network-bound. Returning a synchronous value is fine
1498
1606
  for in-memory or local-file providers.
1499
1607
 
1608
+ ## Administrator Task Reassignment (unreleased)
1609
+
1610
+ Not in a published version yet. It landed after `v0.16.2`.
1611
+
1612
+ When an open task is held by someone who has left or been replaced, an
1613
+ administrator (`@BPMAdminOnly()`) can move it without that person's help:
1614
+
1615
+ - `memberOpenTasks(memberId)` lists the open seats a member holds on running
1616
+ instances, as the assignee or as a `PENDING` / `CLAIMED` / `WAITING`
1617
+ candidate.
1618
+ - `reassignTask(input)` moves one task's seat from `fromMemberId` to
1619
+ `toMemberId`. `reassignMemberTasks(input)` does the same for every open task
1620
+ the source member holds, one transaction and one result per task.
1621
+
1622
+ The rules that differ from the holder's own transfer:
1623
+
1624
+ - The task keeps its id and decision policy. Only the moved seat changes; the
1625
+ other seats of a `PARALLEL_ALL`, `PARALLEL_ANY`, `QUORUM` or `SEQUENTIAL`
1626
+ stage stay where they are, and a `WAITING` seat keeps its place in order.
1627
+ - It is not a decision. No `TaskDecision` and no signature are written. The
1628
+ delegation chain gets an `ADMIN_REASSIGN` step carrying `actorMemberId` and
1629
+ the typed `comment`, and the activity log records `TASK_REASSIGNED`.
1630
+ - The target is not run through delegation rules. The initiator / submitter
1631
+ exclusions and the ORG_MANAGER_CHAIN grade check still apply.
1632
+ - The node's `allowTransfer` is not consulted. It governs what a holder may do
1633
+ with their own task; an administrator override exists for holders who are
1634
+ gone, and a node that forbids transfers must not strand its tasks on them.
1635
+ - A refusal is reported as `REJECTED` with an `errorCode`
1636
+ (`SOURCE_NOT_ON_TASK`, `TARGET_SAME_AS_SOURCE`, `TARGET_ALREADY_ON_TASK`,
1637
+ `TARGET_ALREADY_DECIDED`, `TARGET_IS_INITIATOR`, `TARGET_IS_SUBMITTER`,
1638
+ `TARGET_GRADE_TOO_LOW`, `TASK_NOT_OPEN`, `INSTANCE_NOT_RUNNING`,
1639
+ `REASON_REQUIRED`, `UNEXPECTED_ERROR`). `UNEXPECTED_ERROR` is reported by
1640
+ `reassignMemberTasks` for the one task that failed, and the other tasks are still
1641
+ processed. `reassignTask` throws a `ConflictException` carrying the
1642
+ code unless `dryRun` is set; `dryRun` always reports and never writes.
1643
+
1644
+ A holder's own transfer on a `PARALLEL_ALL`, `PARALLEL_ANY` or `QUORUM` stage
1645
+ also moves only the transferring seat in place and keeps the task open. It
1646
+ still writes a `TaskDecision` with a signature, which the administrator path
1647
+ does not. Its target must not already hold an open seat on the task, nor have
1648
+ decided it already (both answer a `ConflictException`). The task-level
1649
+ assignee, delegation chain and `openedAt` stay as they were: a seat transfer is
1650
+ not a claim of the whole stage.
1651
+
1652
+ ## Delegation Rules
1653
+
1654
+ > The priority order and the root check below are unreleased (they landed after
1655
+ > `v0.16.2`). Earlier versions ordered rules by creation time first.
1656
+
1657
+ A delegation rule passes a principal's approval seats to another member,
1658
+ optionally only when a CEL condition holds. When several active rules apply, the
1659
+ one with the higher `priority` number wins, as with manager rules. Between rules
1660
+ with equal priority, the one created first wins. The rule list is shown in
1661
+ priority-descending, newest-first order.
1662
+
1663
+ A rule's condition may read only these roots: `env`, `form`, `formData`,
1664
+ `initiator` (with `memberId`), `instance` (`id`, `nodeId`, `state`,
1665
+ `templateId`, `templateVersionId`, `title`), and `principal` (with `memberId`).
1666
+ Saving a rule with any other root is refused. Rules saved before that check was
1667
+ added are not changed. They still fail when evaluated.
1668
+
1500
1669
  ## GraphQL Surface
1501
1670
 
1502
1671
  Importing `BPMRootModule` registers GraphQL resolvers for:
1503
1672
 
1504
- - Organization units, positions, memberships, manager resolution, and summary.
1673
+ - Organization units (including the unreleased `orgUnitRetirementImpact` query),
1674
+ positions, memberships, manager resolution, and summary.
1505
1675
  - Member profile lookup and member metadata cache inspection.
1506
1676
  - Form definitions and form definition versions.
1507
1677
  - Form option DataSource catalog, designer preview, authenticated runtime option
@@ -1525,12 +1695,12 @@ Stage approvers can attach **instance-scoped** directives to the single
1525
1695
  approval instance they are deciding — the workflow template is never
1526
1696
  modified. Four directive types exist (`AdhocDirectiveTypeEnum`):
1527
1697
 
1528
- | Type | Trigger | Effect |
1529
- |---|---|---|
1530
- | `COUNTERSIGN` | Next user task is created | Spawns a parallel ad-hoc task for the target on that stage. The token only advances once the original task **and** every countersign task are approved; a countersigner's rejection rejects the instance. |
1531
- | `PRE_APPROVAL` | Immediately | Spawns a blocking ad-hoc task on the current stage. `onReject` (`AdhocPreApprovalRejectBehaviorEnum`) chooses `REJECT_INSTANCE` (whole instance rejected) or `RETURN_TO_ORIGIN` (decision handed back to the original approver, re-opening their task if needed). |
1532
- | `STAGE_NOTIFY` | Current stage ends (approved / rejected / returned) | Notifies the target — members through `NotificationService`, webhooks through `BPM_WORKFLOW_SERVICE_TASK_DISPATCHER`. |
1533
- | `COMPLETION_NOTIFY` | Instance reaches `APPROVED` / `REJECTED` / `CANCELLED` | Same delivery as `STAGE_NOTIFY`. |
1698
+ | Type | Trigger | Effect |
1699
+ | ------------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1700
+ | `COUNTERSIGN` | Next user task is created | Spawns a parallel ad-hoc task for the target on that stage. The token only advances once the original task **and** every countersign task are approved; a countersigner's rejection rejects the instance. |
1701
+ | `PRE_APPROVAL` | Immediately | Spawns a blocking ad-hoc task on the current stage. `onReject` (`AdhocPreApprovalRejectBehaviorEnum`) chooses `REJECT_INSTANCE` (whole instance rejected) or `RETURN_TO_ORIGIN` (decision handed back to the original approver, re-opening their task if needed). |
1702
+ | `STAGE_NOTIFY` | Current stage ends (approved / rejected / returned) | Notifies the target — members through `NotificationService`, webhooks through `BPM_WORKFLOW_SERVICE_TASK_DISPATCHER`. |
1703
+ | `COMPLETION_NOTIFY` | Instance reaches `APPROVED` / `REJECTED` / `CANCELLED` / `EXPIRED` | Same delivery as `STAGE_NOTIFY`. |
1534
1704
 
1535
1705
  GraphQL surface: mutations `requestAdhocCountersign(taskId, target, comment?)`,
1536
1706
  `requestAdhocPreApproval(taskId, target, onReject, comment?)`,
@@ -1568,7 +1738,7 @@ Workflow, form, condition, identity, organization, and status contracts live in
1568
1738
 
1569
1739
  ```ts
1570
1740
  import type { WorkflowDefinition } from '@rytass/bpm-core-shared/workflow';
1571
- import type { FormSchema } from '@rytass/bpm-core-shared/form';
1741
+ import type { FormDefinitionSchema } from '@rytass/bpm-core-shared/form';
1572
1742
  ```
1573
1743
 
1574
1744
  Use shared contracts when building UI clients or host-side integration tests.
@@ -1580,20 +1750,7 @@ Stable feature subpaths are also published for narrower imports such as
1580
1750
  migrations or notification dispatcher tokens.
1581
1751
 
1582
1752
  ```ts
1583
- import {
1584
- AllExceptionsFilter,
1585
- BPMAdminGuard,
1586
- BPMAdminOnly,
1587
- BPMAuthenticated,
1588
- BPMAuthenticatedGuard,
1589
- BPMCurrentAuthContext,
1590
- BPMCurrentMemberId,
1591
- BPMDesignerGuard,
1592
- BPMDesignerOnly,
1593
- BPMRootModule,
1594
- buildTypeOrmModuleOptions,
1595
- createBPMMemberBaseResolverProvider,
1596
- } from '@rytass/bpm-core-nestjs-module';
1753
+ import { AllExceptionsFilter, BPMAdminGuard, BPMAdminOnly, BPMAuthenticated, BPMAuthenticatedGuard, BPMCurrentAuthContext, BPMCurrentMemberId, BPMDesignerGuard, BPMDesignerOnly, BPMRootModule, buildTypeOrmModuleOptions, createBPMMemberBaseResolverProvider } from '@rytass/bpm-core-nestjs-module';
1597
1754
 
1598
1755
  import { BPM_CORE_MIGRATIONS } from '@rytass/bpm-core-nestjs-module/migrations';
1599
1756
  import { BPM_NOTIFICATION_DISPATCHER } from '@rytass/bpm-core-nestjs-module/notification';
@@ -1660,20 +1817,17 @@ pnpm test
1660
1817
  pnpm build
1661
1818
  ```
1662
1819
 
1663
- ## Publishing Checklist
1820
+ ## Publishing
1664
1821
 
1665
- 1. Confirm package metadata in `libs/bpm-core/package.json`.
1666
- 2. Confirm `@rytass/bpm-core-shared` is published at the required version.
1667
- 3. Run `pnpm nx build bpm-core`.
1668
- 4. Inspect `dist/libs/bpm-core/package.json`.
1669
- 5. Confirm `dist/libs/bpm-core/README.md` is present.
1670
- 6. Confirm `src/index.ts` exports every intended public API.
1671
- 7. Publish from the built package directory:
1822
+ Releases are cut with `nx release` for all four published packages
1823
+ (`shared`, `bpm-core`, `bpm-core-client`, `bpm-core-react`). They share one
1824
+ version number. Do not run `npm publish` by hand and do not edit `version` in
1825
+ `package.json`. The full procedure, including the build steps and the order of
1826
+ the release commands, is in the repository root `CLAUDE.md` ("Releasing") and in
1827
+ [`docs/api-reference.md`](../../docs/api-reference.md) ("Publish Procedure").
1672
1828
 
1673
- ```bash
1674
- cd dist/libs/bpm-core
1675
- npm publish --access public
1676
- ```
1829
+ Check the registry after every release, because the command's exit code alone
1830
+ does not prove that every package was published.
1677
1831
 
1678
1832
  ## Production Notes
1679
1833