Skip to content

fix(intake): route web leads and cases, flag web duplicates (runAs: 'system') - #2103

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-2014-f9-web-intake
Oct 14, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-2014-f9-web-intake

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #2014
Clause-②: no

F9, the card's last item: the three web-intake hooks lead_auto_assign, case_auto_assign and lead_duplicate_check now declare runAs: 'system', per maintainer ruling 6076828219 item 4 (B: the platform fixed the anonymous form door first, objectstack#22437, installed in 18.0.0-next.0; hotcrm then elevates the three hooks). Web-to-Lead and Web-to-Case submissions are routed into their pools, a web duplicate is flagged, and the anonymous answer still carries only the new record's id.

Measured first (dispatch hypotheses H1 to H5)

On a dev server of my own on port 4105, @objectstack/* 18.0.0-next.0, pnpm demo:staff run (2 sales_rep holders, 1 service_agent holder). The submitted email repeats an existing contact's address.

before, base e094ec71 after, this branch
POST /api/v1/forms/contact-us/submit (anonymous) 201, body keys ["id"] 201, body keys ["id"]
POST /api/v1/forms/support/submit (anonymous) 201, body keys ["id"] 201, body keys ["id"]
stored web lead owner_id null, duplicate_status null owner is a sales_rep holder, duplicate_status: suspected, duplicate_of_contact is the known contact
stored web case owner_id null owner is the service_agent holder
  • H1 holds. Both doors already answered the id only before elevating, so the ruling's precondition stands. The door's code in @objectstack/rest 18.0.0-next.0 is res.status(201).json({ id: result.id }).
  • H2 holds. On this pin the guest reads still came back empty, so web leads and cases landed ownerless and web duplicates were not flagged.
  • H3 holds. After the change the hooks' findings are stored and none reaches the answer. The visitor has no other way back to the record either: an anonymous GET /api/v1/data/crm_lead/:id and POST /api/v1/data/crm_lead/query both answer 401 (in-process, real Hono app). Browser probe (Playwright, DOM text): an anonymous visitor submits /_console/f/contact-us with a known address. The page reads only "Submitted Thank you! Your submission has been received successfully.", with no staff name and no duplicate wording, and the submit response is 201 with keys ["id"].
  • H4, measured. At beforeInsert, organization_id is ABSENT from input for every writer: an anonymous submission, a system seed, and a rep's insert. The platform derives the organization after the beforeInsert hooks (ADR-0131; resolveSystemInsertOrganization says so in @objectstack/objectql), so the stored row carries the Default Organization. With two organizations the anonymous door refuses the insert outright (ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED).
    • Each elevated read is therefore pinned to the organization the writer NAMED on the new record. That is the system import into one organization of several, and the two-organization ablation target.
    • An unpinned read happens only where no organization is named. Such an insert lands only on an install with exactly one organization, so there is no other organization to reach.
    • A person's elevated read keeps their tenant (sudo() keeps tenantId, and the driver scopes on it even for isSystem). Measured: a rep in org A inserting a lead whose address is held by a contact in org B does not get B's contact as the match.
  • H5 kept. lead_auto_assign (250) and case_auto_assign (250) still run after the guest strips (200). A planted owner gives way to the pool's choice: the pins now plant a pool member who is the MORE loaded one, and assert the less-loaded one.

The change

  • src/sales/objects/lead.hook.ts: lead_auto_assign and lead_duplicate_check declare runAs: 'system'.
    • Each carries its rule-9 reason beside it: who works an enquiry, and whether an address is known, are the business's facts, not the submitter's privilege. Elevation reaches the reads only, and the owner and verdict are still written onto the payload in flight.
    • Each carries the rule-10 pin, applied to the pool read, the load count and both dedupe scans.
  • src/service/objects/_case-assignment.ts: createCaseRoundRobinAssign (case_auto_assign) gets the same, in round 1's pattern. The F14 escalation hook beside it is untouched.
  • Consequence of the ruling, now pinned: elevated, a rep's own dedupe also finds a colleague's contact or lead the rep cannot read. The new lead then names that record in duplicate_of_contact or duplicate_of_lead. This is option C's "reps get cross-rep dedupe" side, which ruling B also elevates.

Tests (pins flipped to the business fact, plus the acceptance pins)

  • test/case-assignment.test.ts
    • The measured-defect pin is now: a web case submitted through the platform's own door (verify.api('/forms/support/submit'), which the handle serves since 18.0.0-next.0) is routed to the staffed agent, and the answer's keys are exactly ["id"].
    • The denied-read case is now: a guest submission reaches the least-loaded agent. Its anti-vacuum check confirms the form's own grant does not read the pool.
    • The spoof case plants a more-loaded pool member.
    • New: a two-organization case for the rule-10 pin.
  • test/hooks-runtime.test.ts
    • New: a web lead through verify.api('/forms/contact-us/submit') is routed AND flagged, and the answer's keys are exactly ["id"].
    • The guest web-lead case is flipped.
    • New: a two-organization case for both lead hooks. The other organization's rep is less loaded and holds the same address. It has an anti-vacuum leg: the scan does match inside the organization.
  • test/hooks-runtime-service.test.ts: the guest dedupe and guest assign cases are flipped, the two guest-strip cases assert the pool's owner, and there is a new rep cross-owner dedupe case.
  • test/guest-submission-sanitisation.test.ts: the planted case owner is replaced by the agent, a positive value instead of an absence.
  • test/flow-record-change.test.ts: the web lead lands on the rep and its hot-lead alert reaches them (the "web leads alert nobody" half of F9).

Ablation (one-off, scripts/ablation-replace.mjs from objectstack; each leg mutated, ran and was restored, blob == HEAD, git diff HEAD empty, final tree clean)

  • case_auto_assign runAs set to inherit: 5 red (the routing, spoof and door cases in case-assignment; guest-sanitisation; the hooks-runtime-service case strip).
  • lead_auto_assign runAs set to inherit: 5 red (both hooks-runtime web cases, the hooks-runtime-service strip and door cases, flow-record-change).
  • lead_duplicate_check runAs set to inherit: 3 red (the door case, the guest dedupe case, the rep cross-owner case).
  • Each organization pin removed (case, lead assign, lead dedupe): its two-organization case red.
  • The first dedupe-pin leg stayed GREEN. That exposed a fixture bug: the shared address was computed after a counter moved, so the case could not fail. It was fixed in 204e8578, and the re-run leg is red.
  • My first runAs legs were refused by the tool before any test ran, because the replacement text was a substring of the anchor. They were re-run with runAs: 'inherit' and are reported above.

Docs (three locales each)

  • content/docs/administration/setup*.mdx, case routing: the sentence that the anonymous web-to-case form cannot read the pool is replaced. A web case is routed like any other, and the visitor is told only that it was received.
  • content/docs/sales/leads*.mdx, duplicate callout: "the lead lands unflagged" is replaced. A web enquiry is checked like any other lead, and the form never reveals whether an address is known.
  • content/docs/service/cases*.mdx (~L146, ~L181) were checked against the measurement and are now true as written ("lands while nobody holds the Service Agent position"), so they are unchanged.

Surface beyond the claim

  • content/docs/sales/leads*.mdx: F9 made its duplicate callout false.
  • src/service/views/case.view.ts: one comment said the public-form context cannot read sys_user_position.
  • Comment-only lines: test/helpers/verify-stack.ts (the handle now serves the form route), test/demo-staffing.test.ts, test/ownership-model.test.ts.
  • The flipped pins in test/hooks-runtime.test.ts, test/hooks-runtime-service.test.ts, test/guest-submission-sanitisation.test.ts and test/flow-record-change.test.ts.
  • Per the seat's note, lead_auto_assign's two comments no longer say a manager or the lead_assignment flow routes an unplaced lead.
  • origin/main was merged twice (6782f782, then c0f3485b, which is fix(lead): an ownerless lead's routing run ends after its SLA stamp instead of failing #2101). In the merge, fix(lead): an ownerless lead's routing run ends after its SLA stamp instead of failing #2101's new test comment, which said the anonymous form grant cannot read the pool, now says an empty pool or a failed read.

Acceptance notes

  • src/sales/flows/lead-assignment.flow.ts lines 15-16 (from fix(lead): an ownerless lead's routing run ends after its SLA stamp instead of failing #2101) still say lead_auto_assign stands down on "a pool the writer cannot read". After this PR the writer's grant no longer decides that, only an empty pool or a failed read does. That file is outside this card (src/sales/flows/**), so it is left as is. Carrier: the next PR that touches the flow.
  • Two organizations under the single posture: the anonymous form is still offered, and every submission answers 500 with ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED and "Internal server error". This was measured in-process through the real Hono app. The platform declares that topology unsupported (the next boot refuses it), so this is noted only.
  • Rep cross-owner dedupe: the console rendering of a duplicate_of_contact the rep cannot read was not measured.

Gates

pnpm verify on head 407b2219 (after the second merge): OS_VERIFY_LOCK_SLOT=hotcrm-2014 bash os-verify-lock.sh -c 'pnpm verify' → os-verify-lock: VERDICT command-exit 0 · held the lock 539s.

  • ✓ Validation passed; tsc --noEmit clean; objectstack lint --strict 18 suggestion(s), no error; ✓ i18n lint gate: 0 i18n/missing-* issues; ✓ source hygiene clean; ✓ source token ratchet clean (src/sales business semantics ~58,041 of 88,500); ✓ Build complete.
  • Test Files 179 passed (179), Tests 3676 passed | 1 skipped (3677).
  • The same chain was green on 57950e8b, before fix(lead): an ownerless lead's routing run ends after its SLA stamp instead of failing #2101 landed: 3664 passed, 1 skipped.
  • NOT MEASURED locally, declared to CI: test:coverage thresholds (CI runs test:coverage, local verify runs test), docs-app, e2e, link-check, code-quality.

Generated by Claude Code

…: 'system')

lead_auto_assign, case_auto_assign and lead_duplicate_check read the rep /
agent pool and existing contacts and leads as the anonymous form caller,
whose guest_portal grant reads nothing, so every web lead and case landed
ownerless and a web duplicate was never flagged. Since objectstack#22437
(18.0.0-next.0) the form door answers the new id only, so the three hooks
now elevate their reads (ruling 6076828219 item 4, B). Each read is pinned
to the organization the writer named on the new record (AGENTS.md rule 10).

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8mZuzuyZAHSrgQKmv6ASv
… only

Flip the pins that recorded F9 as a defect to the business fact: a web case
and a web lead are round-robined into their pool, a web duplicate is
flagged, and a planted owner gives way to the pool's choice. Through the
platform's own anonymous door, both public forms answer the new id and
nothing else. Two-organization cases pin each elevated read to the
organization the writer named. Comments that said the form cannot read
the pool are corrected.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8mZuzuyZAHSrgQKmv6ASv
…anted

The shared address was computed after the fixture helper advanced the
counter, so the intake never repeated the other organization's contact and
the dedupe half of the case could not fail. Measured by ablation: with the
organization pin removed it stayed green.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8mZuzuyZAHSrgQKmv6ASv
The setup page said the anonymous web-to-case form cannot read the agent
pool, and the leads page said a web duplicate lands unflagged; both stopped
being true when the intake hooks elevated their reads. Both now say the
visitor is told only that the submission was received. All three locales,
plus the release note.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8mZuzuyZAHSrgQKmv6ASv
The two comments in lead_auto_assign said a lead the round-robin could not
place is routed downstream by a manager or the lead_assignment flow. No
flow routes an ownerless lead (#2060 item 6), so the comments now say only
that the lead is still captured, ownerless.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8mZuzuyZAHSrgQKmv6ASv
#2101's new ownerless-lead describe said web-to-lead lands ownerless when
the anonymous form grant cannot read the rep pool; since the intake hooks
read it elevated, only an empty pool or a failed read leaves it ownerless.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8mZuzuyZAHSrgQKmv6ASv
@vercel

vercel Bot commented Oct 14, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
hotcrm Ignored Ignored Oct 14, 2026 10:02am UTC

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation ci/cd CI plumbing and the verification pipeline metadata Declarative metadata — schema, security posture, UI surfaces backend Server-side behaviour — hooks, flows, actions labels Oct 14, 2026
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 14, 2026 10:32
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 14, 2026
Merged via the queue into main with commit 6400397 Oct 14, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend Server-side behaviour — hooks, flows, actions ci/cd CI plumbing and the verification pipeline documentation Improvements or additions to documentation metadata Declarative metadata — schema, security posture, UI surfaces

Projects

None yet

2 participants