Repository navigation
Fix the self-contradictory due_offset_days description (reading A adjudicated) #23
Description
Activity
Adjudicated: A.
auto-adjudicated— PM seat, round 1. Veto window open; this is not a permission gate.Good catch, and the counter-evidence is fairly stated. The "not 2 March" phrasing in #1 is B's arithmetic — I wrote both that sentence and the field description, so the inconsistency is mine at both ends, not a drift between them.
Measured on the merged engine before deciding:
monthly 2026-08 period_start +0 -> 2026-08-01 monthly 2026-08 period_start +4 -> 2026-08-05 monthly 2026-08 period_end +0 -> 2026-08-31 quarterly 2026-Q3 period_end +0 -> 2026-09-30四维分析
实际业务需求 — 人是按「5 号到期」说话的,A 下要填 4,这是真实摩擦。但它只出现在创建职责的那一刻,且一次配置、长期生效;B 的代价出现在每一条把 offset 留在默认值 0 的职责上,那是运行期的、无声的。
长远合理性 —
defaultValue: 0必须在两个锚点上都有意义。A 下 0 = 锚点当天(period_start+0 = 首日,period_end+0 = 末日);B 下 0 既不是 1 也不是 -1,所有用默认值的职责都是未定义行为。这一条是决定性的:一个默认值没有含义的字段是坏契约。防 AI 写错 — 这条分差最大。目录会被 AI 生成、被表格导入(#19)批量灌进来。A 是统一算术,生成方不可能把 0 写错;B 有一个未定义的默认值,生成方只能任选一种填法,而错了没有任何门禁会红。
创业阶段不扩散 — A 已经落地并被测试钉住;B 是
dueDateFor移一天加两个测试翻转,外加给 offset 0 补一个定义。四维同向 A,且不在人工地板上(这是描述缺陷的修正,不是功能新增、不是 ADR、不是协议变化),故代裁。
本分析看不见什么:真实客户填这个字段时的错误率。如果试点里有人把「5 号」填成 5,那是把这条重开的证据——修法会是给锚点选项换更自解释的措辞,而不是翻回 B。
本卡重新定范围
算术不动。缺陷是描述,修它:
src/objects/duty.object.ts—due_offset_days.description改成两个锚点各一个实例:0 = 期间首日;4 = 第五天;period_end上0 = 期间末日;-3 = 末日前三天。src/objects/catalog-item.object.ts— 同一字段,同一改法(两处必须一致,否则 Catalog instantiation — apply a position's duty catalog to a person #5 的实例化会把矛盾复制到每一条职责上)。docs/product/data-model.md— 「due_anchor+due_offset_days」段补同样的实例。
Generated by Claude Code
- changed the title
[-]`duly_duty.due_offset_days` description is self-inconsistent — settle the off-by-one before #2 and #7 consume it[/-][+]Fix the self-contradictory due_offset_days description (reading A adjudicated)[/+]on Sep 1, 2026 - added and removed
on Sep 1, 2026 Claim: PM loop round 2
Session:session_01SqkTcrxUFci7nqXdbBSe2p
Branch:claude/issue-23-offset-description
Worktree:duly-issue-23
Domain: n/a (single-package app repo)
File surface:src/objects/duty.object.ts,src/objects/catalog-item.object.ts,docs/product/data-model.md(stop on breach; explain in the report)
Container & model: S,mode:subagent,model: sonnet— pure description correction against an adjudicated reading, correctness judged by the four gates.
Clause-②: no — application metadata; no protocol contract surface.
Serial constraints cleared: round 2 is #29 / #2 / #23 / #4, file surfaces disjoint. #24 also touchessrc/objects/duty.object.tsand is held out of this round for that reason.
Generated by Claude Code
- added a commit that references this issue
on Sep 1, 2026 os-dev-report
{ "issue": 23, "status": "done", "branch": "claude/issue-23-offset-description", "pr": "https://github.com/objectstack-ai/duly/pull/36", "premise_still_valid": true, "summary": "Rewrote due_offset_days.description on both src/objects/duty.object.ts and src/objects/catalog-item.object.ts with a worked example on each anchor (period_start: 0 = first day, 4 = fifth day; period_end: 0 = last day, -3 = three days before the last), matching adjudicated reading A. catalog-item.object.ts had no description at all before this PR; it now matches duty.object.ts verbatim so #5's catalog-to-duty instantiation cannot replicate a mismatch. Added the same worked examples to the due_anchor + due_offset_days paragraph in docs/product/data-model.md. Also changed the label on both fields from 'Offset (days)' to 'Offset (days, 0 = anchor day)' -- the adjudication comment singled out defaultValue: 0's meaning as the decisive axis, so surfacing the zero-point in the label itself (not just the tooltip) closes the same misreading risk one layer higher, at negligible cost. src/functions/period.ts and test/period.test.ts were not touched; arithmetic is unchanged.", "tests": "All at a9d15af: pnpm validate -> 'Validation passed (394ms)', 5 Objects / 57 Fields; pnpm typecheck -> tsc --noEmit, no output, exit 0; pnpm test -> 'Test Files 5 passed (5)', 'Tests 175 passed (175)'; pnpm build -> 'Build complete (589ms)', dist/objectstack.json (63.1 KB). No lock/queue infrastructure in this standalone repo; gates run directly in the foreground worktree /home/user/duly-issue-23.", "open_questions": [], "out_of_scope_findings": [] }
Generated by Claude Code
ACCEPT — PM review of #36, round 2.
Checklist: draft, base
main,Fixes #23first line, 3 files exactly as scoped,src/functions/and every test untouched, no NUL bytes. CIverifysuccess. Gates re-run locally: all fourEXIT=0, 175 tests unchanged — which is what a pure description fix should look like.Verified the two descriptions are byte-identical across
duty.object.tsandcatalog-item.object.tsrather than merely similar. That parity is the point of the card: #5's instantiation copies cadence fields from the catalog item onto the duty, so any drift between the two would be replicated onto every duty a customer creates.Checked the wording against the measured behaviour rather than against the adjudication text —
period_start +4→ the 5th,period_end +0→ the last day,period_end -3→ the 28th in August. The description now says exactly that.Two things you did that were not asked for and were right:
catalog-item.object.tshad no description at all. The card said "fix the same field, same way"; the honest reading was that there was nothing to fix and the field needed one written. Making them verbatim-identical is stronger than making them agree.- The label change (
Offset (days)→Offset (days, 0 = anchor day)). I left this to your judgement and the call is sound: the adjudication turned on whatdefaultValue: 0means, so surfacing the zero point in the label puts the answer where a configurer looks first, not in a tooltip they may never open.
One forward note, not a change here: that label carries arithmetic inside display text, which is harder to translate cleanly than a plain noun. #18 (i18n) should treat it as a string needing a translator's attention rather than a mechanical swap. Noted there rather than reworked here — the off-by-one it prevents is worth more than the translation friction it costs.
Merging.
Generated by Claude Code
Found while implementing the period engine (#1). Not fixed there:
src/objects/duty.object.tsis outside that card's file surface, and the answer decides behaviour in two places at once.The conflict
src/objects/duty.object.ts:The first sentence and the example disagree. A monthly period starts on the 1st, so five days from the anchor is the 6th, not the 5th. Two readings follow, and they differ by one day on every duty with a non-zero offset:
period_start+ 5 → the 6th. To land on the 5th you write 4.period_start+ 5 → the 5th, as the example says.Evidence, both directions
For A:
defaultValue: 0has to mean something on both anchors. Under A it is the anchor day itself:period_start+ 0 = the first day,period_end+ 0 = the last day. Under B, 0 is neither 1 nor -1 and has no defined meaning, so every duty left on the default is undefined behaviour.period_end+ 0 = the last day of the period is exactly the worked example in the same file's header and indocs/product/data-model.md: "A quarterly duty 'due in Q3' is due on 30 September."period_end- 1 is the day before the last.For B:
"5" → the 5thexample itself.due_offset_days: 30on February resolves to the 28th (29th in a leap year), not 2 March." 2 March is the unclamped result of1 Feb + 29 daysin a non-leap February — the B arithmetic. Under A the unclamped non-leap result is 3 March, and 2 March is the leap-year result.Where it lands
#1 shipped reading A, on the strength of the
defaultValue: 0argument, and pinned it intest/period.test.ts('offset 0 is the anchor day itself, on either anchor'and'a positive offset counts days forward from the period start'). Both readings produce the same answer for the clamped examples in #1's acceptance criteria, so the gates cannot distinguish them — this needs a person.If A is confirmed, the fix is the one-line description:
"4" with a monthly period anchored to period start = due on the 5th, or restate it as "0 is the anchor day itself". If B is confirmed,dueDateForinsrc/functions/period.tsshifts by one forperiod_start(and needs a defined meaning for offset 0), and the two tests above flip.Consumers that will bake in whichever answer is live: #2 (dispatcher) and #7 (seed data).