From 4424475a8c8c7f6de9e688b2455f79159c20c039 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 14:28:38 +0000 Subject: [PATCH 1/4] feat(objects): route hook, onward hop and deviation gate (F2, F6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `contract_route` (priority 150, runAs system) evaluates the active approval rules on the way into `submitted` and into `in_approval` and stamps the UNION of their route_* flags, deriving route_legal_head from accepted deviations on clauses that require the head of legal as well; on submission it assigns legal_owner to the legal counsel with the fewest open contracts. The state machine now takes the onward hop of §06 F2 in the same write — draft → submitted continues to in_review (a legal owner was assigned) or to in_approval (no legal review) — as a second iteration over the same table check and guard blocks, not a second copy of them. The open-deviation gate (F6) now guards both edges into in_approval; `deviation_gate` (afterUpdate on clm_deviation, runAs system) stamps route_legal_head on the parent when an accepted deviation's clause requires the head of legal. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcrVDXSptwDukFsHPHPR1V --- src/objects/contract.hook.ts | 470 +++++++++++++++++++++++++++-------- 1 file changed, 364 insertions(+), 106 deletions(-) diff --git a/src/objects/contract.hook.ts b/src/objects/contract.hook.ts index 71dd923..1513f56 100644 --- a/src/objects/contract.hook.ts +++ b/src/objects/contract.hook.ts @@ -11,9 +11,24 @@ import type { HookApi } from './_hook-api.js'; * sequence per type per year, next = max existing + 1 within the * organization (§13 Q5). Changing the type is allowed only on a draft and * re-stamps all four. + * - `contract_route` (beforeUpdate, F2 of §06): on the way into `submitted` + * and into `in_approval`, evaluates the approval matrix and stamps the UNION + * of the `route_*` flags of every matching rule, and — when the type + * requires legal review — assigns `legal_owner` to the legal counsel with + * the fewest open contracts. Runs at priority 150, between the type stamp + * and the state machine, so the machine's onward hop can read what it set. * - `contract_state_machine` (beforeUpdate on `status`): the transition table * and every guard of §03 状态机, refusing with a structured error rather - * than coercing, and stamping the stage timestamp on each entry. + * than coercing, and stamping the stage timestamp on each entry. Since F2 + * the machine also takes the AUTOMATIC onward hop of §06: `draft → + * submitted` continues to `in_review` (a legal owner was assigned) or to + * `in_approval` (the type needs no legal review) in the same write, and the + * hop is validated by the same guard block a hand-made transition meets. + * - `deviation_gate` (afterUpdate on `clm_deviation`, F6 of §06): an accepted + * deviation from a clause that `requires_legal_head` stamps + * `route_legal_head` on the parent contract. The other half of F6 — no + * `open` deviation may enter `in_approval` — is the state machine's guard, + * applied on BOTH edges into `in_approval`. * - `deviation_state_machine`, `signature_state_machine`, * `obligation_state_machine`, `payment_plan_state_machine`: the child * machines. The last two also hold the `overdue` reservation — that state @@ -134,12 +149,21 @@ const contractTypeStamp: Hook = { }, }; -const contractStateMachine: Hook = { - name: 'contract_state_machine', +const contractRoute: Hook = { + name: 'contract_route', object: 'clm_contract', events: ['beforeUpdate'], - priority: 200, - description: 'Enforce the contract status transition table and its guards; stamp the stage timestamp on each entry.', + priority: 150, + // System context, measured as REQUIRED rather than convenient: the two + // reads this hook makes are outside a requester's reach by construction. + // `sys_user_position` is a platform object a requester cannot list, and the + // per-counsel open-contract tally counts OTHER people's contracts on a + // `private` object — under an inherited context every counsel would tally + // 0 and the "fewest open contracts" rule would degrade to "first in the + // list". `runAs` elevates `ctx.api` only; `ctx.session` still describes + // the caller. + runAs: 'system', + description: 'F2: on entering submitted (and again on entering in_approval), stamp the union of route_* from every matching approval rule; on submission assign legal_owner to the legal counsel with the fewest open contracts when the type requires legal review.', handler: async (ctx: HookContext) => { function refuse(message: string, code: string, status: number): Error { const err = new Error(message) as Error & { code: string; status: number }; @@ -153,6 +177,172 @@ const contractStateMachine: Hook = { if (typeof to !== 'string') return; const from = typeof previous.status === 'string' ? previous.status : 'draft'; if (to === from) return; + const submitting = from === 'draft' && to === 'submitted'; + const enteringApproval = to === 'in_approval' && (from === 'submitted' || from === 'in_review'); + if (!submitting && !enteringApproval) return; + + const api = ctx.api as HookApi | undefined; + if (!api) throw refuse('The data API is not available to the contract routing hook.', 'INTERNAL_ERROR', 500); + const id = typeof previous.id === 'string' ? previous.id : ''; + const get = (key: string): unknown => (input[key] !== undefined ? input[key] : previous[key]); + const isSet = (value: unknown): boolean => + !(value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0)); + const organizationId = [ + ctx.user?.organizationId, + ctx.session?.organizationId, + input.organization_id, + previous.organization_id, + ].find((candidate): candidate is string => typeof candidate === 'string' && candidate !== ''); + const inOrganization = (row: Record): boolean => + !organizationId || row.organization_id === undefined || row.organization_id === null || row.organization_id === '' || row.organization_id === organizationId; + + // The category and direction are card 02's stamps (contract_type_stamp, + // priority 100, on insert and on a draft's type change). They are READ + // here, never re-derived: a contract without them is a contract that + // never went through the stamp, and the fix is to set its type on the + // draft, not a second copy of the stamping. + const category = get('category'); + const direction = get('direction'); + if (typeof category !== 'string' || !category) { + throw refuse('The contract carries no category stamp, so the approval matrix cannot be evaluated; set its contract type while it is a draft.', 'INVALID_STATE', 422); + } + const rawAmount = get('amount'); + // A contract with no amount (an NDA, a framework without a value) sits in + // the lowest band: an absent amount is 0, not "unbounded". + const amount = typeof rawAmount === 'number' && Number.isFinite(rawAmount) ? rawAmount : Number(rawAmount) || 0; + + // Accepted deviations decide two things: the `only_with_deviation` match + // and, through the clause's `requires_legal_head`, the legal-head rung + // (F6, derived here so a resubmission never loses the stamp). + const accepted = await api.object('clm_deviation').find({ + where: { contract: id, status: 'accepted' }, + fields: ['id', 'clause'], + top: 500, + }); + const clauseIds = [...new Set(accepted.map((row) => row.clause).filter((c): c is string => typeof c === 'string' && c !== ''))]; + let deviationNeedsLegalHead = false; + if (clauseIds.length > 0) { + const clauses = await api.object('clm_clause').find({ + where: { id: { $in: clauseIds } }, + fields: ['id', 'requires_legal_head'], + top: 500, + }); + deviationNeedsLegalHead = clauses.some((clause) => clause.requires_legal_head === true); + } + const hasAcceptedDeviation = accepted.length > 0; + + // The matrix: every ACTIVE rule that matches contributes its rungs, and + // the contract climbs the union (clm_approval_rule.priority's own words). + // A rule matches when: its categories are empty or contain the contract's; + // its direction is empty / `any` or equals the contract's; the amount is + // inside [amount_min, amount_max) with an absent bound meaning no bound; + // and, if it is `only_with_deviation`, the contract carries an accepted + // deviation. + const rules = await api.object('clm_approval_rule').find({ + where: organizationId ? { is_active: true, organization_id: organizationId } : { is_active: true }, + fields: ['id', 'name', 'applies_to', 'direction', 'amount_min', 'amount_max', 'only_with_deviation', 'route_legal_head', 'route_finance', 'route_executive', 'route_gm'], + top: 1000, + }); + const flags = { route_legal_head: deviationNeedsLegalHead, route_finance: false, route_executive: false, route_gm: false }; + for (const rule of rules) { + const appliesTo = Array.isArray(rule.applies_to) ? rule.applies_to : []; + if (appliesTo.length > 0 && !appliesTo.includes(category)) continue; + const ruleDirection = typeof rule.direction === 'string' ? rule.direction : 'any'; + if (ruleDirection !== 'any' && ruleDirection !== '' && ruleDirection !== direction) continue; + const min = typeof rule.amount_min === 'number' ? rule.amount_min : rule.amount_min === null || rule.amount_min === undefined || rule.amount_min === '' ? null : Number(rule.amount_min); + const max = typeof rule.amount_max === 'number' ? rule.amount_max : rule.amount_max === null || rule.amount_max === undefined || rule.amount_max === '' ? null : Number(rule.amount_max); + if (min !== null && Number.isFinite(min) && amount < min) continue; + if (max !== null && Number.isFinite(max) && amount >= max) continue; + if (rule.only_with_deviation === true && !hasAcceptedDeviation) continue; + if (rule.route_legal_head === true) flags.route_legal_head = true; + if (rule.route_finance === true) flags.route_finance = true; + if (rule.route_executive === true) flags.route_executive = true; + if (rule.route_gm === true) flags.route_gm = true; + } + // The stamp IS the evaluation: all four flags are written, so a flag that + // stopped matching (the amount was lowered before resubmission) clears, + // and the record never carries a rung nothing routes it to. + input.route_legal_head = flags.route_legal_head; + input.route_finance = flags.route_finance; + input.route_executive = flags.route_executive; + input.route_gm = flags.route_gm; + + if (!submitting) return; + + // Legal owner: the legal counsel with the fewest open contracts, if the + // type requires legal review and nobody assigned one by hand. Holders of + // `clm_legal_counsel` are `sys_user_position` rows (position is the + // position NAME there), within their validity window and this + // organization. With no holder the contract stays `submitted` — the legal + // queue of §05 ("待受理: submitted 且未分配") accepts it by hand. + const typeId = get('contract_type'); + const type = isSet(typeId) + ? await api.object('clm_contract_type').findOne({ where: { id: typeId }, fields: ['id', 'requires_legal_review'] }) + : null; + if (!type) return; // the state machine refuses a contract without a type + if (type.requires_legal_review === false) return; + if (isSet(get('legal_owner'))) return; + + const nowMs = Date.now(); + const assignments = await api.object('sys_user_position').find({ + where: { position: 'clm_legal_counsel' }, + fields: ['id', 'user_id', 'organization_id', 'valid_from', 'valid_until'], + top: 1000, + }); + const holders = [...new Set(assignments + .filter((row) => inOrganization(row)) + .filter((row) => { + const fromMs = typeof row.valid_from === 'string' && row.valid_from ? Date.parse(row.valid_from) : row.valid_from instanceof Date ? row.valid_from.getTime() : NaN; + const untilMs = typeof row.valid_until === 'string' && row.valid_until ? Date.parse(row.valid_until) : row.valid_until instanceof Date ? row.valid_until.getTime() : NaN; + if (Number.isFinite(fromMs) && fromMs > nowMs) return false; + if (Number.isFinite(untilMs) && untilMs <= nowMs) return false; + return true; + }) + .map((row) => row.user_id) + .filter((user): user is string => typeof user === 'string' && user !== ''))]; + if (holders.length === 0) return; + + // "Open" = still moving toward execution; a closed or active contract is + // no longer on the lawyer's desk. + const OPEN_STATUSES = ['submitted', 'in_review', 'in_approval', 'approved', 'signing']; + const open = await api.object('clm_contract').find({ + where: organizationId + ? { organization_id: organizationId, legal_owner: { $in: holders }, status: { $in: OPEN_STATUSES } } + : { legal_owner: { $in: holders }, status: { $in: OPEN_STATUSES } }, + fields: ['id', 'legal_owner'], + top: 10000, + }); + const load = new Map(holders.map((user) => [user, 0])); + for (const row of open) { + const owner = typeof row.legal_owner === 'string' ? row.legal_owner : ''; + if (load.has(owner)) load.set(owner, (load.get(owner) ?? 0) + 1); + } + // Fewest open contracts wins; a tie breaks on the user id so the choice is + // deterministic and re-runnable. + const [chosen] = [...load.entries()].sort((a, b) => a[1] - b[1] || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))[0]; + input.legal_owner = chosen; + }, +}; + +const contractStateMachine: Hook = { + name: 'contract_state_machine', + object: 'clm_contract', + events: ['beforeUpdate'], + priority: 200, + description: 'Enforce the contract status transition table and its guards; stamp the stage timestamp on each entry; take the automatic onward hop out of submitted (F2).', + handler: async (ctx: HookContext) => { + function refuse(message: string, code: string, status: number): Error { + const err = new Error(message) as Error & { code: string; status: number }; + err.code = code; + err.status = status; + return err; + } + const { input } = ctx; + const previous = ctx.previous ?? {}; + const requested = input.status; + if (typeof requested !== 'string') return; + let from = typeof previous.status === 'string' ? previous.status : 'draft'; + if (requested === from) return; // DESIGN.md §03 状态机 — from → allowed targets. expired, terminated and // cancelled are terminal; renewal and amendment are new contracts. @@ -169,16 +359,6 @@ const contractStateMachine: Hook = { terminated: [], cancelled: [], }; - const allowed = TRANSITIONS[from] ?? []; - if (!allowed.includes(to)) { - throw refuse( - allowed.length === 0 - ? `A ${from} contract is closed; its status cannot change to ${to}. Start a renewal or an amendment instead.` - : `Contract status cannot go from ${from} to ${to}. Allowed from ${from}: ${allowed.join(', ')}.`, - 'INVALID_STATE', - 422, - ); - } const api = ctx.api as HookApi | undefined; if (!api) throw refuse('The data API is not available to the contract state machine.', 'INTERNAL_ERROR', 500); @@ -188,7 +368,9 @@ const contractStateMachine: Hook = { !(value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0)); const now = new Date().toISOString(); + let cachedType: Record | null = null; async function loadType(): Promise> { + if (cachedType) return cachedType; const typeId = get('contract_type'); const type = isSet(typeId) ? await api!.object('clm_contract_type').findOne({ @@ -197,118 +379,161 @@ const contractStateMachine: Hook = { }) : null; if (!type) throw refuse('The contract has no contract type; one is required to move it forward.', 'INVALID_STATE', 422); + cachedType = type; return type; } - if (from === 'draft' && to === 'submitted') { - const type = await loadType(); - const partyId = get('party'); - if (!isSet(partyId)) throw refuse('A counterparty is required before submission.', 'INVALID_STATE', 422); - const party = await api.object('clm_party').findOne({ where: { id: partyId }, fields: ['id', 'name', 'risk_flag'] }); - if (!party) throw refuse(`Counterparty ${String(partyId)} does not exist.`, 'INVALID_REFERENCE', 422); - if (party.risk_flag === 'blocked') { - throw refuse(`Counterparty ${String(party.name ?? partyId)} is blocked; a contract with a blocked party cannot be submitted.`, 'INVALID_STATE', 422); - } - const intake = Array.isArray(type.intake_fields) ? type.intake_fields : []; - const missing = intake.filter((field): field is string => typeof field === 'string' && !isSet(get(field))); - if (missing.length > 0) { - throw refuse(`Intake fields required by the contract type are missing: ${missing.join(', ')}.`, 'INVALID_STATE', 422); - } - const versions = await api.object('clm_contract_version').count({ where: { contract: id } }); - if (versions === 0 && !isSet(type.template_file)) { - throw refuse('Upload a first version, or choose a contract type that carries a template, before submitting.', 'INVALID_STATE', 422); + // One write may carry MORE than one transition: `draft → submitted` + // continues to `in_review` or `in_approval` in the same write (§06 F2). + // Each hop goes through the SAME table check and the SAME guard blocks a + // hand-made transition meets — the onward hop is a second iteration, not + // a second copy of the guards. Two hops is the ceiling the table allows. + let to = requested; + for (let hop = 0; ; hop += 1) { + if (hop > 2) throw refuse(`The contract status hopped more than twice in one write (${from} → ${to}); refusing to loop.`, 'INTERNAL_ERROR', 500); + let onward: string | null = null; + + const allowed = TRANSITIONS[from] ?? []; + if (!allowed.includes(to)) { + throw refuse( + allowed.length === 0 + ? `A ${from} contract is closed; its status cannot change to ${to}. Start a renewal or an amendment instead.` + : `Contract status cannot go from ${from} to ${to}. Allowed from ${from}: ${allowed.join(', ')}.`, + 'INVALID_STATE', + 422, + ); } - input.submitted_at = now; - } - if (from === 'submitted' && (to === 'in_review' || to === 'in_approval')) { - const type = await loadType(); - // The field defaults to true; an unset value reads as the default. - const requiresLegalReview = type.requires_legal_review !== false; - if (to === 'in_review') { - if (!requiresLegalReview) { - throw refuse('This contract type does not require legal review; a submitted contract of this type goes straight to in_approval.', 'INVALID_STATE', 422); + if (from === 'draft' && to === 'submitted') { + const type = await loadType(); + const partyId = get('party'); + if (!isSet(partyId)) throw refuse('A counterparty is required before submission.', 'INVALID_STATE', 422); + const party = await api.object('clm_party').findOne({ where: { id: partyId }, fields: ['id', 'name', 'risk_flag'] }); + if (!party) throw refuse(`Counterparty ${String(partyId)} does not exist.`, 'INVALID_REFERENCE', 422); + if (party.risk_flag === 'blocked') { + throw refuse(`Counterparty ${String(party.name ?? partyId)} is blocked; a contract with a blocked party cannot be submitted.`, 'INVALID_STATE', 422); } - if (!isSet(get('legal_owner'))) { - throw refuse('Assign a legal owner before the contract enters review.', 'INVALID_STATE', 422); + const intake = Array.isArray(type.intake_fields) ? type.intake_fields : []; + const missing = intake.filter((field): field is string => typeof field === 'string' && !isSet(get(field))); + if (missing.length > 0) { + throw refuse(`Intake fields required by the contract type are missing: ${missing.join(', ')}.`, 'INVALID_STATE', 422); } - input.review_started_at = now; - } else if (requiresLegalReview) { - throw refuse('This contract type requires legal review; the next state after submitted is in_review, not in_approval.', 'INVALID_STATE', 422); + const versions = await api.object('clm_contract_version').count({ where: { contract: id } }); + if (versions === 0 && !isSet(type.template_file)) { + throw refuse('Upload a first version, or choose a contract type that carries a template, before submitting.', 'INVALID_STATE', 422); + } + input.submitted_at = now; + // F2's onward hop. `contract_route` (priority 150) has already stamped + // the `route_*` union and, when the type requires legal review, assigned + // `legal_owner` to the least-loaded legal counsel. No legal review → + // straight to approval; a legal owner in hand → into review; a type + // that wants legal review but no counsel could be assigned → the + // contract stays `submitted` for the legal queue to accept by hand. + if (type.requires_legal_review === false) onward = 'in_approval'; + else if (isSet(get('legal_owner'))) onward = 'in_review'; } - } - if (from === 'in_review' && to === 'in_approval') { - const openDeviations = await api.object('clm_deviation').count({ where: { contract: id, status: 'open' } }); - if (openDeviations > 0) { - throw refuse(`${openDeviations} deviation(s) are still open; decide each one before the contract enters approval.`, 'INVALID_STATE', 422); + if (from === 'submitted' && (to === 'in_review' || to === 'in_approval')) { + const type = await loadType(); + // The field defaults to true; an unset value reads as the default. + const requiresLegalReview = type.requires_legal_review !== false; + if (to === 'in_review') { + if (!requiresLegalReview) { + throw refuse('This contract type does not require legal review; a submitted contract of this type goes straight to in_approval.', 'INVALID_STATE', 422); + } + if (!isSet(get('legal_owner'))) { + throw refuse('Assign a legal owner before the contract enters review.', 'INVALID_STATE', 422); + } + input.review_started_at = now; + } else if (requiresLegalReview) { + throw refuse('This contract type requires legal review; the next state after submitted is in_review, not in_approval.', 'INVALID_STATE', 422); + } } - const legalApprovals = await api.object('clm_review').count({ where: { contract: id, stage: 'legal', decision: 'approved' } }); - if (legalApprovals === 0) { - throw refuse('An approved legal review is required before the contract enters approval.', 'INVALID_STATE', 422); + + // F6, first half (DESIGN.md §06 deviation_gate): no `open` deviation may + // enter approval. Guarded on EVERY edge into `in_approval` — from + // `in_review` (card 02) and from `submitted` (a type that skips legal + // review), since a deviation can be recorded on a draft too. + if (to === 'in_approval') { + const openDeviations = await api.object('clm_deviation').count({ where: { contract: id, status: 'open' } }); + if (openDeviations > 0) { + throw refuse(`${openDeviations} deviation(s) are still open; decide each one before the contract enters approval.`, 'INVALID_STATE', 422); + } } - } - if (from === 'in_review' && to === 'draft') { - const sendBacks = await api.object('clm_review').count({ - where: { contract: id, decision: { $in: ['changes_requested', 'rejected'] } }, - }); - if (sendBacks === 0) { - throw refuse('Returning a contract from review to draft needs a review with the decision changes_requested (or rejected) recorded.', 'INVALID_STATE', 422); + if (from === 'in_review' && to === 'in_approval') { + const legalApprovals = await api.object('clm_review').count({ where: { contract: id, stage: 'legal', decision: 'approved' } }); + if (legalApprovals === 0) { + throw refuse('An approved legal review is required before the contract enters approval.', 'INVALID_STATE', 422); + } } - } - if (from === 'in_approval' && to === 'approved') { - input.approved_at = now; - } + if (from === 'in_review' && to === 'draft') { + const sendBacks = await api.object('clm_review').count({ + where: { contract: id, decision: { $in: ['changes_requested', 'rejected'] } }, + }); + if (sendBacks === 0) { + throw refuse('Returning a contract from review to draft needs a review with the decision changes_requested (or rejected) recorded.', 'INVALID_STATE', 422); + } + } - if (from === 'approved' && to === 'signing') { - const cleanVersions = await api.object('clm_contract_version').count({ - where: { contract: id, kind: 'clean', is_current: true }, - }); - if (cleanVersions === 0) { - throw refuse('A current clean version is required before signing.', 'INVALID_STATE', 422); + if (from === 'in_approval' && to === 'approved') { + input.approved_at = now; } - } - if (from === 'signing' && to === 'active') { - const required = Array.isArray(get('execution_formalities')) - ? (get('execution_formalities') as unknown[]).filter((f): f is string => typeof f === 'string') - : []; - const completed = await api.object('clm_signature').find({ - where: { contract: id, status: 'completed' }, - fields: ['id', 'formalities_done', 'completed_at'], - top: 50, - }); - const executed = completed.find((signature) => { - const done = Array.isArray(signature.formalities_done) ? signature.formalities_done : []; - return required.every((formality) => done.includes(formality)); - }); - if (!executed) { - throw refuse( - completed.length === 0 - ? 'A completed signature round is required before activation.' - : `A completed signature round must record every execution formality the contract type requires (${required.join(', ')}) before activation.`, - 'INVALID_STATE', - 422, - ); + if (from === 'approved' && to === 'signing') { + const cleanVersions = await api.object('clm_contract_version').count({ + where: { contract: id, kind: 'clean', is_current: true }, + }); + if (cleanVersions === 0) { + throw refuse('A current clean version is required before signing.', 'INVALID_STATE', 422); + } } - const finalVersions = await api.object('clm_contract_version').count({ where: { contract: id, kind: 'final_signed' } }); - if (finalVersions === 0) { - throw refuse('A final_signed version is required before activation.', 'INVALID_STATE', 422); + + if (from === 'signing' && to === 'active') { + const required = Array.isArray(get('execution_formalities')) + ? (get('execution_formalities') as unknown[]).filter((f): f is string => typeof f === 'string') + : []; + const completed = await api.object('clm_signature').find({ + where: { contract: id, status: 'completed' }, + fields: ['id', 'formalities_done', 'completed_at'], + top: 50, + }); + const executed = completed.find((signature) => { + const done = Array.isArray(signature.formalities_done) ? signature.formalities_done : []; + return required.every((formality) => done.includes(formality)); + }); + if (!executed) { + throw refuse( + completed.length === 0 + ? 'A completed signature round is required before activation.' + : `A completed signature round must record every execution formality the contract type requires (${required.join(', ')}) before activation.`, + 'INVALID_STATE', + 422, + ); + } + const finalVersions = await api.object('clm_contract_version').count({ where: { contract: id, kind: 'final_signed' } }); + if (finalVersions === 0) { + throw refuse('A final_signed version is required before activation.', 'INVALID_STATE', 422); + } + const executedAt = typeof executed.completed_at === 'string' && executed.completed_at ? executed.completed_at : now; + if (!isSet(get('signed_at'))) input.signed_at = executedAt; + if (!isSet(get('executed_at'))) input.executed_at = executedAt; + input.activated_at = now; } - const executedAt = typeof executed.completed_at === 'string' && executed.completed_at ? executed.completed_at : now; - if (!isSet(get('signed_at'))) input.signed_at = executedAt; - if (!isSet(get('executed_at'))) input.executed_at = executedAt; - input.activated_at = now; - } - if (from === 'active' && to === 'expired' && ctx.session?.isSystem !== true) { - throw refuse('Only the expiry job marks a contract expired; terminate it to end it by hand.', 'INVALID_STATE', 422); - } + if (from === 'active' && to === 'expired' && ctx.session?.isSystem !== true) { + throw refuse('Only the expiry job marks a contract expired; terminate it to end it by hand.', 'INVALID_STATE', 422); + } - if (to === 'terminated') { - input.closed_at = now; + if (to === 'terminated') { + input.closed_at = now; + } + + if (!onward) break; + from = to; + to = onward; + input.status = onward; } }, }; @@ -353,6 +578,37 @@ const deviationStateMachine: Hook = { }, }; +const deviationGate: Hook = { + name: 'deviation_gate', + object: 'clm_deviation', + events: ['afterUpdate'], + priority: 250, + // The parent contract's `route_legal_head` is `readonly` and locked by + // field-level security for every position: it has exactly two writers, the + // routing hook and this one. A system context is what lets a write from + // OUTSIDE the contract's own hook chain land on a read-only column (the + // strip is bypassed under `isSystem`, the same way the engine's roll-up + // recompute lands). After the deviation row is committed, never before: a + // refused deviation write must not leave a stamped contract behind. + runAs: 'system', + description: 'F6: an accepted deviation from a clause that requires the head of legal stamps route_legal_head on the parent contract.', + handler: async (ctx: HookContext) => { + const { input } = ctx; + const previous = ctx.previous ?? {}; + if (input.status !== 'accepted' || previous.status === 'accepted') return; + const api = ctx.api as HookApi | undefined; + if (!api) return; + const clauseId = input.clause !== undefined ? input.clause : previous.clause; + const contractId = input.contract !== undefined ? input.contract : previous.contract; + if (typeof clauseId !== 'string' || !clauseId || typeof contractId !== 'string' || !contractId) return; + const clause = await api.object('clm_clause').findOne({ where: { id: clauseId }, fields: ['id', 'requires_legal_head'] }); + if (clause?.requires_legal_head !== true) return; + const contract = await api.object('clm_contract').findOne({ where: { id: contractId }, fields: ['id', 'route_legal_head'] }); + if (!contract || contract.route_legal_head === true) return; + await api.object('clm_contract').update({ id: contractId, route_legal_head: true }, { where: { id: contractId } }); + }, +}; + const signatureStateMachine: Hook = { name: 'signature_state_machine', object: 'clm_signature', @@ -529,8 +785,10 @@ const paymentPlanStateMachine: Hook = { export default [ contractTypeStamp, + contractRoute, contractStateMachine, deviationStateMachine, + deviationGate, signatureStateMachine, obligationStateMachine, paymentPlanStateMachine, From 5fecf080180e93a4df48256e51fb252f60d0d2c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 14:32:46 +0000 Subject: [PATCH 2/4] feat(flows): contract_intake screen flow and the Launch Contract action (F1) The launch form as one screen flow: the type and the counterparty come from the launch action's param dialog (a flat screen has no record picker on the pinned console), the core screen carries the type's intake_fields as a hidden field so each optional field is shown and required only when the type lists it, the counterparty is picked or created inline, version 1 comes from the type template, a supplied file id, or the platform's own version form, and an optional first instalment and a submit toggle close the flow. A headless caller that supplies `title` goes round every screen; refusals fail the run with a message through the one registered function. `requires` gains `automation`; `triggers` is measured as not needed by a screen flow and stays with the first trigger-launched flow. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcrVDXSptwDukFsHPHPR1V --- objectstack.config.ts | 31 +- src/actions/contract.actions.ts | 95 ++++++ src/actions/index.ts | 2 + src/flows/contract-intake.flow.ts | 467 ++++++++++++++++++++++++++++++ src/flows/index.ts | 17 ++ 5 files changed, 608 insertions(+), 4 deletions(-) create mode 100644 src/actions/contract.actions.ts create mode 100644 src/actions/index.ts create mode 100644 src/flows/contract-intake.flow.ts create mode 100644 src/flows/index.ts diff --git a/objectstack.config.ts b/objectstack.config.ts index 05b7384..018d427 100644 --- a/objectstack.config.ts +++ b/objectstack.config.ts @@ -1,6 +1,8 @@ import { defineStack } from '@objectstack/spec'; import * as objects from './src/objects/index.js'; import { allHooks } from './src/objects/hooks.js'; +import * as actions from './src/actions/index.js'; +import { allFlows, flowFunctions } from './src/flows/index.js'; import * as profiles from './src/profiles/index.js'; import { ClmPositions, ClmSharingRules } from './src/sharing/index.js'; import { registerClmPositionBindings, type BindHostContext } from './src/security/index.js'; @@ -58,11 +60,24 @@ export default defineStack({ // that edition. On the enterprise edition the same metadata widens to the // manager chain. The app states what it MEANS; the edition supplies it. // + // `automation` is the flow engine: it registers `flows`, runs the + // `contract_intake` screen flow (F1, card 05) and resolves `functions`. + // Measured as required on 17.3.0: without the token the flow-typed + // `launch_contract` action is refused at dispatch (503, "flow action + // unavailable"), because the automation service is never resolved. + // + // `triggers` is deliberately NOT declared yet. It installs the record-change + // and schedule PROVIDERS that launch `record_change` / `schedule` flows; + // this card's flow is a `screen` flow launched by its action, and F2 / F6 + // are hooks, not flows — so nothing here is ever fired by a trigger. The + // token arrives with the first card that authors a flow a trigger launches + // (F5 in card 06, the daily jobs in card 09), the same way `sharing` and + // `hierarchy-security` arrived with card 04: measured, then declared. + // // The rest arrive with the cards that need them (DESIGN.md §06: - // `automation` + `triggers` + `approvals` + `messaging` for F1–F15, - // `analytics` for §09). Capability expansion stays tight — a card names the - // token it adds (AGENTS.md). - requires: ['ui', 'auth', 'sharing', 'hierarchy-security'], + // `approvals` + `messaging` for F5–F15, `analytics` for §09). Capability + // expansion stays tight — a card names the token it adds (AGENTS.md). + requires: ['ui', 'auth', 'sharing', 'hierarchy-security', 'automation'], objects: Object.values(objects), // Lifecycle hooks (numbering, type-derived stamps, the state machines and @@ -70,6 +85,14 @@ export default defineStack({ // here — `hooks` is a top-level stack key, not an object key. hooks: allHooks, + // Automation (DESIGN.md §06, card 05): the intake screen flow (F1) and the + // action that launches it. The routing (F2) and the deviation gate (F6) + // are hooks above. `functions` holds the one callable a `script` node + // names — the refusal that fails a run with a message. + flows: allFlows, + actions: Object.values(actions), + functions: flowFunctions, + // Security (DESIGN.md §04, card 04): seven positions, five permission sets, // the sharing rules. Positions and sets are containers — the join rows that // make a position grant anything are written by `onEnable` below. diff --git a/src/actions/contract.actions.ts b/src/actions/contract.actions.ts new file mode 100644 index 0000000..82d5526 --- /dev/null +++ b/src/actions/contract.actions.ts @@ -0,0 +1,95 @@ +import type { Action } from '@objectstack/spec/ui'; +import { P } from '@objectstack/spec'; + +/** + * "Launch Contract" — the button behind F1 (DESIGN.md §05 "发起 = screen flow + * 动作"), bound to the `contract_intake` flow. + * + * ## Why the action carries params at all + * + * The flat screens of a flow have no record picker (measured on the pinned + * console — see the flow's header), so the two pickers the intake needs are + * collected HERE, by the action's param dialog: field-backed lookup params + * render the real pickers and inherit the fields' `lookupFilters` (active + * types only; blocked parties hidden). The console posts them as the flow's + * `params`, which the engine seeds into the input variables of the same name. + * + * ## The headless half + * + * `ai.exposed` makes this the `action_launch_contract` tool over MCP and the + * `POST /api/v1/actions/clm_contract/launch_contract` route — DESIGN.md §07's + * conversational intake for M2. Over those doors the params gate (ADR-0104) + * refuses any key the action does not declare, so EVERY flow input is + * declared below; the ones a person answers on the flow's screens are + * `visible: false`, which keeps them out of the console dialog (and out of + * its submission) while leaving them declared for a headless caller. A + * headless call that supplies `title` completes without a screen; one that + * supplies fewer inputs pauses on the first screen it needs. + * + * `list_toolbar` only: the action creates a record, so it has no record to + * sit on. Declaring a record-scoped location would make the console demand a + * selected row first. + */ + +type ActionParam = NonNullable[number]; + +/** A flow input a headless caller may pass and the screens collect from a person. */ +const headless = (name: string, type: ActionParam['type'], label: string): ActionParam => ({ + name, + type, + label, + visible: P`false`, +}); + +export const LaunchContractAction: Action = { + name: 'launch_contract', + label: 'Launch Contract', + objectName: 'clm_contract', + icon: 'rocket', + type: 'flow', + target: 'contract_intake', + locations: ['list_toolbar'], + params: [ + // The two pickers a flat screen cannot render. + { field: 'contract_type', label: 'Contract type', required: true, helpText: 'The workflow this contract runs: which fields are asked, whether legal reviews it, how it is executed.' }, + { field: 'party', label: 'Counterparty', required: false, helpText: 'Pick an existing counterparty, or leave empty to create one on the next step.' }, + // Everything else is answered on the flow's screens; declared here for + // the headless doors only. + headless('title', 'text', 'Title'), + headless('our_entity', 'text', 'Our signing entity'), + headless('department', 'text', 'Requesting department'), + headless('amount', 'number', 'Contract amount'), + headless('currency_code', 'text', 'Currency (ISO 4217, lowercase)'), + headless('is_amount_estimated', 'boolean', 'Amount is an estimate'), + headless('start_date', 'date', 'Start date'), + headless('end_date', 'date', 'End date'), + headless('term_months', 'number', 'Term (months)'), + headless('summary', 'text', 'Summary'), + headless('governing_law', 'text', 'Governing law'), + headless('payment_terms', 'text', 'Payment terms'), + headless('confidentiality_term_months', 'number', 'Confidentiality term (months)'), + headless('liability_cap', 'number', 'Liability cap'), + headless('auto_renew', 'boolean', 'Auto-renews'), + headless('parent_contract', 'text', 'Parent contract (record id)'), + headless('new_party_name', 'text', 'New counterparty: name'), + headless('new_party_kind', 'text', 'New counterparty: kind'), + headless('new_party_registration_no', 'text', 'New counterparty: registration / tax ID'), + headless('new_party_contact_name', 'text', 'New counterparty: contact name'), + headless('new_party_contact_email', 'text', 'New counterparty: contact email'), + headless('draft_from_template', 'boolean', 'Draft version 1 from the type template'), + headless('first_version_file', 'text', 'Version 1: uploaded file id'), + headless('pay_seq', 'number', 'First instalment: number'), + headless('pay_planned_date', 'date', 'First instalment: planned date'), + headless('pay_planned_amount', 'number', 'First instalment: planned amount'), + headless('pay_condition', 'text', 'First instalment: condition'), + headless('submit_now', 'boolean', 'Submit now'), + ], + successMessage: 'Contract launched.', + refreshAfter: true, + ai: { + exposed: true, + category: 'flow', + description: + 'Launches a contract: creates the contract draft and its first version from the given contract type, counterparty (existing id, or a new one by name), core terms and the fields the type asks for; optionally records a first payment instalment and submits the contract into legal review or approval. Refuses a blocked counterparty.', + }, +}; diff --git a/src/actions/index.ts b/src/actions/index.ts new file mode 100644 index 0000000..8eabca3 --- /dev/null +++ b/src/actions/index.ts @@ -0,0 +1,2 @@ +// Actions bound to flows and to object surfaces (DESIGN.md §05, §06). +export { LaunchContractAction } from './contract.actions.js'; diff --git a/src/flows/contract-intake.flow.ts b/src/flows/contract-intake.flow.ts new file mode 100644 index 0000000..f1508e0 --- /dev/null +++ b/src/flows/contract-intake.flow.ts @@ -0,0 +1,467 @@ +import { P } from '@objectstack/spec'; +import type { Flow } from '@objectstack/spec/automation'; +import { Contract } from '../objects/contract.object.js'; +import { Party } from '../objects/party.object.js'; + +/** + * F1 `contract_intake` — the launch form (DESIGN.md §06 F1, §02 Launch Form, + * §05 表 3): one screen flow that creates a `clm_contract` draft and its first + * version, and optionally submits it into F2's routing. + * + * ## Shape, and why it is this shape + * + * Measured on the pinned @objectstack 17.3.0 packages — the console's screen + * runner (`@objectstack/console` `RecordDetailView` chunk) and + * `service-automation`'s screen executor — rather than assumed: + * + * - A flat screen renders text / number / currency / date / email / textarea + * / boolean, and a select when `options` are declared. It has NO record + * picker and NO file input: a `lookup`-typed field falls through to a plain + * text box. So the two pickers the intake needs — the contract type and the + * counterparty — are collected by the `launch_contract` ACTION's param + * dialog (`src/actions/contract.actions.ts`), whose field-backed lookup + * params render the real pickers and inherit the fields' `lookupFilters` + * (active types only; blocked parties hidden). The flow receives them as + * the input variables `contract_type` and `party`. + * - A screen field's `visibleWhen` is bare CEL the CLIENT evaluates against + * the screen's OWN field values only — flow variables are not in scope + * there (the server re-checks `required` under it with the run's variables + * layered in, which is why an unevaluable predicate renders visible). To + * make the type's `intake_fields` drive the conditional fields, the list is + * carried ONTO the screen as a hidden field (`intakeFields`, a + * server-interpolated `defaultValue`), and each optional field's predicate + * is `"" in intakeFields`. There is no `requiredWhen`: `required: + * true` + `visibleWhen` is "required when shown", enforced on both sides. + * - A screen with fields ALWAYS pauses (the executor never consults the + * variables). Headless completion — MCP `run_action`, REST `/actions` — + * therefore goes round the screens: `decision_headless` tests + * `has(vars.title)`, a variable only a headless caller binds (the action + * declares it `visible: false`, so the console dialog never sends it), and + * routes straight to the same create nodes. + * - Refusals (`clm_intake_refuse`, registered under `functions`) FAIL the run + * with the message: a paused "refusal screen" would leave a headless run + * parked forever, and the console shows a failed run's error as the toast. + * - The first version's file has two headless spellings — `draft_from_template` + * (the type's `template_file` becomes v1, kind `draft`) or a pre-uploaded + * `first_version_file` id — and one interactive one: an OBJECT-FORM screen + * on `clm_contract_version`, the platform's real form with its file widget, + * prefilled with the new contract. An object form always pauses, which is + * why the two headless spellings exist. + * + * Every write runs as the launching user (`runAs: 'user'`, the default): + * DESIGN.md §04 decides who may create a party or a payment instalment + * (legal / finance / admin; a requester reads them) and the platform refuses + * the step for anyone else — the flow does not elevate around the matrix. + * + * Flow copy is English-only (the source language, DESIGN.md §01); a flow has + * no entry in the translation bundles. + */ + +interface SelectLike { + options?: ReadonlyArray<{ label: string; value: unknown }>; +} + +/** The object's own option list, so a screen select cannot drift from the field it feeds. */ +function optionsOf(object: { name: string; fields: Record }, field: string): Array<{ label: string; value: unknown }> { + const options = (object.fields[field] as SelectLike | undefined)?.options; + if (!options || options.length === 0) { + throw new Error(`${object.name}.${field} has no options; the intake screen derives its select from the field.`); + } + return options.map(({ label, value }) => ({ label, value })); +} + +/** The optional contract fields a type may list in `intake_fields`, in the order the screen asks them. */ +const INTAKE_FIELD_NAMES = ['governing_law', 'payment_terms', 'confidentiality_term_months', 'liability_cap', 'auto_renew', 'parent_contract'] as const; + +/** Bare CEL over the screen's own fields: shown only when the type lists the field. */ +const askedFor = (field: (typeof INTAKE_FIELD_NAMES)[number]): string => `"${field}" in intakeFields`; + +/** + * The one registered function of the flow: fail the run with a structured + * refusal that names what was refused. A `script` node is the only node that + * can end a run with a computed message. + */ +export function refuseIntake({ input }: { input: Record }): never { + const kind = typeof input.kind === 'string' ? input.kind : 'invalid'; + const name = typeof input.name === 'string' && input.name ? input.name : 'the counterparty'; + const note = typeof input.note === 'string' && input.note.trim() ? input.note.trim() : ''; + const MESSAGES: Record = { + no_type: 'A contract type is required to launch a contract.', + type_unavailable: 'The chosen contract type does not exist or is inactive; pick an active type.', + party_missing: `Counterparty ${name} does not exist.`, + party_blocked: `Counterparty ${name} is blocked and cannot be put on a new contract${note ? `: ${note}` : '.'}`, + }; + const err = new Error(MESSAGES[kind] ?? 'The contract cannot be launched.') as Error & { code: string; status: number }; + err.code = kind === 'party_missing' ? 'INVALID_REFERENCE' : 'INVALID_STATE'; + err.status = 422; + throw err; +} + +const H = P`has(vars.title)`; +const NOT_H = P`!has(vars.title)`; + +export const ContractIntakeFlow: Flow = { + name: 'contract_intake', + label: 'Launch Contract', + description: 'Launch a contract: pick the type, fill the core and the type-specific fields, choose or create the counterparty, attach the first version, optionally schedule a payment and submit.', + type: 'screen', + status: 'active', + successMessage: 'Contract launched.', + + variables: [ + // The two pickers, supplied by the launch action's dialog (or headlessly). + { name: 'contract_type', type: 'text', isInput: true, isOutput: false }, + { name: 'party', type: 'text', isInput: true, isOutput: false }, + // Core fields. `title` doubles as the headless marker — see the header. + { name: 'title', type: 'text', isInput: true, isOutput: false }, + { name: 'our_entity', type: 'text', isInput: true, isOutput: false, defaultValue: 'head_office' }, + { name: 'department', type: 'text', isInput: true, isOutput: false }, + { name: 'amount', type: 'number', isInput: true, isOutput: false }, + { name: 'currency_code', type: 'text', isInput: true, isOutput: false, defaultValue: 'usd' }, + { name: 'is_amount_estimated', type: 'boolean', isInput: true, isOutput: false, defaultValue: false }, + { name: 'start_date', type: 'date', isInput: true, isOutput: false }, + { name: 'end_date', type: 'date', isInput: true, isOutput: false }, + { name: 'term_months', type: 'number', isInput: true, isOutput: false }, + { name: 'summary', type: 'text', isInput: true, isOutput: false }, + // Optional fields, asked only when the type lists them. + { name: 'governing_law', type: 'text', isInput: true, isOutput: false }, + { name: 'payment_terms', type: 'text', isInput: true, isOutput: false }, + { name: 'confidentiality_term_months', type: 'number', isInput: true, isOutput: false }, + { name: 'liability_cap', type: 'number', isInput: true, isOutput: false }, + { name: 'auto_renew', type: 'boolean', isInput: true, isOutput: false, defaultValue: false }, + { name: 'parent_contract', type: 'text', isInput: true, isOutput: false }, + // Inline counterparty, when none was picked. + { name: 'new_party_name', type: 'text', isInput: true, isOutput: false }, + { name: 'new_party_kind', type: 'text', isInput: true, isOutput: false, defaultValue: 'company' }, + { name: 'new_party_registration_no', type: 'text', isInput: true, isOutput: false }, + { name: 'new_party_contact_name', type: 'text', isInput: true, isOutput: false }, + { name: 'new_party_contact_email', type: 'text', isInput: true, isOutput: false }, + // First version. + { name: 'draft_from_template', type: 'boolean', isInput: true, isOutput: false, defaultValue: false }, + { name: 'first_version_file', type: 'text', isInput: true, isOutput: false }, + // Optional first payment instalment, and the submit toggle. + { name: 'pay_seq', type: 'number', isInput: true, isOutput: false, defaultValue: 1 }, + { name: 'pay_planned_date', type: 'date', isInput: true, isOutput: false }, + { name: 'pay_planned_amount', type: 'number', isInput: true, isOutput: false }, + { name: 'pay_condition', type: 'text', isInput: true, isOutput: false }, + { name: 'submit_now', type: 'boolean', isInput: true, isOutput: false, defaultValue: false }, + ], + + nodes: [ + { id: 'start', type: 'start', label: 'Start', config: { objectName: 'clm_contract' } }, + + // ── Step 1: the type decides everything after it ───────────────────── + { id: 'decision_type_given', type: 'decision', label: 'Type Chosen?' }, + { + id: 'refuse_no_type', type: 'script', label: 'Refuse: No Type', + config: { function: 'clm_intake_refuse', inputs: { kind: 'no_type' } }, + }, + { + id: 'get_type', type: 'get_record', label: 'Load Contract Type', + config: { objectName: 'clm_contract_type', filter: { id: '{contract_type}' }, outputVariable: 'typeRecord' }, + }, + { id: 'decision_type_ok', type: 'decision', label: 'Type Active?' }, + { + id: 'refuse_type', type: 'script', label: 'Refuse: Type Unavailable', + config: { function: 'clm_intake_refuse', inputs: { kind: 'type_unavailable' } }, + }, + // Both branches bind `templateAvailable` and `templateNote`, so no later + // read meets an unbound variable (the house rule of the flow-variable + // conditions table in the spec's FlowVariableSchema note). + { id: 'decision_template', type: 'decision', label: 'Type Has Template?' }, + { + id: 'template_yes', type: 'assignment', label: 'Template Available', + config: { assignments: { templateAvailable: true, templateNote: 'This contract type carries a template. Tick "Draft from template" to attach it as version 1, or leave it clear to upload the first version on the next step.' } }, + }, + { + id: 'template_no', type: 'assignment', label: 'No Template', + config: { assignments: { templateAvailable: false, templateNote: 'This contract type carries no template; upload the first version on the next step.' } }, + }, + // The type's `intake_fields` list, normalised to an array on both branches + // and carried onto the core screen as a hidden field — see the header. + { id: 'decision_intake_fields', type: 'decision', label: 'Type Lists Intake Fields?' }, + { + id: 'intake_from_type', type: 'assignment', label: 'Intake Fields From Type', + config: { assignments: { intakeFields: '{typeRecord.intake_fields}' } }, + }, + { + id: 'intake_none', type: 'assignment', label: 'No Intake Fields', + config: { assignments: { intakeFields: [] } }, + }, + // Interactive or headless? A headless caller binds `title` up front; the + // console never does (the launch action declares it `visible: false`). + { id: 'decision_headless', type: 'decision', label: 'Inputs Supplied Headlessly?' }, + + // ── Step 2: core fields, and the type's optional ones ───────────────── + { + id: 'screen_core', type: 'screen', label: 'Contract Details', + config: { + title: 'Contract details', + description: 'The core terms every contract carries, then the fields this contract type asks for.', + fields: [ + // Carrier: the type's intake_fields, interpolated server-side to the + // raw list, hidden by its own predicate, read by the predicates below. + { name: 'intakeFields', label: 'Intake fields', type: 'text', defaultValue: '{intakeFields}', visibleWhen: 'intakeFields == null' }, + { name: 'title', label: 'Title', type: 'text', required: true, placeholder: 'e.g. Mutual NDA with Acme' }, + { name: 'our_entity', label: 'Our signing entity', type: 'select', options: optionsOf(Contract, 'our_entity'), defaultValue: '{our_entity}' }, + { name: 'department', label: 'Requesting department', type: 'select', options: optionsOf(Contract, 'department') }, + { name: 'amount', label: 'Contract amount', type: 'currency' }, + { name: 'currency_code', label: 'Currency', type: 'select', options: optionsOf(Contract, 'currency_code'), defaultValue: '{currency_code}' }, + { name: 'is_amount_estimated', label: 'Amount is an estimate', type: 'boolean', defaultValue: '{is_amount_estimated}' }, + { name: 'start_date', label: 'Start date', type: 'date' }, + { name: 'end_date', label: 'End date', type: 'date' }, + { name: 'term_months', label: 'Term (months)', type: 'number', placeholder: 'Instead of an end date' }, + { name: 'summary', label: 'Summary', type: 'textarea', placeholder: 'What the deal is, in a few sentences' }, + // Asked — and required — only when the type lists them. + { name: 'governing_law', label: 'Governing law', type: 'text', required: true, placeholder: 'e.g. US-NY, England and Wales', visibleWhen: askedFor('governing_law') }, + { name: 'payment_terms', label: 'Payment terms', type: 'select', required: true, options: optionsOf(Contract, 'payment_terms'), visibleWhen: askedFor('payment_terms') }, + { name: 'confidentiality_term_months', label: 'Confidentiality term (months)', type: 'number', required: true, visibleWhen: askedFor('confidentiality_term_months') }, + { name: 'liability_cap', label: 'Liability cap', type: 'currency', required: true, visibleWhen: askedFor('liability_cap') }, + { name: 'auto_renew', label: 'Auto-renews', type: 'boolean', defaultValue: '{auto_renew}', visibleWhen: askedFor('auto_renew') }, + // A flat screen has no record picker (see the header): the parent + // contract is collected as its record id. + { name: 'parent_contract', label: 'Parent contract (record id)', type: 'text', required: true, placeholder: 'The framework or main contract this one sits under', visibleWhen: askedFor('parent_contract') }, + ], + }, + }, + + // ── Step 3: the counterparty — picked, supplied inline, or asked for ── + { id: 'decision_party_given', type: 'decision', label: 'Counterparty Picked?' }, + { + id: 'screen_party', type: 'screen', label: 'New Counterparty', + config: { + title: 'New counterparty', + description: 'No counterparty was picked. Create one here — legal keeps its risk flag and screening up to date afterwards.', + fields: [ + { name: 'new_party_name', label: 'Name', type: 'text', required: true }, + { name: 'new_party_kind', label: 'Kind', type: 'select', options: optionsOf(Party, 'party_kind'), defaultValue: '{new_party_kind}' }, + { name: 'new_party_registration_no', label: 'Registration / tax ID', type: 'text' }, + { name: 'new_party_contact_name', label: 'Contact name', type: 'text' }, + { name: 'new_party_contact_email', label: 'Contact email', type: 'email' }, + ], + }, + }, + { + id: 'create_party', type: 'create_record', label: 'Create Counterparty', + config: { + objectName: 'clm_party', + fields: { + name: '{new_party_name}', + party_kind: '{new_party_kind}', + registration_no: '{new_party_registration_no}', + contact_name: '{new_party_contact_name}', + contact_email: '{new_party_contact_email}', + }, + outputVariable: 'createdParty', + }, + }, + { id: 'use_new_party', type: 'assignment', label: 'Use New Counterparty', config: { assignments: { partyId: '{createdParty.id}' } } }, + { + id: 'get_party', type: 'get_record', label: 'Load Counterparty', + config: { objectName: 'clm_party', filter: { id: '{party}' }, outputVariable: 'partyRecord' }, + }, + // The blocked-party refusal, and its two neighbours: a party that does not + // resolve is refused too, and a clean one is used. The three edge + // predicates partition every record shape. + { id: 'decision_party_state', type: 'decision', label: 'Counterparty Blocked?' }, + { + id: 'refuse_party_missing', type: 'script', label: 'Refuse: Counterparty Missing', + config: { function: 'clm_intake_refuse', inputs: { kind: 'party_missing', name: '{party}' } }, + }, + { + id: 'refuse_blocked', type: 'script', label: 'Refuse: Counterparty Blocked', + config: { function: 'clm_intake_refuse', inputs: { kind: 'party_blocked', name: '{partyRecord.name}', note: '{partyRecord.risk_note}' } }, + }, + { id: 'use_picked_party', type: 'assignment', label: 'Use Picked Counterparty', config: { assignments: { partyId: '{partyRecord.id}' } } }, + + // ── Step 4: the first version ───────────────────────────────────────── + { id: 'decision_document_screen', type: 'decision', label: 'Ask About The Document?' }, + { + id: 'screen_document', type: 'screen', label: 'First Version', + config: { + title: 'First version', + description: '{templateNote}', + fields: [ + { name: 'draft_from_template', label: 'Draft from template', type: 'boolean', defaultValue: '{draft_from_template}' }, + ], + }, + }, + { + id: 'create_contract', type: 'create_record', label: 'Create Contract', + config: { + objectName: 'clm_contract', + fields: { + title: '{title}', + contract_type: '{contract_type}', + party: '{partyId}', + our_entity: '{our_entity}', + department: '{department}', + amount: '{amount}', + currency_code: '{currency_code}', + is_amount_estimated: '{is_amount_estimated}', + start_date: '{start_date}', + end_date: '{end_date}', + term_months: '{term_months}', + summary: '{summary}', + governing_law: '{governing_law}', + payment_terms: '{payment_terms}', + confidentiality_term_months: '{confidentiality_term_months}', + liability_cap: '{liability_cap}', + auto_renew: '{auto_renew}', + parent_contract: '{parent_contract}', + status: 'draft', + owner_id: '{$User.Id}', + }, + outputVariable: 'contractRecord', + }, + }, + { id: 'decision_version', type: 'decision', label: 'Where Does Version 1 Come From?' }, + { + id: 'create_template_version', type: 'create_record', label: 'Version 1 From Template', + config: { + objectName: 'clm_contract_version', + fields: { + contract: '{contractRecord.id}', + version_no: 1, + kind: 'draft', + turn: 'internal', + is_current: true, + file: '{typeRecord.template_file}', + submitted_by: '{$User.Id}', + notes: 'Drafted from the contract type template.', + }, + outputVariable: 'versionRecord', + }, + }, + { + id: 'create_file_version', type: 'create_record', label: 'Version 1 From Uploaded File', + config: { + objectName: 'clm_contract_version', + fields: { + contract: '{contractRecord.id}', + version_no: 1, + kind: 'draft', + turn: 'internal', + is_current: true, + file: '{first_version_file}', + submitted_by: '{$User.Id}', + }, + outputVariable: 'versionRecord', + }, + }, + { + // The platform's own form for the version, with its file widget. + id: 'screen_upload', type: 'screen', label: 'Upload First Version', + config: { + title: 'Upload the first version', + description: 'The document this contract starts from. It becomes version 1.', + objectName: 'clm_contract_version', + mode: 'create', + defaults: { contract: '{contractRecord.id}', version_no: 1, kind: 'draft', turn: 'internal', is_current: true }, + idVariable: 'versionId', + }, + }, + + // ── Step 5: an optional first instalment, and submit ────────────────── + { id: 'decision_schedule_screen', type: 'decision', label: 'Ask About Payment And Submission?' }, + { + id: 'screen_schedule', type: 'screen', label: 'Payment And Submission', + config: { + title: 'Payment schedule and submission', + description: 'Optionally record the first planned instalment (more can be added on the contract), then choose whether to submit now. Submitting routes the contract to legal review or to approval, as its type decides.', + fields: [ + { name: 'pay_seq', label: 'Instalment no.', type: 'number', defaultValue: '{pay_seq}' }, + { name: 'pay_planned_date', label: 'Planned date', type: 'date' }, + { name: 'pay_planned_amount', label: 'Planned amount', type: 'currency' }, + { name: 'pay_condition', label: 'Condition', type: 'textarea', placeholder: 'What releases the instalment — acceptance, delivery, a milestone' }, + { name: 'submit_now', label: 'Submit now', type: 'boolean', defaultValue: '{submit_now}' }, + ], + }, + }, + { id: 'decision_payment', type: 'decision', label: 'Instalment Given?' }, + { + id: 'create_payment_plan', type: 'create_record', label: 'Create First Instalment', + config: { + objectName: 'clm_payment_plan', + fields: { + contract: '{contractRecord.id}', + seq: '{pay_seq}', + planned_date: '{pay_planned_date}', + planned_amount: '{pay_planned_amount}', + condition: '{pay_condition}', + status: 'planned', + }, + outputVariable: 'instalmentRecord', + }, + }, + { id: 'decision_submit', type: 'decision', label: 'Submit Now?' }, + { + // The write that enters F2: `contract_route` stamps the routing and the + // state machine takes the contract into review or approval. + id: 'submit_contract', type: 'update_record', label: 'Submit Contract', + config: { objectName: 'clm_contract', filter: { id: '{contractRecord.id}' }, fields: { status: 'submitted' } }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + + edges: [ + { id: 'e01', source: 'start', target: 'decision_type_given', type: 'default' }, + { id: 'e02', source: 'decision_type_given', target: 'get_type', type: 'default', condition: P`has(vars.contract_type) && vars.contract_type != null && vars.contract_type != ""`, label: 'Given' }, + { id: 'e03', source: 'decision_type_given', target: 'refuse_no_type', type: 'default', condition: P`!has(vars.contract_type) || vars.contract_type == null || vars.contract_type == ""`, label: 'Missing' }, + { id: 'e04', source: 'get_type', target: 'decision_type_ok', type: 'default' }, + { id: 'e05', source: 'decision_type_ok', target: 'decision_template', type: 'default', condition: P`vars.typeRecord != null && (!has(vars.typeRecord.is_active) || vars.typeRecord.is_active != false)`, label: 'Active' }, + { id: 'e06', source: 'decision_type_ok', target: 'refuse_type', type: 'default', condition: P`vars.typeRecord == null || (has(vars.typeRecord.is_active) && vars.typeRecord.is_active == false)`, label: 'Unavailable' }, + { id: 'e07', source: 'decision_template', target: 'template_yes', type: 'default', condition: P`has(vars.typeRecord.template_file) && vars.typeRecord.template_file != null`, label: 'Template' }, + { id: 'e08', source: 'decision_template', target: 'template_no', type: 'default', condition: P`!has(vars.typeRecord.template_file) || vars.typeRecord.template_file == null`, label: 'No template' }, + { id: 'e09', source: 'template_yes', target: 'decision_intake_fields', type: 'default' }, + { id: 'e10', source: 'template_no', target: 'decision_intake_fields', type: 'default' }, + { id: 'e11', source: 'decision_intake_fields', target: 'intake_from_type', type: 'default', condition: P`has(vars.typeRecord.intake_fields) && vars.typeRecord.intake_fields != null`, label: 'Listed' }, + { id: 'e12', source: 'decision_intake_fields', target: 'intake_none', type: 'default', condition: P`!has(vars.typeRecord.intake_fields) || vars.typeRecord.intake_fields == null`, label: 'None' }, + { id: 'e13', source: 'intake_from_type', target: 'decision_headless', type: 'default' }, + { id: 'e14', source: 'intake_none', target: 'decision_headless', type: 'default' }, + { id: 'e15', source: 'decision_headless', target: 'decision_party_given', type: 'default', condition: H, label: 'Headless' }, + { id: 'e16', source: 'decision_headless', target: 'screen_core', type: 'default', condition: NOT_H, label: 'Interactive' }, + { id: 'e17', source: 'screen_core', target: 'decision_party_given', type: 'default' }, + // Three ways out, partitioned: a picked party, an inline party supplied + // headlessly, or the screen that asks for one. + { id: 'e18', source: 'decision_party_given', target: 'get_party', type: 'default', condition: P`has(vars.party) && vars.party != null && vars.party != ""`, label: 'Picked' }, + { id: 'e19', source: 'decision_party_given', target: 'create_party', type: 'default', condition: P`(!has(vars.party) || vars.party == null || vars.party == "") && has(vars.new_party_name) && vars.new_party_name != null && vars.new_party_name != ""`, label: 'Inline' }, + { id: 'e20', source: 'decision_party_given', target: 'screen_party', type: 'default', condition: P`(!has(vars.party) || vars.party == null || vars.party == "") && (!has(vars.new_party_name) || vars.new_party_name == null || vars.new_party_name == "")`, label: 'Ask' }, + { id: 'e21', source: 'screen_party', target: 'create_party', type: 'default' }, + { id: 'e22', source: 'create_party', target: 'use_new_party', type: 'default' }, + { id: 'e23', source: 'get_party', target: 'decision_party_state', type: 'default' }, + { id: 'e24', source: 'decision_party_state', target: 'refuse_party_missing', type: 'default', condition: P`vars.partyRecord == null`, label: 'Missing' }, + { id: 'e25', source: 'decision_party_state', target: 'refuse_blocked', type: 'default', condition: P`vars.partyRecord != null && has(vars.partyRecord.risk_flag) && vars.partyRecord.risk_flag == "blocked"`, label: 'Blocked' }, + { id: 'e26', source: 'decision_party_state', target: 'use_picked_party', type: 'default', condition: P`vars.partyRecord != null && (!has(vars.partyRecord.risk_flag) || vars.partyRecord.risk_flag != "blocked")`, label: 'Clear' }, + { id: 'e27', source: 'use_new_party', target: 'decision_document_screen', type: 'default' }, + { id: 'e28', source: 'use_picked_party', target: 'decision_document_screen', type: 'default' }, + { id: 'e29', source: 'decision_document_screen', target: 'create_contract', type: 'default', condition: H, label: 'Headless' }, + { id: 'e30', source: 'decision_document_screen', target: 'screen_document', type: 'default', condition: NOT_H, label: 'Interactive' }, + { id: 'e31', source: 'screen_document', target: 'create_contract', type: 'default' }, + { id: 'e32', source: 'create_contract', target: 'decision_version', type: 'default' }, + // Version 1: the template (when asked for and available), a supplied file, + // or the upload form. Partitioned in that order. + { id: 'e33', source: 'decision_version', target: 'create_template_version', type: 'default', condition: P`vars.draft_from_template == true && vars.templateAvailable == true`, label: 'Template' }, + { id: 'e34', source: 'decision_version', target: 'create_file_version', type: 'default', condition: P`!(vars.draft_from_template == true && vars.templateAvailable == true) && has(vars.first_version_file) && vars.first_version_file != null && vars.first_version_file != ""`, label: 'File' }, + { id: 'e35', source: 'decision_version', target: 'screen_upload', type: 'default', condition: P`!(vars.draft_from_template == true && vars.templateAvailable == true) && (!has(vars.first_version_file) || vars.first_version_file == null || vars.first_version_file == "")`, label: 'Upload' }, + { id: 'e36', source: 'create_template_version', target: 'decision_schedule_screen', type: 'default' }, + { id: 'e37', source: 'create_file_version', target: 'decision_schedule_screen', type: 'default' }, + { id: 'e38', source: 'screen_upload', target: 'decision_schedule_screen', type: 'default' }, + { id: 'e39', source: 'decision_schedule_screen', target: 'decision_payment', type: 'default', condition: H, label: 'Headless' }, + { id: 'e40', source: 'decision_schedule_screen', target: 'screen_schedule', type: 'default', condition: NOT_H, label: 'Interactive' }, + { id: 'e41', source: 'screen_schedule', target: 'decision_payment', type: 'default' }, + { id: 'e42', source: 'decision_payment', target: 'create_payment_plan', type: 'default', condition: P`has(vars.pay_planned_amount) && vars.pay_planned_amount != null`, label: 'Instalment' }, + { id: 'e43', source: 'decision_payment', target: 'decision_submit', type: 'default', condition: P`!has(vars.pay_planned_amount) || vars.pay_planned_amount == null`, label: 'None' }, + { id: 'e44', source: 'create_payment_plan', target: 'decision_submit', type: 'default' }, + { id: 'e45', source: 'decision_submit', target: 'submit_contract', type: 'default', condition: P`vars.submit_now == true`, label: 'Submit' }, + { id: 'e46', source: 'decision_submit', target: 'end', type: 'default', condition: P`vars.submit_now != true`, label: 'Keep draft' }, + { id: 'e47', source: 'submit_contract', target: 'end', type: 'default' }, + // Every refusal ends the run; a script node that throws fails it with + // its message, so these edges are only ever reached by a function that + // returned — which `clm_intake_refuse` never does. + { id: 'e48', source: 'refuse_no_type', target: 'end', type: 'default' }, + { id: 'e49', source: 'refuse_type', target: 'end', type: 'default' }, + { id: 'e50', source: 'refuse_party_missing', target: 'end', type: 'default' }, + { id: 'e51', source: 'refuse_blocked', target: 'end', type: 'default' }, + ], +}; diff --git a/src/flows/index.ts b/src/flows/index.ts new file mode 100644 index 0000000..d054d79 --- /dev/null +++ b/src/flows/index.ts @@ -0,0 +1,17 @@ +import type { Flow } from '@objectstack/spec/automation'; +import { ContractIntakeFlow, refuseIntake } from './contract-intake.flow.js'; + +// F1–F15 of DESIGN.md §06 arrive card by card; this barrel is what +// `defineStack({ flows })` reads. +export { ContractIntakeFlow } from './contract-intake.flow.js'; + +export const allFlows: Flow[] = [ContractIntakeFlow]; + +/** + * Named functions the flows' `script` nodes call by string + * (`defineStack({ functions })`). Pure by contract: each returns or throws; + * none writes. + */ +export const flowFunctions = { + clm_intake_refuse: refuseIntake, +}; From f15db0a2a600191f41ef0f6fcf67374d172e1bd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 16:27:44 +0000 Subject: [PATCH 3/4] =?UTF-8?q?wip(flows):=20INCOMPLETE=20AND=20UNREVIEWED?= =?UTF-8?q?=20=E2=80=94=20intake=20flow,=20salvaged=20from=20a=20rate-limi?= =?UTF-8?q?ted=20run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card 05 dev agent was terminated mid-task by an account session limit while about to run the three gates against the full change. This commit preserves its uncommitted working tree so the work is not lost; it has NOT been gated, NOT browser-verified, and NOT reviewed. The next agent must diff it before building on it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcrVDXSptwDukFsHPHPR1V --- src/flows/contract-intake.flow.ts | 82 ++++++++++++++++++++++--------- 1 file changed, 60 insertions(+), 22 deletions(-) diff --git a/src/flows/contract-intake.flow.ts b/src/flows/contract-intake.flow.ts index f1508e0..39d8f74 100644 --- a/src/flows/contract-intake.flow.ts +++ b/src/flows/contract-intake.flow.ts @@ -97,8 +97,12 @@ export function refuseIntake({ input }: { input: Record }): nev throw err; } -const H = P`has(vars.title)`; -const NOT_H = P`!has(vars.title)`; +// Decided ONCE, before any screen: a headless caller binds `title` up front, +// a person answers it on the core screen — so after that screen the test +// would flip, which is why `interactive` is bound by an assignment on each +// branch and every later fork reads the binding rather than re-testing. +const INTERACTIVE = P`vars.interactive == true`; +const NOT_INTERACTIVE = P`vars.interactive != true`; export const ContractIntakeFlow: Flow = { name: 'contract_intake', @@ -190,7 +194,15 @@ export const ContractIntakeFlow: Flow = { }, // Interactive or headless? A headless caller binds `title` up front; the // console never does (the launch action declares it `visible: false`). + // Each branch binds `interactive`, which every later fork reads. { id: 'decision_headless', type: 'decision', label: 'Inputs Supplied Headlessly?' }, + { id: 'mode_headless', type: 'assignment', label: 'Headless Run', config: { assignments: { interactive: false } } }, + { id: 'mode_interactive', type: 'assignment', label: 'Interactive Run', config: { assignments: { interactive: true } } }, + // A PICKED counterparty is checked before anything is asked, so a blocked + // party is refused at the picker and not after a filled screen; a party + // created inline is checked by construction (it is born unflagged). + { id: 'decision_party_precheck', type: 'decision', label: 'Counterparty Picked Up Front?' }, + { id: 'decision_core_screen', type: 'decision', label: 'Ask The Core Fields?' }, // ── Step 2: core fields, and the type's optional ones ───────────────── { @@ -225,8 +237,8 @@ export const ContractIntakeFlow: Flow = { }, }, - // ── Step 3: the counterparty — picked, supplied inline, or asked for ── - { id: 'decision_party_given', type: 'decision', label: 'Counterparty Picked?' }, + // ── Step 3: the counterparty — already resolved, supplied inline, or asked for ── + { id: 'decision_party_given', type: 'decision', label: 'Counterparty Resolved?' }, { id: 'screen_party', type: 'screen', label: 'New Counterparty', config: { @@ -306,7 +318,13 @@ export const ContractIntakeFlow: Flow = { governing_law: '{governing_law}', payment_terms: '{payment_terms}', confidentiality_term_months: '{confidentiality_term_months}', - liability_cap: '{liability_cap}', + // `liability_cap` is NOT in this map: field-level security locks it + // for requesters (DESIGN.md §04, "由法务评定"), and the security layer + // judges a write by the KEYS it carries — an unbound `{liability_cap}` + // still puts the key on the wire and had a requester's launch refused + // with "Field write denied: not permitted to edit [liability_cap]". + // It is written by `set_liability_cap` below, only when a value was + // actually given. auto_renew: '{auto_renew}', parent_contract: '{parent_contract}', status: 'draft', @@ -315,6 +333,12 @@ export const ContractIntakeFlow: Flow = { outputVariable: 'contractRecord', }, }, + // The liability cap, written only when given — see `create_contract`. + { id: 'decision_liability_cap', type: 'decision', label: 'Liability Cap Given?' }, + { + id: 'set_liability_cap', type: 'update_record', label: 'Set Liability Cap', + config: { objectName: 'clm_contract', filter: { id: '{contractRecord.id}' }, fields: { liability_cap: '{liability_cap}' } }, + }, { id: 'decision_version', type: 'decision', label: 'Where Does Version 1 Come From?' }, { id: 'create_template_version', type: 'create_record', label: 'Version 1 From Template', @@ -326,7 +350,10 @@ export const ContractIntakeFlow: Flow = { kind: 'draft', turn: 'internal', is_current: true, - file: '{typeRecord.template_file}', + // A file field READS as `{ id, name, size, mimeType, url }` and is + // WRITTEN as the id (measured: the object form of the value is + // refused with "expected string, received object"). + file: '{typeRecord.template_file.id}', submitted_by: '{$User.Id}', notes: 'Drafted from the contract type template.', }, @@ -419,26 +446,37 @@ export const ContractIntakeFlow: Flow = { { id: 'e12', source: 'decision_intake_fields', target: 'intake_none', type: 'default', condition: P`!has(vars.typeRecord.intake_fields) || vars.typeRecord.intake_fields == null`, label: 'None' }, { id: 'e13', source: 'intake_from_type', target: 'decision_headless', type: 'default' }, { id: 'e14', source: 'intake_none', target: 'decision_headless', type: 'default' }, - { id: 'e15', source: 'decision_headless', target: 'decision_party_given', type: 'default', condition: H, label: 'Headless' }, - { id: 'e16', source: 'decision_headless', target: 'screen_core', type: 'default', condition: NOT_H, label: 'Interactive' }, - { id: 'e17', source: 'screen_core', target: 'decision_party_given', type: 'default' }, - // Three ways out, partitioned: a picked party, an inline party supplied - // headlessly, or the screen that asks for one. - { id: 'e18', source: 'decision_party_given', target: 'get_party', type: 'default', condition: P`has(vars.party) && vars.party != null && vars.party != ""`, label: 'Picked' }, - { id: 'e19', source: 'decision_party_given', target: 'create_party', type: 'default', condition: P`(!has(vars.party) || vars.party == null || vars.party == "") && has(vars.new_party_name) && vars.new_party_name != null && vars.new_party_name != ""`, label: 'Inline' }, - { id: 'e20', source: 'decision_party_given', target: 'screen_party', type: 'default', condition: P`(!has(vars.party) || vars.party == null || vars.party == "") && (!has(vars.new_party_name) || vars.new_party_name == null || vars.new_party_name == "")`, label: 'Ask' }, - { id: 'e21', source: 'screen_party', target: 'create_party', type: 'default' }, - { id: 'e22', source: 'create_party', target: 'use_new_party', type: 'default' }, + { id: 'e15', source: 'decision_headless', target: 'mode_headless', type: 'default', condition: P`has(vars.title)`, label: 'Headless' }, + { id: 'e16', source: 'decision_headless', target: 'mode_interactive', type: 'default', condition: P`!has(vars.title)`, label: 'Interactive' }, + { id: 'e15a', source: 'mode_headless', target: 'decision_party_precheck', type: 'default' }, + { id: 'e16a', source: 'mode_interactive', target: 'decision_party_precheck', type: 'default' }, + // A picked party is loaded and judged first; without one the run goes on + // to the core fields and asks for the party afterwards. + { id: 'e18', source: 'decision_party_precheck', target: 'get_party', type: 'default', condition: P`has(vars.party) && vars.party != null && vars.party != ""`, label: 'Picked' }, + { id: 'e18b', source: 'decision_party_precheck', target: 'decision_core_screen', type: 'default', condition: P`!has(vars.party) || vars.party == null || vars.party == ""`, label: 'Not picked' }, { id: 'e23', source: 'get_party', target: 'decision_party_state', type: 'default' }, { id: 'e24', source: 'decision_party_state', target: 'refuse_party_missing', type: 'default', condition: P`vars.partyRecord == null`, label: 'Missing' }, { id: 'e25', source: 'decision_party_state', target: 'refuse_blocked', type: 'default', condition: P`vars.partyRecord != null && has(vars.partyRecord.risk_flag) && vars.partyRecord.risk_flag == "blocked"`, label: 'Blocked' }, { id: 'e26', source: 'decision_party_state', target: 'use_picked_party', type: 'default', condition: P`vars.partyRecord != null && (!has(vars.partyRecord.risk_flag) || vars.partyRecord.risk_flag != "blocked")`, label: 'Clear' }, + { id: 'e28', source: 'use_picked_party', target: 'decision_core_screen', type: 'default' }, + { id: 'e27a', source: 'decision_core_screen', target: 'screen_core', type: 'default', condition: INTERACTIVE, label: 'Interactive' }, + { id: 'e27b', source: 'decision_core_screen', target: 'decision_party_given', type: 'default', condition: NOT_INTERACTIVE, label: 'Headless' }, + { id: 'e17', source: 'screen_core', target: 'decision_party_given', type: 'default' }, + // Three ways out, partitioned: a party already resolved up front, an + // inline party supplied headlessly, or the screen that asks for one. + { id: 'e17b', source: 'decision_party_given', target: 'decision_document_screen', type: 'default', condition: P`has(vars.partyId) && vars.partyId != null && vars.partyId != ""`, label: 'Resolved' }, + { id: 'e19', source: 'decision_party_given', target: 'create_party', type: 'default', condition: P`(!has(vars.partyId) || vars.partyId == null || vars.partyId == "") && has(vars.new_party_name) && vars.new_party_name != null && vars.new_party_name != ""`, label: 'Inline' }, + { id: 'e20', source: 'decision_party_given', target: 'screen_party', type: 'default', condition: P`(!has(vars.partyId) || vars.partyId == null || vars.partyId == "") && (!has(vars.new_party_name) || vars.new_party_name == null || vars.new_party_name == "")`, label: 'Ask' }, + { id: 'e21', source: 'screen_party', target: 'create_party', type: 'default' }, + { id: 'e22', source: 'create_party', target: 'use_new_party', type: 'default' }, { id: 'e27', source: 'use_new_party', target: 'decision_document_screen', type: 'default' }, - { id: 'e28', source: 'use_picked_party', target: 'decision_document_screen', type: 'default' }, - { id: 'e29', source: 'decision_document_screen', target: 'create_contract', type: 'default', condition: H, label: 'Headless' }, - { id: 'e30', source: 'decision_document_screen', target: 'screen_document', type: 'default', condition: NOT_H, label: 'Interactive' }, + { id: 'e29', source: 'decision_document_screen', target: 'create_contract', type: 'default', condition: NOT_INTERACTIVE, label: 'Headless' }, + { id: 'e30', source: 'decision_document_screen', target: 'screen_document', type: 'default', condition: INTERACTIVE, label: 'Interactive' }, { id: 'e31', source: 'screen_document', target: 'create_contract', type: 'default' }, - { id: 'e32', source: 'create_contract', target: 'decision_version', type: 'default' }, + { id: 'e32', source: 'create_contract', target: 'decision_liability_cap', type: 'default' }, + { id: 'e32a', source: 'decision_liability_cap', target: 'set_liability_cap', type: 'default', condition: P`has(vars.liability_cap) && vars.liability_cap != null && vars.liability_cap != ""`, label: 'Given' }, + { id: 'e32b', source: 'decision_liability_cap', target: 'decision_version', type: 'default', condition: P`!has(vars.liability_cap) || vars.liability_cap == null || vars.liability_cap == ""`, label: 'Not given' }, + { id: 'e32c', source: 'set_liability_cap', target: 'decision_version', type: 'default' }, // Version 1: the template (when asked for and available), a supplied file, // or the upload form. Partitioned in that order. { id: 'e33', source: 'decision_version', target: 'create_template_version', type: 'default', condition: P`vars.draft_from_template == true && vars.templateAvailable == true`, label: 'Template' }, @@ -447,8 +485,8 @@ export const ContractIntakeFlow: Flow = { { id: 'e36', source: 'create_template_version', target: 'decision_schedule_screen', type: 'default' }, { id: 'e37', source: 'create_file_version', target: 'decision_schedule_screen', type: 'default' }, { id: 'e38', source: 'screen_upload', target: 'decision_schedule_screen', type: 'default' }, - { id: 'e39', source: 'decision_schedule_screen', target: 'decision_payment', type: 'default', condition: H, label: 'Headless' }, - { id: 'e40', source: 'decision_schedule_screen', target: 'screen_schedule', type: 'default', condition: NOT_H, label: 'Interactive' }, + { id: 'e39', source: 'decision_schedule_screen', target: 'decision_payment', type: 'default', condition: NOT_INTERACTIVE, label: 'Headless' }, + { id: 'e40', source: 'decision_schedule_screen', target: 'screen_schedule', type: 'default', condition: INTERACTIVE, label: 'Interactive' }, { id: 'e41', source: 'screen_schedule', target: 'decision_payment', type: 'default' }, { id: 'e42', source: 'decision_payment', target: 'create_payment_plan', type: 'default', condition: P`has(vars.pay_planned_amount) && vars.pay_planned_amount != null`, label: 'Instalment' }, { id: 'e43', source: 'decision_payment', target: 'decision_submit', type: 'default', condition: P`!has(vars.pay_planned_amount) || vars.pay_planned_amount == null`, label: 'None' }, From fd2a4d7a5f2e9320151e53df936b36af6ecd1e25 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 17:08:35 +0000 Subject: [PATCH 4/4] =?UTF-8?q?fix(flows):=20drop=20the=20two=20intake=20s?= =?UTF-8?q?teps=20DESIGN.md=20=C2=A704=20forbids=20the=20launcher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewing the inherited work of this branch against the spec found two writes the launch form makes that the launching user is not permitted to make. Both were measured against a real `clm_requester` — the set every employee holds — each with its positive control. `liability_cap`. DESIGN.md §04 makes it read-only for `clm_requester` ("由法务评定"). The salvaged WIP commit had spotted the refusal at `create_contract` and moved the write to a later `update_record` node; measured, that node is refused identically — `PERMISSION_DENIED` "[Security] Field write denied: not permitted to edit [liability_cap]" on CREATE and on UPDATE alike (control: the same calls without the key return 201 / 200). The fix moved the failure later, not away, and left the contract already committed when it fired. The field is no longer asked for or written, and it is no longer an option on `clm_contract_type.intake_fields`: a type that listed it deadlocked its own contracts, since the requester can neither write the field nor submit without it ("Intake fields required by the contract type are missing: liability_cap."). Legal sets the cap during review, where §04 puts it. `clm_payment_plan`. §04 grants the requester R only; legal's RC creates the same row. Measured through the action door, the run failed at `create_payment_plan` AFTER the contract and its version were committed, orphaning both. DESIGN.md §06 agrees the step does not belong here: F1 ends at "建合同(draft)与版本 v1 → 可选一键提交", and F9 `contract_activate` is what generates `clm_payment_plan` on activation. Step 5 is now the submit toggle alone. Where the intake-captured arrangement F9 reads should live is a design gap, raised as its own decision card rather than guessed at. Also corrects three claims the inherited comments asserted as measured: the action's lookup params do NOT inherit the field's `lookupFilters` (the dialog offers the blocked party, which makes `refuse_blocked` the only guard, not a second one); MCP ships the generic `run_action` rather than a per-action tool; and `ai: { exposed: true }` cannot go on the flow at all — `FlowSchema` has no `ai` key on 17.3.0, so both `tsc` and `objectstack validate` refuse it. The action carries the exposure, which is what DESIGN.md §06 F1 asks for and what the MCP surface reads. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcrVDXSptwDukFsHPHPR1V --- src/actions/contract.actions.ts | 16 ++-- src/flows/contract-intake.flow.ts | 121 ++++++++++++++-------------- src/objects/contract-type.object.ts | 11 ++- 3 files changed, 76 insertions(+), 72 deletions(-) diff --git a/src/actions/contract.actions.ts b/src/actions/contract.actions.ts index 82d5526..9b862df 100644 --- a/src/actions/contract.actions.ts +++ b/src/actions/contract.actions.ts @@ -16,9 +16,14 @@ import { P } from '@objectstack/spec'; * * ## The headless half * - * `ai.exposed` makes this the `action_launch_contract` tool over MCP and the + * `ai.exposed` publishes this action to the MCP surface and to the * `POST /api/v1/actions/clm_contract/launch_contract` route — DESIGN.md §07's - * conversational intake for M2. Over those doors the params gate (ADR-0104) + * conversational intake for M2. Measured on 17.3.0: MCP does not mint one + * tool per action; it ships the generic pair `list_actions` / `run_action`, + * and an exposed action appears in `list_actions` (with its params) for + * `run_action` to call by name. The REST route takes the params under a + * `params` wrapper — a bare top-level body is refused + * "Invalid action params: Action param \"contract_type\" is required". Over those doors the params gate (ADR-0104) * refuses any key the action does not declare, so EVERY flow input is * declared below; the ones a person answers on the flow's screens are * `visible: false`, which keeps them out of the console dialog (and out of @@ -68,7 +73,6 @@ export const LaunchContractAction: Action = { headless('governing_law', 'text', 'Governing law'), headless('payment_terms', 'text', 'Payment terms'), headless('confidentiality_term_months', 'number', 'Confidentiality term (months)'), - headless('liability_cap', 'number', 'Liability cap'), headless('auto_renew', 'boolean', 'Auto-renews'), headless('parent_contract', 'text', 'Parent contract (record id)'), headless('new_party_name', 'text', 'New counterparty: name'), @@ -78,10 +82,6 @@ export const LaunchContractAction: Action = { headless('new_party_contact_email', 'text', 'New counterparty: contact email'), headless('draft_from_template', 'boolean', 'Draft version 1 from the type template'), headless('first_version_file', 'text', 'Version 1: uploaded file id'), - headless('pay_seq', 'number', 'First instalment: number'), - headless('pay_planned_date', 'date', 'First instalment: planned date'), - headless('pay_planned_amount', 'number', 'First instalment: planned amount'), - headless('pay_condition', 'text', 'First instalment: condition'), headless('submit_now', 'boolean', 'Submit now'), ], successMessage: 'Contract launched.', @@ -90,6 +90,6 @@ export const LaunchContractAction: Action = { exposed: true, category: 'flow', description: - 'Launches a contract: creates the contract draft and its first version from the given contract type, counterparty (existing id, or a new one by name), core terms and the fields the type asks for; optionally records a first payment instalment and submits the contract into legal review or approval. Refuses a blocked counterparty.', + 'Launches a contract: creates the contract draft and its first version from the given contract type, counterparty (an existing record id, or a new one by name) and the core terms plus whichever optional fields the type asks for, then optionally submits it into legal review or approval. Refuses a blocked counterparty, naming it and why.', }, }; diff --git a/src/flows/contract-intake.flow.ts b/src/flows/contract-intake.flow.ts index 39d8f74..3fb3528 100644 --- a/src/flows/contract-intake.flow.ts +++ b/src/flows/contract-intake.flow.ts @@ -20,9 +20,13 @@ import { Party } from '../objects/party.object.js'; * text box. So the two pickers the intake needs — the contract type and the * counterparty — are collected by the `launch_contract` ACTION's param * dialog (`src/actions/contract.actions.ts`), whose field-backed lookup - * params render the real pickers and inherit the fields' `lookupFilters` - * (active types only; blocked parties hidden). The flow receives them as - * the input variables `contract_type` and `party`. + * params render the real record pickers. The flow receives them as the + * input variables `contract_type` and `party`. Those pickers do NOT + * inherit the fields' `lookupFilters` — measured on 17.3.0, the dialog + * fetches `GET /api/v1/data/clm_party?top=50` with no filter on the wire + * and offers the blocked party like any other. So `refuse_blocked` below + * is not belt-and-braces over an already-filtered picker: on this console + * it is the only thing between the launcher and a blocked counterparty. * - A screen field's `visibleWhen` is bare CEL the CLIENT evaluates against * the screen's OWN field values only — flow variables are not in scope * there (the server re-checks `required` under it with the run's variables @@ -38,6 +42,15 @@ import { Party } from '../objects/party.object.js'; * `has(vars.title)`, a variable only a headless caller binds (the action * declares it `visible: false`, so the console dialog never sends it), and * routes straight to the same create nodes. + * - The flow itself CANNOT carry `ai: { exposed: true }` — measured, not + * assumed: `FlowSchema` has no `ai` key on 17.3.0, so `tsc` reports + * "'ai' does not exist in type" and `objectstack validate` refuses the + * stack with "flows.0: Unrecognized key(s) on this flow: `ai`". DESIGN.md + * §06 F1's "`ai.exposed`,输入变量齐全时可由 MCP 调用" is delivered by the + * ACTION instead (`launch_contract.ai.exposed`), which is what the MCP tool + * surface and `POST /api/v1/actions/...` are actually built from; every + * flow input is an `isInput` variable and a declared action param, so a + * caller that supplies them all completes the run with no screen. * - Refusals (`clm_intake_refuse`, registered under `functions`) FAIL the run * with the message: a paused "refusal screen" would leave a headless run * parked forever, and the console shows a failed run's error as the toast. @@ -48,10 +61,31 @@ import { Party } from '../objects/party.object.js'; * prefilled with the new contract. An object form always pauses, which is * why the two headless spellings exist. * - * Every write runs as the launching user (`runAs: 'user'`, the default): - * DESIGN.md §04 decides who may create a party or a payment instalment - * (legal / finance / admin; a requester reads them) and the platform refuses - * the step for anyone else — the flow does not elevate around the matrix. + * ## Every write runs as the launching user, and the graph respects §04 + * + * `runAs: 'user'` (the default): the flow does not elevate around the + * permission matrix. That constrains WHICH records it may create, and the + * boundary was measured per object rather than assumed — as `clm_requester`, + * the set every employee holds: + * + * - `clm_contract`, `clm_contract_version`: created (201). These are the + * flow's two products and every launcher may write them. + * - `clm_payment_plan`: refused, `PERMISSION_DENIED` "You do not have + * permission to perform this action" (§04 grants the requester R only; + * legal's RC creates the same row 201). So F1 does NOT create one — which + * is what DESIGN.md §06 says too: F1's steps end at "建合同(draft)与版本 + * v1 → 可选一键提交", and it is F9 `contract_activate` that generates + * `clm_payment_plan` on activation. An earlier draft of this flow asked + * for an instalment on the last screen and created the row; measured, that + * failed the run at `create_payment_plan` AFTER the contract and its + * version were already committed, orphaning both. WHERE the intake-captured + * arrangement F9 reads should be stored is a design gap, raised as its own + * decision card rather than guessed at here. + * - `clm_party`: refused for a requester the same way, granted to legal + * (§04 gives the requester R, legal RCU). The inline-create branch stays + * because DESIGN.md §06 F1 names it ("相对方查找或新建") and legal and + * admin do launch contracts; it fails ATOMICALLY — `create_party` runs + * before any other write, so a refused requester leaves nothing behind. * * Flow copy is English-only (the source language, DESIGN.md §01); a flow has * no entry in the translation bundles. @@ -71,7 +105,7 @@ function optionsOf(object: { name: string; fields: Record }, fi } /** The optional contract fields a type may list in `intake_fields`, in the order the screen asks them. */ -const INTAKE_FIELD_NAMES = ['governing_law', 'payment_terms', 'confidentiality_term_months', 'liability_cap', 'auto_renew', 'parent_contract'] as const; +const INTAKE_FIELD_NAMES = ['governing_law', 'payment_terms', 'confidentiality_term_months', 'auto_renew', 'parent_contract'] as const; /** Bare CEL over the screen's own fields: shown only when the type lists the field. */ const askedFor = (field: (typeof INTAKE_FIELD_NAMES)[number]): string => `"${field}" in intakeFields`; @@ -131,7 +165,6 @@ export const ContractIntakeFlow: Flow = { { name: 'governing_law', type: 'text', isInput: true, isOutput: false }, { name: 'payment_terms', type: 'text', isInput: true, isOutput: false }, { name: 'confidentiality_term_months', type: 'number', isInput: true, isOutput: false }, - { name: 'liability_cap', type: 'number', isInput: true, isOutput: false }, { name: 'auto_renew', type: 'boolean', isInput: true, isOutput: false, defaultValue: false }, { name: 'parent_contract', type: 'text', isInput: true, isOutput: false }, // Inline counterparty, when none was picked. @@ -143,11 +176,7 @@ export const ContractIntakeFlow: Flow = { // First version. { name: 'draft_from_template', type: 'boolean', isInput: true, isOutput: false, defaultValue: false }, { name: 'first_version_file', type: 'text', isInput: true, isOutput: false }, - // Optional first payment instalment, and the submit toggle. - { name: 'pay_seq', type: 'number', isInput: true, isOutput: false, defaultValue: 1 }, - { name: 'pay_planned_date', type: 'date', isInput: true, isOutput: false }, - { name: 'pay_planned_amount', type: 'number', isInput: true, isOutput: false }, - { name: 'pay_condition', type: 'text', isInput: true, isOutput: false }, + // The submit toggle. { name: 'submit_now', type: 'boolean', isInput: true, isOutput: false, defaultValue: false }, ], @@ -228,7 +257,6 @@ export const ContractIntakeFlow: Flow = { { name: 'governing_law', label: 'Governing law', type: 'text', required: true, placeholder: 'e.g. US-NY, England and Wales', visibleWhen: askedFor('governing_law') }, { name: 'payment_terms', label: 'Payment terms', type: 'select', required: true, options: optionsOf(Contract, 'payment_terms'), visibleWhen: askedFor('payment_terms') }, { name: 'confidentiality_term_months', label: 'Confidentiality term (months)', type: 'number', required: true, visibleWhen: askedFor('confidentiality_term_months') }, - { name: 'liability_cap', label: 'Liability cap', type: 'currency', required: true, visibleWhen: askedFor('liability_cap') }, { name: 'auto_renew', label: 'Auto-renews', type: 'boolean', defaultValue: '{auto_renew}', visibleWhen: askedFor('auto_renew') }, // A flat screen has no record picker (see the header): the parent // contract is collected as its record id. @@ -318,13 +346,14 @@ export const ContractIntakeFlow: Flow = { governing_law: '{governing_law}', payment_terms: '{payment_terms}', confidentiality_term_months: '{confidentiality_term_months}', - // `liability_cap` is NOT in this map: field-level security locks it - // for requesters (DESIGN.md §04, "由法务评定"), and the security layer - // judges a write by the KEYS it carries — an unbound `{liability_cap}` - // still puts the key on the wire and had a requester's launch refused - // with "Field write denied: not permitted to edit [liability_cap]". - // It is written by `set_liability_cap` below, only when a value was - // actually given. + // `liability_cap` is deliberately absent, and the flow does not ask + // for it: DESIGN.md §04 makes it read-only for `clm_requester` ("由法 + // 务评定"), and the launch form is the requester's. Measured on + // 17.3.0: a requester's write carrying the key is refused + // `PERMISSION_DENIED` "[Security] Field write denied: not permitted + // to edit [liability_cap]" — on CREATE and on UPDATE alike, so + // moving the write to a later node does not rescue it. Legal sets + // the cap during review, where §04 puts it. auto_renew: '{auto_renew}', parent_contract: '{parent_contract}', status: 'draft', @@ -333,12 +362,6 @@ export const ContractIntakeFlow: Flow = { outputVariable: 'contractRecord', }, }, - // The liability cap, written only when given — see `create_contract`. - { id: 'decision_liability_cap', type: 'decision', label: 'Liability Cap Given?' }, - { - id: 'set_liability_cap', type: 'update_record', label: 'Set Liability Cap', - config: { objectName: 'clm_contract', filter: { id: '{contractRecord.id}' }, fields: { liability_cap: '{liability_cap}' } }, - }, { id: 'decision_version', type: 'decision', label: 'Where Does Version 1 Come From?' }, { id: 'create_template_version', type: 'create_record', label: 'Version 1 From Template', @@ -389,38 +412,18 @@ export const ContractIntakeFlow: Flow = { }, }, - // ── Step 5: an optional first instalment, and submit ────────────────── - { id: 'decision_schedule_screen', type: 'decision', label: 'Ask About Payment And Submission?' }, + // ── Step 5: submit ──────────────────────────────────────────────────── + { id: 'decision_schedule_screen', type: 'decision', label: 'Ask About Submission?' }, { - id: 'screen_schedule', type: 'screen', label: 'Payment And Submission', + id: 'screen_schedule', type: 'screen', label: 'Submission', config: { - title: 'Payment schedule and submission', - description: 'Optionally record the first planned instalment (more can be added on the contract), then choose whether to submit now. Submitting routes the contract to legal review or to approval, as its type decides.', + title: 'Submit the contract', + description: 'Choose whether to submit now. Submitting routes the contract to legal review or to approval, as its type decides.', fields: [ - { name: 'pay_seq', label: 'Instalment no.', type: 'number', defaultValue: '{pay_seq}' }, - { name: 'pay_planned_date', label: 'Planned date', type: 'date' }, - { name: 'pay_planned_amount', label: 'Planned amount', type: 'currency' }, - { name: 'pay_condition', label: 'Condition', type: 'textarea', placeholder: 'What releases the instalment — acceptance, delivery, a milestone' }, { name: 'submit_now', label: 'Submit now', type: 'boolean', defaultValue: '{submit_now}' }, ], }, }, - { id: 'decision_payment', type: 'decision', label: 'Instalment Given?' }, - { - id: 'create_payment_plan', type: 'create_record', label: 'Create First Instalment', - config: { - objectName: 'clm_payment_plan', - fields: { - contract: '{contractRecord.id}', - seq: '{pay_seq}', - planned_date: '{pay_planned_date}', - planned_amount: '{pay_planned_amount}', - condition: '{pay_condition}', - status: 'planned', - }, - outputVariable: 'instalmentRecord', - }, - }, { id: 'decision_submit', type: 'decision', label: 'Submit Now?' }, { // The write that enters F2: `contract_route` stamps the routing and the @@ -473,10 +476,7 @@ export const ContractIntakeFlow: Flow = { { id: 'e29', source: 'decision_document_screen', target: 'create_contract', type: 'default', condition: NOT_INTERACTIVE, label: 'Headless' }, { id: 'e30', source: 'decision_document_screen', target: 'screen_document', type: 'default', condition: INTERACTIVE, label: 'Interactive' }, { id: 'e31', source: 'screen_document', target: 'create_contract', type: 'default' }, - { id: 'e32', source: 'create_contract', target: 'decision_liability_cap', type: 'default' }, - { id: 'e32a', source: 'decision_liability_cap', target: 'set_liability_cap', type: 'default', condition: P`has(vars.liability_cap) && vars.liability_cap != null && vars.liability_cap != ""`, label: 'Given' }, - { id: 'e32b', source: 'decision_liability_cap', target: 'decision_version', type: 'default', condition: P`!has(vars.liability_cap) || vars.liability_cap == null || vars.liability_cap == ""`, label: 'Not given' }, - { id: 'e32c', source: 'set_liability_cap', target: 'decision_version', type: 'default' }, + { id: 'e32', source: 'create_contract', target: 'decision_version', type: 'default' }, // Version 1: the template (when asked for and available), a supplied file, // or the upload form. Partitioned in that order. { id: 'e33', source: 'decision_version', target: 'create_template_version', type: 'default', condition: P`vars.draft_from_template == true && vars.templateAvailable == true`, label: 'Template' }, @@ -485,12 +485,9 @@ export const ContractIntakeFlow: Flow = { { id: 'e36', source: 'create_template_version', target: 'decision_schedule_screen', type: 'default' }, { id: 'e37', source: 'create_file_version', target: 'decision_schedule_screen', type: 'default' }, { id: 'e38', source: 'screen_upload', target: 'decision_schedule_screen', type: 'default' }, - { id: 'e39', source: 'decision_schedule_screen', target: 'decision_payment', type: 'default', condition: NOT_INTERACTIVE, label: 'Headless' }, + { id: 'e39', source: 'decision_schedule_screen', target: 'decision_submit', type: 'default', condition: NOT_INTERACTIVE, label: 'Headless' }, { id: 'e40', source: 'decision_schedule_screen', target: 'screen_schedule', type: 'default', condition: INTERACTIVE, label: 'Interactive' }, - { id: 'e41', source: 'screen_schedule', target: 'decision_payment', type: 'default' }, - { id: 'e42', source: 'decision_payment', target: 'create_payment_plan', type: 'default', condition: P`has(vars.pay_planned_amount) && vars.pay_planned_amount != null`, label: 'Instalment' }, - { id: 'e43', source: 'decision_payment', target: 'decision_submit', type: 'default', condition: P`!has(vars.pay_planned_amount) || vars.pay_planned_amount == null`, label: 'None' }, - { id: 'e44', source: 'create_payment_plan', target: 'decision_submit', type: 'default' }, + { id: 'e41', source: 'screen_schedule', target: 'decision_submit', type: 'default' }, { id: 'e45', source: 'decision_submit', target: 'submit_contract', type: 'default', condition: P`vars.submit_now == true`, label: 'Submit' }, { id: 'e46', source: 'decision_submit', target: 'end', type: 'default', condition: P`vars.submit_now != true`, label: 'Keep draft' }, { id: 'e47', source: 'submit_contract', target: 'end', type: 'default' }, diff --git a/src/objects/contract-type.object.ts b/src/objects/contract-type.object.ts index feb1df9..f6580a6 100644 --- a/src/objects/contract-type.object.ts +++ b/src/objects/contract-type.object.ts @@ -85,12 +85,19 @@ export const ContractType = ObjectSchema.create({ label: 'Intake Fields', group: 'workflow', multiple: true, - description: 'Optional contract fields the launch form shows (and requires) for this type. Core fields are always asked.', + // The list is exactly the optional fields a LAUNCHING user may write. + // `liability_cap` is not among them and must not be re-added: DESIGN.md + // §04 makes it read-only for `clm_requester` ("由法务评定"), so a type + // that asked for it deadlocked its own contracts — measured on 17.3.0, + // a requester's write of the key is refused `PERMISSION_DENIED`, while + // `contract_state_machine` refuses the submission without it + // ("Intake fields required by the contract type are missing: + // liability_cap."). Legal sets the cap during review. + description: 'Optional contract fields the launch form shows (and requires) for this type. Core fields are always asked. Only fields a launching requester may write belong here.', options: [ { label: 'Governing Law', value: 'governing_law' }, { label: 'Payment Terms', value: 'payment_terms' }, { label: 'Confidentiality Term', value: 'confidentiality_term_months' }, - { label: 'Liability Cap', value: 'liability_cap' }, { label: 'Auto Renewal', value: 'auto_renew' }, { label: 'Parent Contract', value: 'parent_contract' }, ],