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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,10 @@ pnpm lint:i18n-gate # every zh-CN key present — a missing one renders as Engl

## The demo dataset

`pnpm demo` loads 820 rows: nine contract types, a thirty-clause playbook, a
six-rule approval matrix, forty counterparties and 120 contracts with their
reviews, deviations, signature rounds, obligations and payment instalments.
`pnpm demo` loads 830 rows: nine contract types, a thirty-clause playbook, a
six-rule approval matrix, three signing companies and seven departments, forty
counterparties and 120 contracts with their reviews, deviations, signature
rounds, obligations and payment instalments.
Every date is relative to boot, so ten contracts always expire within the next
thirty days and the arrears lists are always in arrears.

Expand Down
5 changes: 3 additions & 2 deletions src/apps/clm.app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ import { App } from '@objectstack/spec/ui';
* already pruned every row the caller's capabilities do not open, so the
* landing is the first surviving row, which can differ per person.
*
* Every position also holds `clm_requester` (`bind-position-sets.ts`), so
* Every member, position or not, also holds `clm_requester` (the app's
* default set, `isDefault: true` in `src/profiles/requester.profile.ts`), so
* 我的合同 survives for everyone. With it listed first, every audience opened
* on 我发起的: the right page for a business requester, and an empty one for
* a lawyer, who launches nothing. §05 gives each audience its own partition and
Expand Down Expand Up @@ -319,7 +320,7 @@ export const ClmApp = App.create({
{ id: 'nav_legal_workbench', type: 'dashboard', dashboardName: 'legal_workbench', label: 'Legal Workbench', icon: 'scale', requiredPermissions: ['clm_legal.access'] },
// NO row gate, and not by oversight. §09's 管理层 board is for the two
// leadership rungs, and `clm_executive` / `clm_general_manager` hold
// no set of their own (`bind-position-sets.ts`): their capabilities
// no set of their own (`src/sharing/positions.ts`): their capabilities
// are exactly a requester's, so every spelling that hides this row
// from a requester hides it from leadership too. Who reads it is an
// open question on #99, not a gate to invent here.
Expand Down
3 changes: 2 additions & 1 deletion src/datasets/cycle-time.dataset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,9 @@ import { defineDataset } from '@objectstack/spec/ui';
export const CycleTimeDataset = defineDataset({
name: 'contract_cycle_time',
label: 'Contract Cycle',
// Why there is no duration measure: the module comment above.
description:
'Stage coverage by contract type and legal owner: how many contracts have reached each lifecycle stamp, and when. Carries no duration measure — see the module comment for the measurement that rules one out.',
'Stage coverage by contract type and legal owner: how many contracts have reached each lifecycle stage, and when. It counts the stages reached; it does not measure how long a stage took.',
object: 'clm_contract',
include: ['contract_type'],

Expand Down
2 changes: 1 addition & 1 deletion src/datasets/obligation.dataset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { defineDataset } from '@objectstack/spec/ui';
export const ObligationDataset = defineDataset({
name: 'obligation_metrics',
label: 'Obligation Metrics',
description: 'Post-signature commitments by status, kind, owner, contract and due month — including the arrears the daily job stamps.',
description: 'Post-signature commitments by status, kind, owner, contract and due month — including those the daily check has marked overdue.',
object: 'clm_obligation',

// `contract` is the master-detail parent; one hop to the contract, two to its
Expand Down
2 changes: 1 addition & 1 deletion src/datasets/payment.dataset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export const PaymentDataset = defineDataset({
name: 'payment_metrics',
label: 'Payment Metrics',
description:
'Instalments planned against actual, by month, counterparty and contract direction — plus the arrears the daily job stamps. Amounts are each contract\'s own currency and are not FX-converted.',
'Instalments planned against actual, by month, counterparty and contract direction — including those the daily check has marked overdue. Amounts are each contract\'s own currency and are not FX-converted.',
object: 'clm_payment_plan',
include: ['contract', 'contract.party'],

Expand Down
11 changes: 8 additions & 3 deletions src/flows/contract-approval.flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,10 @@ const RUNG_POLICY = {
export const ContractApprovalFlow: Flow = {
name: 'contract_approval',
label: 'Contract Approval Ladder',
description: 'Five-rung approval for a contract entering approval: direct manager, then the legal/finance joint sign-off, the executive and the general manager as the routing flags require.',
// "As the approval matrix requires" = the route_* flags: F2 stamps them from
// the matching clm_approval_rule rows, and F6 adds route_legal_head for an
// accepted deviation from a flagged clause (see the header).
description: 'Approval for a contract entering approval: the direct manager first, then the head of legal and/or the finance controller, the executive and the general manager, as the approval matrix and any accepted clause deviations require.',
type: 'record_change',
status: 'active',
// See the header — the approvals service's mirror write carries no user.
Expand Down Expand Up @@ -288,7 +291,9 @@ export const ContractApprovalFlow: Flow = {
{
id: 'legal_finance_joint',
type: 'approval',
label: 'Head of Legal + Finance Controller (会签)',
// The step name approvers read. 会签 in the zh-CN bundle; "joint
// sign-off" here, so the English UI carries no Chinese.
label: 'Head of Legal + Finance Controller (joint sign-off)',
config: {
// 会签: `per_group` advances only once EACH group has reached
// `minApprovals`. The two group labels are what make them two groups —
Expand Down Expand Up @@ -441,7 +446,7 @@ export const ContractApprovalFlow: Flow = {
// Rung 2 selection — four branches over two flags, in opposite polarity so
// they partition: exactly one is true for every pair of values.
{
id: 'e06', source: 'decision_rung2', target: 'legal_finance_joint', type: 'default', label: 'Legal + finance (会签)',
id: 'e06', source: 'decision_rung2', target: 'legal_finance_joint', type: 'default', label: 'Legal + finance, jointly',
condition: when(routed('legal'), routed('finance')),
},
{
Expand Down
27 changes: 17 additions & 10 deletions src/flows/contract-intake.flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,17 +205,23 @@ export const ContractIntakeFlow: Flow = {
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).
// Both branches bind `templateAvailable`, so no later read meets an
// unbound variable (the house rule of the flow-variable conditions table
// in the spec's FlowVariableSchema note).
//
// No sentence is assigned here. An assignment value is not translatable,
// and a screen text slot's `{{ }}` hole is a variable path with no
// branching, so a note picked here rendered in English under every locale.
// The version screen below says what holds for every type, and the
// template sentence is the toggle's own help text — both bundle keys.
{ 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.' } },
config: { assignments: { templateAvailable: true } },
},
{
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.' } },
config: { assignments: { templateAvailable: false } },
},
// 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.
Expand Down Expand Up @@ -325,16 +331,17 @@ export const ContractIntakeFlow: Flow = {
id: 'screen_document', type: 'screen', label: 'First Version',
config: {
title: 'First version',
description: '{{ templateNote }}',
description: 'The document this contract starts from. By default you upload it on the next step.',
fields: [
// Carrier, as `intakeFields` on the core screen: `visibleWhen` sees
// only this screen's own fields, so the type's answer is brought onto
// the screen (server-interpolated, a boolean) and hidden by its own
// predicate. The toggle is then offered only on a type that carries
// a template; on one that does not, the description above says so
// and there is nothing to tick.
// predicate. The toggle — and the sentence about the template, its
// help text — is then offered only on a type that carries a
// template; on one that does not, the description above is the
// whole screen and there is nothing to tick.
{ name: 'templateAvailable', label: 'Template available', type: 'boolean', defaultValue: ref('templateAvailable'), visibleWhen: 'templateAvailable == null' },
{ name: 'draft_from_template', label: 'Draft from template', type: 'boolean', defaultValue: ref('draft_from_template'), visibleWhen: 'templateAvailable == true' },
{ name: 'draft_from_template', label: 'Draft from template', type: 'boolean', defaultValue: ref('draft_from_template'), visibleWhen: 'templateAvailable == true', inlineHelpText: 'This contract type carries a template. Tick to attach it as version 1 instead of uploading a file.' },
],
},
},
Expand Down
3 changes: 2 additions & 1 deletion src/flows/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ import { ExpirationSweepFlow } from './expiration-sweep.flow.js';
import { ExecutedUploadFlow, backfillStamps, refuseBackfill } from './executed-upload.flow.js';

// F1–F16 of DESIGN.md §06; this barrel is what `defineStack({ flows })` reads.
// F8 (e-signature) and F15 (CRM hand-off) arrive with cards 12 and 13.
// F8 (e-signature) and F15 (CRM hand-off) are not built yet; both arrive with
// card 12 (docs/backlog/12-esign-and-crm-handoff.md).
export { ContractIntakeFlow } from './contract-intake.flow.js';
export { ContractApprovalFlow } from './contract-approval.flow.js';
export { SignatureRecordFlow, SignatureRecordOnCreateFlow } from './signature-record.flow.js';
Expand Down
3 changes: 2 additions & 1 deletion src/flows/signature-record.flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,8 @@ export const SignatureRecordFlow: Flow = {
version_no: ref('execution.nextVersionNo'),
kind: 'final_signed',
turn: 'internal',
// `is_current` is deliberately left at its `false` default. The
// `is_current` is deliberately written `false` here, against the
// field's `true` default (contract-version.object.ts). The
// negotiation flag is what `approved → signing` reads ("a current
// clean version is required"), and DESIGN.md §03 keeps
// `signing → approved` open for a failed signing round — moving the
Expand Down
4 changes: 3 additions & 1 deletion src/objects/party.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ export const Party = ObjectSchema.create({
label: 'Country',
group: 'identity',
maxLength: 2,
description: 'ISO 3166-1 alpha-2 country code of the party (e.g. US, DE, CN). Drives governing-law defaults and screening.',
// Nothing reads this field yet: no governing-law default and no
// screening step uses it, so the help text promises neither.
description: 'Two-letter ISO 3166-1 code of the country the party is based in (e.g. US, DE, CN).',
}),
registration_no: Field.text({
label: 'Registration / Tax ID',
Expand Down
8 changes: 6 additions & 2 deletions src/profiles/admin.profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,15 +85,19 @@ export const AdminSet = definePermissionSet({
{
name: 'contract_admin_edit_any_status',
label: 'The administrator edits a contract in any status',
description: 'The permissive companion of contract_requester_edit_window (policies OR-merge across the sets a person holds; Modify All Data does not skip them on this object).',
// The permissive companion of contract_requester_edit_window: policies
// OR-merge across the sets a person holds, and Modify All Data does not
// skip them on this object.
description: 'Lets the CLM administrator edit a contract in any status, not only while it is Draft or Submitted.',
object: 'clm_contract',
operation: 'update',
using: inList('status', CONTRACT_STATUSES),
},
{
name: 'obligation_admin_update_any',
label: 'The administrator updates any obligation',
description: 'The permissive companion of obligation_requester_own.',
// The permissive companion of obligation_requester_own.
description: 'Lets the CLM administrator update any obligation, not only the ones assigned to them.',
object: 'clm_obligation',
operation: 'update',
using: inList('status', OBLIGATION_STATUSES),
Expand Down
10 changes: 7 additions & 3 deletions src/profiles/finance.profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,23 +53,27 @@ export const FinanceSet = definePermissionSet({
{
name: 'contract_finance_post_approval_window',
label: 'Finance edits a contract from approved onward',
description: 'DESIGN.md §04 contract_finance_post_approval, restated as the row policy that survives the OR-merge with clm_requester.',
// DESIGN.md §04 contract_finance_post_approval, restated as the row policy
// that survives the OR-merge with clm_requester.
description: 'Lets finance edit a contract that is Approved, Signing, Active, Expired or Terminated.',
object: 'clm_contract',
operation: 'update',
using: inList('status', POST_APPROVAL_STATUSES),
},
{
name: 'review_finance_stage_insert',
label: 'Finance records finance-stage reviews',
description: 'DESIGN.md §04: RCU(stage=finance).',
// DESIGN.md §04: RCU(stage=finance).
description: 'Finance can record a review only at the finance stage.',
object: 'clm_review',
operation: 'insert',
check: 'stage == "finance"',
},
{
name: 'review_finance_stage_update',
label: 'Finance updates finance-stage reviews',
description: 'DESIGN.md §04: RCU(stage=finance).',
// DESIGN.md §04: RCU(stage=finance).
description: 'Finance can update a review only at the finance stage.',
object: 'clm_review',
operation: 'update',
using: 'stage == "finance"',
Expand Down
13 changes: 9 additions & 4 deletions src/profiles/legal.profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,31 +75,36 @@ export const LegalSet = definePermissionSet({
{
name: 'contract_legal_edit_any_status',
label: 'Legal edits a contract in any status',
description: 'The permissive companion of contract_requester_edit_window (policies OR-merge across the sets a person holds).',
// The permissive companion of contract_requester_edit_window (policies
// OR-merge across the sets a person holds).
description: 'Lets legal edit a contract in any status, not only while it is Draft or Submitted.',
object: 'clm_contract',
operation: 'update',
using: inList('status', CONTRACT_STATUSES),
},
{
name: 'review_legal_insert_any_stage',
label: 'Legal records a review at any stage',
description: 'The permissive companion of the finance stage policies on clm_review.',
// The permissive companion of the finance stage policies on clm_review.
description: 'Lets legal record a review at any stage, the finance stage included.',
object: 'clm_review',
operation: 'insert',
check: inList('stage', REVIEW_STAGES),
},
{
name: 'review_legal_update_any_stage',
label: 'Legal updates a review at any stage',
description: 'The permissive companion of the finance stage policies on clm_review.',
// The permissive companion of the finance stage policies on clm_review.
description: 'Lets legal update a review at any stage, the finance stage included.',
object: 'clm_review',
operation: 'update',
using: inList('stage', REVIEW_STAGES),
},
{
name: 'obligation_legal_update_any',
label: 'Legal updates any obligation of a contract it can edit',
description: 'The permissive companion of obligation_requester_own.',
// The permissive companion of obligation_requester_own.
description: 'Lets legal update any obligation of a contract it can edit, not only the ones assigned to it.',
object: 'clm_obligation',
operation: 'update',
using: inList('status', OBLIGATION_STATUSES),
Expand Down
12 changes: 8 additions & 4 deletions src/profiles/records.profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ import { editableOnly, hidden, inList } from './_grants.js';
* no seal keeper; execution registration and archive are one position).
*
* "RU(signing 及之后;执行与归档字段)" on contracts. The rows arrive through
* `contract_records_execution` (`edit`, signing onward); the row policy
* repeats the window for the OR-merge; and field-level security limits the
* edit to the two execution/archive fields a person actually writes:
* two sharing rules: `contract_records_execution` (`edit`, signing onward) and
* `contract_records_approved_read` (`read`, approved only — ruling A on #100,
* so records can read the contract it starts signing). The row policy repeats
* the edit window for the OR-merge, and field-level security limits the edit
* to the two execution/archive fields a person actually writes:
*
* - `status` — execution IS the `signing → active` transition the
* `execute_contract` gate authorises (the state machine guards it);
Expand Down Expand Up @@ -52,7 +54,9 @@ export const RecordsSet = definePermissionSet({
{
name: 'contract_records_execution_window',
label: 'Records edits a contract from signing onward',
description: 'DESIGN.md §04 contract_records_execution, restated as the row policy that survives the OR-merge with clm_requester.',
// DESIGN.md §04 contract_records_execution, restated as the row policy
// that survives the OR-merge with clm_requester.
description: 'Lets the records manager edit a contract that is Signing, Active, Expired or Terminated.',
object: 'clm_contract',
operation: 'update',
using: inList('status', EXECUTION_STATUSES),
Expand Down
6 changes: 4 additions & 2 deletions src/profiles/requester.profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,15 +138,17 @@ export const RequesterSet = definePermissionSet({
{
name: 'contract_requester_edit_window',
label: 'Requesters edit a contract only while it is draft or submitted',
description: 'DESIGN.md §04: RCU(本人发起,draft/submitted 可改). From review onward the contract is edited by legal, finance and records through their own grants.',
// DESIGN.md §04: RCU(本人发起,draft/submitted 可改).
description: 'A requester can change a contract only while it is Draft or Submitted. From review onward, legal, finance and records make the changes.',
object: 'clm_contract',
operation: 'update',
using: inList('status', REQUESTER_EDITABLE_STATUSES),
},
{
name: 'obligation_requester_own',
label: 'Requesters update the obligations they own',
description: 'DESIGN.md §04: RU(本人负责). Reading follows the contract; updating needs the obligation to be assigned to the caller.',
// DESIGN.md §04: RU(本人负责).
description: 'A requester sees the obligations of the contracts they can see, and updates only the ones assigned to them.',
object: 'clm_obligation',
operation: 'update',
using: 'owner == current_user.id',
Expand Down
Loading
Loading