Skip to content

F1 intake screen flow · F2 route hook · F6 deviation gate (card 05, M2) - #15

Merged
hotlong merged 4 commits into
mainfrom
claude/issue-13-intake-and-route
Sep 7, 2026
Merged

hotlong merged 4 commits into
mainfrom
claude/issue-13-intake-and-route

Conversation

@hotlong

@hotlong hotlong commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Fixes #13

F1 contract_intake, F2 contract_route and F6 deviation_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) and 5fecf08 (flow + action) were the previous agent's own commits. f15db0a was 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 the deviationGate afterUpdate 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 real clm_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 it

DESIGN.md §04 makes clm_contract.liability_cap read-only for clm_requester(「由法务评定」). The WIP had spotted the refusal at create_contract and moved the write to a later update_record node. Measured, that node is refused identically:

A) requester POST /api/v1/data/clm_contract  (no liability_cap)      201   <- control
B) requester POST /api/v1/data/clm_contract  (+ liability_cap)       403
     {"error":"[Security] Field write denied: not permitted to edit
       [liability_cap] on 'clm_contract'","code":"PERMISSION_DENIED"}
C) requester PATCH .../clm_contract/<id>     {liability_cap}         403   <- the WIP's fix path, same refusal
D) requester PATCH .../clm_contract/<id>     {summary}               200   <- control

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:

requester PATCH .../clm_contract/<id> {"status":"submitted"}         422
     {"error":"Intake fields required by the contract type are missing:
       liability_cap.","code":"INVALID_STATE"}
control: same contract, type whose intake fields a requester CAN write  200 -> in_review

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.

requester POST /api/v1/data/clm_payment_plan                         403 PERMISSION_DENIED
legal     POST /api/v1/data/clm_payment_plan                         201   <- control

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 F9 contract_activate that 「发起时填了付款安排则生成 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. FlowSchema has no ai key 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 HEAD empty before continuing.
  • The action's lookup params do NOT inherit the field's lookupFilters. The inherited header claimed "blocked parties hidden". Measured, the dialog fetches GET /api/v1/data/clm_party?top=50 — no filter on the wire — and offers Sanctioned Holdings SA · Risk Flag: Blocked like any other row. This makes refuse_blocked the only guard, not a second one, so the comment now says so.
  • MCP does not mint one tool per action. tools/list returns the generic 11; an exposed action appears in list_actions for run_action to call by name. The stale ai.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:

$ pnpm validate                                                    EXIT=0
  ✓ Validation passed (358ms)
  Data: 11 Objects  169 Fields   UI: 1 Actions   Logic: 1 Flows
  Security: 7 Positions  5 Permissions
  ⚠ Capability "hierarchy-security" is provided by @objectstack/security-enterprise …
  ⚠ No apps or plugins defined — this stack may not do much

$ pnpm lint                                                        EXIT=0
  ✓ All checks passed (388ms)

$ pnpm typecheck                                                   EXIT=0
  > tsc --noEmit          (no diagnostics)

Both validate warnings are pre-existing on main (the enterprise-edition note card 04 recorded; no App until card 07).

Boot

pnpm dev --seed-admin on OS_PORT=3117, 31 plugins loaded, exactly one warning — the known ADR-0087 artifact conversion (#8). No degraded capabilities, no no such table, no failed plugin. The banner also reads Flows: 1 flow(s) 0 bound to triggers, which corroborates the inherited decision to declare automation but not triggers: nothing in this card is trigger-launched. The card's third assumption is falsified in half — automation is required, triggers is not yet.


Browser evidence

Driven with Playwright against /opt/pw-browsers/chromium-1194/chrome-linux/chrome. As admin@objectos.ai, because the launch form lives on the clm_contract list toolbar and, until card 07 ships the HotCLM App, that list is only reachable inside the Setup app — which is gated on setup.access, so requester@objectos.ai gets "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 Agreement false), 2 parties (one risk_flag: blocked), 2 deliberately overlapping approval rules, 2 legal counsels, 1 requester.

An NDA through the real form — contract, version 1, in_review, legal_owner

Clicked Launch Contract → picked Mutual NDA + Acme Industrial Ltd → Confirm → filled the core screen → Submit → "Draft from template" left clear → attached nda-v1.pdf on the version form → ticked Submit now → Submit.

POST /api/v1/automation/contract_intake/trigger                      200  (paused on screen_core)
POST /api/v1/automation/contract_intake/runs/<id>/resume             200  x4
POST /api/v1/data/clm_contract_version                               201

Read back all three:

contract_number  'NDA-2026-0009'      status            'in_review'
legal_owner      '7Hbf…' (Counsel Alpha)
submitted_at     '2026-09-07T16:52:22.280Z'
review_started_at'2026-09-07T16:52:22.280Z'
version_count    1        category 'nda'   direction 'other'
version          v1 · Draft · kind draft · is_current true
                 file {name: 'nda-v1.pdf', mimeType: 'application/pdf', size: 193}

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:

type intake_fields fields the screen rendered
Mutual NDA governing_law, confidentiality_term_months core + Governing law * + Confidentiality term (months) *
Supplier Agreement payment_terms core + Payment terms *

Neither type's fields leak into the other, and the unasked ones stay None on 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:

party = Sanctioned Holdings SA   POST …/trigger   400
  {"code":"FLOW_FAILED","message":"Node 'refuse_blocked' failed: … Counterparty
    Sanctioned Holdings SA is blocked and cannot be put on a new contract:
    On the group sanctions list since 2026-03."}
  toast rendered with that text; no screen; 0 contracts on that party, 0 stray parties

party = Acme Industrial Ltd      POST …/trigger   200
  {"status":"paused","runId":"run_5bc81dda…","screen":{"nodeId":"screen_core", …}}

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; B any category, amount ≥ 50 000 → route_executive.

Launched a 250 000 Supplier Agreement through the same form:

contract_number 'SUP-2026-0004'  status 'in_approval'  amount 250000
route_finance True   route_executive True   route_legal_head False   route_gm False

Both flags — the union, not the last matching rule. Three-point control at the REST layer pins it:

amount rules matched route_finance route_executive
250 000 A + B True True
75 000 B only False True
10 000 none False False

The 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_owner round-robin really queries position holders

Assumption 2 holds. sys_user_position is a registered object (control: a bogus name returns 404 OBJECT_NOT_FOUND, this one returns 200 with rows), its field really is the position name, and under the hook's runAs: 'system' the tally reads other people's private contracts. Four consecutive NDAs, Alpha 7Hbf… and Bravo 8nZE…:

#1 -> Alpha (0/0, tie broken on user id)   #2 -> Bravo (1/0)
#3 -> Alpha (1/1, tie)                     #4 -> Bravo (2/1)

Load-balanced and deterministic, exactly as the hook claims.

F6, both halves, with its positive control

open deviation, in_review -> in_approval        422 {"error":"1 deviation(s) are still open;
                                                      decide each one before the contract
                                                      enters approval.","code":"INVALID_STATE"}
route_legal_head before accepting               False
accept the deviation (clause requires_legal_head: true)     200
route_legal_head after                          True        <- deviationGate afterUpdate stamped it
same edge, same caller, deviation now decided   200 -> status 'in_approval'
                                                route_legal_head still True

That last line matters: contract_route runs again on the in_review → in_approval edge 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 into in_approval, not duplicated.

Headless completion, as the requester

POST /api/v1/actions/clm_contract/launch_contract with every input supplied (params go under a params wrapper — a bare body is refused Invalid action params: Action param "contract_type" is required):

{"success":true,"successMessage":"Contract launched.",
 "summary":{"selected":2,"acted":3,"skipped":16,"unmeasured":0}}

contract_number 'NDA-2026-0011'  status 'in_review'  legal_owner '8nZE…' (Bravo)
version_count 1   v1 · Draft · nda-v1.pdf   owner_id = the requester
amount 42000  is_amount_estimated True  department 'procurement'
start_date '2026-10-01'  end_date '2027-09-30'
governing_law 'US-NY'  confidentiality_term_months 24

No screen. Same call over MCP tools/call → run_action → NDA-2026-0013, identical result; and over MCP with the blocked party → "isError":true carrying the same named refusal.

This is also the requester security path the browser could not reach: a clm_requester completes 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:

  • A platform gap, not ours. Uploading a .txt to clm_contract_version.file (which declares accept: application/pdf, .docx) surfaces as the toast "Internal server error", with [REST] Unhandled error: FileConstraintError … ERR_FILE_CONSTRAINT in 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.
  • The refusal toast carries two layers of engine plumbing — 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 blanket errorMessage: one static string would replace every refusal and delete "which party and why". Cosmetic, and the fix belongs to the platform's error rendering.
  • The inline "create a counterparty" branch is dead for a requester. §04 gives them R on clm_party (legal has RCU), so a requester who fills that screen gets PERMISSION_DENIED. It is kept because §06 F1 names it(「相对方查找或新建」)and legal and 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.
  • §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

`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
@hotlong
hotlong marked this pull request as ready for review September 7, 2026 17:16
@hotlong
hotlong merged commit c2c895a into main Sep 7, 2026
2 checks passed
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.

F1 intake screen flow · F2 route hook · F6 deviation gate (card 05, M2)

2 participants