Skip to content

Card 09 — F9 activation · F3/F4/F10–F14 scheduled jobs · F16 executed upload (M3) - #42

Merged
zhuangjianguo merged 6 commits into
mainfrom
claude/issue-39-post-signature-jobs
Sep 9, 2026
Merged

zhuangjianguo merged 6 commits into
mainfrom
claude/issue-39-post-signature-jobs

Conversation

@claude

@claude claude Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Fixes #39
Fixes #6
Fixes #10
Fixes #14

#31 is not addressed here and remains open, blocked on the platform (objectstack-ai/objectstack#16737). What this PR does with it is obey the 1C + 2B ruling: F3 uses a fixed threshold and every string it writes states that threshold.

The reminder layer. Before this PR src/flows/ held three flows — intake, approval, signature-record — and a contract lifecycle product that never told anyone anything was due. It now holds eleven, and M3's 「到期、逾期提醒在收件箱可见」 has a browser screenshot behind it.


⚠️ What was inherited, and what I stand behind

This is the second run on this card. The first dev's container was restarted mid-task; its working tree was rescued as bebd0f7, titled INCOMPLETE AND UNREVIEWED, never gate-run, never browser-driven, never checked against the four rulings. A reviewer diffing against origin/main cannot tell which lines I actually verified, so:

Inherited all six scheduled flows, _daily-sweep.ts, executed-upload.flow.ts, contract-backfill.actions.ts, the contract_activate / contract_archive hooks, the two state-machine edges, the termination_reason declaration, the seed and translation changes
Kept as authored the six flows' graphs, filters and windows; the sweep builder; the two hooks' logic; both state-machine edges; the seed and translation changes — all re-derived against the rulings and then run, per the measurements below
Deleted .counts.local.mjs — a scratch measurement script the rescue swept up (0485f7e)
Rewritten three end refusal nodes → throwing script nodes (75d300c); termination_reason's field-level security (c1ae888); the F14 freeze allow-list (c1ae888); the detail page header action list (5e61d73)

Three defects in the inherited work were found only by running it. All three passed every gate.

1. termination_reason could not be written by anybody

Declared group: 'lifecycle' — and _grants.ts derives CONTRACT_STAMPED_FIELDS, the "only the platform writes this" lock every permission set applies read-only, from fieldsInGroups(['routing', 'lifecycle', 'ai']). Right for the nine other lifecycle members, which are all hook-stamped timestamps. Wrong for this one, which a person answers.

PATCH /api/v1/data/clm_contract/CONTRACT_ID  {"status":"terminated","termination_reason":"…"}
→ 403 [Security] Field write denied: not permitted to edit [termination_reason]

as an admin holding clm_admin and every position. The state machine required the reason; FLS forbade supplying it; active → terminated was unreachable through any surface. Now excluded from the derived lock by name, locked explicitly for clm_requester and clm_finance, open to clm_legal and clm_admin — the two sets §04 gives terminate_contract.

2. The F14 archive freeze refused the one field it exists to keep open

OPEN_AFTER_ARCHIVE spelled the platform's audit columns modified_at / modified_by; clm_contract carries updated_at / updated_by. Those ride along on every update, so every write to an archived contract was refused — Fields refused: updated_at — including the summary edit the exemption is for.

3. start_renewal had no button

Declared, translated into both bundles, wired to its flow, and absent from PageHeaderProps.actions. The page's own comment says it: "an action not named here is unreachable from the record." Measured before the fix: an active contract's header offered Terminate and Edit, nothing else.

And one platform gap, reported not patched

Both action-launched flows refused through end nodes carrying outcome: 'refused' — the shape EndConfigSchema declares. On 17.4.0 only the spec half has shipped:

POST /api/v1/actions/clm_contract/executed_upload   params.signed_date = 2027-01-01
→ HTTP 200 {"success":true,"successMessage":"Executed contract recorded."}
→ GET /api/v1/automation/executed_upload/runs → status "completed"
→ clm_contract rows created: 0

Pressing Start Renewal twice answered the same way. The guards protected the data and lied to the caller. Already on the record upstream as objectstack-ai/objectstack#15788 (open) — so, per AGENTS.md, reported and not patched. Both flows now refuse the way contract_intake already refuses next door: a script node throwing an ADR-0112 envelope. After: HTTP 400 carrying the author's own sentence, including the interpolated AMD-2026-0005 in the renewal case.


