Skip to content

A signature round can be created already declined or voided — the round's state machine judges updates only - #150

Merged
zhuangjianguo merged 1 commit into
mainfrom
claude/issue-147-signature-insert-state
Oct 26, 2026
Merged

zhuangjianguo merged 1 commit into
mainfrom
claude/issue-147-signature-insert-state

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #147

Clause-②: no
Metadata-protocol: none

A signature round could be created in any status. As Legal Counsel 1 on 9248c18, a REST POST, a /api/v1/batch create and an import each accepted a round born declined, voided, or completed by e-signature (table below). The round's state machine judged updates only. This PR adds the insert check.

The entry set, and how it was derived

A round may be created as draft, sent, or completed when the method is wet ink. Everything else is refused.

What changed (4 files, metadata only)

  1. signature_initial_status, a declarative state_machine rule on clm_signature (src/objects/signature.object.ts): events: ['insert'], initialStates: ['draft', 'sent', 'completed']. Its message is in both bundles under _validations in src/translations/{en,zh-CN}/objects.lifecycle.ts.
  2. The method half goes in the signature_state_machine hook's insert leg (src/objects/contract.hook.ts). An e-signature round is not created completed. System writes are exempt (ctx.session.isSystem), which is the same exemption contract_type_stamp gives a contract created past draft. The completed_at stamp from The signature-round form asks a lawyer to type JSON for signers, and "Completed At" promises a stamp the round never gets #145 is unchanged.

Why a declarative rule, measured on 18.1.0

  • Its message is translated. authoredRuleMessage reads _validations.signature_initial_status.message for the caller's locale. An Error thrown from a hook has no translation seam (A write can make a department the signing entity — our_entity's company-only filter shapes the picker, not the write #125 measured the same), so a hook refusal reaches every caller in English.
  • Seed replay and historical import skip it. shouldSkipStateMachine is ctx.seedReplay || ctx.skipStateMachine, and it skips only the state_machine rule type. This is the platform's own exemption for established facts (ExecutionContext.seedReplay; treatAsHistorical import, system principal only). The seed's 24 e-signature completed rounds therefore still load. A script rule is not skipped on seed replay, so it would have refused them.
  • transitions repeats the hook's table exactly. The schema requires the key, and GET /api/v1/meta/object/clm_signature/state/status?from=sent answers from it: measured next: ["completed","declined","voided"]. With events: ['insert'] the engine judges no update with it. The update leg stays in the hook, because one of its edges depends on the method. The comment says to change both together.

Why the method half is a hook, and what that costs

A state_machine rule reads one field, so completed only when wet_ink cannot be written in initialStates. A conditional that wraps a state_machine branch is not skipped on seed replay: the skip filters top-level rule types only. The same is true of a script rule. Either one would refuse the 24 seeded e-signature completed rounds. Only the hook can tell a system write apart on 18.1.0. The cost: this one refusal is English for a zh-CN caller, exactly like the existing update refusal for the same guard ("An e-signature round completes from sent, not from draft…"). The open question below asks how to close it.

Evidence: real boots on 18.1.0, pnpm demo --port 3147, clean .objectstack/data each

Seed. Both boots, before 9248c18 and after ce42ca5, gave {"inserted":830,"updated":0,"skipped":0,"errored":404,"referencesDropped":0}. clm_signature read 30 rows on both: esign/completed 24 · esign/sent 4 · wet_ink/sent 2. The 404 errors are the same unresolved persona names the README describes.

Boot warnings. I de-duplicated the WARN/ERROR/⚠/✗ signatures (timestamps and ids normalised): 425 before, 423 after. Nothing appears only after. The two lines only before has came from my own probes (a sign-in before the persona existed, and one malformed sys_user_position write), not from the boot. degraded, no such table and failed to load match 0 lines.

Re-running pnpm demo on the same after database (the seed's upsert replay): {"inserted":0,"updated":74,"skipped":755,"errored":332}. No line names clm_signature, the rule or the hook message.

Persona: Legal Counsel 1, created the README way. As the dev admin: create-user → 200, then sys_user_position {position: clm_legal_counsel} → 201. The account must change its temporary password: change-password → 200, then sign-in → 200. Probe contract: DPA-2026-0002 (signing).

Door (Legal Counsel 1) Born as before 9248c18 after ce42ca5
REST POST esign draft / status omitted 201 / 201 201 / 201 (draft)
REST POST esign sent / wet_ink sent 201 / 201 201 / 201
REST POST wet_ink completed 201, completed_at stamped 201, completed_at stamped
REST POST esign declined / wet_ink declined 201 / 201 400 VALIDATION_FAILED, fields[0] = {field: status, code: invalid_initial_state, constraint: {allowed: "draft, sent, completed"}}
REST POST esign voided / wet_ink voided 201 / 201 400, same envelope
REST POST, accept-language: zh-CN declined / voided 201 / 201 400, 签署记录只能以「草稿」或「已发出」状态新建;……
REST POST esign completed (en and zh-CN) 201 / 201 422 INVALID_STATE, "An e-signature round is not created as completed: …" (English on both)
POST /api/v1/batch create declined (en) / voided (zh-CN) / draft 200 / 200 / 200 400 / 400, zh message on the zh-CN call / 200
POST /api/v1/batch create esign completed n/a 422 INVALID_STATE
Import (format: json, insert) esign declined, wet_ink voided, esign draft ok=3 created=3 ok=1 errors=2, each failed row code: invalid_initial_state, field: status
Import, zh-CN esign voided ok=1 ok=0 errors=1, the zh message
Import esign completed, wet_ink completed, esign sent n/a ok=2 errors=1, row 1 INVALID_STATE
REST PATCH, the update leg draft→sent→declined→draft→voided 200 ×4 200 ×4

Existing rows are not touched. These ran as Legal Counsel 1 on a seeded e-signature sent round on SUP-2026-0011:

  • PATCH {envelope_id} → 200.
  • sent → declined → 200, then declined → draft → 200.
  • draft → completed → 422, the unchanged update-leg message.
  • draft → sent → 200.

The rule is insert-only, so a row stored in any status is never judged by it.

Browser (Chromium /opt/pw-browsers/chromium, 1440x900, signed in as Legal Counsel 1):

  • en, NDA-2026-0005 › Signing & Archive › New, E-signature + Declined → Create. Wire: POST /api/v1/data/clm_signature {"method":"esign","status":"declined",…} → 400 VALIDATION_FAILED. The dialog stays open and the message appears under Status. The toast reads "Please check the highlighted fields: Status".
  • zh-CN, NDA-2026-0007, 电子签 + 已拒签 → 创建. → 400. The message appears under 状态: 「签署记录只能以「草稿」或「已发出」状态新建;……」. Toast: 「请检查表单中标记的字段:状态」.
  • zh-CN, 电子签 + 已完成 → 创建. → 422. The dialog stays open, and the hook's message shows in English as a toast and inside the dialog (the cost named above).
  • en, NDA-2026-0005, Wet ink + Completed + executed copy (PDF upload), Completed At left empty → Create. Wire: POST /api/v1/data/clm_signature {"method":"wet_ink","status":"completed","executed_file":"…"} → 201. Toast: "Signature created successfully".
    • API read-back: completed_at 2026-10-26T14:47:32.586Z.
    • F7's on-create twin ran: a final_signed v1 version exists, holding the uploaded file.
    • Then as Records Manager 1 (created the same way, clm_records_manager): POST /api/v1/actions/clm_contract/activate_contract → 200. The contract reads status active, executed_at = signed_at = 2026-10-26T14:47:32.586Z (the round's completion), and activated_at 14:47:45.545Z.
  • Browser console: the pre-login 404 and 401 and the pre-existing record.status header-action predicate warnings (recorded on clm_contract.department is a fixed option list — make it a lookup to the platform's sys_business_unit, as #20 ruled for our_entity #128), plus the 400 / 422 of the refused POSTs themselves. Nothing else.

NOT MEASURED

  • Editing a seeded e-signature completed round. Those rounds hang off active contracts. As the dev admin, PATCH clm_signature/ID answered 403, and per the persona rule no other route was taken. The rule is events: ['insert'], so by construction it never judges an update.
  • Historical import (treatAsHistorical). It is system principal only, and no signed-in persona can send it. Per the engine source it sets skipStateMachine, and its writes are system writes.
  • A runAs: system flow creating rounds. No such flow exists until F8 (Card 12 — F8 e-signature connector · F15 HotCRM hand-off behind CLM_COMPOSITION (M4) #83).

Gates (head ce42ca5, each exit captured to a file before any pipe)

pnpm install                    exit 0   Done in 742ms
pnpm validate                   exit 0   ✓ Validation passed (1068ms)
pnpm lint                       exit 0   6 suggestion(s), 0 warnings (the six contract_approval approval-approvers-may-resolve-empty notes)
pnpm typecheck                  exit 0   tsc --noEmit (no output)
pnpm lint:i18n-gate             exit 0   ✓ i18n gate · COVERAGE : 0 missing keys across 2 locale(s)
CLM_DEMO_SEED=1 pnpm validate   exit 0   ✓ Validation passed (968ms)

The i18n gate does not walk validation messages, so the zh-CN message was proven at runtime: REST POST, batch, import and the browser form each return the zh text to a zh-CN caller.

Acceptance notes

  • Open question: the e-signature-completed refusal is English for a zh-CN caller. Three options:

    • (A) Keep it as shipped: one hook refusal, English, the same as the existing update refusal for the same guard. Ask the platform for a translation seam for hook refusals, or for a CEL rule that seed replay skips.
    • (B) Drop the hook half: e-signature completed becomes a legal birth for a person, which makes the update leg's draft → completed guard bypassable through an insert.
    • (C) Express it as a script rule (translated): it would refuse the 24 seeded rounds, so it needs the seed to change, which §10 does not allow.

    Recommendation: A, because it keeps §03's method guard on every door, and the language gap is the platform's, the same gap A write can make a department the signing entity — our_entity's company-only filter shapes the picker, not the write #125 recorded.

  • The create form still offers all five statuses (Draft, Sent, Completed, Declined, Voided). On 18.1.0 the Console's status picker does not read initialStates. The write refuses, and the message lands under the field.

  • Two copies of the transition table now exist: the hook's (enforcing updates) and the rule's (introspection only). The rule's comment names the hook's table as its source and says to change both; the hook's header names the rule.

  • The F7 flow header (src/flows/signature-record.flow.ts) still says signature_state_machine "guards beforeUpdate only". That has not been true since The signature-round form asks a lawyer to type JSON for signers, and "Completed At" promises a stamp the round never gets #145. It is not in this card's file surface, so it is left unchanged.

  • No data was rewritten. The probe personas and rows were written to throwaway local databases only.

🤖 Generated with Claude Code

https://claude.ai/code/session_017nzWFRUo2U7evMkPdVniAn

An insert into clm_signature judged nothing: a REST POST, a batch create
or an import could create a round already declined, voided, or completed
by e-signature without passing through sent.

The entry set is draft, sent, and completed for wet ink. It is a
declarative state_machine rule (signature_initial_status, insert only)
so its refusal is translated in both bundles and seed replay / a
historical import skip it, plus the one method-dependent half in the
signature_state_machine hook's insert leg: an e-signature round is not
created completed, system writes exempt (the demo seeds 24 such rounds).
Existing rows and the update leg are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nzWFRUo2U7evMkPdVniAn
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 — ACCEPT · head ce42ca5 · 2026-10-26T14:55Z

check reading
shape draft → main, Fixes #147; assignee zhuangjianguo; mergeable clean
CI on the head both Validate check-runs success on ce42ca5 = PR head.sha
scope 4 files (+95/−12), the claim's surface: a declarative state_machine validation on clm_signature (insert only, initialStates draft / sent / completed) with its message in both bundles, and the hook's insert leg refusing an e-signature round created as completed (system writes exempt, as contract_type_stamp). Nothing governed; lint stays at 0 warnings
the card a round born declined or voided is refused on REST, batch and import, in the caller's locale; draft, sent and the wet-ink completed round still save; the wet-ink path PR #145 measured (form → completed_at → F7 final version → activation) re-measured as Legal Counsel 1 and Records Manager 1; seed (30 rounds) and its replay untouched — the engine skips this rule type on seed replay
entry-state assumption the e-signature connector (#83) creates its round as sent and completes it by update — recorded in the PR, to be re-checked when F8 is built
seat call on the open question A: keep the hook half. Its English-only message under zh-CN is the platform's missing translation seam for hook refusals, already open upstream as objectstack-ai/objectstack#26170
not in this PR the create form's status picker still offers all five statuses (platform renderer, observation); signature-record.flow.ts's header comment says the machine guards updates only — stale since #145, comment-only, left for the next edit of that file

Landing: under the maintainer's merge authorization (project thread: 「你可以自己审核的就自己合并」).


Generated by Claude Code

@zhuangjianguo
zhuangjianguo marked this pull request as ready for review October 26, 2026 14:55
@zhuangjianguo
zhuangjianguo merged commit eace613 into main Oct 26, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A signature round can be created already declined or voided — the round's state machine judges updates only

2 participants