diff --git a/src/objects/contract.hook.ts b/src/objects/contract.hook.ts index 4301d29..40b29ab 100644 --- a/src/objects/contract.hook.ts +++ b/src/objects/contract.hook.ts @@ -49,7 +49,9 @@ import type { HookApi } from './_hook-api.js'; * a person is refused on BOTH write paths rather than hidden in the form. * `signature_state_machine` also stamps `completed_at` on BOTH write paths * (a round created as completed included); its transition table is - * update-only. + * update-only. Which status a round may be CREATED in is the declarative + * `signature_initial_status` rule on `clm_signature`; the hook's insert leg + * adds the one method-dependent half (only wet ink is created completed). * * ## Why every helper lives INSIDE its handler * @@ -958,7 +960,7 @@ const signatureStateMachine: Hook = { object: 'clm_signature', events: ['beforeInsert', 'beforeUpdate'], priority: 200, - description: 'Signature round transitions: draft → sent / completed / voided, sent → completed / declined / voided, declined → draft; wet ink may complete straight from draft. Stamp completed_at when a round is completed, on create as well as on update.', + description: 'Signature round transitions: draft → sent / completed / voided, sent → completed / declined / voided, declined → draft; wet ink may complete straight from draft, and only wet ink is created completed. Stamp completed_at when a round is completed, on create as well as on update.', handler: async (ctx: HookContext) => { function refuse(message: string, code: string, status: number): Error { const err = new Error(message) as Error & { code: string; status: number }; @@ -971,16 +973,31 @@ const signatureStateMachine: Hook = { const to = input.status; if (typeof to !== 'string') return; - // The insert leg stamps `completed_at` and nothing else. A round can be - // CREATED as completed — the wet-ink round a lawyer registers from the - // contract's signing tab, an import, a provider callback — and F7 reads - // `completed_at` from that row for the contract's `executed_at` (and the - // `signing → active` guard for `signed_at`). Before this leg the field - // stayed empty on that path, and both contract stamps fell back to the - // activation moment. The transition table below is NOT applied on - // insert: a new row has no `from`, and a provider callback may land a - // round straight as completed. + // The insert leg. Which status a round may be created in — draft, sent or + // completed, never declined or voided — is the `signature_initial_status` + // rule on `clm_signature` (signature.object.ts), declarative so that its + // refusal is translated and so that seed replay skips it (#147). The + // method half of the entry is here, because a `state_machine` rule cannot + // read a second field: like the `draft → completed` edge below, only a + // wet-ink round is created completed. An e-signature round completes + // when its envelope does, from `sent`. A system write is exempt — seed + // replay (24 e-signature rounds are seeded completed), a historical + // import, a provider callback running as system — the same exemption + // `contract_type_stamp` gives a contract born past draft. + // + // Then `completed_at`: a round created as completed (the wet-ink round a + // lawyer registers from the contract's signing tab) gets it here, and F7 + // reads it for the contract's `executed_at` (and the `signing → active` + // guard for `signed_at`). Before this stamp the field stayed empty on that + // path, and both contract stamps fell back to the activation moment. if (event === 'beforeInsert') { + if (to === 'completed' && input.method !== 'wet_ink' && ctx.session?.isSystem !== true) { + throw refuse( + 'An e-signature round is not created as completed: it completes from sent, when its envelope does. Create it as draft or sent; only a wet-ink round is created completed.', + 'INVALID_STATE', + 422, + ); + } if (to === 'completed' && (input.completed_at === undefined || input.completed_at === null || input.completed_at === '')) { input.completed_at = new Date().toISOString(); } diff --git a/src/objects/signature.object.ts b/src/objects/signature.object.ts index bbcce93..1ed83b8 100644 --- a/src/objects/signature.object.ts +++ b/src/objects/signature.object.ts @@ -13,7 +13,8 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * `execution_formalities` (stamped from the type). `status` is a state * machine (`draft` → `sent` / `completed` / `voided`, `sent` → `completed` / * `declined` / `voided`, `declined` → `draft`; wet ink may complete straight - * from draft) enforced in `contract.hook.ts`. `display_name` is a stored + * from draft) enforced in `contract.hook.ts`; which status a round may be + * CREATED in is the `signature_initial_status` rule below. `display_name` is a stored * mirror (" · ") stamped by `mirror.hook.ts`. */ export const Signature = ObjectSchema.create({ @@ -149,6 +150,61 @@ export const Signature = ObjectSchema.create({ }), }, + validations: [ + // The machine's ENTRY POINT (#147): which status a round may be created + // in. Before this rule the insert path judged nothing, so a REST POST, a + // `/api/v1/batch` create or an import could create a round already + // declined or voided. + // + // The entry set is `draft`, plus the two statuses one edge out of draft + // that a creating caller legitimately records in the same write: + // - `sent`: the e-signature dispatch (F8, hotclm#83 on the platform + // connector objectstack-ai/objectstack#25747) registers the round as it + // sends the envelope; a wet-ink counterpart sealed and posted is the + // same state (the demo seeds both). + // - `completed`: the wet-ink round a lawyer registers from the contract's + // signing tab, executed copy attached (§03: 湿签直接 completed). That it + // is wet ink is the hook's half — see `signature_state_machine`. + // `declined` and `voided` are outcomes of a round that existed; a round is + // never born in one. `draft → voided` stays an update edge (abandoning a + // draft), and an insert has nothing to abandon. + // + // A declarative `state_machine` rule rather than the hook's insert leg, + // measured on 18.1.0: + // - its message is translated per locale (`_validations` in both + // bundles); an Error thrown from a hook reaches every caller in English; + // - the engine skips a `state_machine` rule — and only that rule type — on + // seed replay and on a historical import (`treatAsHistorical`, system + // principal only), the platform's own exemption for established facts. + // The demo seed creates 24 rounds as e-signature `completed`, which no + // person may. + // + // `events: ['insert']`: this rule judges creation only. The update leg is + // the hook's, because one edge depends on the method (an e-signature round + // completes from `sent`, not from `draft`), which a `state_machine` rule + // cannot express. `transitions` is that hook's table, verbatim — the + // schema requires the key, and the platform's state introspection + // (`GET /api/v1/meta/object/clm_signature/state/status?from=…`) answers + // from it. Change one, change both. + { + type: 'state_machine' as const, + name: 'signature_initial_status', + label: 'Signature round entry status', + description: 'A round is created as draft, sent, or (wet ink) completed; never declined or voided.', + field: 'status', + events: ['insert' as const], + initialStates: ['draft', 'sent', 'completed'], + transitions: { + draft: ['sent', 'completed', 'voided'], + sent: ['completed', 'declined', 'voided'], + declined: ['draft'], + completed: [], + voided: [], + }, + message: 'A signature round is created as Draft or Sent, or as Completed for a wet-ink round that is already signed. Declined and Voided record what happened to a round that was sent: create the round first, then change its status.', + }, + ], + enable: { apiEnabled: true, searchable: true, diff --git a/src/translations/en/objects.lifecycle.ts b/src/translations/en/objects.lifecycle.ts index 02a9897..13e81cd 100644 --- a/src/translations/en/objects.lifecycle.ts +++ b/src/translations/en/objects.lifecycle.ts @@ -202,6 +202,11 @@ export const lifecycle: Record = { help: 'Filled in automatically when the round is saved as completed. For a wet-ink round signed earlier, enter the actual signing time instead.', }, }, + _validations: { + signature_initial_status: { + message: 'A signature round is created as Draft or Sent, or as Completed for a wet-ink round that is already signed. Declined and Voided record what happened to a round that was sent: create the round first, then change its status.', + }, + }, _sections: { round: { label: 'Signing Round', diff --git a/src/translations/zh-CN/objects.lifecycle.ts b/src/translations/zh-CN/objects.lifecycle.ts index 96c533c..5295429 100644 --- a/src/translations/zh-CN/objects.lifecycle.ts +++ b/src/translations/zh-CN/objects.lifecycle.ts @@ -202,6 +202,11 @@ export const lifecycle: Record = { help: '本轮保存为「已完成」时自动填入。湿签如果实际签署得更早,请填写实际签署时间。', }, }, + _validations: { + signature_initial_status: { + message: '签署记录只能以「草稿」或「已发出」状态新建;已经签好的湿签轮次可以直接以「已完成」新建。「已拒签」和「已作废」记录的是已发出轮次的结果:请先新建这一轮,再修改它的状态。', + }, + }, _sections: { round: { label: '签署轮次',