Skip to content

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

Description

@zhuangjianguo

Found while browser-verifying card 09 (#39). Not a defect in the jobs — a consequence of them existing that the seed does not account for. Filed unassigned, out of scope for #39.

The documented workflow that now breaks

README.md tells the evaluator to re-run the demo:

Every dataset is an upsert, so create the accounts and run pnpm demo again and their contracts are handed over.

That works on a freshly seeded database. It stops working once card 09's daily jobs have run once, because those jobs are the declared writer of two statuses the fixture also declares, and the state machines refuse the way back.

Reproduction, measured on claude/issue-39-post-signature-jobs @ 83eda0a, 17.4.0, better-sqlite3

  1. pnpm demo on an empty database — 820 ok / 0 errors.
  2. Run the sweeps once each: POST /api/v1/automation/expiration_sweep/trigger, POST /api/v1/automation/payment_overdue/trigger.
  3. pnpm demo again.

Boot banner on step 3:

⚠ Seeds:   app.objectstack.hotclm 818 ok / 2 errors ⚠
  ✗ Failed to write clm_contract record #99 (title=Data Processing Agreement — Fernway Cleaning):
    hook 'contract_state_machine' threw: A expired contract is closed; its status cannot
    change to active. Start a renewal or an amendment instead.
  ✗ Failed to write clm_payment_plan record #2 (contract+seq=["…","3"]):
    hook 'payment_plan_state_machine' threw: Payment instalment status cannot go from
    overdue to planned. Allowed from overdue: partial, paid.

Two rows are dropped and every other row still loads, so the run looks almost clean — the same "the summary reads like a clean load" shape #39's acceptance criteria warn about.

Why it happens

The fixture writes status as a literal on every upsert. Once a job has moved a row on:

  • expiration_sweep (F13) moves an active contract past its end date to expired. §03 makes expired terminal, so re-asserting the fixture's active is refused.
  • payment_overdue (F11) moves a due or partial instalment past its planned date to overdue. §03 gives overdue only → partial / → paid, so re-asserting planned is refused.

Both refusals are correct: the state machines are doing exactly what DESIGN.md §03 asks. It is the fixture's unconditional status that has no answer for "this row has since moved on".

The two rows are the ones whose dates the jobs act on, so the count grows as the demo database ages: any contract that expires and any instalment that falls into arrears joins the set.

What a fix has to decide (hence: not a rider on #39)

Whichever way it goes it is a change to card 08's seed, and it needs a product answer first:

  • A — leave status out of the upsert on re-seed. The fixture stops fighting the jobs, and a demo database shows the lifecycle actually moving. Costs the guarantee that pnpm demo restores a known corpus.
  • B — re-seed onto a clean database only, and say so in the README instead of promising re-runnability. Cheapest, and honest, but it takes away the account-handover workflow the README currently recommends.
  • C — keep the promise and make the seed reset the row first (overdue → paid → … is not reachable either, so this means a system-context reset, not a transition). Most machinery, and it puts a second writer on the columns Card 09 — F9 activation · F3/F4/F10–F14 scheduled jobs · F16 executed upload (M3) #39 gave exactly one.

I have no view worth acting on between them; A looks smallest but it changes what "the demo dataset" means, which is a §10 question.

Activity

  1. zhuangjianguo commented on Sep 9, 2026

    @zhuangjianguo
    CollaboratorAuthor

    维护者速读

    分诊:本席把这张卡收进决策箱(needs-user-decision),并补上入箱必带的四棱块与速读。⛔ 不裁 —— 选项之间的差别是「demo 数据集意味着什么」,那是 §10 的产品问题。

    事情:README 现在告诉试用者「建好账号后再跑一次 pnpm demo,合同就交接过来了」。卡 09 的定时任务上线后,这条路只在全新数据库上还成立。任务跑过一次之后再跑 demo,会有行被拒绝。

    为什么:种子把 status 当字面值无条件写入。而定时任务是这两个状态的唯一声明写手,状态机不许回头:

    • 到期扫描把过了终止日的 active 合同推到 expired,而 expired 是终态 —— 种子再写 active 被拒。
    • 逾期任务把过了计划日的 due / partial 期次推到 overdue,而 overdue 只准去 partial / paid —— 种子再写 planned 被拒。

    两条拒绝都是对的,状态机在照 §03 干活。是种子那个无条件的 status 没有「这行已经往前走了」的答案。

    ⚠️ 最坏的部分不是掉两行,是它看起来几乎是干净的:818 ok / 2 errors,其余全部照常加载。这正是卡 09 验收标准反复警告的「摘要读起来像成功」那个形状。而且随着演示库变老,掉的行会变多 —— 任何到期的合同、任何进入逾期的期次都会加入这个集合。

    你要做的:回一个字母,A、B 或 C。

    选项 × 真实代价

    做法 客户感知到的后果
    A 重跑时不写 status(只在首次种入时写) 种子不再跟任务打架,演示库能真实展现生命周期在动。代价:失去「pnpm demo 能把库恢复到已知状态」这个保证
    B 只支持在干净数据库上重种,README 如实改写 最省,且诚实。代价:拿掉 README 现在推荐的「建账号→重跑→合同交接」这条动线,而那条动线正是 #28 在讨论的开箱体验
    C 保住承诺,让种子先把行重置回去 保留一切。代价:overdue → planned 走不通,所以这意味着系统上下文强改,等于给卡 09 刚刚确立单一写手的那两列再加一个写手

    业务含义直译

    A = 演示库像一个真实运行了半年的系统,你重跑只补数据不倒转时钟;B = 演示库是一次性快照,要重来就重装;C = 演示库有一个「时光倒流」按钮,代价是有两个人能改同一块表。

    四棱分析

    os-decision-facets

    ① 项目长远合理性 — 偏 A,明确反对 C。卡 09 刚刚把「谁写 overdue / expired」收敛成唯一写手(overdue is written by these jobs only,写在卡里也写在代码里)。C 立刻再开一个写手,把刚建立的不变量拆掉,方向反了。A 是让种子退出这两列的争夺。

    ② 实际业务拉动 — 偏 B,判据实测。今天谁撞上?试用者,在 #28 描述的那条动线上 —— 建三个账号、重跑 demo 拿合同归属。那条动线是真的(README 写着,卡 09 的浏览器验证也照做了),但它只在首次种入后立刻执行,那时任务还没跑过,所以今天没有人真的被这个 bug 挡住。拉动是潜在的,不是现行的。

    ③ 防 AI 犯错 — 这一棱最要紧,且三个选项都要配一件事。出错时谁看到什么:今天是 818 ok / 2 errors 混在启动日志里,没有任何人会注意到,而缺的恰恰是演示库里最有意思的两行。⚠️ 无论选哪个,都应要求「种子写入被状态机拒绝时,启动横幅必须响亮」—— 否则演示库会静悄悄地烂掉。C 在这一棱最差:系统上下文强改会让「状态机拒绝」这个信号本身消失。

    ④ 创业阶段不扩散 — 偏 B,其次 A。B 零新增(改一段 README)。A 是种子逻辑的一处收窄,也不算扩张。C 要新增一套重置机制,是 declare-and-maintain,最重。

    推荐:A,回退 B。 ①③④ 都反对 C,②在 A/B 之间偏 B 但拉动是潜在的。选 A 的理由是它同时满足①和④,且保住了 #28 那条动线;选 B 的理由是最省且诚实。⛔ 不建议 C。

    本分析看不见什么:你是否把「pnpm demo 幂等地恢复一个已知语料」当成一条产品承诺。如果是,A 就动了那条承诺,B 是把承诺改小,而这两者的差别只有你能定。

    关联

    #39 / PR #42(发现处,已合并 a7b7db5)· #28(README 那条开箱动线,决策箱中)· 卡 08 的种子 · DESIGN.md §10


    Generated by Claude Code

  2. zhuangjianguo commented on Sep 10, 2026

    @zhuangjianguo
    CollaboratorAuthor

    ⚠️ 前提刷新:本卡的复现被推翻了,而替代它的观察更糟

    ⛔ 不是裁决,不动标签。这条是把只存在于会话里的结论落到卡上 —— 结论只在 chat 就是半状态。

    本卡的正文说:任务跑过之后重跑 pnpm demo 会报错并掉两行(818 ok / 2 errors,两条状态机拒绝)。dogfood 走查(#45,main @ a7b7db5,17.4.0)测到的不是这个。

    读数 本卡正文 #45 实测
    重跑 pnpm demo 818 ok / 2 errors,两行被拒 三轮全部干净,0 报错
    付款期次 overdue → planned 被状态机拒绝 payment_overdue 跑完后那一轮把 18 期已逾期的款静默改回去了(overdue 30 → 12)
    到期合同 expired → active 被拒 ⛔ 未测 —— 那次 expiration_sweep 选中 0 行,这条路径从未被走到

    这为什么比原来的缺陷更严重

    原缺陷是响亮的:种子撞上状态机、被拒、启动横幅上有两行错。难看,但可见,而且数据是对的。

    新观察是静默的:重跑把一个每日任务刚做完的工作撤销了,0 errors,横幅干净。演示库里「这 18 笔款逾期了」这个事实消失了,没有任何人会知道。一个安静的错误结果和一个正确结果无法区分 —— 这正是这个仓反复在防的形状。

    ⚠️ 而且它与卡 09 的实测直接矛盾,这一点牵连到已合并的代码

    卡 09(#39 / PR #42,已合并 a7b7db5)的验收明确测到 payment_plan_state_machine 拒绝 overdue → planned:

    Payment instalment status cannot go from overdue to planned. Allowed from overdue: partial, paid.

    而 #45 在同一棵树上测到同一转换被静默接受。两份读数不可能都对。 三种可能,⛔ 都还没被排除:

    1. 状态机在种子路径上没有被强制(种子以系统上下文写入,可能绕过了 hook)—— 若是这样,卡 09 交付的那台状态机在它最需要生效的地方是空的。
    2. 两次测的不是同一个转换(例如 Dogfood pass on M3-complete main: drive the whole contract lifecycle in a browser and report what a real user hits #45 那轮里那些行当时并非 overdue)。
    3. 其中一次的读数取错了(例如从日志而非数据库读)。

    第 1 种成立的话,这不只是 #41 的问题 —— 它意味着我以复核人身份验收 PR #42 时,接受了一个在种子路径上不成立的不变量。

    本卡状态与下一步

    ⛔ 不关、不改选项、不撤卡。 但正文的复现步骤现在是未经证实的,读者不要照抄。

    需要按序做两件事,然后本卡的 A/B/C 才可判:

    1. 测 expiration 那半边 —— 需要一个足够"老"的数据库让 expiration_sweep 真的选中行。Dogfood pass on M3-complete main: drive the whole contract lifecycle in a browser and report what a real user hits #45 没走到这条路径,所以本卡关于 expired → active 的那一半既未证实也未推翻。
    2. 查清与卡 09 的矛盾 —— 一个具名判据:以种子路径把一个 overdue 期次写成 planned,从数据库读回它的状态,并同时读 hook 是否被调用。答案决定这是 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 的选项问题,还是一张关于状态机强制面的新缺陷卡。

    ⇒ 若第 2 项证实是路径绕过,那是一张独立的缺陷卡,且优先于本卡:本卡在问「种子该怎么和任务共处」,而那张在问「状态机到底有没有生效」。

    出处:#45 的走查报告与其席位复核;读数时刻 2026-09-10T01:5xZ,树 a7b7db5。


    Generated by Claude Code

  3. zhuangjianguo commented on Sep 10, 2026

    @zhuangjianguo
    CollaboratorAuthor

    ⚠️ 更正我自己上一条评论:那个「更糟」的判断是错的,而本卡现在可判了

    上一条我说 dogfood 观察到的「静默改回 18 期」比原报的响亮拒绝更糟,并列了三种可能。#50 的调查把它settle了:候选 (b)。⛔ 没有绕过,状态机是好的,而我那个「更糟」的定性不成立 —— 我把一条正常工作的合法边说成了缺陷。

    真正在发生的事

    未改动的种子语料里,payment_overdue 只能动那 18 行 partial:arrears 级选 due/partial 且过了 planned_date,而 150 行 fixture planned 的日期全在 +35 天以后,planned→due 那一级选中 0。那 18 行的 fixture 目标值就是 partial,所以重跑把它们从 overdue 写回 partial —— 而 overdue → partial 是你裁的 #10 → 2A 明确声明的合法边。

    ⇒ 「18 行被改回、0 报错」是一台正常状态机的正确输出。

    而卡 09 那次拒绝来自它自己造的探针行(fixture 值为 planned、被回拨日期推到 overdue),overdue → planned 确实被拒。#50 重造了那个探针,拿到卡 09 的报错原文与 818 ok / 2 errors。两次都对,测的是不同的行。

    逐行证据(全部 300 行按 id 快照、逐行 diff 而非看总数):A→B 恰好 18 行变,全是 partial → overdue;B→C 同一批 18 个 id,全是 overdue → partial,断言了集合相等。且 upsert 会跳过已与 fixture 相等的行(inserted 0, updated 18, skipped 802),所以 820 行里只有那 18 行被真正写过。

    这对本卡的三个后果

    ① 正文的复现步骤仍然未经证实,但原因变了。 它描述的 818 ok / 2 errors 需要一行语料里不存在的行 —— 卡 09 造的那种。在未改动的演示库上,重跑 demo 是干净的,且只会沿合法边把那 18 行写回。⇒ 正文该改写成这个形状。

    ② 新增一条抬高代价的事实,属 A/B/C 权衡的输入:被拒的种子记录是被整行丢弃的。 #50 的探针 A 里,被回拨的 planned_date 连同状态一起没写进去。⚠️ 也就是说:fixture 行里一个非法状态,会静默连带损失那一行的其它列。选项 C(让种子先重置行)本来看着最保守,这条事实让它更贵;而选项 A(重跑不写 status)因此更有吸引力 —— 它把整类整行丢弃的风险一起移走。

    ③ 「无声」这个论点要从 A 的理由里撤掉。 我上一条把静默无报错当成 A 的论据。那是错的:静默是合法边的正常表现,不是隐患。A 的理由现在只剩两条 —— 卡 09 刚把 overdue/expired 收敛成单一写手(C 会再开一个写手,方向相反),以及整行丢弃的代价。

    仍然缺的那一半,⛔ 不要当成已测

    expiration 那半边(expired → active)依然未被走到。 #50 只做了付款侧;dogfood 那次 expiration_sweep 选中 0 行。⛔ 本卡关于到期合同的那一半既未证实也未推翻,别照抄。

    ⇒ 本卡出决策箱之前该先把那半边测掉,否则 A/B/C 是在半份事实上选。

    一条与本卡相邻、影响读者判断的事实

    #50 顺带测出:面向人的种子横幅本身是不可靠的那一面,不是 loader 的计数器。 第一次启动时内联 seed 超了 8000ms 预算转入后台,Seeds: … 820 rows 那一行从未打印,而 seed 在 Server is ready 之后还写了 3.5 分钟。权威的是后来那条 WARN [Seeder] Seed loading completed JSON。任何基于「重跑后横幅说了什么」的论证都要改用 loader 的 JSON。

    (也因此顺带解释了 #49 的 dev 为何只看到 0 条 ERROR [SeedLoader] 而 dogfood 看到 120 条 —— 同一个脚本、不同的预算结局。)

    出处:#50(已关闭,调查完成)· 读数时刻 2026-09-10T07:1xZ · 树 a7b7db5


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 10, 2026

    @objectstack-fleet
    Contributor

    Evidence from the 17.7.0 browser pass · 2026-10-10T02:47Z — #87, suspect 6. The failure has changed shape on 17.7.0:

    • Re-running pnpm demo after the daily jobs ran: seed summary {"inserted":0,"updated":18,"skipped":801,"errored":1}.
    • The one error: Failed to write clm_contract record #104 (Lease Agreement — Granite Facilities): A terminated contract is closed; its status cannot change to active — a contract legal terminated during the run.
    • Worse, silently: the 18 "updates" reverse payment_overdue's work — 18 part-paid instalments go from overdue back to partial, because overdue → partial is an allowed transition. Overdue drops from 30 to 12 and nothing says so.
    • The overdue → planned error this card quoted does not appear on 17.7.0.

    So the re-run no longer fails loudly on the jobs' instalments; it quietly undoes them. Relevant to the options here, and to #82 (whether pnpm demo arms the daily jobs at all).


    Generated by Claude Code

  5. added a commit that references this issue on Oct 10, 2026
  6. objectstack-fleet commented on Oct 10, 2026

    @objectstack-fleet
    Contributor

    Two more measured additions to this card's set · 2026-10-10T04:32Z — from #97 (PR #102, 17.7.0):

    1. With PR The renewal-notice and expiration-sweep jobs produce nothing although 11 contracts carry is_expiring, and five of the six daily flows report acted: 0 while they notify #102's placed rows, re-running pnpm demo after expiration_sweep has run prints 819 ok / 1 error — contract_state_machine refuses to move NDA-2026-0006 from expired back to active. Loud, same class.
    2. A re-seed resets ICA-2026-0002's is_expiring to 0, so the next F12 run sends that renewal notice again. Silent — the fixture re-asserting a column a job wrote, like the 18 overdue → partial reversals measured in Full browser test on 17.7.0 main: drive the whole contract lifecycle as every audience, in en and zh-CN, and report what a real user hits #87.

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions