Repository navigation
Card 09 — F9 activation · F3/F4/F10–F14 scheduled jobs · F16 executed upload (M3) - #42
Merged
Merged
Conversation
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
marked this pull request as ready for review
September 9, 2026 17:05
This was referenced Sep 9, 2026
This was referenced Oct 9, 2026
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 #39
Fixes #6
Fixes #10
Fixes #14
#31is 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.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 againstorigin/maincannot tell which lines I actually verified, so:_daily-sweep.ts,executed-upload.flow.ts,contract-backfill.actions.ts, thecontract_activate/contract_archivehooks, the two state-machine edges, thetermination_reasondeclaration, the seed and translation changes.counts.local.mjs— a scratch measurement script the rescue swept up (0485f7e)endrefusal nodes → throwingscriptnodes (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_reasoncould not be written by anybodyDeclared
group: 'lifecycle'— and_grants.tsderivesCONTRACT_STAMPED_FIELDS, the "only the platform writes this" lock every permission set applies read-only, fromfieldsInGroups(['routing', 'lifecycle', 'ai']). Right for the nine other lifecycle members, which are all hook-stamped timestamps. Wrong for this one, which a person answers.as an admin holding
clm_adminand every position. The state machine required the reason; FLS forbade supplying it;active → terminatedwas unreachable through any surface. Now excluded from the derived lock by name, locked explicitly forclm_requesterandclm_finance, open toclm_legalandclm_admin— the two sets §04 givesterminate_contract.2. The F14 archive freeze refused the one field it exists to keep open
OPEN_AFTER_ARCHIVEspelled the platform's audit columnsmodified_at/modified_by;clm_contractcarriesupdated_at/updated_by. Those ride along on every update, so every write to an archived contract was refused —Fields refused: updated_at— including thesummaryedit the exemption is for.3.
start_renewalhad no buttonDeclared, 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
endnodes carryingoutcome: 'refused'— the shapeEndConfigSchemadeclares. On 17.4.0 only the spec half has shipped: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 waycontract_intakealready refuses next door: ascriptnode throwing an ADR-0112 envelope. After:HTTP 400carrying the author's own sentence, including the interpolatedAMD-2026-0005in the renewal case.The four rulings
clm_contract.termination_reason, textarea,requiredWhenstatus isterminated, on no type'sintake_fields; asked by the Terminate dialog; the state machine refuses the transition without itclosed_atstamped, reason storedin_progress → overdueonclm_obligation, and F10's arrears stage selectsstatus IN (pending, in_progress)in_progress11 → 10,overdue10 → 12partial → paidandpartial → overdueonclm_payment_plan, and F11's arrears stage selectsdueandpartialpartialrows past their planned date →overduein one runGates
Exit codes captured before any pipe (
cmd > file 2>&1; EXIT=$?), on83eda0a:os lintreports zeroflow-runas-unscoped. The 21 warnings are allfield-no-consumersand the 5 suggestions allapproval-approvers-may-resolve-empty;pnpm linton the baseline worktree at5d5a254reports the same 21 / 5, so this PR adds none.Boot, same command and an empty database on both trees:
5d5a254_objectstack_sequences×2,sys_oauth_resourceunique)4 flow(s) 3 bound to triggers12 flow(s) 9 bound to triggersThe 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-sqlite3opening the driver's own file read-only, per the acceptance criterion.pnpm demoon 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_requestergrants, dev admin inclm_admin); without it every contract'sowner_idis 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
legal_review_sla(F3)turn_stalled(F4)obligation_due(F10)payment_overdue(F11)renewal_notice(F12)expiration_sweep(F13)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'sis_expiring = 1and are excluded by the once-per-contract key. Payments:partial18 → 0,overdue12 → 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_progressand apendingobligation past due, one due today, an instalment past its planned date, and three contracts given past/near end dates). Before → after:Every delta is exactly the probe set and nothing else:
in_progressprobe and thependingprobe.doneandwaiveduntouched, andin_progressfell by exactly one — that row is ruling [Decision] Two states inDESIGN.md§03 are dead ends: a started obligation cannot go overdue, a partly paid instalment cannot be completed #10 → 1A.planned → due → overduein a single run, which is why F11's two stages are ordered.contract_state_machinerefuses to anyone butctx.session.isSystem— sorunAs: 'system'is doing its job.renewed_from0 → 1 = the auto-renewing probe's renewal draft:DPA-2026-0011,start_date2026-09-09 (the day after the old term ended),end_date2029-09-08 (36 months less a day), owner and legal owner carried over.{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_expiringunchanged 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
status: 'overdue'by handsigning → activeactivated_atstamped; onekind: renewalobligation,due_date2027-08-10 =end_date2027-09-09 − 30 days; no payment planarchive_noon anactivecontractarchive_noon aterminatedonearchived_atstampedsummary/ edittitleexecuted_uploadMSA-2026-0017bornactive,is_backfilled1, category/direction/formalities stamped from the type,version_count1 with afinal_signedv1,created_by= the calling admin (audit survives the elevation), 0 payment plans, 0 obligationsstart_renewalpressed twiceAMD-2026-0005draft; 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 asadmin@objectos.aiat/_console/.playwright installwas not run./_console/apps/clm/clm_contract/view/my_contracts,200 GET /api/v1/data/clm_contract?…filter=[["owner_id","equals","03PhTym…"]], full five-section navigation.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: …".DESIGN.md§03 requires a termination reason but declares no field for it #6's user-facing half.5e61d73; before it, the header carried only Terminate and Edit.Signed On, and anExecuted Copydrop zone.404for 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.tsis a builder, not new metadata — it emits no surface of its own. It setsrunAs: 'system'once for all six jobs (the reason theflow-runas-unscopedcount is zero rather than six chances to forget) and wraps every per-row body in atry_catch, which the run summaries confirm:*_guard try_catch runs Nonce per row in every job.loop-node.tsiterates with a bareawait, so without that wrapper the first unreachable recipient would end the sweep and reportacted: 0.contract-backfill.actions.tsis the card's F16 action. Splitting it fromcontract-lifecycle.actions.tsis right: those six aretype: 'script'status writes on a record, this one is atype: '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.requiredPermissionsis an AND, and no capability is held by both sets, so the action gates onexecute_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.clm_contracthas nonotesfield — §03 gives the contractsummaryand putsnoteson the child objects. The hook keepssummaryopen, which is the only reading that leaves an archived record annotatable. A §06/§03 wording question;DESIGN.mdis out of scope for this PR by the card's own instruction, so it is reported here rather than edited.pnpm demostops being re-runnable once these jobs have run — the fixture re-assertsactiveon a contract F13 expired andplannedon an instalment F11 flagged, and both state machines correctly refuse. Two dropped rows on the second seed, with the run still reporting818 ok. Filed aspnpm demostops 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.is_expiringand 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.🤖 Generated with Claude Code
https://claude.ai/code/session_01R3n3GGzobdegM4HUzah1iR
Generated by Claude Code