Skip to content

Contract domain: clm_contract and its four review-time children (card 02, M1) - #5

Merged
hotlong merged 2 commits into
mainfrom
claude/issue-2-contract-domain
Sep 7, 2026
Merged

hotlong merged 2 commits into
mainfrom
claude/issue-2-contract-domain

Conversation

@hotlong

@hotlong hotlong commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Fixes #2

What changed

The contract domain of DESIGN.md §03: five objects, two hook files, and the one line in objectstack.config.ts that registers the hooks.

Objects — src/objects/

  • contract.object.ts — clm_contract: sharingModel: 'private', nameField: 'title', icon file-signature, 58 fields: every field of §03 plus the card's governing_law / jurisdiction / contract_language. Readonly with no writer in this card: the type-derived stamps (contract_number, category, direction, execution_formalities — written by the hook below), the stage timestamps submitted_at … closed_at, route_* / approval_status / is_expiring / executed_at / archived_at (flows), the four ai_* fields (the adopt-suggestion action only, §07) and is_backfilled (F16, card 09; §13 Q8). Unique index (contract_number) scope organization. No roll-ups (card 03).
  • contract-version.object.ts · review.object.ts · deviation.object.ts · signature.object.ts — controlled_by_parent, nameField: 'display_name' (a stored text mirror, never a formula), contract = Field.masterDetail('clm_contract') with deleteBehavior: 'cascade', inlineEdit: 'grid' and the inline titles from the card (Versions / Reviews / Deviations / Signatures); icons file-stack / gavel / git-branch / pen-line. Fields and enums as §03; clm_signature.formalities_done carries the same value set as clm_contract_type.execution_formalities, so sealing stays a formality value and not a module (§13).
  • index.ts — the five exports under the "Contract domain" comment, Contract first.

