Skip to content

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

Description

@hotlong

Milestone: M1 · Card: docs/backlog/02-contract-domain.md · Blocked-by: — (builds on the initial commit; base on main after PR #1 merges — it carries the V1.0 rulings this card depends on)

Scope

src/objects/contract.object.ts, contract-version.object.ts, review.object.ts, deviation.object.ts,
signature.object.ts, plus contract.hook.ts (state machine) and mirror.hook.ts (display_name
stamps). Export from src/objects/index.ts under the "Contract domain" comment, Contract first.

Spec — pinned in DESIGN.md §03, repeated here where a choice exists

clm_contract — sharingModel: 'private', nameField: 'title', icon file-signature. Fields exactly as
DESIGN.md §03, with these decisions taken:

  • contract_number: text, stored, generated by the hook (ruled 2026-09-07, DESIGN.md §13 Q5 — autonumber cannot express <type.code>-<YYYY>-<seq>). Format ${type.code}-${year}-${4-digit seq per type per year}, stamped beforeInsert, readonly, unique index (contract_number) scope organization.
  • category, direction, execution_formalities: stamped from contract_type beforeInsert/beforeUpdate, readonly.
  • amount: currency, scale 2, min 0. currency_code: select seeded (USD default, EUR, GBP, CNY, JPY); the organization default is a setting, not schema. Add governing_law (text, ISO country or state, e.g. US-NY, England and Wales), jurisdiction (text), contract_language (select seeded en default).
  • status: select with the exact values of the §03 state machine, default draft; every option carries a color.
  • route_*, approval_status, stage timestamps (submitted_at … closed_at), is_expiring, executed_at, archived_at: readonly, hook/flow-written only.
  • Lookups: contract_type* → clm_contract_type; party* → clm_party; legal_owner → Field.user; renewed_from / parent_contract → clm_contract; crm_contract → lookup crm_contract only if objectstack validate accepts a cross-package reference to an object not in this stack — otherwise leave the field out and return needs_decision naming the refusal.
  • is_backfilled boolean, readonly, default false — set only by the F16 executed_upload action (card 09).
  • The four ai_* fields (ai_summary richtext, ai_risk_score number 0–100, ai_risk_rationale textarea, ai_reviewed_at datetime): readonly, written only by the "adopt suggestion" action (DESIGN.md §07); declare them now, no writer in this card.
  • Roll-ups (version_count, open_deviation_count, planned_amount, actual_amount, overdue_obligation_count) are card 03 — declare nothing here.

Children — all sharingModel: 'controlled_by_parent', nameField: 'display_name', contract*
masterDetail clm_contract with deleteBehavior: 'cascade', inlineEdit: 'grid':
clm_contract_version (icon file-stack, inlineTitle Versions) · clm_review (icon gavel, Reviews) ·
clm_deviation (icon git-branch, Deviations) · clm_signature (icon pen-line, Signatures) — one execution record per signing round: method esign/wet_ink, provider, envelope_id, signers json ([{ side: our|counterparty, name, email, order, status, signed_at }]), status draft/sent/completed/declined/voided, formalities_done multiselect mirroring the type's execution_formalities, completed_at, executed_file file.
Fields and enums exactly as DESIGN.md §03. display_name is a stored text mirror stamped by mirror.hook.ts with the format DESIGN.md gives per object.

State machine — contract.hook.ts, beforeUpdate on status: the transition table of DESIGN.md §03, including every guard (required intake fields, party not blocked, no open deviation before in_approval, clean version before signing, a completed signature whose formalities_done covers the type's execution_formalities + final_signed version before active, closed_at on terminated). Refuse with a structured error (code, status: 422) — never silently coerce. Stamp the stage timestamp on each entry. Child state machines (deviation, signature) in the same file.

Acceptance

  • Gates green; pnpm validate reports 9 objects.
  • A REST update draft → active is refused; draft → submitted with a blocked party is refused.
  • pnpm lint shows no title-unresolvable / missing-name-field / security-role-word.

Out of scope

Roll-ups (03), permissions (04), flows (05/06), seeds (08).

Activity

  1. self-assigned this
    on Sep 7, 2026
  2. hotlong commented on Sep 7, 2026

    @hotlong
    ContributorAuthor

    Claim: PM loop round 1
    Session: session_01KcrVDXSptwDukFsHPHPR1V
    Branch: claude/issue-2-contract-domain
    Worktree: hotclm-issue-2
    Domain: repo:hotclm (single-lane repo)
    File surface: src/objects/ (stop on breach; explain in the report)
    Container & model: L, mode:subagent, model: fable (state machine + numbering hook carry design judgment)
    Clause-②: no
    Serial constraints cleared: none — #3 and #4 are pm:blocked on this card; no other in-flight claim in this repo. Base is main @ ef17697 (PR #1 merged, carries the V1.0 rulings).


    Generated by Claude Code

  3. hotlong commented on Sep 7, 2026

    @hotlong
    ContributorAuthor

    os-dev-report

    {
      "issue": 2,
      "status": "done",
      "branch": "claude/issue-2-contract-domain",
      "pr": "https://github.com/objectstack-ai/hotclm/pull/5",
      "premise_still_valid": true,
      "pm_assumptions": {
        "1_crm_contract_validate_refuses": false,
        "1_note": "Falsified, and the card's own condition proved vacuous: validate/lint/build exit 0 with NO diagnostic for Field.lookup('crm_contract'); the control probe Field.lookup('zzz_object_that_does_not_exist') also passes all three. validate does not read Field.lookup.reference targets at all (lint validate-object-references covers action params/dashboards/datasets/nav; defineStack validateCrossReferences covers hooks/views/seeds/mappings/grants/nav/actions). Field LEFT OUT (same as card 01 did for clm_party.crm_account); open question below; gap filed as objectstack-ai/objectstack#16611.",
        "2_rollups_not_required_by_masterdetail": true,
        "2_note": "lint only SUGGESTS a summary (rollup/missing-summary, info level, exit 0) — card 03's version_count.",
        "3_beforeUpdate_structured_refusal": true,
        "3_note": "code INVALID_STATE (ledger member) + status 422 propagate through both the in-process wrapper and the QuickJS SandboxError; measured on a real engine, both surfaces.",
        "4_nine_objects": true,
        "4_note": "Data: 9 Objects 143 Fields at HEAD 31a80f3."
      },
      "summary": "Landed clm_contract (private, nameField title, 58 fields incl. readonly stamps/timestamps/ai_*/is_backfilled, unique contract_number per organization) and the four controlled_by_parent children (version/review/deviation/signature, master-detail cascade, inlineEdit grid, stored display_name mirrors). contract.hook.ts: born-draft rule (system exempt), type-derived stamps, TYPE_CODE-YYYY-0000 numbering (max+1 per type per year, organization-scoped, runAs system), the full DESIGN §03 transition table with every guard refusing INVALID_STATE/422 and stamping stage timestamps, plus the deviation and signature machines; mirror.hook.ts stamps the four titles. Hooks wired via src/objects/hooks.ts + one `hooks: allHooks` line in objectstack.config.ts (outside the src/objects/ file surface — a metadata Hook registers only from the top-level stack key; declared, not silent). crm_contract lookup omitted (see pm_assumptions.1). All 8 handlers lower to metadata-only bodies (no runtime bundle).",
      "tests": "At HEAD 31a80f3: `pnpm validate` exit 0 — `✓ Validation passed` `Data: 9 Objects  143 Fields`; `pnpm lint` exit 0 — `1 suggestion(s)` = rollup/missing-summary only (no title-unresolvable / missing-name-field / security-role-word / hook-body/not-lowerable); `pnpm typecheck` exit 0 (tsc --noEmit clean); `pnpm build` exit 0 — `Skipping legacy runtime bundle (all 8 callables are body-only)`, dist/objectstack.json: 8 hooks body.language js, runAs system on contract_type_stamp, api.read inferred on the 4 reading hooks. Real-engine scenario (scratch script, not a repo file): ObjectQL + InMemoryDriver + bindHooksToEngine, walked the whole lifecycle on two surfaces — in-process handlers from objectstack.config.ts, and the LOWERED bodies from dist/objectstack.json run by hookBodyRunnerFactory(new QuickJSScriptRunner()) — 42/42 PASS each, including the two acceptance lines: `PASS: ACCEPTANCE: draft → active is refused — code=INVALID_STATE status=422` and `PASS: ACCEPTANCE: draft → submitted with a blocked party is refused — code=INVALID_STATE status=422 msg=\"Counterparty Shady Ltd is blocked…\"`. Numbering: NDA-2026-0001, -0002, MSA-2026-0001, gap-not-duplicate after delete, renumber on type change of a draft. Not driven: the REST door itself (objectstack start refuses to boot a data API on a stack with no auth tier; adding auth is a capability expansion the card does not grant). crm_contract probe + control probe: validate/lint exit 0, 144 fields, no diagnostic, both restored with `git checkout HEAD --`, `git diff HEAD` empty. Control-byte scan of changed files: no hits.",
      "gates": {
        "validate": "exit 0 — ✓ Validation passed; Data: 9 Objects 143 Fields (HEAD 31a80f3)",
        "lint": "exit 0 — ✓ 1 suggestion(s): rollup/missing-summary (info; card 03). None of title-unresolvable / missing-name-field / security-role-word / hook-body/not-lowerable",
        "typecheck": "exit 0 — tsc --noEmit clean",
        "build": "exit 0 (evidence, not a gate) — all 8 callables are body-only, no runtime bundle emitted",
        "ci": "in_progress — PR just opened; CI convergence is the PM's to read"
      },
      "line_budget": "n/a — no skills/** files touched",
      "files_changed": [
        "src/objects/contract.object.ts",
        "src/objects/contract-version.object.ts",
        "src/objects/review.object.ts",
        "src/objects/deviation.object.ts",
        "src/objects/signature.object.ts",
        "src/objects/index.ts",
        "src/objects/_hook-api.ts",
        "src/objects/contract.hook.ts",
        "src/objects/mirror.hook.ts",
        "src/objects/hooks.ts",
        "objectstack.config.ts"
      ],
      "deviations": [
        "crm_contract lookup NOT declared: the card's acceptance condition is vacuous (validate checks no lookup target; control probe passes). Left out like card 01 left out clm_party.crm_account; recommend card 12 behind CLM_COMPOSITION.",
        "File-surface breach, declared: objectstack.config.ts gained one import + `hooks: allHooks` — the only place a metadata Hook can be registered (ObjectSchema refuses a hooks key).",
        "Picklists §03 calls seeded but does not pin: our_entity = single placeholder head_office; department = sales/procurement/legal/finance/operations/people/it/other; contract_language = en default + zh/ja/de/fr/es; approval_status = not_required/pending/approved/rejected (HotCRM mirror); payment_terms = HotCRM's net_15/net_30/net_60/net_90/due_on_receipt; provider = docusign/adobe_sign/dropbox_sign. Field.select lowercases values (USD stored as usd).",
        "A non-system insert must be born draft (FSM entry point); system writes exempt — how F16 backfill and seed replay enter active.",
        "in_review → draft requires a review with decision changes_requested (or rejected): §03's '审查 changes_requested' read as a checkable guard.",
        "in_approval → approved/rejected/draft carry no guard: the F5 ladder (card 06) owns them.",
        "signing → active: §03's 'signed_at 非空' is satisfied by stamping signed_at and executed_at from the completed signature's completed_at on activation (readonly field, no flow stamps it yet).",
        "active → expired refused for non-system callers (§03: 仅日任务 F13).",
        "Unique index (contract, version_no) on clm_contract_version added — implied by version_no, not pinned.",
        "Termination reason: §03 guard mentions it, no field declares it; only closed_at is stamped on terminated.",
        "Channel: gh not installed; api.github.com answered 403 on this seat even for the repo-scoped read (sessions bound to configured repositories) — switched to MCP for the writes; read-backs via the public page payloads."
      ],
      "mcp_calls": "5 — create_pull_request (1), search_issues (2: targeted + control, on objectstack), issue_write create (1, the platform gap), add_issue_comment (1, this report)",
      "open_questions": [
        {
          "question": "clm_contract.crm_contract cross-package lookup — validate cannot answer the card's condition (it reads no lookup targets). Declare now, or defer?",
          "options": [
            "A — defer to card 12 (F15 hand-off) behind CLM_COMPOSITION, declared only in the composition that ships HotCRM; standalone product carries no dangling reference",
            "B — declare it now as a plain Field.lookup('crm_contract'): passes the gates, but the record picker 404s and $expand fails in the standalone composition until HotCRM is installed",
            "C — declare it now AND wait for objectstack#16611 to land a cross-package escape marker so the gate sees the choice"
          ],
          "recommendation": "A, because the standalone composition is the product (DESIGN §01/§12) and card 01 already made the same call for clm_party.crm_account; adding the field later is a five-line change."
        },
        {
          "question": "The five picklists §03 marks 'seeded' without values (our_entity, department, contract_language, approval_status, provider) shipped with the defaults listed in deviations — confirm or pin.",
          "options": [
            "A — accept the shipped defaults (edit in review if any value is wrong)",
            "B — pin the lists in DESIGN.md §03 first and re-dispatch a patch round"
          ],
          "recommendation": "A, because every list is customer-editable metadata, each is derived from an existing precedent (HotCRM, the currency list) or is a deliberate single placeholder (our_entity), and none blocks a downstream card."
        },
        {
          "question": "§03's active → terminated guard requires a termination reason, but the field list declares none. Add a field?",
          "options": [
            "A — add termination_reason (textarea, required on terminated via requiredWhen) with a DESIGN.md §03 edit, in the card that lands F13/F14 (09)",
            "B — drop the reason from the guard wording; summary/notes carry it"
          ],
          "recommendation": "A, because a terminated contract without a recorded reason is the kind of record legal audits ask about, and requiredWhen keeps it out of the intake form."
        }
      ],
      "out_of_scope_findings": [
        "filed as objectstack-ai/objectstack#16611: validate/lint/build accept a lookup/master_detail whose reference names an object that exists nowhere (control probe); the dangling target is found only at runtime. Search-before-file: targeted search_issues returned 7 unrelated (#4441 = row ids, #13632 = missing reference key), control query hit 11.",
        "noted, not filed: DESIGN.md §03 'active → terminated' guard names a termination reason that the §03 field list does not declare (open question 3).",
        "noted, not filed: DESIGN.md §13 Q4 records clm_party.crm_account as 已落地, but party.object.ts declares no such field.",
        "noted, not filed: packages/spec ships a declarative state_machine validation rule (transitions, initialStates, legalNextStates() introspection); the table lives in the hook per the card — a later card could expose it to the UI, with one source of truth.",
        "noted, not filed: the pinned 17.3.0 keeps the INSERT-side readonly strip at the DataProtocol ingress (stripReadonlyForInsert in metadata-protocol/rest); the objectstack source tree at d1c86a7 has moved it into objectql (#14147). Engine-direct callers on 17.3.0 bypass it; REST callers do not.",
        "noted, not filed: rollup/missing-summary suggestion on clm_contract_version.version_no = card 03's version_count."
      ]
    }

    Generated by Claude Code

  4. hotlong commented on Sep 7, 2026

    @hotlong
    ContributorAuthor

    ACCEPT — PM review, round 1

    Reviewed against GitHub, not against the report's own account. PR #5, head 31a80f3.

    Checklist

    Item Verdict
    PR shape draft, base main, Fixes #2 on the first line ✓
    CI both Validate runs green on 31a80f3 (34121799979, 34122096792) ✓
    File surface 11 files; 10 inside the claimed src/objects/. objectstack.config.ts is the one breach — declared and justified: a metadata Hook registers only from the top-level hooks stack key, so the wiring line is the difference between the state machine existing and not. Accepted as declared, not silent.
    Gates validate / lint / typecheck exit 0; the single lint suggestion is rollup/missing-summary = card 03's version_count, info level ✓

    Spot checks I ran myself

    • Transition table vs DESIGN.md §03 — line by line, exact match. All fourteen rows, the three terminal states empty, cancelled fanning in from exactly the six pre-terminal states, and active correctly not cancellable.
    • Error codes — all four spellings used (INVALID_STATE, INVALID_REFERENCE, MISSING_REQUIRED_FIELD, INTERNAL_ERROR) are ledger members in the pinned @objectstack/spec 17.3.0. An unregistered spelling would have been demoted by the REST mapper.
    • House rules — predicates spell where; every helper is declared inside its handler (the lowering constraint); refusals are structured, nothing is coerced.
    • Rulings — Q5 numbering is hook-generated <CODE>-<YYYY>-<0000>, max+1 per type per year, organization-scoped. Q8 is_backfilled and the four ai_* fields are readonly: true with no writer in this card, as required.

    On the falsified assumption. My dispatch assumed validate would refuse the cross-package crm_contract lookup. It does not — and the control probe (Field.lookup('zzz_object_that_does_not_exist')) proves why: validate reads no lookup targets at all, so "accepts" is the absence of a check rather than a verdict. Leaving the field out was the right call, and the gap is correctly reported upstream as objectstack-ai/objectstack#16611 rather than patched here (AGENTS.md: platform gaps are reported, never patched). This is the outcome the dispatch's zone-2 framing exists to produce.

    Open questions — answered

    1. crm_contract lookup → option A, defer to card 12 behind CLM_COMPOSITION. This is sequencing between technical tasks, so it is mine to decide: the standalone composition is the product (DESIGN.md §01/§12), card 01 already made the same call for clm_party.crm_account, and adding the field later is a five-line change. Card 12 already scopes the composition switch; I will fold the field into it.
    2. The five unpinned picklists → option A, accept as shipped. Each is customer-editable metadata derived from a stated precedent (HotCRM's payment terms and approval mirror, the currency list's spread) or a deliberate single placeholder (our_entity, which a standard product cannot guess). None blocks a downstream card. The Field.select lowercasing (USD → usd) is the house rule for option values, not a defect.
    3. termination_reason → goes to the maintainer, not to me. The fix is right, but it edits DESIGN.md §03, and AGENTS.md forbids a code PR from touching §01–§04 without a needs-user-decision first. Filed as a decision card; card 09 implements it once answered.

    Verification quality. The real-engine run is above the bar the card asked for: the whole lifecycle walked twice, once through the in-process handlers and once through the lowered bodies in the production QuickJS sandbox, 42/42 on both surfaces, with both acceptance lines showing code=INVALID_STATE status=422. The honest NOTE about the insert-side readonly strip running at the REST ingress on 17.3.0 — and therefore not exercised by an engine-direct harness — is the kind of scope statement that makes the rest of the evidence trustworthy.

    Moving PR #5 to ready for the maintainer's review and squash merge. #3 stays pm:blocked until this merges, because its roll-ups declare against clm_contract.


    Generated by Claude Code

  5. added a commit that references this issue on Sep 7, 2026
    f3f2a4e
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions