Skip to content

do_not_call is declared on lead and contact but enforced nowhere, while its twin email_opt_out is enforced three times #1180

Description

@os-steve

Problem

crm_contact.do_not_call and crm_lead.do_not_call (both Field.boolean, group preferences) render in forms and detail pages — and nothing else. Measured on main @ 2342811:

grep -rn "do_not_call" src/actions src/flows src/objects/*.hook.ts src/datasets src/dashboards src/reports
→ 0 hits

Its sibling email_opt_out, declared in the same group, is enforced in three places:

  1. src/actions/contact.actions.ts:121,211 — visible: P\record.email_opt_out == false`` on the send-email actions;
  2. src/flows/campaign-enrollment.flow.ts:151-209 — enrollment filters on email_opt_out: false;
  3. src/objects/campaign_member.hook.ts:98-154 — an unsubscribed member round-trips back to the person's email_opt_out.

src/flows/contact-welcome.flow.ts:64 gates on it too. So the app demonstrably knows how to honor a communication preference — it just only does so for email. A compliance-shaped promise (declared, rendered, labeled "Do Not Call") that the app does not keep is exactly the declared≠enforced shape ADR-0049 forbids, in the reference app every marketplace fork copies.

Ruled

Maintainer ruling, 2026-08-17, PM session chat (verbatim): 「逐个 enforce-or-remove(推荐)」 — for this field the direction is enforce, mirroring the email_opt_out pattern. Split out of the sweep card (#1182) because it has a concrete, copyable fix shape and a compliance angle.

Expected shape (dev has latitude; validate against real behavior)

  • The phone-outreach surfaces are log_call / schedule_meeting (src/actions/global.actions.ts, per-entity clones for lead/contact) and schedule_followup (flow + action). Gate the forward-looking ones (schedule_meeting, phone-type follow-ups) with a visible:/disabled: predicate like the email twin's.
  • Judgment call to make explicitly in the PR: log_call records a call that already happened — hiding it hides evidence; a visible-but-honest treatment (allow logging, never prompt outbound) is likely correct. State the choice in the PR body.
  • PM mechanism assumption (test it, feel free to falsify): the action-level visible: predicate pattern from contact.actions.ts:121 transfers as-is to the global activity actions.

Premise re-check: grep -rn "do_not_call" src/ --include=*.ts | grep -v views/ | grep -v translations/ — if enforcement sites exist by dispatch time, re-triage.

Activity

  1. added
    bugSomething isn't working
    metadataDeclarative metadata — schema, security posture, UI surfaces
    pm:queueReady for the PM dispatch loop
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 17, 2026
  2. self-assigned this
    on Aug 17, 2026
  3. os-steve commented on Aug 17, 2026

    @os-steve
    CollaboratorAuthor

    Claim: PM loop round 1
    Session: session_01XeAz8tSjwAibjdSDn9niiZ
    Branch: claude/issue-1180-do-not-call-enforcement
    Worktree: hotcrm-issue-1180
    Domain: metadata
    File surface: src/actions/ (global.actions.ts, contact.actions.ts, lead.actions.ts), src/flows/schedule-followup.flow.ts, test/ (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: opus
    Serial constraints cleared: no in-flight PR or claim touches src/actions/; sibling batch cards #1181 (src/objects/account.*, src/flows/contract-renewal.flow.ts) and #1183 (scripts/, CI) have disjoint file surfaces and neither pins behavior this card asserts. #1182 (decorative-field sweep) is held to a later batch — it touches object/view files, not the action surface, but shares the enforce-or-remove ruling, so it runs after this lands.


    Generated by Claude Code

  4. os-steve commented on Aug 17, 2026

    @os-steve
    CollaboratorAuthor

    os-dev-report

    {
      "issue": 1180,
      "status": "done",
      "branch": "claude/issue-1180-do-not-call-enforcement",
      "pr": "https://github.com/objectstack-ai/hotcrm/pull/1192",
      "premise_still_valid": true,
      "summary": "Premise re-verified on main @ 2342811a: the consumer scan returned only the two Field.boolean declarations, zero enforcement sites. Enforced do_not_call on both crm_lead and crm_contact via two beforeInsert/beforeUpdate guards that refuse to SCHEDULE a phone touch — task_do_not_call_guard (open crm_task of type 'call') and event_do_not_call_guard (planned crm_event of type 'call') — both reading the effective value so retyping and re-parenting are closed too. Recording a call that already happened is deliberately never refused: a completed Call task, a held Call event, and the log_call action all pass untouched, because refusing them hides the evidence of a call rather than preventing one and penalises the rep who logs honestly. TWO PM ASSUMPTIONS MEASURED, ONE FALSIFIED: (1) the visible: predicate transfers structurally but is only a RENDERING hint — it leaves the write reachable over REST, AI tool calls, flows and imports, so it was rejected on architecture in favour of enforcing on the write ('one writer, not one per entry point', the repo's own principle in global.actions.ts); (2) schedule_meeting is NOT phone outreach — it hardcodes eventType: 'meeting', so gating it would widen do_not_call into do_not_contact, and it is deliberately left ungated with a test pinning that. schedule_followup needed no change: the phone OPTION cannot be gated (a screen field's options are static metadata with no record in scope), and the flow reaches crm_task through the very write the guard sits on. SCOPE NOTE: measurement moved the correct fix out of the claim's declared file surface (src/actions/, schedule-followup.flow.ts, test/) — an in-surface fix could not have covered crm_contact at all, and 'both objects' was non-negotiable — so src/objects/task.hook.ts and src/objects/event.hook.ts are touched instead. Both are unclaimed by the sibling batch cards (#1181 = src/objects/account.*, #1183 = scripts/CI); #1181 has since landed on main with ZERO file overlap with this branch. Flagged in the PR body for the reviewer rather than assumed. Docs: content/docs/sales/contacts.mdx stated the exact opposite ('A marker only. Nothing in the product blocks a call, or the logging of a Call task') — corrected in en, zh-Hans and zh-Hant.",
      "tests": "All gates run at the FINAL commit 5a7cb0cb, all exit 0 (captured individually, not through a pipe): validate=0 typecheck=0 lint=0 i18n-gate=0 hygiene=0 stackblitz-lock=0 build=0 test:coverage=0. Full suite: 'Test Files 107 passed (107) / Tests 2624 passed | 1 skipped (2625)'. Per #1149 the local 'pnpm verify' is NOT the full CI set, so scripts/check-stackblitz-lock.mjs (ci.yml) and test:coverage (CI runs this, not 'test') were added; NOT run locally: e2e.yml (Playwright), codeql.yml, link-check.yml, docs-app.yml. New file test/do-not-call-enforcement.test.ts, 17 tests. WHICH PATH: these drive the SHIPPED lowered body via runHookBody (extractHookBody + QuickJSScriptRunner + hookBodyRunnerFactory), not the in-process handler — so repo issue #1167 is PARTIALLY FALSIFIED: its claim holds for the hook-harness.ts tests but the runHookBody path already exists and this file uses it. REFUSAL EVIDENCE / #1075: the assertion is weaker than the card asked, and deliberately so. Every business rejection in this app is a bare 'throw new Error(msg)' (17 across src/objects/*.hook.ts) and these guards match that idiom; #1075 (open, not fixed here) means code/status reach REST undefined. The test therefore MEASURES both — expect(err.code).toBeUndefined() / expect(err.status).toBeUndefined(), each with the message '#1075 has landed — tighten this assertion' — as a tripwire that goes red when #1075 lands, and pins the error message, which is the entire contract a caller gets until then. REVERSE VERIFICATION, direction predicted before each run and matched exactly: (a) neutering the task guard's flag check → predicted task refusals red / event refusals green, observed 5 failed | 12 passed (4 task refusals + the #1075 probe); (b) making effective() read input only → predicted only the two single-key update tests red, observed 2 failed | 15 passed. Restored from the commit both times (git checkout <branch> -- <path>), tree confirmed clean against HEAD. CONSISTENCY SWEEP, re-run post-implementation: 'grep -rn do_not_call src/ --include=*.ts | grep -v views/ | grep -v translations/' now returns 17 lines — the 2 original declarations (lead.object.ts:291, contact.object.ts:172) plus real enforcement in event.hook.ts (name/fields/predicate at :238,:273,:279) and task.hook.ts (:354,:397,:406), the rest being the guards' doc blocks. No changeset exemption used: skip-changeset does not apply to a behavior change, and .changeset/do-not-call-enforcement.md ships with the PR.",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Generated by Claude Code

  5. os-steve commented on Aug 17, 2026

    @os-steve
    CollaboratorAuthor

    ACCEPT — PM review of #1192 (round 1). Verified against GitHub and the branch, not the report. All 9 checks green, mergeable_state: clean.

    First, a PM correction: I sent a status probe claiming no PR was open and no report had arrived. Both had landed ~40 minutes earlier. I checked for the remote branch and inferred the rest instead of scanning GitHub for the PR and the report comment, which is the wrong order. The probe cost a round-trip; the premise was mine, not the dev's.

    The PM's suggested route was rejected, and the rejection is correct. I proposed mirroring email_opt_out with a visible: predicate on the outreach actions. The dev verified it would have worked mechanically — Action.visible is present on the generated activity actions and the predicate form parses — and rejected it anyway, because visible is a rendering hint: it hides a button in the Console and leaves the write reachable over REST, from an AI tool call, from a flow, and from an import. For a compliance-shaped promise in the app every marketplace fork copies, that is "enforced unless you use the mouse" — the same defect in a smaller font. Enforcing on the write (beforeInsert/beforeUpdate guards on crm_task and crm_event) covers every entry point at once. That is a better fix than the one specified, and the reasoning is the kind that should outrank a PM route.

    A second PM assumption was falsified, correctly. I listed schedule_meeting as phone outreach. It hardcodes eventType: 'meeting'; gating it would have widened do_not_call into do_not_contact — inventing a promise the app never made, which is the mirror image of the defect being closed. Left ungated, with a test pinning it. schedule_followup needed no change either: the screen field's options are static metadata with no record in scope, so the phone option cannot be gated — and the flow reaches crm_task through the very write the guard sits on. The test pins that linkage so a refactor moving the flow off crm_task surfaces here instead of silently reopening the hole.

    Other things I checked:

    On the declared file-surface breach: the fix lives in src/objects/task.hook.ts and src/objects/event.hook.ts, outside the src/actions/ surface declared in the claim. Flagged in the report rather than taken silently, which is the contract. Accepted: measurement forced it (an action-side fix could not have covered crm_contact at all, and "both objects" was non-negotiable), and neither file is claimed by #1181 or #1183, nor reached by #1182's held batch.

    On not merging main: accepted as argued. Zero file overlap with #1189 (that PR touched account.object.ts, event_attendee.object.ts, seeds, locales and accounts.mdx; this one touches the two hooks and contacts.mdx), GitHub reports mergeable_state: clean, and PR checks already run against the merge commit — so merging would have invalidated the "all gates green at 5a7cb0cb" evidence to buy nothing. Refusing to leave stale gate evidence attached to a moved head is the right instinct.

    Flipping to ready and queueing.


    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

Labels

bugSomething isn't workingmetadataDeclarative metadata — schema, security posture, UI surfacespm:dispatchedDispatched to a dev agent by /pm-dispatch

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions