HotCLM
Contract lifecycle management on ObjectStack — buy-side, sell-side and everything in between, as typed metadata. Self-serve intake, a clause playbook, a data-driven approval matrix, e-signature and execution formalities, obligations and payment schedules: the whole lifecycle in one readable repository.
基于 ObjectStack 的合同全生命周期管理。 业务自助发起、条款库与偏离、审批矩阵、电子签与执行形式、履约义务、收付款计划 —— 全部是类型化元数据。
Status: M1–M3 are in. The model and the permission layer, intake, the approval ladder, signing and execution formalities, the post-signature reminder layer, the analytics and both locale bundles all land in this tree. M4 is what remains — e-signature, the HotCRM hand-off, the AI skills and the release: DESIGN.md §11 is the milestone table, and cards 12 · 13 · 14 in docs/backlog are what is left in it. Sibling app of HotCRM: commercial terms stay in the CRM, legal state lives here.
- Ironclad-shaped, not OA-shaped. A contract type is a workflow: intake fields, review, approval ladder, signing method, archive rules — configuration, not code.
- Business users launch, legal controls. One intake form per contract type; the approval matrix decides who signs off.
- Contracts are data. Obligations, payment schedules, renewals and deviations from the clause playbook are queryable records with reminders, not paragraphs in a PDF.
- Global by default, local by configuration. English-first UI with full
zh-CN; multi-entity, multi-currency, governing law and jurisdiction on every contract; e-signature through DocuSign, Adobe Acrobat Sign or Dropbox Sign; a company seal, notarization or witnessing are execution formalities a contract type can require, not modules. - AI is a participant under governance. Intake by chat or MCP, extraction of executed contracts, review memos for approvers, deviation detection against the playbook — every AI step proposes, a person confirms, and the audit trail records both.
- Industry- and region-neutral by rule — contract types, thresholds, execution formalities, currencies and payment terms live in seed data only.
pnpm install
pnpm dev # REST + Console on http://localhost:3000 — an EMPTY app
pnpm demo # the same app with the demo group loaded (English)
pnpm demo:zh # the same rows, in Chinesepnpm dev is deliberately empty: a standard product does not install somebody
else's contract book into a fresh deployment. pnpm demo is the opposite
intention — a fictional multi-entity group, six months of negotiation and a
book of contracts already in force, so every list has something on its first
screen. Sign in with admin@objectos.ai / admin123, or as one of the
demo staff with the same password: a
business requester such as requester1@example.com is an account whose 我的合同
the demo fills.
pnpm demo also turns on the platform's scheduled work, which is off by
default, so the daily reminder sweeps run on the demo book; a deployment that
wants those reminders in the inbox sets OS_AUTOMATION_SCHEDULED_WORK_ENABLED=true.
Point the two locales at different databases; they key their rows on localised titles, so seeding one on top of the other gives you both books.
Every metadata change is gated:
pnpm validate # protocol schema + CEL predicates + bindings
pnpm lint # data-model conventions (reserved vocabulary, titles, master-detail)
pnpm typecheck
pnpm lint:i18n-gate # every zh-CN key present — a missing one renders as English, silently| Path | Contents |
|---|---|
objectstack.config.ts |
defineStack() — the single entry point |
src/objects/ |
clm_* business objects |
DESIGN.md |
Architecture authority — objects, permissions, audiences, automation, milestones |
AGENTS.md |
Conventions for humans and coding agents; read by the PM dispatch loop |
src/data/ |
The demo dataset — demo-en/ (default) and demo-zh/, selected by OS_SEED_LOCALE |
docs/backlog/ |
Dispatch-ready work cards (each file is an issue body) |
pnpm demo loads 1,130 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 300 document versions, 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.
The people the demo book is dealt to are declared in objectstack.config.ts
(demoStaff), and the platform creates them on the demo boot — after the dev
admin, before the seed runs — so every seeded row that names a person reaches
them on the first pnpm demo, with no step of yours. They are the persona mix
of DESIGN.md §10: three business requesters and one account per
position of §04, two for legal counsel.
Every one signs in with the development seed password, the dev admin's:
admin123 (or whatever OS_SEED_ADMIN_PASSWORD says when it is set). The
console does not list them; this table is where they are named.
| Account | Sign in as | Position | What it holds in the demo |
|---|---|---|---|
Business Requester 1 · 2 · 3 |
requester1@example.com · requester2@example.com · requester3@example.com |
none — every member holds clm_requester |
the 120 contracts' owner_id, dealt by counterparty — 43 / 43 / 34 · and the 144 delivery and reporting obligations under them — 50 / 54 / 40 · the 2 business reviews — 0 / 1 / 1 |
Legal Counsel 1 · 2 |
counsel1@example.com · counsel2@example.com |
clm_legal_counsel |
receives contracts on submission (round-robin by open load) and owns review · the legal_owner of every contract legal has accepted — 34 / 34 · all 56 compliance obligations — 29 / 27 · the decided_by of all 17 decided deviations — 10 / 7 · the reviewer of their contracts' 34 legal reviews — 16 / 18, the 7 still pending among them — 2 / 5 |
Head of Legal |
legal.head@example.com |
clm_legal_head |
rung 2 of the approval ladder whenever route_legal_head is stamped · the reviewer of the legal review of the contract handed back to the queue |
Finance Controller |
finance@example.com |
clm_finance_controller |
rung 2/3, reads every contract with route_finance, and the only position that may edit bank details · the reviewer of all 23 finance reviews, the 7 on contracts still in review or in approval among them, recorded there through Record Finance Review (记录财务审查) |
Executive |
executive@example.com |
clm_executive |
the three requesters' line manager, so rung 1 of every approval on their contracts · rung 4, and reads every contract with route_executive |
General Manager |
gm@example.com |
clm_general_manager |
rung 5, and reads every contract with route_gm |
Records Manager |
records@example.com |
clm_records_manager |
execution formalities, the executed copy and the archive number |
CLM Administrator |
clm.admin@example.com |
clm_admin |
maintains the configuration objects and holds full reach over every CLM object: the only position that may write clm_contract_type and clm_approval_rule, and the only one the app's Administration group is served to |
A business requester is not a position. Every member of the organization holds
clm_requester by default (DESIGN.md §04): it is the app's
default permission set, bound to the platform's everyone anchor, so a
requester needs no position and no grant. They launch contracts and see their
own. The seven positions hold the same set the same way, on top of their own.
Signed in, each requester lands on 我的合同 › 我发起的 (Launched by Me) with their contracts, and finds their obligations under 我负责的履约 (My Obligations); each lawyer lands on 法务工作台 and finds their contracts under 我的审查 (My Reviews); finance lands on 财务, records on 执行与档案 and the CLM administrator on 管理. A decided deviation's Decided By shows its lawyer on the contract page's Deviations grid.
Three things about where these accounts exist:
- On a local development boot only.
os devcreates them; a production boot, a migration run or a server whose auth base URL is not a loopback address creates none and logs why.os buildleaves the declaration out of the artifact, so no deployment boots with them. They are not seeded users: no fixture carries asys_userrow, and §10's 「用户不可种子」 stands. - With the demo only. The declaration is gated on
CLM_DEMO_SEED, the switch that loads the dataset, sopnpm devstays empty of people as well as of contracts: it signs in the dev admin and nobody else. - Once. Running
pnpm demoagain finds every address already there and leaves that account exactly as it is — name, password, manager — adding only a position it is missing, and a requester's manager when it has none. No account is duplicated.
Every dataset is an upsert, so pnpm demo also runs again on a database that
already holds the demo, after the daily jobs have run and after you have worked
in it. A re-run slides every boot-relative date forward, puts back any demo row
that has been deleted, and refreshes the rest of the fixture.
It does not turn the clock back. The columns the app itself writes once a
row exists are declared applyOn: 'create' in the dataset (src/data/): the
seed writes them when it creates the row and leaves them alone after that. They
are status on contracts, obligations and payment instalments, the flag F12
sets, the stage timestamps and routing flags, the termination reason, the
archive number, and the receipt recorded on an instalment. So what the daily
jobs did stays done. An instalment F11 moved to overdue stays overdue, a
contract F13 expired stays expired, and F12 does not send a renewal notice
twice. The same goes for a contract someone has terminated or archived, or an
instalment finance has marked paid. Measured after one run of every daily job
plus those four desk actions: the re-run refused no row and dropped none.
Three limits. Any other column you edited, such as a summary, a reassigned
legal owner or obligation owner, or a review's reviewer, goes back to the
fixture's value. A
deviation, signature round or review you decided is not covered yet: the re-run
puts its fixture status or decision back, and for a deviation or a signature
round the state machine may refuse that. The lifecycle columns also keep the dates they
were stamped with, while the fixture's own dates (start, end, due and planned
dates) move forward on each re-run. To get back to the corpus exactly as
shipped, start from a clean database: rm -rf .objectstack/data && pnpm demo.
Every obligation has an owner, its platform owner (owner_id), and that is
the person accountable for it: the one who finds it under 我负责的履约, gets
F10's (obligation_due) reminders, and may update its progress. Legal and the
CLM administrator can hand an obligation to someone else; the owner cannot.
The dev admin is deliberately none of them. Like every member it holds
clm_requester, so it sees 我的合同 and 分析 (GET /api/v1/meta/app/clm serves
it exactly those two groups), but it holds no position and no other clm_* set:
clm_legal.access gates the legal workbench away, and the finance, records and
administration groups stay hidden the same way. A row parked there still lands
on the wrong account — as a contract's legal owner it sits behind a workbench
the dev admin cannot open, and as a business owner or an obligation's owner it
fills the setup account's 我的合同 instead of the requester's — and its
reminders go to somebody who is not going to act on them.
The 60 reviews are dealt to the desk whose stage they are. A legal review is the
contract's lawyer's, or the head of legal's on the one contract handed back to
the queue. A finance review is the finance controller's, before approval as well
as after: finance edits a contract (and so writes its reviews directly) only
from approved onward, so on a contract still in review or in approval it
records its review through the Record Finance Review (记录财务审查) button
on the contract instead. A business review is the requester's who launched the
contract. A compliance review would be the head of legal's; the demo deals
none. src/data/keys.ts (reviewerOf) says why each.
Every contract sent for approval opens on rung 1: the direct manager of the
contract's owner — the business requester who launched it, not the lawyer
who pressed Send for Approval — read from that account's
sys_user.manager_id. No seed, position or permission set fills that column.
On the demo it is filled for you. The three business requesters report to
Executive (executive@example.com): each requester's demoStaff entry names
that address as its manager (@objectstack 18.3), and the demo boot writes it
once every demo account exists and before the seed runs. So on the first
pnpm demo a requester's contract opens rung 1 on the Executive, who finds it
under 我的合同 › 待我处理 (Waiting on Me) as 直属上级 (Direct Manager), with
nothing for you to do first. The persona mix has no line managers of its own,
so the one account takes rung 1 for all three; it is also rung 4 when
route_executive is stamped.
The manager needs nothing more: approve_contract, the gate a rung acts
through, is in clm_requester, which every account holds.
The boot writes a manager only on an account that has none. A manager you set
on a requester by hand stays on every later pnpm demo; one you clear is
written again on the next boot. The other demo staff have no manager, and
nothing on the demo needs them to: rung 1 reads the owner of the contract, and
the demo deals every contract to a requester.
On your own organization it is a setup step. For anyone else who launches
contracts, set it in Setup → Users → the user → ⋯ → Set Manager (or by
script, below). An owner with no manager does not stall the request: rung 1
resolves to nobody, so it opens on the organization's administrators instead
(onEmptyApprovers: 'admin_rescue'). Measured on 18.3.0 with the requesters'
manager unset: pending_approver_labels read Dev Admin and the request
recorded the reason "no approver on the node resolved to a person". Setting the
manager afterwards does not reach a request that is already open: its approvers
were resolved when the rung opened. A platform admin moves it on from
Setup → Approvals → Approvals Inbox → All, with Override Reassign (or
Override Approve / Reject).
No rung sends its approver a message when it opens — nothing in their inbox, no email. They see it waiting in the Console: the bell counts the approvals waiting on them and 我的合同 › Waiting on Me lists them. A reminder sent on a request does arrive as a message; the missing one on opening is the platform's, not this app's.
The rung-1 manager reads the contract while the request waits on them, and
only then. From @objectstack 18.2.0 the platform gives every person a pending
step waits on a read-only view of the one record the request is about
(upstream objectstack-ai/objectstack#26180); this app declares nothing for
it. So a manager who holds no position at all opens the request from
我的合同 › Waiting on Me and lands on the contract page itself, with
通过 / 拒绝 above it. The whole page opens: the key terms, the 合同版本
table (the version's PDF opens), 审查, 偏离, 履约义务, 收付款计划,
签署记录, the approval panel and the activity feed, and each review,
deviation, obligation, signature round and version opens on its own page. Nothing can be changed: the page has no 编辑, its ⋯ menu
offers only 添加到收藏 and 分享, and a write through the API answers
403 PERMISSION_DENIED.
The read ends with the step. Once the manager approves or rejects, the contract,
its child rows and its files are closed to them again — from @objectstack 18.3
the page they decided on says so straight away (「你的审批决定已记录 · 你的审批环节已结束,
对此记录的访问权限也随之结束」) with a link back to the approvals inbox. A contract of a report's that is not waiting on them (a
draft, or one already past rung 1) stays closed too: on the open edition
being the owner's manager grants no read. The scope that would
(own_and_reports, DESIGN.md §04 contract_manager_reports) is an
enterprise-edition hierarchy scope that the open edition resolves to
owner-only. The rungs after rung 1 are positions, and each reads the contracts
it approves.
The demo staff get their positions from the platform. Staffing an organization of your own goes
through Setup → Users, and automating that needs one more fact: the
assignment binds by the position's name, not by its id. Read that off the declaration, not off
the field name — sys_user_position.position is declared text, and its own description says what
it carries: "Position machine name (references sys_position.name)." The field name tells you
nothing here, because this object mixes both kinds freely: user_id, granted_by, delegated_from
and certified_by are all genuine lookups, and only three of its fourteen fields carry an _id
suffix at all. The neighbouring object binds the same way: a direct permission-set grant is a
sys_user_permission_set row whose permission_set column is text and carries the set's name
({ "user_id": "…", "permission_set": "clm_legal" }) — there is no id-typed column to reach for.
POST /api/v1/data/sys_user_position
{ "user_id": "…", "position": "clm_legal_counsel" }A name-spelled row grants the permission set that position names (permissionSets in
src/sharing/positions.ts). clm_requester does not come from the position but from membership — a
lawyer holds clm_legal and clm_requester both, and security/explain attributes the second to the
"additive baseline". To read back what an account actually ended up with:
POST /api/v1/security/explain
{ "userId": "…", "object": "clm_contract", "operation": "read" }object and operation are both required there — a body carrying only userId answers
400 VALIDATION_FAILED.
An id in the position field is accepted, not refused. Nothing resolves that column on write, so
a row spelled with the position's id is stored verbatim, matches no position and grants no permission
set — and the write still answers 201, with no diagnostic anywhere to say so. The account lands where
a plain member does: signed in with clm_requester alone, served 我的合同 and 分析 but none of the
position's own groups, with nothing in the response that created it pointing at why. The silent acceptance is
the platform's — objectstack-ai/objectstack#16712, still open — so until that lands, the spelling is
the whole defence.
A manager is an admin operation, not a data write. Set Manager posts exactly this, as a platform admin (the dev admin is one):
POST /api/v1/auth/admin/set-user-manager
{ "userId": "…", "managerId": "…" }The data API refuses the column outright: a PATCH /api/v1/data/sys_user/… carrying manager_id
answers 403 PERMISSION_DENIED, because it edits only name, image and locale on a user.
The demo is loaded as system data, not driven through the flows. Two consequences are worth knowing before you read a number:
- No approval requests exist. F5 (
contract_approval) opens asys_approval_requeston entry toin_approval, and the seed loader suppresses record-change automation — so 42 contracts sit atin_approvalor beyond withapproval_statusset and no request behind them. The approvals inbox is empty, and a dashboard counting approval requests will read zero while the contract list reads 42. The other 48 executed contracts carryis_backfilled: true, which is the model's own word for a contract that reachedactivewithout review or approval (DESIGN.md §13 Q8) — they claim no approval history because they have none. - Every document is a demo PDF. The 300 contract versions, the templates
of six contract types, the executed copy of the 24 completed signature rounds
and the evidence of the 90 completed obligations each hold one of seven
one-page PDFs shipped in
src/data/fixtures/. Each says on its face that it is a demo document and carries no terms, party or signature. The platform stores a separate copy for every row (seedFile, @objectstack 18.2.0), so each one downloads and can be replaced or deleted on its own. Every contract has exactly one current version: the newest negotiation version, never an executed copy unless it is the only version. The 30 contracts in negotiation carry three to five versions with at least one counterparty redline, the 24 negotiated executed contracts add their executed copy, the 48 backfilled ones carry only the executed copy, and every draft, submitted and cancelled contract carries its first draft. That first draft is what lets a seeded draft be submitted. The supplier, lease and amendment types ship without a template, so the launch form asks for an upload on those and starts from the template on the other six. A re-run ofpnpm demostores no file again, and it leaves alone which version is current, a template, an executed copy and evidence once you have changed them.
Apache-2.0 — see LICENSE.