Repository navigation
feat(crm): enforce do_not_call on scheduled phone outreach - #1192
Merged
Merged
Conversation
Refuse to schedule a phone touch against a crm_lead or crm_contact flagged do_not_call, on the write rather than on a button: - task_do_not_call_guard rejects an open crm_task of type 'call' - event_do_not_call_guard rejects a planned crm_event of type 'call' Recording a call that already happened is deliberately never refused (a completed Call task, a held Call event, the log_call action), and the flag stays scoped to the phone: meetings, demos and email are untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeAz8tSjwAibjdSDn9niiZ
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeAz8tSjwAibjdSDn9niiZ
…arve-out Adds test/do-not-call-enforcement.test.ts and registers it in the runtime-coverage ledger, which requires every registered hook to be named in a runtime test file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeAz8tSjwAibjdSDn9niiZ
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
os-steve
marked this pull request as ready for review
August 17, 2026 03:32
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #1180
crm_lead.do_not_callandcrm_contact.do_not_callwere declared, rendered, labelled in four locales — and read by nothing. Premise re-verified onmain@2342811a: the consumer scan returned only the twoField.booleandeclarations, zero enforcement sites. ADR-0049 enforce-or-remove, ruled enforce (maintainer, 2026-08-17: 「逐个 enforce-or-remove(推荐)」).What this enforces
Two
beforeInsert/beforeUpdateguards, one per object that can carry a forward-looking phone touch:task_do_not_call_guard(src/objects/task.hook.ts)crm_taskoftype: 'call'parented to a flagged lead or contactevent_do_not_call_guard(src/objects/event.hook.ts)crm_eventoftype: 'call'against a flagged lead or contactBoth objects are covered, and both guards read the effective value (
inputfalling back toprevious), so the two single-key routes in — retyping an existing meeting task tocall, and re-parenting an existing call task onto a flagged person — are closed too. Each is pinned by its own test.The judgment call:
log_callstays reachable, and so does every other record of a callScheduling a phone touch is refused; recording one that already happened never is. A
completedCall task and aheldCall event both pass the guards untouched, which is exactly what thelog_callaction writes (EVENT_STATUS = "held").The reasoning, as the card asked me to state explicitly:
So
log_callcarries novisible:gate and needs none — the status split inevent_do_not_call_guardis what makes that carve-out principled rather than accidental. The test asserts both halves (no gate on the action,heldon its body), so a later "consistency" pass cannot close it without reading why.Two PM mechanism assumptions, both measured — one falsified
1. The
visible:predicate route — rejected on architecture, not on mechanism. The predicate atsrc/actions/contact.actions.ts:121does transfer structurally;Action.visibleis on the generated activity actions too. But it 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. Foremail_opt_outthat is only half the story and the other half exists (campaign-enrollment.flow.tsfilters on the flag,campaign_member.hook.tswrites it).do_not_callhad no such backing, so a predicate alone would have moved the field from declared and unenforced to declared and unenforced unless you use the mouse — in the reference app every marketplace fork copies.Enforcing on the write covers every entry point at once. That is the repo's own stated principle, quoted from
src/actions/global.actions.ts: the recency bubble lives oncrm_event's hook so that an event "created from the calendar, from an import, or from a future flow" behaves identically — "One writer, not one per entry point."2.
schedule_meetingis NOT phone outreach — the suggested route is falsified here. It hardcodeseventType: 'meeting'(global.actions.ts:490); the meeting kind is not a user choice, and its only location input is a free-textlocation.do_not_callis a promise about the phone, notdo_not_contact: someone who will not take calls may still be met in person or over video, and email is governed by its own separateemail_opt_outflag. Gatingschedule_meetingwould have silently widened a declared field into a promise the app never made — the mirror image of the defect being closed. It is deliberately left ungated, with a test pinning that.3.
schedule_followupneeds no change, and the phone option cannot be gated. The card asked which the metadata can express. A screen field'soptionsare static metadata (plainOptions(TASK_TYPE_OPTIONS)) evaluated with no record in scope — there is no expression in which "hide Call for THIS lead" can be written. Refusing at the point the phone type is chosen is what the metadata can express, and it costs the flow zero changes, because the flow reachescrm_taskthrough the very write the guard sits on. A test pins that linkage so a refactor moving the flow offcrm_tasksurfaces here.Refusal evidence, and the #1075 wall
The card asked for
code/statusassertions. They are asserted at the level that is real, which is currently neither. Every business rejection in this app is a barethrow new Error(msg)(17 of them acrosssrc/objects/*.hook.ts); these two guards match that idiom deliberately rather than inventing a private error class for one field. #1075 (open) records that such rejections reach REST withcodeandstatusundefined — that is its job, not this card's.So the test measures both properties instead of assuming them, and pins the message, which until #1075 lands is the entire contract a caller gets:
Those
toBeUndefined()calls are a deliberate tripwire, not an endorsement: when #1075 lands this test goes red and is the right place to strengthen.Which code path the tests drive (#1167)
#1167 is partially falsified. It records that every guard assertion here drives the in-process handler rather than the shipped QuickJS path. That is true of the
hook-harness.tstests — it is not true of this file, and need not be:test/helpers/action-sandbox.tsalready exportsrunHookBody, which lowers a hook via the CLI'sextractHookBodyand runs the resultingbody.sourcethrough the sameQuickJSScriptRunner+hookBodyRunnerFactorythe runtime binds at boot. Every assertion here drives that shipped body. It matters for these two guards specifically: both are body-only closures over nothing, so a body reaching for an import or a module constant would be a runtimeReferenceErrorwhile a handler-level test stayed green.Reverse verification
Two ablations, each predicted before running, both landing exactly as predicted:
effective()readsinputonlyRestored from the commit both times, tree confirmed clean against
HEAD.Docs
content/docs/sales/contacts.mdxstated the opposite of this change in three locales — "A marker only. Nothing in the product blocks a call, or the logging of a Call task…". Updated inen,zh-Hansandzh-Hantto state precisely what is now enforced, what is deliberately still allowed, and that the flag is phone-scoped.Gates
Run at the final commit
5a7cb0cb, all green:test:coverage— 107 files, 2624 passed, 1 skipped. Per #1149 the localpnpm verifyis not the full CI set, soscripts/check-stackblitz-lock.mjs(ci.yml) andtest:coverage(CI runs this, nottest) were run too.test/runtime-coverage.test.tscaught the new hooks as untested and is updated to register this file in its ledger. Not run locally:e2e.yml(Playwright),codeql.yml,link-check.yml,docs-app.yml. This PR carries a changeset, soskip-changesetdoes not apply.Scope note for the reviewer
The claim comment's declared file surface was
src/actions/,src/flows/schedule-followup.flow.ts,test/. Measurement moved the correct fix out of that surface: the action-level predicate is not enforcement, and the only forward-looking phone surface insrc/actions/(schedule_meeting) turned out not to be phone outreach at all — so an in-surface fix could not have coveredcrm_contactat all, and the card's "both objects" requirement is non-negotiable.src/objects/task.hook.tsandsrc/objects/event.hook.tsare touched instead; both are unclaimed by the sibling batch cards (#1181 issrc/objects/account.*, #1183 isscripts//CI), and #1182 is held to a later batch. Flagging rather than assuming — happy to re-shape if the surface boundary was meant to be hard.Out of scope and deliberately left open: #1075 (rejections reach REST without
code/status) and #1167 (handler-vs-shipped-path coverage) both remain open, are pre-existing, and are referenced above only as measurement context.Generated by Claude Code