Hooks

  • contract.hook.ts
    • contract_type_stamp (beforeInsert / beforeUpdate, runAs: 'system' for the sequence read — the object is private, so an inherited context would number per user): a non-system insert must be born draft (F16 backfill and seed replay are system writes and exempt); category, direction, execution_formalities are copied from the type; contract_number = TYPE_CODE-YYYY-0000, one sequence per type per year, next = max existing + 1 scoped to the organization (§13 Q5 — autonumber cannot express it). Changing the type is allowed on a draft only and re-stamps all four.
    • contract_state_machine (beforeUpdate on status): the §03 transition table and every guard — required intake fields of the type, party set and not blocked, a first version or a type template, requires_legal_review routing, legal_owner before review, no open deviation and an approved legal review before approval, a current clean version before signing, a completed signature whose formalities_done covers the contract's execution_formalities plus a final_signed version before activation, expired reserved for the system (F13), closed_at on termination. Every refusal is a structured error code: 'INVALID_STATE', status: 422 (the ledger's code for "understood, but the record is not in a state that allows this"); nothing is coerced. Stage timestamps are stamped on entry.
    • deviation_state_machine (open → accepted / rejected / withdrawn, decided states final; stamps decided_at / decided_by when empty) and signature_state_machine (draft → sent / completed / voided, sent → completed / declined / voided, declined → draft; an e-signature round must be sent before completed, wet ink may complete from draft; stamps completed_at when empty).
  • mirror.hook.ts — the four display_name mirrors: v3 · Clean, Legal · Lena Legal (reviewer name read from sys_user, id fallback), Limitation of liability · Accepted (clause title), E-signature · Completed.
  • hooks.ts — the flat hook barrel; _hook-api.ts — the type-only shape of ctx.api (HotCRM's key sets, measured on 17.3.0). Every helper lives inside its handler: objectstack build reports "all 8 callables are body-only" and emits no runtime bundle.
  • objectstack.config.ts — hooks: allHooks. One import and one key outside the src/objects/ file surface of the claim: a metadata Hook is registered only from the top-level hooks stack key (ObjectSchema refuses a hooks key with a prescriptive message), so the wiring line is the difference between the state machine existing and not.

Gates — HEAD 31a80f3

pnpm validate:

  → Validating against ObjectStack Protocol...
  → Running author-time rules (42)...
  → Checking capability providers (#3366)...
  → Checking package docs (ADR-0046)...
  ✓ Validation passed (197ms)
  HotCLM v0.1.0
  Contract lifecycle management — intake, review, approval, execution, obligations and archive. Global by default, AI-assisted under governance.
  Data: 9 Objects  143 Fields
  UI: 0 Apps
  Logic: 0 Flows
  Security: 0 Positions  0 Permissions
  Runtime: 0 plugins

pnpm lint:

◆ Lint
────────────────────────────────────────
  → Loading configuration...
  ℹ Config: /home/user/hotclm-issue-2/objectstack.config.ts
  Suggestions (1)
  ℹ "clm_contract" owns "clm_contract_version" (master_detail) with numeric field "version_no" but has no roll-up summary; consider a summary field (count/sum) on clm_contract
    rollup/missing-summary  at objects[2].fields
  1 suggestion(s) (177ms)

The one suggestion (info level, exit 0) is the version_count roll-up — card 03. No title-unresolvable, missing-name-field, security-role-word, or hook-body/not-lowerable.

pnpm typecheck:

> hotclm@0.1.0 typecheck /home/user/hotclm-issue-2
> tsc --noEmit

(exit 0.) pnpm build (not a gate, run for the lowering evidence): → Skipping legacy runtime bundle (all 8 callables are body-only); dist/objectstack.json carries 8 hooks with body.language: 'js', runAs: 'system' on contract_type_stamp, inferred capability api.read on the four hooks that read.

Real-engine run (one-off evidence, not a repo file)

A scratch script booted a real ObjectQL engine on the memory driver with the nine objects (plus a minimal sys_user), bound the hooks with bindHooksToEngine, and walked the whole lifecycle twice: once with the in-process handlers from objectstack.config.ts, once with the LOWERED bodies from dist/objectstack.json executed by the real QuickJS sandbox (hookBodyRunnerFactory(new QuickJSScriptRunner()), the production shape). Both surfaces: 42 / 42 PASS. Both card acceptance lines are in it, refused with code=INVALID_STATE status=422:

[body] hooks bound: registered=13 skipped=0 errors=[]
PASS: contract_number stamped NDA-YYYY-0001
PASS: category/direction stamped from type
PASS: execution_formalities stamped as []
PASS: status defaults to draft
NOTE: is_backfilled=true on an engine-direct insert — on 17.3.0 the INSERT-side readonly strip runs at the DataProtocol ingress (REST), which this harness bypasses; not asserted here.
PASS: sequence increments per type per year
PASS: separate sequence per type
PASS: max+1 numbering: a deleted draft leaves a gap, not a duplicate
PASS: insert born active is refused (non-system)
PASS: system write may create an active (backfilled) contract
PASS: ACCEPTANCE: draft → active is refused
PASS: ACCEPTANCE: draft → submitted with a blocked party is refused
PASS: draft → submitted with a missing intake field is refused
PASS: draft → submitted with no version and no template is refused
PASS: version display_name mirrored (hand-typed value overridden)
PASS: draft → submitted accepted; submitted_at stamped
PASS: submitted → in_approval refused when type requires legal review
PASS: submitted → in_review refused without legal_owner
PASS: submitted → in_review accepted; review_started_at stamped
PASS: deviation display_name mirrored from clause title + status
PASS: in_review → in_approval refused with an open deviation
PASS: deviation open → accepted stamps decided_at/decided_by and re-mirrors
PASS: deviation accepted → open refused (final)
PASS: in_review → in_approval refused without an approved legal review
PASS: review display_name mirrored with the reviewer NAME
PASS: in_review → in_approval → approved; approved_at stamped
PASS: approved → signing refused without a current clean version
PASS: signing → active refused without a completed signature
PASS: signature display_name mirrored
PASS: e-signature draft → completed refused (must be sent first)
PASS: signature sent → completed stamps completed_at and re-mirrors
PASS: signature completed → draft refused (final)
PASS: signing → active refused without a final_signed version
PASS: signing → active; signed_at/executed_at/activated_at stamped
PASS: active → expired refused for a non-system caller
PASS: type change refused once the contract is not a draft
PASS: active → terminated; closed_at stamped
PASS: terminated → draft refused (terminal)
PASS: type change on a draft re-stamps category and renumbers under the new code
PASS: submitted → in_review refused when the type needs no legal review
PASS: signing → active refused when formalities_done does not cover the type's execution_formalities
PASS: wet-ink round completes from draft; activation passes once company_seal is recorded
PASS: active → expired accepted for a system caller (the expiry job)
[body] ALL PASS

(The NOTE line: on 17.3.0 the INSERT-side readonly strip runs at the DataProtocol ingress — stripReadonlyForInsert in metadata-protocol / rest — which an engine-direct harness bypasses; the objectql copy of that strip is newer than the pinned release. The hook-written stamps on readonly fields survive on both paths: contract_number overrode a forged caller value on insert, submitted_at etc. landed on update.)

The REST door itself was not driven: objectstack start refuses to boot a data API on a stack that mounts no auth tier (requires: ['ui'] here; adding auth is a capability expansion the card does not grant). What REST adds over the engine is the envelope mapping, which reads exactly status and a ledger-registered code off the thrown error — both set on every refusal.

Measurements against the dispatch assumptions

  1. crm_contract cross-package lookup — the assumption "validate will refuse" is falsified, but so is the card's condition. With crm_contract: Field.lookup('crm_contract', …) declared, pnpm validate and pnpm lint both exit 0 with no diagnostic (144 fields). Control probe: a lookup to zzz_object_that_does_not_exist also passes both gates with no diagnostic. So validate does not evaluate Field.lookup.reference targets at all (@objectstack/lint's validate-object-references covers action params, dashboards, datasets and navigation; defineStack's cross-reference check covers hooks, views, seeds, mappings, grants, nav and actions — not field references). "Accepts" is therefore the absence of a check, not a verdict on cross-package references, and a dangling lookup is a silent runtime failure (record picker 404, $expand failure) in the standalone composition. The field is left out — the same choice card 01 made for clm_party.crm_account — and the question is in the report's open_questions with a recommendation (declare it in card 12 behind CLM_COMPOSITION). Adding it later is a five-line change.
  2. Roll-ups are card 03 — holds. A child master-detail needs no parent-side field; lint only suggests a summary (rollup/missing-summary, info level, exit 0).
  3. beforeUpdate hook with a structured refusal — holds. code + status propagate through both the in-process wrapper and the QuickJS SandboxError; INVALID_STATE is a ledger member, so the REST mapper keeps it (an unregistered spelling would be demoted to declaredCode and code derived from the status).
  4. 9 objects — holds: Data: 9 Objects 143 Fields.

Decisions taken where the card left room — please confirm in review

  • Picklists §03 marks "seeded" without pinning values. our_entity: one placeholder head_office (a group's legal entities are configuration, not a taxonomy the standard product can guess — the description says so). department: sales / procurement / legal / finance / operations / people / it / other. contract_language: en default + zh / ja / de / fr / es (mirrors the currency list's spread). approval_status: not_required (default) / pending / approved / rejected — HotCRM's approval mirror. payment_terms: HotCRM's set (net_15 / net_30 / net_60 / net_90 / due_on_receipt) so the F15 hand-off maps 1:1. provider: docusign / adobe_sign / dropbox_sign. Note Field.select lowercases every option value, so USD is stored as usd (AGENTS.md's lowercase rule).
  • A non-system insert must be born draft (the FSM entry point; otherwise a REST create could be born active). System writes are exempt, which is how F16 and seed replay enter active directly.
  • in_review → draft requires a review with decision changes_requested (or rejected) — my reading of §03's "审查 changes_requested" as a checkable guard rather than an actor note.
  • in_approval → approved / rejected / draft carry no guard here: the approval ladder (F5, card 06) owns those decisions.
  • signing → active: §03 also requires signed_at non-empty; signed_at is readonly and no flow stamps it yet, so the hook stamps signed_at and executed_at from the completed signature's completed_at (and activated_at = now) on activation. F7 (card 06) may stamp them earlier; the guard stays satisfiable without it.
  • active → expired is refused for a non-system caller (§03: "仅日任务 F13"); terminate is the manual exit.
  • Unique index (contract, version_no) on clm_contract_version — an integrity constraint implied by version_no, not pinned in §03.
  • Picker filters (lookupFilters): active types, non-blocked parties, active clauses — convenience only; the hook is the rule.

验收备注

  • noted, not filed: DESIGN.md §03's active → terminated guard says "closed_at 与终止原因必填", but the §03 field list declares no termination-reason field; this card stamps closed_at only. A termination_reason field (or a note that summary carries it) is a DESIGN.md §03 edit — outside a code PR without a decision.
  • noted, not filed: DESIGN.md §13 Q4 records the optional clm_party.crm_account lookup as "已落地", but party.object.ts declares no such field (card 01 left the cross-package lookup out, as this card does for crm_contract).
  • noted, not filed: packages/spec ships a declarative state_machine validation rule (transitions map, initialStates, legalNextStates() introspection). The table lives in the hook per the card; mirroring it declaratively later would let the UI grey out illegal transitions — worth a card, not a rider, and only with one source of truth.
  • filing candidate (platform gap, objectstack-ai/objectstack): objectstack validate / lint / build accept a Field.lookup / Field.masterDetail whose reference names an object that exists nowhere (control probe above), so the metadata fails only at runtime. Per AGENTS.md "Platform gaps: report, never patch". Search-before-file is recorded in the report.
  • The rollup/missing-summary suggestion is card 03's version_count.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KcrVDXSptwDukFsHPHPR1V


Generated by Claude Code

Contract domain of DESIGN.md §03: clm_contract (private, title as
nameField, hook-generated contract_number per §13 Q5) plus the four
master-detail children clm_contract_version, clm_review, clm_deviation
and clm_signature (controlled_by_parent, stored display_name mirrors).
Type-derived stamps, stage timestamps, routing flags, the ai_* fields
and is_backfilled are readonly with no writer here; the hooks that
stamp them land in the next commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KcrVDXSptwDukFsHPHPR1V
…hooks

contract.hook.ts: a contract is born draft (system writes exempt, for the
F16 backfill and seed replay); category, direction and execution
formalities are copied from the type; the number is
TYPE_CODE-YYYY-0000 per type per year (max existing + 1, scoped to the
organization, DESIGN.md §13 Q5). The status transition table of §03 and
every guard — intake fields, blocked party, first version or template,
legal owner, open deviations, approved legal review, current clean
version, completed signature covering the type's formalities plus a
final_signed version — refuse with code INVALID_STATE / status 422 and
stamp the stage timestamp on entry. Deviation and signature machines in
the same file.

mirror.hook.ts: stored display_name mirrors for the four children.

hooks.ts + objectstack.config.ts: hooks are a top-level stack key, so
the barrel and the one wiring line are what registers them; every one
of the 8 handlers lowers to a metadata-only body (no runtime bundle).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KcrVDXSptwDukFsHPHPR1V
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Contract domain: clm_contract and its four review-time children (card 02, M1)

2 participants