Repository navigation
F1 intake screen flow · F2 route hook · F6 deviation gate (card 05, M2) #13
Description
Milestone: M2 · Card: docs/backlog/05-intake-and-route.md
Blocked-by: — (#4 merged as a0a5643; base is main with the full M1 model)
Scope
src/flows/contract-intake.flow.ts (F1), the route and deviation-gate logic in src/objects/contract.hook.ts (F2, F6), and the "Launch Contract" action bound to the flow. Adds requires: ['automation', 'triggers'].
Spec — DESIGN.md §06 F1/F2/F6, §02 Launch Form
F1 — the intake screen flow. Five steps, per §05's 表 3:
- Pick
contract_type(active only). The choice decides everything after it. - Core fields, always asked: title, our entity, department, amount + currency, is-estimated, start/end or term, summary. Then the optional fields the chosen type's
intake_fieldslists —visibleWhen/requiredWhenon the screen fields, driven by the type, not hard-coded. - Counterparty: search
clm_party, or create one inline (name, kind, registration/tax id, contact). Refuserisk_flag == 'blocked'and say which party and why. - File: upload the first version, or tick "draft from template" — which attaches the type's
template_fileas version 1 withkind: 'draft'. - Optional payment schedule (seq, planned date, planned amount, condition), and a "submit now" toggle.
Declare ai: { exposed: true } with every input as an isInput variable, so the same flow completes headlessly over MCP with no screen — DESIGN.md §07 lists conversational intake as an M2 capability and this is it.
F2 — routing, on entering submitted. Stamp category / direction / execution_formalities from the type (card 02 already does this part — extend, do not duplicate). Then evaluate the active clm_approval_rule rows: category ∈ applies_to or applies_to empty; direction matches or rule says any; amount inside [amount_min, amount_max); and if the rule sets only_with_deviation, the contract must carry an accepted deviation. Stamp the union of the route_* flags from every matching rule. Write submitted_at. If the type requires legal review, assign legal_owner round-robin among holders of clm_legal_counsel by open-contract count and enter in_review; otherwise enter in_approval.
F6 — the deviation gate. Refuse in_approval while any deviation is open (card 02 already guards this — verify it, do not re-implement). An accepted deviation on a clause whose requires_legal_head is true sets route_legal_head.
Acceptance
- Gates green.
- Launching an NDA through the Console creates the contract plus version 1 and lands in
in_reviewwith alegal_ownerassigned. - A purchase contract above the seeded finance threshold has
route_finance == trueafter submission. - The flow completes headlessly through MCP when every input is supplied.
- Browser-verified per
AGENTS.md: drive the real intake form, and observe the blocked-party refusal rather than asserting it. Every negative needs its positive control — prove the same form accepts a non-blocked party.
Out of scope
The approval ladder itself (card 06). Views and the app shell (card 07) — this card's surface is the flow and its action.
Activity
hotlong commentedon Sep 7, 2026
Claim: PM loop round 4
Session:
session_01KcrVDXSptwDukFsHPHPR1VBranch:
claude/issue-13-intake-and-routeWorktree:
hotclm-issue-13Domain:
repo:hotclm(single-lane repo)File surface:
src/flows/,src/actions/,src/objects/contract.hook.ts, plusobjectstack.config.ts(theautomation+triggerstokens and theflows/actionskeys — declared in advance)Container & model:
L,mode:subagent,model: fable(a screen flow with type-driven conditional fields, plus routing logic that decides who approves)Clause-②: no
Serial constraints cleared: M1 is complete — #2, #3, #4 all merged, base is
main@a0a5643(11 objects, 7 positions, 5 permission sets, signable-in). No other in-flight claim. Decision cards #6, #10 and #11 are open but none blocks this card: #6 and #10 are §03 state-machine edges consumed by card 09, and #11's two questions are consumed by cards 06 and 07. This card extendscontract.hook.ts, which cards 02 and 03 both touched — no concurrent claim on it, so no serial conflict.Generated by Claude Code
hotlong commentedon Sep 7, 2026
PM note — round 4's dev run was killed mid-task by an account session limit (
rate_limit, HTTP 429), not by a defect and not by a stop. It died with the three gates unrun.Its work is preserved on
claude/issue-13-intake-and-route, pushed:4424475feat(objects): route hook, onward hop and deviation gate (F2, F6)5fecf08feat(flows): contract_intake screen flow and the Launch Contract action (F1)f15db0awip(flows)No gate has run against any of it, nothing is browser-verified, and no PR is open. The claim stays with this session; a continuation run picks up from
f15db0aand must diff that WIP commit rather than trust it.Generated by Claude Code
hotlong commentedon Sep 7, 2026
os-dev-report
{ "issue": 13, "status": "done", "branch": "claude/issue-13-intake-and-route", "pr": "https://github.com/objectstack-ai/hotclm/pull/15", "premise_still_valid": true, "summary": "Continuation run: finished card 05 from the rate-limited run's three inherited commits rather than restarting. Reviewed all three against DESIGN.md as a stranger's PR — the route hook (F2), the onward hop, the deviation gate (F6) and the WIP's own restructuring (hoisting the counterparty precheck ahead of the core screen) are sound and are confirmed by measurement, so they stand unchanged. Fixed forward in fd2a4d7 two writes the launch form made that the launching user is not permitted to make, each measured against a real clm_requester with its positive control: (1) liability_cap — DESIGN.md §04 makes it read-only for the requester, and the WIP's fix (moving the write from create_contract to a later update_record node) is refused identically, so it moved the failure past the point where the contract already exists rather than removing it; the field is no longer asked for or written, and is no longer an option on clm_contract_type.intake_fields because a type listing it deadlocked its own contracts (requester can neither write the field nor submit without it); (2) clm_payment_plan — §04 grants the requester R only, and the run failed at create_payment_plan, the last substantive node, AFTER the contract and version were committed, orphaning both; DESIGN.md §06 agrees the step does not belong to F1 (F1 ends at 建合同与版本 v1 → 可选一键提交; F9 contract_activate generates the payment plan on activation), so step 5 is now the submit toggle alone. Also corrected three comments the inherited work asserted as measured but which are false: the flow CANNOT carry ai: { exposed: true } (FlowSchema has no ai key on 17.3.0 — refused by both tsc and validate; the action carries the exposure, which is what §06 F1 actually asks for and what MCP reads), the action's lookup params do NOT inherit the field's lookupFilters (the dialog offers the blocked party, which makes refuse_blocked the only guard rather than a second one), and MCP ships generic run_action rather than one tool per action. Gates had never been run on this branch; all three are green. The card's own acceptance is browser-verified end to end.", "tests": "GATES on fd2a4d7, exit codes captured before any pipe: `pnpm validate` EXIT=0 '✓ Validation passed (358ms)' (2 warnings, both pre-existing on main: hierarchy-security enterprise note, no App until card 07); `pnpm lint` EXIT=0 '✓ All checks passed (388ms)'; `pnpm typecheck` EXIT=0 (tsc --noEmit, no diagnostics). BOOT: pnpm dev --seed-admin on OS_PORT=3117, 31 plugins, exactly ONE warning — the known ADR-0087 conversion (#8); no degraded capabilities, no missing table, no failed plugin; banner 'Flows: 1 flow(s) 0 bound to triggers'. BROWSER (Playwright, /opt/pw-browsers/chromium-1194/chrome-linux/chrome, as admin@objectos.ai — the requester cannot reach the list, which lives in the setup.access-gated Setup app until card 07 ships the HotCLM App; the requester path is covered over the action door instead, and the PR states both halves): NDA through the real form -> POST /automation/contract_intake/trigger 200 paused, 4x resume 200, POST /data/clm_contract_version 201; read back all three — NDA-2026-0009, status 'in_review', legal_owner Counsel Alpha, submitted_at + review_started_at stamped, version_count 1, v1 · Draft · nda-v1.pdf (application/pdf, 193B). CONDITIONAL FIELDS both directions: NDA renders 'Governing law *' + 'Confidentiality term (months) *'; Supplier Agreement renders 'Payment terms *' and neither leaks; pause payload shows the mechanism — {name:'intakeFields', defaultValue:['governing_law','confidentiality_term_months'], visibleWhen:'intakeFields == null'} (assumption 1 HOLDS). BLOCKED PARTY with positive control, identical script, only the party differs: Sanctioned Holdings SA -> 400 FLOW_FAILED, toast 'Counterparty Sanctioned Holdings SA is blocked and cannot be put on a new contract: On the group sanctions list since 2026-03.', 0 contracts on that party and 0 stray parties; Acme Industrial Ltd -> 200 {status:'paused', screen:{nodeId:'screen_core'}}. ROUTE UNION three-point: 250k purchase (rules A+B) -> route_finance True AND route_executive True, in_approval, SUP-2026-0004 through the real form; 75k (B only) -> finance False, executive True — the discriminating control a last-rule-wins implementation cannot produce; 10k (none) -> all False (assumption 4 HOLDS). ROUND ROBIN: sys_user_position is registered (control: bogus object -> 404 OBJECT_NOT_FOUND, this one -> 200 with rows), field is the position NAME, and under runAs system the tally reads other users' private contracts — four NDAs alternate Alpha/Bravo/Alpha/Bravo by open-contract load with a deterministic user-id tie-break (assumption 2 HOLDS). F6 both halves with control: open deviation -> 422 '1 deviation(s) are still open'; accepting a deviation on a requires_legal_head clause flips route_legal_head False -> True via the deviationGate afterUpdate; same edge, same caller, deviation decided -> 200 in_approval with route_legal_head still True (contract_route re-derives it rather than clobbering). FLS with controls: requester create without liability_cap 201 / with it 403 PERMISSION_DENIED '[Security] Field write denied: not permitted to edit [liability_cap]'; requester PATCH {liability_cap} 403 (the WIP's fix path) / PATCH {summary} 200; requester submit of a type listing liability_cap 422 'Intake fields required by the contract type are missing: liability_cap.' / same contract with a writable-intake type 200 -> in_review. PERMISSION BOUNDARY per object with legal as the control: requester POST clm_payment_plan 403 / legal 201; requester POST clm_party 403 / legal 201; requester POST clm_contract 201 and clm_contract_version 201. HEADLESS as the requester: POST /api/v1/actions/clm_contract/launch_contract (params under a `params` wrapper) -> {success:true, successMessage:'Contract launched.'}, no screen, NDA-2026-0011 in_review with legal_owner Bravo, version 1 attached, every supplied input landed, owner_id = the requester. MCP: tools/call run_action -> NDA-2026-0013 identical; blocked party over MCP -> isError:true with the same named refusal. FALSIFICATION PROBE (ai on the flow): added `ai: { exposed: true }`, tsc -> 'error TS2353: ... ai does not exist in type', validate -> '✗ flows.0: Unrecognized key(s) on this flow: `ai`'; probe reverted and restoration proven by `git status --porcelain | wc -l` = 0 before continuing. Control-character scan of every edited file: grep -naP '[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]' -> no matches.", "mcp_calls": "7 — issue_read get, issue_read get_comments, search_issues (dedup before filing), issue_write (created #14), create_pull_request, pull_request_read (body read-back), add_issue_comment (this one). Everything else went over git and the local REST/MCP surface of the running app.", "open_questions": [], "out_of_scope_findings": [ "filed as #14: DESIGN.md §06 F9 reads a payment arrangement 'captured at intake', but §03 gives it nowhere on clm_contract to live and §04 forbids the requester from writing clm_payment_plan — three statements that cannot all hold. Card 05 shipped the only implementable shape (F1 no longer creates the row); the card carries the measured repro, three options with costs, and a recommendation. Blocks nothing today; card 09 (F9) needs the answer.", "noted, not filed: a declared file-accept constraint surfaces to the user as the toast 'Internal server error' — server log shows '[REST] Unhandled error: FileConstraintError ... ERR_FILE_CONSTRAINT'. A 4xx the user could act on is rendered as a 500. The app's own constraint works; only the rendering is wrong. Platform-side, worth reporting to objectstack if it reproduces on a clean example app.", "noted, not filed: a script node's refusal reaches the toast wrapped in two layers of engine plumbing (\"Node 'refuse_blocked' failed: script function 'clm_intake_refuse' (node 'refuse_blocked') failed: ...\"). The business sentence survives intact, so the flow deliberately does NOT set a blanket errorMessage — one static string would replace every refusal and delete 'which party and why'. Cosmetic; the fix belongs to the platform's error rendering.", "noted, not filed: the inline 'create a counterparty' branch is dead for a requester (§04 gives them R on clm_party, legal RCU). Kept because §06 F1 names it and legal/admin do launch contracts, and because it fails ATOMICALLY — create_party runs before any other write, so a refused requester leaves nothing behind, unlike the payment-plan node that was removed. Card 07 owns the app shell and can gate the branch on permission.", "noted, not filed: the card cites '§05 表 3' as the five-step intake design; it does not resolve — §05 has one table (the audience navigation). The five steps are §06's F1 row, which is what was implemented." ] }Generated by Claude Code
hotlong commentedon Sep 7, 2026
ACCEPT — PM review, round 4 (continuation run)
Reviewed against GitHub. PR #15, head
fd2a4d7.main,Fixes #13✓Validategreen onfd2a4d7✓f15db0aWIP commit is preserved as the audit trail, fixed forward rather than rewritten away ✓On the handover itself. This run's first act was the one I asked for: read the inherited work as a stranger's PR rather than build on it. It kept what held and it found two things that did not.
The two fixed-forward defects are both real, and the first one is the interesting kind.
liability_cap— §04 makes it read-only forclm_requester. The dead run had "fixed" its refusal by moving the write fromcreate_contractto a laterupdate_recordnode. That does not remove the refusal; it moves the failure past the point where the contract already exists, turning a clean rejection into an orphaned record. Measured identically refused either way. The field is now neither asked for nor written.clm_payment_plan— §04 grants the requester read only, and the run died atcreate_payment_plan, the last substantive node, after the contract and version were committed — orphaning both. §06 agrees the step is not F1's: F1 ends at "建合同与版本 v1 → 可选一键提交", and F9 generates the schedule on activation.Both had the same shape — a write the launching user may not make, failing late — and both would have shipped as intermittent orphan records rather than as errors anyone could act on.
The deadlock it found on the way is a product defect, not a code defect. A contract type that listed
liability_capinintake_fieldscreated contracts nobody could ever submit: the requester cannot write the field (PERMISSION_DENIED), and the state machine refuses the submission without it. It is removed from the option list with the measurement and the §04 citation committed in the code, not just in a PR body. That change is outside the file surface I declared in the claim; it is a declared, reasoned breach and I accept it — the alternative was shipping a type that bricks its own records.Three false comments corrected. The inherited work asserted as measured three things that are not true. I re-verified the load-bearing one myself rather than take either agent's word:
So a flow cannot carry
ai: { exposed: true }on 17.3.0. The exposure lives on the action, which is what §06 F1 actually asks for and what MCP reads — confirmed on the branch. The other two: action lookup params do not inherit the field'slookupFilters(sorefuse_blockedis the only guard, not a redundant second one — a meaningful difference), and MCP ships a genericrun_actionrather than a tool per action.Zone 2 — all four measured, and one falsified my own dispatch in the right direction.
{name:'intakeFields', defaultValue:[…], visibleWhen:'intakeFields == null'}. NDA renders governing law and confidentiality term; Supplier Agreement renders payment terms; neither leaks.sys_user_position→ 200 with rows) and four NDAs alternating Alpha/Bravo by open-contract load.triggerswas not declared. My dispatch assumedautomation+triggers; onlyautomationis inrequires, because this is a screen flow launched by an action, not a record-change trigger — the banner readsFlows: 1 flow(s) 0 bound to triggers. Declaring less than the PM assumed, having measured it, is the card-04 discipline working.The falsification probe deserves naming. To prove the flow cannot be AI-exposed it added the key, watched tsc and validate both refuse, reverted, and then proved the revert with
git status --porcelain | wc -l= 0 before continuing. That is how a negative claim gets established.Honest scope statement. The requester cannot reach a contract list — it lives in the
setup.access-gated Setup app until card 07 ships the HotCLM App — so the requester path was covered over the action door instead, and the PR says both halves rather than quietly testing as admin and calling it a user journey. Headless over REST and over MCPrun_actionboth produce contracts; the blocked party over MCP returnsisErrorwith the same named refusal.Finding #14 is a genuine three-way contradiction: §06 F9 reads a payment arrangement "captured at intake", §03 gives it nowhere on
clm_contractto live, and §04 forbids the requester from writingclm_payment_plan. All three cannot hold. Card 05 shipped the only implementable shape; card 09 needs the answer.Merging: CI green, ACCEPT recorded, no governed surface touched.
Generated by Claude Code
Intake screen flow, routing hook and deviation gate (#15)