Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 28 additions & 11 deletions src/objects/contract.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -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 };
Expand All @@ -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();
}
Expand Down
58 changes: 57 additions & 1 deletion src/objects/signature.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ("<method> · <status>") stamped by `mirror.hook.ts`.
*/
export const Signature = ObjectSchema.create({
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions src/translations/en/objects.lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,11 @@ export const lifecycle: Record<string, ObjectTranslationData> = {
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',
Expand Down
5 changes: 5 additions & 0 deletions src/translations/zh-CN/objects.lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,11 @@ export const lifecycle: Record<string, ObjectTranslationData> = {
help: '本轮保存为「已完成」时自动填入。湿签如果实际签署得更早,请填写实际签署时间。',
},
},
_validations: {
signature_initial_status: {
message: '签署记录只能以「草稿」或「已发出」状态新建;已经签好的湿签轮次可以直接以「已完成」新建。「已拒签」和「已作废」记录的是已发出轮次的结果:请先新建这一轮,再修改它的状态。',
},
},
_sections: {
round: {
label: '签署轮次',
Expand Down
Loading