Repository navigation
Contract domain: clm_contract and its four review-time children (card 02, M1) #2
Description
Activity
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 arepm:blockedon this card; no other in-flight claim in this repo. Base ismain@ef17697(PR #1 merged, carries the V1.0 rulings).
Generated by Claude Code
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
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 #2on the first line ✓CI both Validateruns green on31a80f3(34121799979,34122096792) ✓File surface 11 files; 10 inside the claimed src/objects/.objectstack.config.tsis the one breach — declared and justified: a metadataHookregisters only from the top-levelhooksstack 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'sversion_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,cancelledfanning in from exactly the six pre-terminal states, andactivecorrectly not cancellable. - Error codes — all four spellings used (
INVALID_STATE,INVALID_REFERENCE,MISSING_REQUIRED_FIELD,INTERNAL_ERROR) are ledger members in the pinned@objectstack/spec17.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. Q8is_backfilledand the fourai_*fields arereadonly: truewith no writer in this card, as required.
On the falsified assumption. My dispatch assumed
validatewould refuse the cross-packagecrm_contractlookup. It does not — and the control probe (Field.lookup('zzz_object_that_does_not_exist')) proves why:validatereads 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
crm_contractlookup → option A, defer to card 12 behindCLM_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 forclm_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.- 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. TheField.selectlowercasing (USD→usd) is the house rule for option values, not a defect. termination_reason→ goes to the maintainer, not to me. The fix is right, but it editsDESIGN.md§03, and AGENTS.md forbids a code PR from touching §01–§04 without aneeds-user-decisionfirst. 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:blockeduntil this merges, because its roll-ups declare againstclm_contract.
Generated by Claude Code
- Transition table vs
- added a commit that references this issue
on Sep 7, 2026
Milestone: M1 · Card:
docs/backlog/02-contract-domain.md· Blocked-by: — (builds on the initial commit; base onmainafter 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, pluscontract.hook.ts(state machine) andmirror.hook.ts(display_namestamps). Export from
src/objects/index.tsunder the "Contract domain" comment,Contractfirst.Spec — pinned in DESIGN.md §03, repeated here where a choice exists
clm_contract—sharingModel: 'private',nameField: 'title', iconfile-signature. Fields exactly asDESIGN.md §03, with these decisions taken:
contract_number: text, stored, generated by the hook (ruled 2026-09-07, DESIGN.md §13 Q5 —autonumbercannot 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 fromcontract_typebeforeInsert/beforeUpdate, readonly.amount: currency, scale 2, min 0.currency_code: select seeded (USDdefault,EUR,GBP,CNY,JPY); the organization default is a setting, not schema. Addgoverning_law(text, ISO country or state, e.g.US-NY,England and Wales),jurisdiction(text),contract_language(select seededendefault).status: select with the exact values of the §03 state machine, defaultdraft; 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.contract_type*→clm_contract_type;party*→clm_party;legal_owner→Field.user;renewed_from/parent_contract→clm_contract;crm_contract→ lookupcrm_contractonly ifobjectstack validateaccepts a cross-package reference to an object not in this stack — otherwise leave the field out and returnneeds_decisionnaming the refusal.is_backfilledboolean, readonly, default false — set only by the F16executed_uploadaction (card 09).ai_*fields (ai_summaryrichtext,ai_risk_scorenumber 0–100,ai_risk_rationaletextarea,ai_reviewed_atdatetime): readonly, written only by the "adopt suggestion" action (DESIGN.md §07); declare them now, no writer in this card.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_contractwithdeleteBehavior: 'cascade',inlineEdit: 'grid':clm_contract_version(iconfile-stack, inlineTitleVersions) ·clm_review(icongavel,Reviews) ·clm_deviation(icongit-branch,Deviations) ·clm_signature(iconpen-line,Signatures) — one execution record per signing round:methodesign/wet_ink,provider,envelope_id,signersjson ([{ side: our|counterparty, name, email, order, status, signed_at }]),statusdraft/sent/completed/declined/voided,formalities_donemultiselect mirroring the type'sexecution_formalities,completed_at,executed_filefile.Fields and enums exactly as DESIGN.md §03.
display_nameis a stored text mirror stamped bymirror.hook.tswith the format DESIGN.md gives per object.State machine —
contract.hook.ts, beforeUpdate onstatus: the transition table of DESIGN.md §03, including every guard (required intake fields, party notblocked, noopendeviation beforein_approval,cleanversion beforesigning, acompletedsignature whoseformalities_donecovers the type'sexecution_formalities+final_signedversion beforeactive,closed_atonterminated). 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
pnpm validatereports 9 objects.draft → activeis refused;draft → submittedwith a blocked party is refused.pnpm lintshows notitle-unresolvable/missing-name-field/security-role-word.Out of scope
Roll-ups (03), permissions (04), flows (05/06), seeds (08).