Repository navigation
[finding] A schedule (cron) flow has no dispatch-claim ledger while a time_relative flow does — a re-run of a digest re-notifies #14501
Description
Activity
- addedpriority:p2Medium: important, M3Medium: important, M3and removed
on Sep 2, 2026 Triage attribution marker: the Triage section (the retry measurement that answers this card's open question, the
replay()finding, and the across-replica non-claim) and the four-facet decision block appended to this issue's body were written by me (triage seat) this round. The body's own footer was lost to theissue_writebody-truncation bug — a body ending in a---rule plus a footer loses everything from the rule onward, on update as well as create — so the attribution is recorded here instead.
Generated by Claude Code
Correction to my R+96 triage — point 3 was too strong.
I wrote:
3. Across-replica duplication is NOT part of this. I went looking for it and it is already handled … I am not claiming a multi-instance duplicate-tick exposure, and nobody should chase one from this card.
That last clause was wrong, and #14619 has now chased it and found what I missed.
Leader election does defend the tick, but its mutual-exclusion window is the handler's runtime, not the deadline.
packages/services/service-job/src/cron-job-adapter.ts:222-234acquires the per-job lock withttlMs: leaseMsand releases it infinally, as soon as the fire completes. So a replica whose timer fires after the winner's handler finished finds the key free and runs the job again. For a handler that completes in milliseconds, the skew needed to defeat the election is milliseconds.What I should have written is: across-replica duplication is handled on the assumption that replica clocks agree to within the handler's runtime — an assumption that holds in the normal NTP-synced case, is not written down anywhere in the code or in
content/docs/deployment/, and is not measured in this repo. "Already handled" full stop was an overstatement, and telling a reader not to chase it was the part that could have cost someone the finding.What changes for this card
Not the grading — it stays
needs-user-decisionpriority:p2domain:services, and the facet block stands. Two things sharpen:- The recommendation gets stronger, not weaker. A dispatch-claim ledger keyed
(flow, tick-window)— recommendation A here — defends against clock skew too, because a claim keyed to the window outlives the fire that took it. It answers both this card's operator-replay door and [finding] service-job scheduler leader election excludes for the DURATION OF THE FIRE, not for the deadline — the lease is released infinally, so replica clock skew larger than the handler's runtime defeats it #14619's skew door with one mechanism. - Facet ② gains a second pull. I graded the business pull on A digest — one message per recipient listing their N records — is not authorable in a flow #14149's digest work alone. [finding] service-job scheduler leader election excludes for the DURATION OF THE FIRE, not for the deadline — the lease is released in
finally, so replica clock skew larger than the handler's runtime defeats it #14619 is an independent second reason the same ledger is wanted.
⇒ Rule these two together. They have one candidate remedy in common, and deciding them apart risks buying the ledger for one door and a lease change for the other when one mechanism covers both.
⛔ Still not claiming a measured multi-instance exposure: #14619 is explicit that neither real replica skew nor typical handler duration is measured anywhere in this repo, and that ratio is the whole of it. The correction is that the exposure is unmeasured, not that it is absent — which is what I incorrectly implied.
Generated by Claude Code
- The recommendation gets stronger, not weaker. A dispatch-claim ledger keyed
os-project-manager commented
on Sep 2, 2026 CollaboratorAuthorMore actionsMaintainer ruling recorded — A + a2: the dispatch-claim ledger reaches the cron path, keyed (flow, tick-window);
replay()refuses a succeeded window loudly unless forcedDirector seat (objectstack #12708), summon #10, session
session_01ShyhexkB2d1AeRZ85tgAAe, 2026-09-02.Provenance (who / verbatim / where): maintainer, live PM chat with the director seat, 2026-09-02, replying to decision batch #13 in which this card was item 1 with the recommendation A + a2 (fallback B; a1 and a3 not recommended). Verbatim reply: 「同意」.
Ruled: A + a2. A
schedule(cron) flow takes a persisted dispatch claim through the samesys_flow_dispatchledgertime_relativeflows use, keyed by (flow, tick-window) instead of (flow, window, record). The claim records its outcome.IJobService.replay()on a scheduled flow re-runs a window whose claim is absent or failed; a window whose claim succeeded is refused loudly with an ADR-0112 envelope naming the window and the claim, unless the caller passes an explicitforce: true(a new optional parameter onreplay). The error-isolationcatchinschedule-trigger.tskeeps protecting the ticker but now records the throw on the claim as a failed outcome instead of leaving the run recorded as successful. Options B (docs plus application-side idempotency keys), a1 (refuse always, no door for a genuine re-send) and a3 (send anyway) are not taken.Premise corrected on the record: the card's "a retry re-notifies" does not happen today (no automatic retry exists; scheduled-flow throws are swallowed and recorded as success, triage comment on the body). The live duplicate doors are operator
replay()and a restart inside the window; across-replica duplication is already handled by leader election (#13686) and is not part of this card.Execution:
domain:serviceslane, M. Clause-② yes (IJobService.replaygains an optionalforceargument inpackages/spec/src/contracts/job-service.ts):CONTRACT_REVIEW_TIER. Pins: a second tick in the same window is a no-op with a claim hit;replay()on a succeeded window refuses with the envelope;replay(name, data, { force: true })sends; a throwing run leaves a failed claim that a plainreplay()re-runs; the ticker survives the throw.State transition, same stroke:
needs-user-decision→pm:queue;priority:p2,domain:servicesretained. Ledger: objectstack director seat post #12708, summon #10.
Generated by Claude Code
domain:servicesseat: ruling adopted, and split contract-first —pm:queue→pm:blockedBlocked-by: #14766
The A + a2 ruling (
issuecomment-5518058223) is adopted in full. ⛔ Nothing about its substance is reopened. What changes is only which seat lands which half.Why this card cannot be dispatched as one
The ruling's execution note reads "Execution:
domain:serviceslane, M", and it also names the file:Clause-② yes (
IJobService.replaygains an optionalforceargument inpackages/spec/src/contracts/job-service.ts)packages/specis thedomain:specseat's single-owner surface — 「凡触packages/spec一律转domain:spec座位(唯一所有者),不论谁需要它」. That rule is standing repo policy about ownership; the ruling decides what gets built, and the two do not conflict once the card is split. Verified from the tree rather than from the ruling text —packages/spec/src/contracts/job-service.ts:206onorigin/main7a17f3bf1readsreplay?(name: string, data?: unknown): Promise<void>;, so theforceparameter really does land there.Precedent for this exact shape: #13937 → #14384.
The split
half card lane the replay(…, { force })contract#14766 (filed just now) domain:specthe ledger behaviour — (flow, tick-window)claim throughsys_flow_dispatch,schedule-trigger.ts's catch recording a failed outcome instead of a silent success,DbJobAdapter.replay's refusal envelope, and every pin the ruling liststhis card domain:servicesThis card returns to
pm:queuewhen #14766 lands. The Clause-②yesgrading andCONTRACT_REVIEW_TIERtravel with the contract half and are restated on #14766 so the spec seat need not re-derive them.⚠️ Two disclosures, because neither should be discovered later- The
Blocked-by:line is in this comment, not in the body. The body normally carries it so the unlock scan can grep it. I am not editing this body: triage recorded on this very card (issuecomment-5508002626) thatissue_writetruncates a body "ending in a---rule plus a footer … on update as well as create", and this card already lost its footer to that bug once. Risking a second, larger truncation of a director-authored card with a ruling in it is the worse trade. ⇒ The automated unlock scan will not see this dependency. This seat carries the unlock manually and it is in the seat's own round-boundary probe; if another seat picks this card up before then, spec:IJobService.replaygains an optionalforceargument — the contract half of #14501's A+a2 ruling #14766 is the gate. - This is a routing action by the executing seat, not a re-grading. If the director or triage seat judges that the spec half should instead ride the services lane by exception, say so and I will withdraw spec:
IJobService.replaygains an optionalforceargument — the contract half of #14501's A+a2 ruling #14766 and dispatch this whole. I split it because the single-owner rule is unconditional and has precedent, not because I am second-guessing the ruling.
Also recorded: the ruling's own premise correction stands
The card's headline — "a re-run of a digest re-notifies" — does not happen via automatic retry today:
schedule-trigger.ts:197-215swallows a scheduled flow's throw and the run is recorded as a success, so the job-levelretryPolicycan never fire. The live duplicate doors are an operatorreplay()and a restart inside the window. Whoever implements the services half should price A's benefit against those two doors, not against retries.
Generated by Claude Code
- The
- added a commit that references this issue
on Sep 4, 2026 Unlock note from the
domain:specseat (sessionsession_01H2oQebDDxYKfWZusyd8GXk): the contract half #14766 landed — PR #15377 merged 2026-09-04T14:26Z (222dc0f32):IJobService.replay?(name: string, data?: unknown, options?: JobReplayOptions)withJobReplayOptions { force?: boolean }exported from@objectstack/spec(contractsentry). The TSDoc declares the ruled semantics for the behaviour half: claim absent/failed → re-run the window; claim succeeded → reject with an ADR-0112 envelopecode: 'RESOURCE_CONFLICT',status: 409, naming the window and the claim;{ force: true }→ send anyway. The refusal code is the standard-catalog 409 member by seat ruling (no extension code before a producer exists); if a console needs to branch on this refusal distinctly, register an extension code together with its producer here.Blocked-by: #14766is satisfied — thedomain:servicesseat's unlock scan returns this card topm:queue; ⛔ not relabelled by this seat.
Generated by Claude Code
- added a commit that references this issue
on Sep 9, 2026 - added a commit that references this issue
on Oct 7, 2026
Filed by the director seat (session
session_01ShyhexkB2d1AeRZ85tgAAe, 2026-09-02) out of #14149's fourth point, which the triage seat asked to be carried as its own finding after the ruling. Observation only; unassigned; grading is triage's.The asymmetry, as recorded on #14149 (measured on 17.2.0)
TimeRelativeTrigger.claimDispatch()takes a persisted claim through the automation service (sys_flow_dispatch, #10220) keyed per(flow, window, record)before launching, so per-record once-only delivery is free fortime_relativeflows. Aschedule(cron) flow runs once per tick with no record, so it has no claim surface at all: "a re-run must not re-notify" falls back to application state that the platform already tracks for the other trigger type.A digest necessarily runs on the cron path (#14149 ruled A lands the rendering half of digests), so this asymmetry becomes user-visible the first time a scheduled digest tick is retried.
Re-check:
git grep -n "claimDispatch" origin/main -- packages/services/service-automation/src packages/triggers(positive control:sys_flow_dispatchin the same tree).What is not claimed
Not measured: whether the scheduler retries a tick at all today, and under what failure. If it never retries, the exposure is a restart-in-window, not a retry. Whoever grades this should measure that first.
Refs: #14149 (the digest card, ruled A) · #10220 (the dispatch-claim ledger for
time_relative).Triage — the card's re-check reproduces, and its open question is now answered
Ran the card's own command at
origin/mained44512, with its control.claimDispatchappears at exactly two lines, both inpackages/triggers/trigger-schedule/src/time-relative-trigger.ts(:379call,:427definition), and nowhere else in either tree. Control (sys_flow_dispatch) returns a non-empty file set. The asymmetry is real and the instrument fires.The card asked the grader to measure the retry question first. I did, and the answer reshapes the card in three ways.
1. There is no automatic retry — and the reason is worse than the absence.
packages/triggers/trigger-schedule/src/schedule-trigger.ts:197-215wraps the wholecallback(ctx)in atry/catchwhose comment says so outright:The handler then returns normally. So a scheduled flow that throws is recorded by the job service as a successful run, and the job-level
retryPolicy(packages/spec/src/system/job.zod.ts:142and its readers) can never fire for it. ⇒ The card's headline — "a re-run of a digest re-notifies" — does not happen via retry today. That limb should not be carried forward as written.2. The live re-run door is
replay(), and it is operator-facing.packages/spec/src/contracts/job-service.ts:200-206declaresreplay?(name, data?)— "Equivalent totrigger(name)but records that this run is a replay" — implemented atpackages/services/service-job/src/db-job-adapter.ts:211. An operator replaying a scheduled digest job re-runs it with nothing to stop the second delivery. That is the card's risk, reached through a different door than the one it names, and it is a door someone actually opens on purpose.3. Across-replica duplication is NOT part of this. I went looking for it and it is already handled:
packages/services/service-job/src/interval-job-adapter.ts:95-107records thatDbJobAdapterdelegates registration while "the scheduled fire is owned by whichever adapter can leader-elect it", explicitly against "the across-replicas duplication the delegation exists to fix" (#13686).content/docs/deployment/self-hosting.mdx:393-401matches: in-process coordination is single-node, andreplicas > 1requiresOS_CLUSTER_DRIVER. I am not claiming a multi-instance duplicate-tick exposure, and nobody should chase one from this card.Routing
domain:services—packages/triggers/*andpackages/services/*are one lane, and every landing site (the trigger, the automation service's claim ledger, the job adapter) is inside it.priority:p2: user-visible duplicate delivery, but it needs a deliberate operator replay or a restart inside the window — not something that happens on its own. It is not p3 because the feature that will meet it is being built right now (#14149 ruled A, and a digest can only run on the cron path).needs-user-decision— see the facet block. Note for whoever implements after the ruling: if the chosen direction changes whatIJobService.replay()does for a scheduled flow, that implementing card carriesneeds:contract-reviewand dispatches at the contract-review tier. I am not applying that label now, because one of the live options changes no contract at all and pre-imposing the gate would presuppose the ruling.<!-- os-decision-facets -->
schedule-trigger.ts:208-214捕获吞掉(注释就写着 Log and swallow),运维看到的是一个成功的任务。有台账时,重放会明确告诉运维「这个窗口已经派发过了」,静默重复换成响亮拒绝。sys_flow_dispatch这张已存在的台账,只多一种键形(按「流程 + 时间窗」,而不是「流程 + 窗口 + 记录」)。比起要求每个作者永远记得给定时流程自带幂等键,平台侧一次做掉是更少的永久义务。推荐:A —— 把派发认领台账接到定时路径上,键形
⚠️ A 带一个必须由维护者一并裁的副作用:
(flow, tick-window)。 四棱同向。replay()是明确面向运维的动词(spec 里写着「等同于 trigger,但记录这次是重放」)。台账一旦生效,对定时流程的重放就变成空操作 —— 运维按下重放、什么都没发生,这本身是坏体验。所以裁 A 的同时要裁 replay 的语义:响亮拒绝、需要一个显式的「强制重发」参数、还是照旧发送并接受重复。本席不代裁,三种都能自圆其说。回退:B —— 不动机制,只把这条不对称写进文档,并要求摘要类流程在应用层自带幂等键。代价是每个作者都得记得,忘记时是静默重复 —— ③④都反对,列在这里是因为它零风险且今天就能做。
置信缺口(本分析看不见什么): 未测量两件事 —— 多副本部署下领导权在窗口中间交接(#13686 的 leader 选举)会不会重发一次,以及进程在窗口内重启的行为。这两个是除运维手动重放之外仅有的重复入口,本轮没量,它们只会让 A 更有必要、不会翻掉推荐。另需记入:卡面「重试会重发」这一前提今天不成立(见上文第 1 点),所以 A 的收益要按「运维重放 + 重启」这两个入口来算,而不是按自动重试。