The four rulings

Ruling Where it lands Evidence
#6 → A clm_contract.termination_reason, textarea, requiredWhen status is terminated, on no type's intake_fields; asked by the Terminate dialog; the state machine refuses the transition without it terminate with no reason → 422; with a reason → 200, closed_at stamped, reason stored
#10 → 1A in_progress → overdue on clm_obligation, and F10's arrears stage selects status IN (pending, in_progress) a started, past-due obligation was flagged: in_progress 11 → 10, overdue 10 → 12
#10 → 2A partial → paid and partial → overdue on clm_payment_plan, and F11's arrears stage selects due and partial 18 partial rows past their planned date → overdue in one run
#14 → A F9 expands no payment arrangement. No hook, no TODO, no comment anticipating one; the Activate button's description no longer promises one, in both bundles activating a contract: obligations for it 0 → 1, payment plans for it 5 → 5, total 300 → 300
#31 → 1C+2B F3 uses a fixed 30/60-day threshold. ⛔ No job stamps a computed duration onto any field inbox title reads "In legal review over 30 days: …", body reads "This is a fixed 30-day threshold, not this contract type's own review SLA"

Gates

Exit codes captured before any pipe (cmd > file 2>&1; EXIT=$?), on 83eda0a:

VALIDATE=0   LINT=0   TYPECHECK=0   I18N=0

  ✓ Validation passed (976ms)
  Data: 11 Objects  170 Fields · UI: 1 Apps  7 Views  1 Pages  3 Dashboards  9 Actions
  Logic: 12 Flows · Security: 7 Positions  5 Permissions

  21 warning(s), 5 suggestion(s)          ← identical to origin/main @ 5d5a254
  LOCALES  : "en", "zh-CN" checked (required: en, zh-CN)
  COVERAGE : 0 missing keys across 2 locale(s)

os lint reports zero flow-runas-unscoped. The 21 warnings are all field-no-consumers and the 5 suggestions all approval-approvers-may-resolve-empty; pnpm lint on the baseline worktree at 5d5a254 reports the same 21 / 5, so this PR adds none.

Boot, same command and an empty database on both trees:

baseline 5d5a254 this branch
Boot diagnostics block none none
ERROR lines 3 (_objectstack_sequences ×2, sys_oauth_resource unique) the same 3
Flows 4 flow(s) 3 bound to triggers 12 flow(s) 9 bound to triggers

The two known platform ERROR lines (objectstack#17175, #17176) are present on both, which is what makes them pre-existing rather than a claim. Nothing else.


One run of each job — counts read from the database, not the log

Read with better-sqlite3 opening the driver's own file read-only, per the acceptance criterion. pnpm demo on an empty database: 120 contracts, 200 obligations, 300 instalments, 60 reviews, 30 signature rounds, 25 deviations, 40 counterparties. README operator setup performed first (three requester accounts, clm_requester grants, dev admin in clm_admin); without it every contract's owner_id is empty and the notifications have nowhere to go — documented behaviour (#28), and visible in the very first run below, where F4 selected its row and sent nothing.

Run 1 — the seeded corpus as it stands

Job selected acted notified
legal_review_sla (F3) 10 over 30d · 0 over 60d 0 5
turn_stalled (F4) 1 0 1
obligation_due (F10) 2 · 0 · 0 0 2
payment_overdue (F11) 0 · 18 18 18
renewal_notice (F12) 21 0 0
expiration_sweep (F13) 0 · 0 0 0

F3 notified 5 of 10 because only 5 of those contracts carry a legal_owner; the other 5 took the partitioned "nobody to tell" edge and the run stayed green rather than failing on an unassignable notification. F12's zero is correct and independently confirmed: SELECT … WHERE date(end_date, '-'||renewal_notice_days||' day') <= date('now') over the same 21 candidates returns 0 — the 11 contracts already inside their notice window carry the seed's is_expiring = 1 and are excluded by the once-per-contract key. Payments: partial 18 → 0, overdue 12 → 30, nothing else moved.

Run 2 — five probe rows for the windows the corpus leaves empty

F13, F10's arrears stage and F12's write path had nothing to act on above, so five rows were placed to reach them (an in_progress and a pending obligation past due, one due today, an instalment past its planned date, and three contracts given past/near end dates). Before → after:

obligations   pending 77→76   in_progress 11→10   overdue 10→12   done 90→90   waived 15→15
payments      planned 150→149   due 30→30   overdue 30→31   paid 90→90
contracts     active 60→59   expired 8→9   draft 10→11   (all other statuses unchanged)
is_expiring   11 → 14          renewed_from rows  0 → 1
notifications 26 → 37 (+11)    inbox rows        44 → 61 (+17)

Every delta is exactly the probe set and nothing else:

  • +2 overdue obligations = the in_progress probe and the pending probe. done and waived untouched, and in_progress fell by exactly one — that row is ruling [Decision] Two states in DESIGN.md §03 are dead ends: a started obligation cannot go overdue, a partly paid instalment cannot be completed #10 → 1A.
  • planned −1, overdue +1, due unchanged = one instalment taking planned → due → overdue in a single run, which is why F11's two stages are ordered.
  • active −1 / expired +1 = the non-auto-renewing probe, through a transition contract_state_machine refuses to anyone but ctx.session.isSystem — so runAs: 'system' is doing its job.
  • draft +1, renewed_from 0 → 1 = the auto-renewing probe's renewal draft: DPA-2026-0011, start_date 2026-09-09 (the day after the old term ended), end_date 2029-09-08 (36 months less a day), owner and legal owner carried over.
  • +11 notifications / +17 inbox rows. 11 = 5 (F10) + 1 (F11) + 3 (F12) + 2 (F13); 17 because F11's, F12's and F13's each fan out to two recipients — one {recipients.userIds} template resolving to a list, which is the thing an array of templates would have got wrong.

Run 3 — every job a second time

All six: acted 0. Zero status changes, no second renewal draft, is_expiring unchanged at 14. The only new rows were 9 notifications from the reminder stages (F3's daily nudge, F4, F10's T-7 and T-0), which is what a daily reminder is; every state-changing stage is keyed on the state it writes and stayed silent.

Hooks and actions

Probe Result
Insert an obligation with status: 'overdue' by hand 422 — "Only the daily obligation job marks an obligation overdue"
F9 on signing → active activated_at stamped; one kind: renewal obligation, due_date 2027-08-10 = end_date 2027-09-09 − 30 days; no payment plan
F14: archive_no on an active contract 422, naming the status
F14: archive_no on a terminated one 200, archived_at stamped
F14: after archiving, edit summary / edit title 200 / 422 "Fields refused: title"
F16 executed_upload MSA-2026-0017 born active, is_backfilled 1, category/direction/formalities stamped from the type, version_count 1 with a final_signed v1, created_by = the calling admin (audit survives the elevation), 0 payment plans, 0 obligations
F16 with a future signing date / a blocked counterparty 400 carrying the author's message; 0 rows written
start_renewal pressed twice first: AMD-2026-0005 draft; second: 400 "This contract already has a renewal draft (AMD-2026-0005)"

Browser

Chromium 1194 at /opt/pw-browsers/chromium-1194/chrome-linux/chrome, pnpm demo, signed in as admin@objectos.ai at /_console/. playwright install was not run.

  • Sign-in → /_console/apps/clm/clm_contract/view/my_contracts, 200 GET /api/v1/data/clm_contract?…filter=[["owner_id","equals","03PhTym…"]], full five-section navigation.
  • The inbox is seen. Header badge 9+, panel reads "18 total · 18 notifications · 0 pending approvals", rendering rows from every job that fired — "In legal review over 30 days: Mutual Non-Disclosure Agreement — Copperfield Travel" with the body "…more than 30 days. This is a fixed 30-day threshold, not this contract type's own review SLA.", "Due in 7 days: Refresh the insurance certificate ×2", "Renewal drafted: Data Processing Agreement — Kestrel Analytics", "Expired: Data Processing Agreement — Fernway Cleaning", "Renewal decision due: …".
  • Terminate on a contract header opens a dialog reading "Terminate this contract? It is a terminal state… The reason is required and is recorded on the contract." with a required Termination Reason textarea and its help text. Decision [Decision] DESIGN.md §03 requires a termination reason but declares no field for it #6's user-facing half.
  • Start Renewal appears on the header after 5e61d73; before it, the header carried only Terminate and Edit.
  • Backfill Executed Contract appears on the contract-register toolbar with all its params — the two field-backed pickers, Signed On, and an Executed Copy drop zone.
  • Console: one 404 for a static asset, present on the baseline too. No application errors.

Judgement on the two files the card does not name

Both were invented by the previous run and both earn their place, on the evidence rather than on their comments:

  • _daily-sweep.ts is a builder, not new metadata — it emits no surface of its own. It sets runAs: 'system' once for all six jobs (the reason the flow-runas-unscoped count is zero rather than six chances to forget) and wraps every per-row body in a try_catch, which the run summaries confirm: *_guard try_catch runs N once per row in every job. loop-node.ts iterates with a bare await, so without that wrapper the first unreachable recipient would end the sweep and report acted: 0.
  • contract-backfill.actions.ts is the card's F16 action. Splitting it from contract-lifecycle.actions.ts is right: those six are type: 'script' status writes on a record, this one is a type: 'flow' creator on the list toolbar.

Acceptance notes

  • executed_upload's gate is half of what §06 F16 asks. F16 says 「仅 clm_records.access 与 clm_legal.access 可用」 — records OR legal. requiredPermissions is an AND, and no capability is held by both sets, so the action gates on execute_contract (records + admin) and legal loses this one button while keeping every other path. Inherited, kept, and documented in the action's header. Closing it needs either a new §04 capability granted to both sets — a governed decision, not a developer's — or an OR-form gate upstream. Reported, not decided.
  • DESIGN.md §06 F14 says 「此后除 notes 外只读」 and clm_contract has no notes field — §03 gives the contract summary and puts notes on the child objects. The hook keeps summary open, which is the only reading that leaves an archived record annotatable. A §06/§03 wording question; DESIGN.md is out of scope for this PR by the card's own instruction, so it is reported here rather than edited.
  • pnpm demo stops being re-runnable once these jobs have run — the fixture re-asserts active on a contract F13 expired and planned on an instalment F11 flagged, and both state machines correctly refuse. Two dropped rows on the second seed, with the run still reporting 818 ok. Filed as pnpm demo stops being re-runnable once the daily jobs have run: the seed re-asserts statuses the state machines refuse to go back to #41 with three options and no rider here: it is card 08's seed, and which way it goes is a §10 question.
  • Not filed, noted only: F12 sets is_expiring and nothing ever clears it, so a contract renewed and re-termed keeps the flag. Harmless today (the flag's only reader is F12's own exclusion) and it belongs with whatever card gives renewal a second act.
  • No changeset: this repo has no changeset gate.

🤖 Generated with Claude Code

https://claude.ai/code/session_01R3n3GGzobdegM4HUzah1iR


Generated by Claude Code

Salvaged by the PM seat after the container running the dispatched dev was
restarted mid-task. This commit is a rescue of the worktree's uncommitted
state, NOT a reviewed deliverable. It was never gate-run, never browser-driven
and never reported on.

⛔ Do not build on this without diffing it first. Nothing here has been
verified against the four rulings it is meant to implement (#6, #10, #14, #31).

Observed at rescue time (facts about the tree, not claims about behaviour):
- 13 tracked files modified, 10 untracked files added
- 539 insertions / 32 deletions across the tracked half
- all six flows card 09 requires are present as untracked files, plus
  executed-upload.flow.ts (F16), a _daily-sweep.ts helper, and
  contract-backfill.actions.ts
- .counts.local.mjs is a scratch file and must not survive into the PR

Refs #39

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R3n3GGzobdegM4HUzah1iR
`.counts.local.mjs` was a local measurement helper the previous run left in
the working tree; the rescue commit picked it up wholesale. It is not part of
the product and must not reach the PR. The query it holds is kept outside the
repository and its readings are reported in the PR body instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R3n3GGzobdegM4HUzah1iR
`executed_upload` and `renewal_start` each ended a refused run on an `end`
node carrying `outcome: 'refused'` — the shape `EndConfigSchema` declares.
Measured against the pinned 17.4.0 runtime, only the spec half of that
contract has shipped: the schema accepts the node and `pnpm validate` is
green, but the engine does not stamp the outcome, does not persist the
rendered message, and does not suppress the invoking action's
`successMessage`.

    POST /api/v1/actions/clm_contract/executed_upload
      params.signed_date = 2027-01-01   (in the future)
    → HTTP 200 {"success":true,"successMessage":"Executed contract recorded."}
    → GET /api/v1/automation/executed_upload/runs → status "completed"
    → clm_contract rows created: 0

Pressing Start Renewal twice answered the same way: "Renewal draft created."
with no draft created. The guards protected the data on both paths; what they
did not do was tell the caller, which is the dishonest capability AGENTS.md
forbids.

Both flows now refuse the way `contract_intake` already refuses in this same
directory: a `script` node whose registered function throws an ADR-0112
envelope (`code` + `status: 422`). One function, `clm_backfill_refuse`,
shared by both — the message stays the author's, the caller now receives it.

The platform half is reported upstream, not patched here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R3n3GGzobdegM4HUzah1iR
Two defects the rescued commit carried, both invisible to the gates and both
found by driving a booted app.

1. `termination_reason` could not be written by anyone.

   The field was declared `group: 'lifecycle'`, and `_grants.ts` derives
   `CONTRACT_STAMPED_FIELDS` — the "only the platform writes this" lock every
   permission set applies read-only — from `fieldsInGroups(['routing',
   'lifecycle', 'ai'])`. That derivation is right for the nine other lifecycle
   members, which are all hook-stamped timestamps, and wrong for this one,
   which a person answers in the Terminate dialog. Measured as an admin
   holding `clm_admin` and every position:

       PATCH /api/v1/data/clm_contract/<id>
         {"status":"terminated","termination_reason":"…"}
       → 403 [Security] Field write denied:
             not permitted to edit [termination_reason] on 'clm_contract'

   The state machine requires the reason; FLS forbade supplying it. So
   `active → terminated`, a transition DESIGN.md §03 declares, could not be
   taken through any surface. The field is now excluded from the derived lock
   by name and locked explicitly for `clm_requester` and `clm_finance`;
   `clm_records` already locks it through `editableOnly`. `clm_legal` and
   `clm_admin` — the two sets §04 gives `terminate_contract` — get it through
   `openAllExcept`. After: 422 with the state machine's own message when the
   reason is missing, 200 with `closed_at` stamped when it is supplied.

2. The F14 archive freeze refused the one field it means to keep open.

   `OPEN_AFTER_ARCHIVE` spelled the platform's audit columns `modified_at` /
   `modified_by`; `clm_contract` carries `updated_at` / `updated_by`. Those
   columns ride along on every update, so every write to an archived contract
   was refused — "Fields refused: updated_at" — including the `summary` edit
   the exemption exists for, leaving an archived record with no in-product way
   to annotate it. Now: `summary` accepted (200), `title` refused by name
   (422). A field-name typo in an allow-list is silent until something runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R3n3GGzobdegM4HUzah1iR
`StartRenewalAction` was declared, translated into both bundles and wired to
`renewal_start`, and had no button anywhere: `PageHeaderProps.actions` is a
list of action IDs, a custom record page replaces the default header, and
`start_renewal` was not in the list. The page's own comment says it — "an
action not named here is unreachable from the record" — and still carried the
line explaining that 发起续签 was missing "because F12 is card 09". This is
card 09.

Measured on the booted app before the change: the header of an active
contract offered Terminate and Edit and nothing else. F12's whole second half
is that button — the sweep flags `is_expiring` and the notification says a
renewal decision is due, and the decision is taken here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R3n3GGzobdegM4HUzah1iR
… the refusal workaround

The gap is already on the record upstream — "service-automation: honour
`outcome: 'refused'` on the flow `end` node", lane 2 of the #14945 ruling —
so the code points at it rather than describing it twice. When it lands, the
two refusal nodes can go back to being `end` nodes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R3n3GGzobdegM4HUzah1iR
@zhuangjianguo
zhuangjianguo marked this pull request as ready for review September 9, 2026 17:05
@zhuangjianguo
zhuangjianguo merged commit a7b7db5 into main Sep 9, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment