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
5 changes: 3 additions & 2 deletions objectstack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ export default defineStack({
positions: ClmPositions,
permissions: Object.values(profiles),

// The eleven authorization tokens the five permission sets grant in
// The twelve authorization tokens the five permission sets grant in
// `systemPermissions` (ADR-0066 D1) — this app DEFINES them; it is not a
// `requires:` service and adds no plugin. Two reasons to declare rather than
// let the platform derive them from the sets:
Expand Down Expand Up @@ -227,7 +227,8 @@ export default defineStack({
{ name: 'clm_records.access', scope: 'org', label: 'CLM: Records', description: 'Serves the Records group of the CLM app.' },
{ name: 'clm_admin.access', scope: 'org', label: 'CLM: Administration', description: 'Serves the Administration group of the CLM app.' },
{ name: 'approve_contract', scope: 'org', label: 'Approve contracts', description: 'DESIGN.md §04 action gate for the approval ladder; which request a holder decides is the approval flow\'s assignment.' },
{ name: 'execute_contract', scope: 'org', label: 'Execute contracts', description: 'DESIGN.md §04 action gate for signing, activation and backfill of executed contracts.' },
{ name: 'execute_contract', scope: 'org', label: 'Execute contracts', description: 'DESIGN.md §04 action gate for signing and activation.' },
{ name: 'backfill_contract', scope: 'org', label: 'Backfill contracts', description: 'DESIGN.md §04 gate for F16: recording a contract executed outside this system. Held by records, legal and admin; gates the backfill action and its flow.' },
{ name: 'archive_contract', scope: 'org', label: 'Archive contracts', description: 'DESIGN.md §04 action gate for archiving.' },
{ name: 'terminate_contract', scope: 'org', label: 'Terminate contracts', description: 'DESIGN.md §04 action gate for ending a contract in force.' },
{ name: 'manage_clauses', scope: 'org', label: 'Manage clauses', description: 'DESIGN.md §04 action gate for the clause playbook.' },
Expand Down
52 changes: 32 additions & 20 deletions src/actions/contract-backfill.actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,32 +10,41 @@ import type { Action } from '@objectstack/spec/ui';
* CREATES a contract, and it creates it born `active`, which
* `contract_type_stamp` refuses for anything but a system write ("A contract
* is created as a draft, not as active"). `executed_upload` is that system
* write — a flow with `runAs: 'system'` — and this action is its door. The
* caller's identity still rides through the elevation, so `created_by` names
* the person who backfilled and the row lands in their organization.
* write — a `screen` flow with `runAs: 'system'` — and this action is its
* door. The caller's identity still rides through the elevation, so
* `created_by` names the person who backfilled and the row lands in their
* organization.
*
* `list_toolbar` only, like `launch_contract`: the action makes a record, so
* it has no record to sit on, and declaring a record-scoped location would
* make the console demand a selected row first.
*
* ## The gate, and the half of it this platform cannot express
* ## The gate: `backfill_contract`
*
* §06 F16 says "仅 `clm_records.access` 与 `clm_legal.access` 可用" — records
* OR legal. `requiredPermissions` is an AND: the runtime refuses unless the
* caller holds EVERY capability listed
* (`missing = required.filter(p => !held.has(p))`), and no capability is held
* by both `clm_records` and `clm_legal` today. So the declaration below gates
* on `execute_contract`, the §04 capability for the execution lane — held by
* `clm_records` and `clm_admin` — which delivers the records half of the rule
* exactly and leaves the legal half out. The legal counsel keeps every other
* path (§04 grants `clm_legal` RCU on `clm_contract`); what they lose is this
* button.
* §06 F16 says the records manager OR legal may backfill. The gate is one
* capability both hold rather than an OR of two: `backfill_contract`, granted
* by `clm_records`, `clm_legal` and `clm_admin` (§04; issue #43, ruling A —
* backfilling and registering an execution are two business acts, so the
* backfill gets its own name instead of borrowing `execute_contract`).
*
* That gap is REPORTED, not papered over: closing it needs either a new §04
* capability granted to both sets (a governed-surface decision, not a
* developer's) or an OR-form gate on the platform. Gating on `visible` instead
* would hide the button without stopping the request, which is the thing the
* spec's own guidance calls out — "hiding is not gating".
* It is declared twice, on purpose, with the same list. The flow's
* `requiredPermissions` is the gate: every door that starts the flow by name
* (this action over REST and MCP, the trigger route) refuses a caller without
* it, `403 PERMISSION_DENIED`, before anything runs. The action repeats it
* because only the action's OWN list hides the button: the console's action
* strip and MCP `list_actions` read the action, not the flow it starts
* (objectstack#25643). Hiding is not gating — the flow's list is what stops
* the request — but without the repeat every member would see a button that
* answers 403.
*
* ## The business owner is asked, not assumed
*
* `owner_id` is a required param (issue #99, item 7): whoever types a legacy
* contract in is rarely the requester who owns it on the business side, and
* the owner is who §04 widens rows to (the private OWD, 我的合同, rung 1's
* manager). The platform stamps the acting user only on an insert that leaves
* `owner_id` empty, so the value given here is the one stored. `legal_owner`
* is a different field (§03) and is not asked here.
*/
export const ExecutedUploadAction: Action = {
name: 'executed_upload',
Expand All @@ -47,7 +56,8 @@ export const ExecutedUploadAction: Action = {
locations: ['list_toolbar'],
// The flow inserts the row straight into `active` with `is_backfilled: true`.
description: 'Record a contract that was signed before this system, or outside it. It is created directly as active and marked as backfilled, skipping review and approval, and the executed copy is filed as its final signed version.',
requiredPermissions: ['execute_contract'],
// The same list as the flow's (see the header): this copy hides the button.
requiredPermissions: ['backfill_contract'],
params: [
// Field-backed where a picker is wanted: the param inherits the field's
// type, its label and its `lookupFilters`, so inactive types, blocked
Expand All @@ -57,6 +67,8 @@ export const ExecutedUploadAction: Action = {
{ field: 'contract_type', required: true, helpText: 'The workflow this contract would have run. It still stamps the number, the category and the execution formalities.' },
{ field: 'party', required: true, helpText: 'The counterparty on the executed document.' },
{ field: 'title', required: true },
// Who owns the contract on the business side; the flow writes it as given.
{ field: 'owner_id', required: true, helpText: 'The requester who owns this contract on the business side. It is stored as given: the person recording the backfill is not assumed to be the owner.' },
// `signed_date` becomes both `signed_at` and `executed_at`; `executed_file`
// is filed as version 1 with `kind: 'final_signed'` (executed-upload.flow.ts).
{ name: 'signed_date', type: 'date', label: 'Signed On', required: true, helpText: 'The date on the executed document. It is recorded as both the signed date and the execution date.' },
Expand Down
57 changes: 50 additions & 7 deletions src/flows/executed-upload.flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,48 @@ import { must, ref } from './_values.js';
* other contract in the product earns `active` by walking §03 from `draft`,
* and that is the rule; a backfill is the declared exception (§13 Q8), so it
* is taken through the one door the model already opened for it rather than by
* weakening the guard.
* weakening the guard. The same system write is what lets the run set the
* fields §04 locks for every position — `is_backfilled` and the stage stamps
* `signed_at` / `executed_at` / `activated_at` — and file the version for a
* caller whose own grants could not (`clm_records` creates no contract, and
* locks every contract field but two). Under `runAs: 'user'` the first insert
* is refused, so the elevation stays.
*
* Elevation is not anonymity here. A flow launched from an action carries the
* CALLER's `userId` and `tenantId` alongside `isSystem`, so the platform's
* audit stamps (`created_by` / `updated_by`) name the person who did the
* backfill, the row lands inside the caller's organization, and the contract
* number takes its org-scoped sequence. That is what "全部留审计" needs, and it
* is why the action is a flow rather than a script body: a script body runs as
* the caller and would be refused at the first insert.
* the caller and would be refused at the first insert. `owner_id` is not the
* caller: the dialog asks for the business owner and the insert writes it
* (issue #99, item 7), because the platform stamps the acting user only where
* `owner_id` is left empty.
*
* ## Who may start it: `type: 'screen'` + `requiredPermissions`
*
* Elevation decides what the run may write; it says nothing about who may
* start it. On 18.x a door that starts a flow by name (the trigger route, a
* `type: 'flow'` action over REST and MCP, a declared endpoint) asks two
* questions in order (`flowStartRefusal`, objectstack#25643):
*
* 1. Is it `runAs: 'system'` with a self-triggered type (`autolaunched`,
* `record_change`, `schedule`)? Then only the system principal may start
* it, and no capability opens it. This flow was `autolaunched` until
* issue #43, and every member — the records manager included — was
* refused "This caller may not start this flow".
* 2. Does the caller hold every capability in the flow's
* `requiredPermissions`? Otherwise `403 PERMISSION_DENIED`, nothing run.
*
* So the type is `screen`: an entry a member starts from a button, which is
* what this is, elevated by the author's explicit choice. It has no screen
* node — the action's dialog collects every input — so a run goes straight
* from start to its end or a refusing end. Not `api`: that type is an
* HMAC-signed inbound webhook, and the engine refuses to arm one without a
* per-flow secret. The gate is `requiredPermissions: ['backfill_contract']`,
* the capability §04 grants `clm_records`, `clm_legal` and `clm_admin` (§06
* F16: records or legal). The action that starts it repeats the list only to
* hide its button (see `src/actions/contract-backfill.actions.ts`).
*
* ## What it does NOT do
*
Expand Down Expand Up @@ -116,19 +149,26 @@ export const ExecutedUploadFlow: Flow = {
// Creates the contract in `active` with `is_backfilled` set and files the
// executed copy as a `final_signed` version.
description: 'Record a contract that was signed outside this system: create it directly as active, marked as backfilled, and file the executed copy as its final signed version.',
type: 'autolaunched',
// An entry a member starts from the 补录已签合同 button. Not `autolaunched`:
// 18.x refuses every non-system caller at the door of an elevated
// self-triggered flow, whatever they hold (see the header).
type: 'screen',
status: 'active',
// See the header: the ONE thing elevation buys is the right to insert a
// contract born `active`, which `contract_type_stamp` reserves for a system
// write (§13 Q8's declared exception). The caller's identity rides along, so
// the audit trail names them.
// See the header: elevation buys the right to insert a contract born
// `active`, which `contract_type_stamp` reserves for a system write (§13
// Q8's declared exception), and the stamps §04 locks for every position.
// The caller's identity rides along, so the audit trail names them.
runAs: 'system',
// The gate: records, legal and admin hold it (§04, §06 F16). Checked at
// every door that starts this flow by name, before anything runs.
requiredPermissions: ['backfill_contract'],
successMessage: 'Executed contract recorded.',

variables: [
{ name: 'contract_type', type: 'text', isInput: true, isOutput: false },
{ name: 'party', type: 'text', isInput: true, isOutput: false },
{ name: 'title', type: 'text', isInput: true, isOutput: false },
{ name: 'owner_id', type: 'text', isInput: true, isOutput: false },
{ name: 'signed_date', type: 'text', isInput: true, isOutput: false },
{ name: 'executed_file', type: 'text', isInput: true, isOutput: false },
{ name: 'our_entity', type: 'text', isInput: true, isOutput: false },
Expand Down Expand Up @@ -215,6 +255,9 @@ export const ExecutedUploadFlow: Flow = {
objectName: 'clm_contract',
fields: {
title: ref('title'),
// The business owner the dialog asked for. Left unbound (a headless
// caller that omits it), the platform stamps the acting user.
owner_id: ref('owner_id'),
contract_type: ref('contract_type'),
party: ref('party'),
our_entity: ref('our_entity'),
Expand Down
3 changes: 2 additions & 1 deletion src/profiles/admin.profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { CONTRACT_STAMPED_FIELDS, PARTY_ASSESSED_FIELDS, inList, openAllExcept,
/**
* `clm_admin` — the CLM administrator (DESIGN.md §04): RCUD on every object,
* write access to the four configuration objects that every other set only
* reads, and all six action gates.
* reads, and all seven action gates.
*
* `viewAllRecords` / `modifyAllRecords` on the private contract and its
* children: an administrator reaches every row without a sharing rule, and
Expand Down Expand Up @@ -56,6 +56,7 @@ export const AdminSet = definePermissionSet({
'clm_admin.access',
'approve_contract',
'execute_contract',
'backfill_contract',
'archive_contract',
'terminate_contract',
'manage_clauses',
Expand Down
5 changes: 3 additions & 2 deletions src/profiles/legal.profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,15 @@ import { CONTRACT_STAMPED_FIELDS, PARTY_ASSESSED_FIELDS, inList, openAllExcept,
* `manage_clauses` (clause RCU is legal's alone), `terminate_contract` (legal
* and admin are the only sets that may write `status` on an active contract:
* finance's is locked by §13 Q1, records' edit is limited to execution and
* archive) and `approve_contract` (the head of legal is a ladder rung) are the
* archive), `approve_contract` (the head of legal is a ladder rung) and
* `backfill_contract` (F16, 补录已签合同: records or legal, §06) are the
* action gates §04 assigns here.
*/
export const LegalSet = definePermissionSet({
name: 'clm_legal',
label: 'Legal',
description: 'Legal counsel and the head of legal: every contract, every review, the clause playbook.',
systemPermissions: ['clm_legal.access', 'approve_contract', 'terminate_contract', 'manage_clauses'],
systemPermissions: ['clm_legal.access', 'approve_contract', 'terminate_contract', 'manage_clauses', 'backfill_contract'],
objects: {
clm_contract: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false, readScope: 'own' },
clm_contract_version: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false },
Expand Down
7 changes: 6 additions & 1 deletion src/profiles/records.profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,17 @@ import { editableOnly, hidden, inList, onRowsWhere } from './_grants.js';
* date a wet-ink round records, and the round's status so the round can be
* completed at all (F7's wet-ink path names this position). On `clm_party`
* the banking and phone fields are withheld (§04 FLS table).
*
* `backfill_contract` opens F16 (补录已签合同), which records shares with legal
* and admin (§06 F16). The flow writes the backfilled contract as the system
* (`executed-upload.flow.ts`), so the object and field grants above do not
* limit it.
*/
export const RecordsSet = definePermissionSet({
name: 'clm_records',
label: 'Records',
description: 'The records manager: execution registration, archive and the contract ledger.',
systemPermissions: ['clm_records.access', 'execute_contract', 'archive_contract'],
systemPermissions: ['clm_records.access', 'execute_contract', 'archive_contract', 'backfill_contract'],
objects: {
clm_contract: { allowCreate: false, allowRead: true, allowEdit: true, allowDelete: false, readScope: 'own' },
clm_contract_version: { allowCreate: true, allowRead: true, allowEdit: false, allowDelete: false },
Expand Down
3 changes: 3 additions & 0 deletions src/translations/en/objects.contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,9 @@ export const contract: Record<string, ObjectTranslationData> = {
party: {
helpText: 'The counterparty on the executed document.',
},
owner_id: {
helpText: 'The requester who owns this contract on the business side. It is stored as given: the person recording the backfill is not assumed to be the owner.',
},
signed_date: {
label: 'Signed On',
helpText: 'The date on the executed document. It is recorded as both the signed date and the execution date.',
Expand Down
3 changes: 3 additions & 0 deletions src/translations/zh-CN/objects.contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,9 @@ export const contract: Record<string, ObjectTranslationData> = {
party: {
helpText: '执行副本上的相对方。',
},
owner_id: {
helpText: '业务侧对这份合同负责的发起人。按所选人员登记,不会默认成办理补录的人。',
},
signed_date: {
label: '签署日期',
helpText: '执行副本上的日期,同时写入签署时间与执行完成时间。',
Expand Down
Loading