Repository navigation
F1 intake screen flow · F2 route hook · F6 deviation gate (card 05, M2) - #15
Merged
Merged
Conversation
`contract_route` (priority 150, runAs system) evaluates the active approval rules on the way into `submitted` and into `in_approval` and stamps the UNION of their route_* flags, deriving route_legal_head from accepted deviations on clauses that require the head of legal as well; on submission it assigns legal_owner to the legal counsel with the fewest open contracts. The state machine now takes the onward hop of §06 F2 in the same write — draft → submitted continues to in_review (a legal owner was assigned) or to in_approval (no legal review) — as a second iteration over the same table check and guard blocks, not a second copy of them. The open-deviation gate (F6) now guards both edges into in_approval; `deviation_gate` (afterUpdate on clm_deviation, runAs system) stamps route_legal_head on the parent when an accepted deviation's clause requires the head of legal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KcrVDXSptwDukFsHPHPR1V
…on (F1) The launch form as one screen flow: the type and the counterparty come from the launch action's param dialog (a flat screen has no record picker on the pinned console), the core screen carries the type's intake_fields as a hidden field so each optional field is shown and required only when the type lists it, the counterparty is picked or created inline, version 1 comes from the type template, a supplied file id, or the platform's own version form, and an optional first instalment and a submit toggle close the flow. A headless caller that supplies `title` goes round every screen; refusals fail the run with a message through the one registered function. `requires` gains `automation`; `triggers` is measured as not needed by a screen flow and stays with the first trigger-launched flow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KcrVDXSptwDukFsHPHPR1V
…rate-limited run The card 05 dev agent was terminated mid-task by an account session limit while about to run the three gates against the full change. This commit preserves its uncommitted working tree so the work is not lost; it has NOT been gated, NOT browser-verified, and NOT reviewed. The next agent must diff it before building on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KcrVDXSptwDukFsHPHPR1V
Reviewing the inherited work of this branch against the spec found two
writes the launch form makes that the launching user is not permitted to
make. Both were measured against a real `clm_requester` — the set every
employee holds — each with its positive control.
`liability_cap`. DESIGN.md §04 makes it read-only for `clm_requester`
("由法务评定"). The salvaged WIP commit had spotted the refusal at
`create_contract` and moved the write to a later `update_record` node;
measured, that node is refused identically — `PERMISSION_DENIED`
"[Security] Field write denied: not permitted to edit [liability_cap]"
on CREATE and on UPDATE alike (control: the same calls without the key
return 201 / 200). The fix moved the failure later, not away, and left
the contract already committed when it fired. The field is no longer
asked for or written, and it is no longer an option on
`clm_contract_type.intake_fields`: a type that listed it deadlocked its
own contracts, since the requester can neither write the field nor
submit without it ("Intake fields required by the contract type are
missing: liability_cap."). Legal sets the cap during review, where §04
puts it.
`clm_payment_plan`. §04 grants the requester R only; legal's RC creates
the same row. Measured through the action door, the run failed at
`create_payment_plan` AFTER the contract and its version were committed,
orphaning both. DESIGN.md §06 agrees the step does not belong here: F1
ends at "建合同(draft)与版本 v1 → 可选一键提交", and F9
`contract_activate` is what generates `clm_payment_plan` on activation.
Step 5 is now the submit toggle alone. Where the intake-captured
arrangement F9 reads should live is a design gap, raised as its own
decision card rather than guessed at.
Also corrects three claims the inherited comments asserted as measured:
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, not a second one); MCP ships the generic `run_action` rather
than a per-action tool; and `ai: { exposed: true }` cannot go on the
flow at all — `FlowSchema` has no `ai` key on 17.3.0, so both `tsc` and
`objectstack validate` refuse it. The action carries the exposure, which
is what DESIGN.md §06 F1 asks for and what the MCP surface reads.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KcrVDXSptwDukFsHPHPR1V
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #13
F1
contract_intake, F2contract_routeand F6deviation_gate(DESIGN.md §06), plus the "Launch Contract" action that runs the flow.This is a continuation run. Three commits were inherited from a run that a rate limit killed with no gate run, nothing browser-verified and no PR. The first section says what was inherited and what changed about it; everything below that is measured on this branch.
What I inherited, and what I changed about it
4424475(route hook, onward hop, deviation gate) and5fecf08(flow + action) were the previous agent's own commits.f15db0awas its uncommitted working tree, salvaged unreviewed by the PM. I reviewed all three as a stranger's PR before trusting a line.Kept, and confirmed by measurement — the inherited design is sound. The route hook's union-of-matching-rules stamp, its
runAs: 'system'justification, the state machine's onward hop as a second iteration through the same guard block rather than a second copy of the guards, and thedeviationGateafterUpdate stamp all behave exactly as their comments claim. The WIP commit's own restructuring — hoisting the counterparty precheck ahead of the core screen so a blocked party is refused before anything is asked — is right, and measurably so: the refused run leaves 0 contracts and 0 parties behind.Fixed forward in
fd2a4d7. Two writes the launch form makes that the launching user is not permitted to make. Both were measured against a realclm_requester— the set every employee holds — each with its positive control.1.
liability_cap— the WIP's fix moved the failure, it did not remove itDESIGN.md §04 makes
clm_contract.liability_capread-only forclm_requester(「由法务评定」). The WIP had spotted the refusal atcreate_contractand moved the write to a laterupdate_recordnode. Measured, that node is refused identically:Worse than the original: the create-time refusal left nothing behind, the update-time one fires after the contract exists.
The field is no longer asked for or written. It is also no longer an option on
clm_contract_type.intake_fields, because a type that listed it deadlocked its own contracts — the requester can neither write the field nor submit without it:That is a one-line change in card 02's file (
contract-type.object.ts), taken in place because it closes the whole class at its source; leaving the option would mean a declared intake field the form silently never shows and the state machine then refuses.2.
clm_payment_plan— step 5 asked for a row the requester may not create§04 grants the requester R on
clm_payment_plan; legal's RC creates the same row.Through the action door as a requester with every input supplied, the run failed at
create_payment_plan— the last substantive node — after the contract and its version were already committed, orphaning both (NDA-2026-0010: draft,version_count: 1, never submitted).DESIGN.md §06 agrees the step does not belong here. F1's steps end at 「建合同(
draft)与版本 v1 → 可选一键提交」 and never mention a payment schedule; it is F9contract_activatethat 「发起时填了付款安排则生成clm_payment_plan」 on activation. Step 5 is now the submit toggle alone. Where the intake-captured arrangement F9 reads should be stored is a genuine design gap — §06 F9 assumes it, §03 gives it nowhere to live, §04 forbids the requester from writing the object — so it is raised as #14 rather than guessed at, per the Zone 1 rule cards 02/03/04 followed.3. Three comments asserted as measured that are not true
ai: { exposed: true }cannot go on the flow.FlowSchemahas noaikey on 17.3.0. Probed:tsc→error TS2353: ... 'ai' does not exist in type,objectstack validate→✗ flows.0: Unrecognized key(s) on this flow: 'ai'. The action carries the exposure, which is what DESIGN.md §06 F1 asks for(「ai.exposed,输入变量齐全时可由 MCP 调用」)and what the MCP surface actually reads. Probe reverted;git diff HEADempty before continuing.lookupFilters. The inherited header claimed "blocked parties hidden". Measured, the dialog fetchesGET /api/v1/data/clm_party?top=50— no filter on the wire — and offersSanctioned Holdings SA · Risk Flag: Blockedlike any other row. This makesrefuse_blockedthe only guard, not a second one, so the comment now says so.tools/listreturns the generic 11; an exposed action appears inlist_actionsforrun_actionto call by name. The staleai.description(still advertising the instalment step to AI callers) was corrected with it.Gates
Never run on this branch before. All three green on
fd2a4d7:Both
validatewarnings are pre-existing onmain(the enterprise-edition note card 04 recorded; no App until card 07).Boot
pnpm dev --seed-adminonOS_PORT=3117, 31 plugins loaded, exactly one warning — the known ADR-0087 artifact conversion (#8). Nodegraded capabilities, nono such table, no failed plugin. The banner also readsFlows: 1 flow(s) 0 bound to triggers, which corroborates the inherited decision to declareautomationbut nottriggers: nothing in this card is trigger-launched. The card's third assumption is falsified in half —automationis required,triggersis not yet.Browser evidence
Driven with Playwright against
/opt/pw-browsers/chromium-1194/chrome-linux/chrome. Asadmin@objectos.ai, because the launch form lives on theclm_contractlist toolbar and, until card 07 ships the HotCLM App, that list is only reachable inside the Setup app — which is gated onsetup.access, sorequester@objectos.aigets "App not available". The requester's path is covered over the action's REST/MCP door instead, below; both halves are stated for what they are.Seeded for the run: 2 contract types (NDA
requires_legal_review: true; Supplier Agreementfalse), 2 parties (onerisk_flag: blocked), 2 deliberately overlapping approval rules, 2 legal counsels, 1 requester.An NDA through the real form — contract, version 1,
in_review,legal_ownerClicked Launch Contract → picked
Mutual NDA+Acme Industrial Ltd→ Confirm → filled the core screen → Submit → "Draft from template" left clear → attachednda-v1.pdfon the version form → ticked Submit now → Submit.Read back all three:
Re-run after the fix:
NDA-2026-0012, same three, step 5 now reads "Submit the contract / Choose whether to submit now."The conditional fields appear and disappear
Same screen node, same run shape, only the chosen type differs:
intake_fieldsgoverning_law,confidentiality_term_monthspayment_termsNeither type's fields leak into the other, and the unasked ones stay
Noneon the record. The mechanism is visible in the pause payload:{"name":"intakeFields","type":"text", "defaultValue":["governing_law","confidentiality_term_months"], "visibleWhen":"intakeFields == null"}The type's list is server-interpolated onto the screen as a hidden carrier and each optional field's predicate reads it. Assumption 1 holds — a screen field can be shown and required from data fetched earlier in the same run, so "a contract type is a workflow" is real rather than a hard-coded union.
The blocked-party refusal, with its positive control
Identical script, identical clicks, only the counterparty differs:
The refusal names which party and why (it carries
risk_note), and fires before any write.A purchase above the finance threshold, and the union of two rules
Seeded overlapping: A
purchase, amount ≥ 100 000 → route_finance; Bany category, amount ≥ 50 000 → route_executive.Launched a 250 000 Supplier Agreement through the same form:
Both flags — the union, not the last matching rule. Three-point control at the REST layer pins it:
route_financeroute_executiveThe 75 000 row is the discriminating one: a "last rule wins" or "any match sets all" implementation cannot produce it. Assumption 4 holds.
Verified over REST / MCP (the requester path, and the headless half)
legal_ownerround-robin really queries position holdersAssumption 2 holds.
sys_user_positionis a registered object (control: a bogus name returns404 OBJECT_NOT_FOUND, this one returns200with rows), its field really is the position name, and under the hook'srunAs: 'system'the tally reads other people'sprivatecontracts. Four consecutive NDAs, Alpha7Hbf…and Bravo8nZE…:Load-balanced and deterministic, exactly as the hook claims.
F6, both halves, with its positive control
That last line matters:
contract_routeruns again on thein_review → in_approvaledge and rewrites all four flags, and it preserves the legal-head rung because it re-derives it from the accepted deviations rather than trusting the stored value. Card 02's guard was extended to both edges intoin_approval, not duplicated.Headless completion, as the requester
POST /api/v1/actions/clm_contract/launch_contractwith every input supplied (params go under aparamswrapper — a bare body is refusedInvalid action params: Action param "contract_type" is required):No screen. Same call over MCP
tools/call → run_action→NDA-2026-0013, identical result; and over MCP with the blocked party →"isError":truecarrying the same named refusal.This is also the requester security path the browser could not reach: a
clm_requestercompletes the whole flow end to end — contract, version, submission, routing, legal owner — writing only the two objects §04 lets them write.验收备注
Out of scope, noted, not filed:
.txttoclm_contract_version.file(which declaresaccept: application/pdf, .docx) surfaces as the toast "Internal server error", with[REST] Unhandled error: FileConstraintError … ERR_FILE_CONSTRAINTin the server log. A declared file constraint is a 4xx the user should be able to act on, not a 500. The app's own constraint works correctly; only the rendering is wrong. Worth reporting to objectstack per AGENTS.md if it reproduces on a clean example app.Node 'refuse_blocked' failed: script function 'clm_intake_refuse' (node 'refuse_blocked') failed: <the message>. The business sentence is intact and is what the card asked for, so the flow deliberately does not set a blanketerrorMessage: one static string would replace every refusal and delete "which party and why". Cosmetic, and the fix belongs to the platform's error rendering.clm_party(legal has RCU), so a requester who fills that screen getsPERMISSION_DENIED. It is kept because §06 F1 names it(「相对方查找或新建」)and legal and admin do launch contracts, and because it fails atomically —create_partyruns 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.§05 表 3, cited by the card as the five-step intake design, does not resolve: §05 has one table (the audience navigation). The five steps are §06's F1 row, which is what was implemented.🤖 Generated with Claude Code
https://claude.ai/code/session_01KcrVDXSptwDukFsHPHPR1V
Generated by Claude Code