Skip to content

Fix the self-contradictory due_offset_days description (reading A adjudicated) #23

Description

@os-warren

Found while implementing the period engine (#1). Not fixed there: src/objects/duty.object.ts is outside that card's file surface, and the answer decides behaviour in two places at once.

The conflict

src/objects/duty.object.ts:

due_offset_days: Field.number({
  label: 'Offset (days)',
  defaultValue: 0,
  description: 'Days from the anchor. "5" with a monthly period anchored to period start = due on the 5th. Negative offsets count back from period end.',
}),

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:

  • A — days from the anchor (offset 0 = the anchor day). period_start + 5 → the 6th. To land on the 5th you write 4.
  • B — the Nth day of the period, 1-based. period_start + 5 → the 5th, as the example says.

Evidence, both directions

For A:

  • The field's own first sentence: "Days from the anchor."
  • defaultValue: 0 has 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 in docs/product/data-model.md: "A quarterly duty 'due in Q3' is due on 30 September."
  • Symmetry with "Negative offsets count back from period end" — under A, period_end - 1 is the day before the last.

For B:

  • The "5" → the 5th example itself.
  • Weakly, Period engine — period keys, boundaries and due dates, timezone-correct #1's own phrasing: "due_offset_days: 30 on February resolves to the 28th (29th in a leap year), not 2 March." 2 March is the unclamped result of 1 Feb + 29 days in 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: 0 argument, and pinned it in test/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, dueDateFor in src/functions/period.ts shifts by one for period_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).

Activity

  1. os-warren commented on Sep 1, 2026

    @os-warren
    CollaboratorAuthor

    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。

    本卡重新定范围

    算术不动。缺陷是描述,修它:

    1. src/objects/duty.object.ts — due_offset_days.description 改成两个锚点各一个实例:0 = 期间首日;4 = 第五天;period_end 上 0 = 期间末日;-3 = 末日前三天。
    2. src/objects/catalog-item.object.ts — 同一字段,同一改法(两处必须一致,否则 Catalog instantiation — apply a position's duty catalog to a person #5 的实例化会把矛盾复制到每一条职责上)。
    3. docs/product/data-model.md — 「due_anchor + due_offset_days」段补同样的实例。

    排进队列,与 #2、#7 同批但文件面不相交。


    Generated by Claude Code

  2. 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
  3. self-assigned this
    on Sep 1, 2026
  4. os-warren commented on Sep 1, 2026

    @os-warren
    CollaboratorAuthor

    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 touches src/objects/duty.object.ts and is held out of this round for that reason.


    Generated by Claude Code

  5. added a commit that references this issue on Sep 1, 2026
    a9d15af
  6. os-warren commented on Sep 1, 2026

    @os-warren
    CollaboratorAuthor

    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

  7. os-warren commented on Sep 1, 2026

    @os-warren
    CollaboratorAuthor

    ACCEPT — PM review of #36, round 2.

    Checklist: draft, base main, Fixes #23 first line, 3 files exactly as scoped, src/functions/ and every test untouched, no NUL bytes. CI verify success. Gates re-run locally: all four EXIT=0, 175 tests unchanged — which is what a pure description fix should look like.

    Verified the two descriptions are byte-identical across duty.object.ts and catalog-item.object.ts rather 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.ts had 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 what defaultValue: 0 means, 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

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions