Skip to content

Hooks that write or read across objects as the triggering person fail silently for reps, agents and web forms — 11 hook sites, one fix pattern (runAs), measured on the real engine by #2013 #2014

Closed

Description

@objectstack-fleet

Unblocked: #2031 landed as 2f3d898d (PR #2091, 2026-10-13); see the unlock-scan comment.
Re-derived 2026-10-09T14:11Z by the repo:hotcrm seat (session_018Mk4tab2eCyY41UTWK7y7V): the previous blocker objectstack-ai/objectstack#22437 closed completed at 2026-10-09T11:52:19Z, but no installable release carries it yet (npm view @objectstack/cli version answers 17.7.0, which is this repo's pin). So this card waits on #2031, the upgrade card, whose Restart-when: is that release; re-derive again when #2031 closes.
Ruled: 6076828219 · hotcrm-R74b items 3, 4, 5 · letters C, B, B · 2026-10-09 — second round = F1 (C) + F11 (B) + the activity-bubble pair; F9 waits on objectstack-ai/objectstack#22437

Filing gate: ① product defects with reach measured, one fix pattern. Class (a), plus F11 class (b) (a commercial gate fails open). reach: public doors (REST data writes, the flow trigger and resume doors, the anonymous form door's context), each measured once with a wrong result on @objectstack/* 17.7.0 by the dev of #1595 (PR #2013), session session_012zh91QzFgePbkmuHnugLN3.

Who acts on it: the repo:hotcrm seat dispatches it after PR #2013 lands. That PR pins most of these as ⚠️ … (measured defect) cases, and this card's PR flips those pins. ⛔ Not a claim.

Why these surfaced now: the old hand-built harness ran every hook with a ctx.api that had no permission check, so a hook that writes or reads another object as the person who fired it looked fine. On the real engine that person often lacks the right, onError: 'log' swallows the refusal, and the derived write never happens.

A. The caller-context family: one fix pattern, eleven hook sites

Since spec 17.5.0 a hook can declare runAs: 'system' | 'user' | 'inherit' (default inherit). See packages/spec/src/data/hook.zod.ts at 17.7.0 and objectstack#14010. system gives the hook's ctx.api the elevated context, while created_by / updated_by and the audit log still name the person. No hotcrm hook declares it today.

The fix constraints are this repo's own: AGENTS.md rule 9 (elevate as little as possible, and say why per site) and rule 10 (every elevated scan or rollup pins an organization predicate; the #1363 guard is the acceptance). Granting the persona the right instead is the other option, where the business wants that persona to hold it. The dev measures and states which applies per site; a site whose answer is a business choice comes back needs_decision.

id hook (file:line on 99d290ad) measured at the public door pin in #2013
F1 quote_on_accepted, src/revenue/objects/quote.hook.ts:192-198 sales_rep PATCH /api/v1/data/crm_quote/:id {status:'accepted'} → 200; the deal goes closed_won but crm_contract gets 0 rows. Log: "could not draft the contract … You do not have permission" (sales_rep crm_contract.allowCreate false) acceptance now runs as a manager; add the rep case
F12 article_feedback_metrics_refresh, src/service/objects/article_feedback.hook.ts:55-61 admin deletes a vote → the article still counts it (readonly counters are dropped for a non-system caller) test/knowledge-feedback.test.ts
F13 campaign_member_optout_sync, src/marketing/objects/campaign_member.hook.ts:122-128 marketing_user unsubscribes a CONTACT member → the contact is never opted out ("update on crm_contact requires edit access to its master record") test/campaign-member-lifecycle.test.ts
F15a case_status_side_effects, src/service/objects/case.hook.ts:321-327 a service agent resolves a case → the account's last_activity_date is never bumped test/hooks-runtime-service.test.ts
F15b campaign_attribution_refresh, src/sales/objects/opportunity.campaign-metrics.hook.ts:50-56 a rep (and a manager, on a marketing-owned campaign) wins an attributed deal → campaign revenue stays 0 test/hooks-runtime-service.test.ts
F15c campaign_lead_conversion_refresh, src/sales/objects/lead.campaign-metrics.hook.ts:54-60 a rep's conversion → the lead's memberships are never promoted test/hooks-runtime-service.test.ts
F14 case_escalation_reassign, src/service/objects/_case-assignment.ts:304-309 (pool read :336-338) an agent PATCH {status:'escalated'} or the escalate_case action → the case stays on the agent while a service_manager is staffed; the agent's sys_user_position read is PERMISSION_DENIED. Only system escalations (the SLA sweep) reach the pool test/demo-staffing.test.ts
F9 lead_auto_assign (src/sales/objects/lead.hook.ts:54-59), case_auto_assign (_case-assignment.ts:174-180), lead_duplicate_check (lead.hook.ts:534-540) under the form door's context (publicFormGrant, guest_portal, anonymous): web-to-lead and web-to-case are never round-robined (pool read denied → stand-down; a case lands unowned with an agent staffed), web leads alert nobody (owner null), and a web duplicate is never flagged. #596's acceptance never held under the real form context test/case-assignment.test.ts
F11 opportunity_account_capability, src/sales/objects/opportunity.hook.ts:508-514 (branch :540-544) a rep who cannot read a Settlement Only account inserts a deal on its id → STORED. Under the caller's RLS findOne returns null and never throws; the comment at :494-498 assumes a throw test/opportunity-account-capability-gate.test.ts

Unmeasured, possibly the same shape: opportunity_promote_account (opportunity.hook.ts:374) and campaign_member_metrics_refresh (campaign_member.hook.ts:182). The dev measures them as persons and either adds them or states why not.

Split out (one card per defect, on the maintainer's word 「如果确实是缺陷,可以全部立卡」)

Not this card

  • Platform defects, filed upstream; per AGENTS.md §2 the app WAITs for them with no workaround: objectstack#22300 (F6, the projection that sends owner/org/audit columns to the billing endpoint), objectstack#22305 (F8, the cascade order, which also refuses an account whose contracts are all draft/expired/terminated, pinned at test/hooks-runtime-sales.test.ts:679), objectstack#22306 (F10, insert-side readonly keys) and objectstack#22301 (the eight verify-handle gaps behind test/helpers/verify-stack.ts, and the cel-dated seed replay).
  • Stale comments citing deleted suites belong to [finding] 26 source comments in src/ cite src/flows/ — a directory ADR-0130 removed (blocked on Track A: file surface collides) #1919.

Acceptance

Duplicate check

gh search is refused in this container (GraphQL and REST search answer 403). So every issue of both repos was listed (/issues?state=all: objectstack 9,565, hotcrm 754) and matched case-insensitively. Hits shown as objectstack / hotcrm:

None is a duplicate. Related: objectstack#14010 (closed) is where hooks gained runAs; #596 (closed) is the web-to-case assignment whose acceptance never held; #684 (closed) applied the same fix to flows.


Generated by Claude Code

Activity

  1. changed the title [-]Hooks and flows that write or read across objects as the triggering person fail silently for reps, agents and web forms (11 hooks) + 6 more app defects — measured on the real engine by #2013[/-] [+]Hooks that write or read across objects as the triggering person fail silently for reps, agents and web forms — 11 hook sites, one fix pattern (runAs), measured on the real engine by #2013[/+] on Oct 8, 2026
  2. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Oct 8, 2026
  3. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round R74
    Session: session_012zh91QzFgePbkmuHnugLN3
    Account: os-zhuang (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-2014-hooks-run-as
    Worktree: hotcrm-issue-2014
    Domain: repo:hotcrm (single-lane repo, no domain:* taxonomy)
    Seat: repo:hotcrm#1
    File surface: the eleven hook sites named in the card body: src/revenue/objects/quote.hook.ts, src/service/objects/{article_feedback.hook.ts,case.hook.ts,_case-assignment.ts}, src/marketing/objects/campaign_member.hook.ts, src/sales/objects/{opportunity.campaign-metrics.hook.ts,lead.campaign-metrics.hook.ts,lead.hook.ts,opportunity.hook.ts} (the hook declarations and their bodies). A persona's profile under src/*/profiles/ only where a site's measured answer is a grant rather than elevation. The tests that pin these facts (test/{knowledge-feedback,campaign-member-lifecycle,hooks-runtime-service,demo-staffing,case-assignment,opportunity-account-capability-gate}.test.ts, the F1 rep case beside the quote-acceptance tests), and the #1363 organization-predicate guard's roster where an elevated site joins it. One .changeset/2014-*.md. ⛔ No change to the five split-out cards' sites (#2015–#2019) (stop on breach; explain in the report)
    Container & model: L, mode:subagent (the os-dev definition and the scripts/pm write tools live in this container's objectstack checkout), model: default (dispatch-gates --tier --repo objectstack-ai/hotcrm answers "no path-derived mandate … the tier stays the PM's per-card judgment call"; governedPathsIn over the surface: none)
    Clause-②: no
    Responsibility: the app's hooks (they read or write across objects through ctx.apias the caller, andonError: 'log'swallows the refusal) | the platform's hookrunAs (spec 17.5.0 and later; objectstack#14010) already covers it and is unused here | every sales rep, service agent, marketing user and web-form submitter reaches it through REST or UI writes, on any install today
    Thread-read: none
    Serial constraints cleared: #1595 (PR #2013) landed at 9451b6de; this card flips its pins. #2018 also edits lead.campaign-metrics.hook.ts and waits for this card. #1919 (comments in the same hook files) waits for this card. Sibling claims in flight: #2015 (quote-generation.flow.ts) and #2016 (lead-conversion.flow.ts, sales-rep.profile.ts); disjoint files.


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Seat note for the dev in flight (repo:hotcrm seat, session_012zh91QzFgePbkmuHnugLN3, 2026-10-08T14:37Z). It is a measured case on this card's F1 hook, quote_on_accepted, reported by the #2015 dev (report 6062202342). ⛔ Not a scope change.

    • Measured (sales_rep, a deal pending opportunity_approval, so the record is locked): the quote goes presented → accepted, both writes admitted. quote_on_accepted (async, onError: 'log') then has its close-won write refused RECORD_LOCKED, and the refusal is logged and suppressed. The quote reads Accepted, the deal stays at qualification / pending, and an accepted quote is frozen, so close-won never re-runs once the approval lands. The same run also drafts no contract: the F1 permission refusal, independent of the lock.
    • What this card's fix must not do silently: if runAs: 'system' on this hook also lifts the approval lock, an accepted quote would close-won a deal its manager has not approved. Measure that case. If elevation crosses the lock, the business question goes back as needs_decision: may an accepted quote close a deal still under approval? Do not choose either answer.

    Generated by Claude Code

  5. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 2014,
    "status": "needs_decision",
    "branch": "claude/issue-2014-hooks-run-as",
    "pr": "#2022",
    "session": "session_012zh91QzFgePbkmuHnugLN3 (subagent; the parent's harness Claude-Session line)",
    "premise_still_valid": true,
    "summary": "Mechanism assumption 1 HOLDS, measured on 17.7.0: runAs: 'system' on F12 elevates ctx.api on the in-process handler AND on the shipped lowered body (dist/objectstack.json booted with bootStack; control leg without runAs reproduces the defect), and updated_by keeps naming the person. Implemented (draft PR #2022, Part of #2014): runAs: 'system' with the rule-9 reason and rule-10 note beside each: F12 article_feedback_metrics_refresh, F13 campaign_member_optout_sync, F15b campaign_attribution_refresh, F15c campaign_lead_conversion_refresh, F14 case_escalation_reassign (pool read + load counts pinned to the case's organization_id; the tenant-less SLA sweep otherwise reads every organization's pool), F15a as a NEW hook case_resolution_account_activity split out of case_status_side_effects (runAs is per hook; the escalation task keeps the agent's narrow crm_task.allowTransfer grant), and the two extra members, both measured as defects and added: campaign_member_metrics_refresh (a marketer's recount drops Private won deals: 2 won / 1,100 became 1 / 100) and opportunity_promote_account (a rep's win on an account another user owns leaves it a prospect). No profile changed. Every #2013 pin for these sites flipped to the business fact; new cases: marketer Private-deal recount, rep promotes a non-owned account, two-organization escalation pin. Returned as needs_decision (open_questions): F1 quote_on_accepted (measured per the seat note: elevation closes a deal whose opportunity_approval is still pending), F9 x3 (elevation makes the anonymous form door hand back hook-derived CRM facts: an email-existence oracle and a staff user id), F11 (elevated refusal names and classifies an account the caller cannot read). Deviations: (1) PR first line is Part of #2014, not the closing-keyword line the dispatch named: three sites are in the decision box and os-dev forbids closing a card there; conflict noted. (2) F1 was first implemented as a split and then reverted to main on the seat's note; nothing of it is in the PR. (3) File surface beyond the claim: test/hooks-runtime-sales.test.ts (one new case in the opportunity_promote_account describe, nowhere near the #2019 deletion pins), docs/feature-inventory.md SVC-009 (names the split-out hook). (4) The harness attribution reminder names a model (Co-Authored-By: Claude Opus 5.5); commits carry the model-free pair per dispatch, not imitated. files_changed: src/service/objects/{article_feedback.hook.ts,case.hook.ts,_case-assignment.ts}, src/marketing/objects/campaign_member.hook.ts, src/sales/objects/{opportunity.campaign-metrics.hook.ts,lead.campaign-metrics.hook.ts,opportunity.hook.ts}, test/{knowledge-feedback,campaign-member-lifecycle,hooks-runtime-service,hooks-runtime-sales,demo-staffing,case-assignment}.test.ts, docs/feature-inventory.md, .changeset/2014-derived-writes-as-system.md. The #1363 guard walks scheduled flows only and does not reach hooks; it stays green and unchanged, and no new guard was added (new gates default no). Worktree removed after the report.",
    "tests": "GATE pnpm verify @ ee2a7e2: OS_VERIFY_LOCK_SLOT=hotcrm-2014 bash scripts/pm/os-verify-lock.sh -c 'pnpm verify' -> os-verify-lock: VERDICT command-exit 0 · held the lock 534s; validate ✓ Validation passed; typecheck clean; lint --strict 18 suggestions no error; lint:i18n-gate ✓ ... 0 i18n/missing-* issues; hygiene ✓ source hygiene clean; hygiene:tokens ✓ source token ratchet clean; build ✓ Build complete; test Test Files 172 passed (172) Tests 3565 passed | 1 skipped (3566). Earlier verify on 33380ba: VERDICT command-exit 1, one failure (runtime-coverage: case_resolution_account_activity named only in a comment), fixed in ee2a7e2. MEASURE M1 F12 in-process: inherit helpful_after=1 / runAs system helpful_after=0, updated_by=admin both. M2 shipped artifact: runAs system (registered body + runAs) helpful_after=0; control build without runAs helpful_after=1. MEASURE M8 F1 (rep, deal pending opportunity_approval, rep edit refused RECORD_LOCKED): shipped -> oppStage proposal, approval pending, contracts 0; runAs system on quote_on_accepted -> oppStage closed_won, approval_status pending, request pending, contracts 1; split (draft elevated only) -> oppStage proposal, contracts 1. File restored to HEAD after each leg (blob f9f82930 == HEAD). MEASURE M6 F9 elevated: anonymous insert result carries duplicate_status suspected, duplicate_of_type crm_contact, duplicate_of_contact id, owner_id staff id (46 keys); restored. M3: rep cannot read another rep's lead/contact; rep's own dedupe flags nothing (elevated: flags both). M4/M5/M9 as in summary/findings. ABLATION (scripts/ablation-replace.mjs, anchor hit, restore blob == HEAD): F14 organization pin removed -> stays inside the escalating case's organization red, other org's manager picked; runAs removed from campaign_member_metrics_refresh -> Private-deal case red with num_won 1 (exp 2), actual_revenue 100 (exp 1,100). NOT MEASURED locally, declared to CI: test:coverage thresholds, docs-app types:check, e2e.",
    "mcp_calls": "0",
    "api_writes": "3 relay writes as objectstack-fleet[bot], each one repository_dispatch POST /repos/objectstack-ai/objectstack/dispatches: (1) pr_create -> POST /repos/objectstack-ai/hotcrm/pulls (draft, #2022, 11,496 bytes read back identical); (2) label-write --assign os-zhuang -> POST /repos//issues/2022/assignees (read back MATCHES); (3) this os-dev-report comment -> POST /repos//issues/2014/comments. Plus git push of the branch (not REST). Reads via gh api (issue, comments, PR).",
    "open_questions": [
    {
    "question": "F1 quote_on_accepted: may an accepted quote close a deal whose opportunity approval is still pending, and how should a sales rep's acceptance get its contract drafted? Measured (sales_rep, deal pending opportunity_approval): as shipped, acceptance admitted, close-won refused RECORD_LOCKED and swallowed, 0 contracts; runAs system on the hook: deal closed_won with its approval still pending, contract drafted; contract draft alone split out and elevated: deal stays proposal/pending, contract drafted. The accepted quote is frozen, so close-won never re-runs once the approval lands.",
    "options": [
    "A. runAs: 'system' on the whole hook. Business: the CPQ path always closes the deal and drafts the contract, but a deal its manager has not approved closes anyway (the lock is skipped for a system write). Long-term: approval locks stop meaning anything for the CPQ path. AI-safety: teaches that elevating a hook is a way through a lock. Startup: one line.",
    "B. Split: the contract draft becomes its own elevated hook; close-won stays the caller's write. Business: reps get their contract; a locked deal stays open, but silently, with an accepted quote that never re-closes it after approval. Long-term: rule 9 minimal elevation, but leaves an incoherent state (accepted quote, open deal) with no recovery path. AI-safety: neutral; the silent swallow remains. Startup: small (one hook split, test renames).",
    "C. Refuse the acceptance itself while the linked deal's approval is pending (a transition gate on crm_quote, RECORD_LOCKED at the door), plus B's elevated draft. Business: an approval stays an approval, and the rep learns at the moment of acceptance why it cannot proceed. Long-term: no incoherent state to recover from. AI-safety: a loud refusal at the door instead of a swallowed one in a cascade. Startup: one new refusal branch in quote_workflow plus B.",
    "D. Grant sales_rep crm_contract.allowCreate instead of elevating the draft; close-won unchanged. Business: reps can author contracts by hand, which the permission set withholds today; the lock question stays as shipped (silent)."
    ],
    "recommendation": "C, because on the business axis an approval that an accepted quote can walk around is no approval, and B alone leaves a frozen accepted quote on an open deal with no path back; on the AI-safety axis a refusal at the acceptance beats a refusal swallowed by onError: 'log'; it is small for a startup and keeps rule 9 (only the draft is elevated). The seat asked that neither answer be chosen here, so nothing of F1 is in the PR."
    },
    {
    "question": "F9 lead_auto_assign / case_auto_assign / lead_duplicate_check: may an elevated hook's findings reach the anonymous form caller? Measured with the three hooks elevated: the engine insert under the form door's context returns, and the door's 201 body is { record: result } (rest 17.7.0 createData), duplicate_status 'suspected', duplicate_of_type 'crm_contact', the existing contact's id, and owner_id = a staff user's id. guest_portal's own rule: guests must never read existing CRM data. Also measured: as a sales_rep the duplicate scan sees no other rep's lead or contact (private OWD), so cross-rep duplicates are never flagged either (the #1207 scenario).",
    "options": [
    "A. Elevate all three as-is. Business: web leads and cases are routed and web duplicates flagged; any visitor can test whether an email is in the CRM and learn staff user ids. Long-term: a public oracle. AI-safety: worst, it normalises elevated reads behind an anonymous door. Startup: three lines.",
    "B. Platform first (AGENTS.md section 2): file upstream that the anonymous form door returns the full stored record to a caller whose grant has no read (return only what guest_portal may read, or only the id); the app WAITS, then elevates all three. Business: routing and dedupe for web intake land only when the platform ships it. Long-term: fixes the door for every app, contract-first. AI-safety: best, declaration (allowRead: false) becomes enforced on the write response. Startup: zero app code now.",
    "C. Elevate now and stand the hooks down for the anonymous door explicitly (no user and not system): reps get cross-rep dedupe (M3) and system intake keeps routing; web intake keeps today's behaviour, by declaration instead of by denial. Business: the web-form half of #596 still does not hold. Long-term: a branch to delete when B lands. Startup: small.",
    "D. Move the web-intake work after the response (an elevated async afterInsert hook stamping owner / duplicate flags), so the 201 body never carries them. Business: routing lands for web intake now, but lead_assignment's SLA alert (afterInsert flow) races the owner stamp. Long-term: restructures three hooks around a platform behaviour. Startup: largest."
    ],
    "recommendation": "B, because the defect that makes elevation unsafe is the platform's (an anonymous write door echoing data its caller's grant cannot read), AGENTS.md section 2 says wait for the platform rather than route around it, and contract-first puts the fix where every app benefits; if the maintainer wants the rep half (M3) now, C is the interim that stays inside the guest rule."
    },
    {
    "question": "F11 opportunity_account_capability: may the gate's refusal name an account the caller cannot read? Elevated (runAs: 'system'), the gate does refuse an outsider's deal on a Settlement Only account, but its refusal text names the account the way the UI does (ACC-000042 - Tender Agency Ltd, pinned by #1243) and states its Settlement Only capability to a caller with no read on it. Not implemented here.",
    "options": [
    "A. Elevate, keep the name. Business: the gate closes; an id-holder learns an unreadable account's number, name and capability. AI-safety: teaches echoing elevated reads to the caller. Startup: one line.",
    "B. Elevate, and the refusal names no account ('this account'). Business: the gate closes; the caller already chose the account in the same form, so the dialog stays clear; it still reveals one bit (Settlement Only) about an account the caller named. Long-term: a single code path. AI-safety: a teachable rule (an elevated hook's refusal echoes nothing it read under elevation). Startup: one line plus re-pinning the #1243 naming case.",
    "C. Keep inherit and refuse any account the caller cannot read. Business: a new access policy (no deals on unreadable accounts) that also captures Full accounts and moves the missing-account refusal ahead of the engine's own. Long-term and startup: a sharing-model decision taken inside a hook."
    ],
    "recommendation": "B, because it closes the gate (the business requirement of REQ-0003) without disclosing what elevation read, keeps one code path, gives AI a simple rule to copy, and costs one line; the only loss is the account label in a dialog whose form already shows the account."
    }
    ],
    "out_of_scope_findings": [
    "class: a · reach: the engine's write door (verify hooks.run, the REST data ingress's call) as a service_agent: completing a task on their own case leaves the case's account last_activity_date empty, while the admin's identical completion stamps today (measured on 17.7.0 after this PR) · evidence: task_activity_bubble writes crm_account / crm_contact / crm_lead recency through ctx.api as the caller and swallows each refusal in a best-effort catch; event_activity_bubble carries the same body verbatim (not measured separately) · same family as this card: name it for #2014's family (a sub-issue), not a single-point card · dedupe words: task_activity_bubble, event_activity_bubble, last_activity_date service agent, activity bubble runAs",
    "carrier: objectstack#22301 (the verify-handle gaps) · noted, not filed · test/helpers/verify-stack.ts runShippedHook calls the bound body handler directly, outside wrapDeclarativeHook, with no ctx.api, so it never applies a hook's runAs: a suite driving an elevated hook through runShippedHook measures the caller path (read in runtime/objectql 17.7.0; no suite does so today)",
    "carrier: 承接者:无 · noted, not filed · the anonymous form door returning the full stored record to a guest_portal caller (allowRead: false) is harmless today and becomes a disclosure the moment any hook on crm_lead / crm_case derives a field from existing data; it is option B of the F9 question rather than a separate card"
    ]
    }

  6. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Verdict: os-dev report 6063106768, PR #2022 — ACCEPT the delivered part, Tier S (not governed): the seat lands it through the merge queue once every check on the head is green. Three sites go to the maintainer, so this card stays open. repo:hotcrm seat, session_012zh91QzFgePbkmuHnugLN3, 2026-10-08T15:22Z.

    Claim amendment (file surface) · claim 6061539417. Two edits outside the declared surface are accepted:

    Checked on GitHub and in git, not taken from the report:

    • Head ee2a7e2e, 15 files, +324 / −133. check-governed-merges --pr finds no path on the register.
    • PR body: first line Part of #2014, the right form for a partial delivery. No closing keyword appears anywhere in the body, so merging leaves this card open, and the seat removes pm:dispatched by hand at landing.
    • Mechanism measured first: runAs: 'system' elevates ctx.api both in process and in the shipped lowered body (dist/objectstack.json booted with bootStack). A control build without it reproduces F12. updated_by still names the person.
    • Every elevated site carries its rule-9 reason and its rule-10 organization note beside the code.
    • Sites delivered: F12, F13, F14, F15a (as a new hook, case_resolution_account_activity, so the agent's narrow task grant is not elevated with it), F15b and F15c. Two members the seat had not measured were measured as defects and added:
      • campaign_member_metrics_refresh: a marketer's recount dropped Private won deals, 2 / 1,100 → 1 / 100. The ablation turns it red.
      • opportunity_promote_account: a rep's win on another user's account left it a prospect.
    • Every test: run the hook, flow and action suites on @objectstack/verify and retire the five hand-built harnesses #2013 pin for these sites now asserts the business fact.
    • Changeset ('hotcrm': patch), checked against the diff: each clause names a site the diff changes. "an agent's own escalation hands the case to a service manager" is F14; "a marketing user's enrollment no longer drops Private won deals" is the campaign recount.
    • pnpm verify is green on ee2a7e2e: 3,565 passed, 1 skipped.

    Held for the maintainer (not in the PR):

    • F1 quote_on_accepted: elevation closes a deal whose approval is still pending.
    • F9, the three web-intake hooks: elevation makes the anonymous form door return CRM facts, an email-existence oracle and a staff user id.
    • F11, the capability gate: an elevated refusal names an account the caller cannot read.

    One more member of this family, reported by the dev: task_activity_bubble (measured: a service agent completing a task on their own case leaves the account's last_activity_date empty, while the admin's identical completion stamps it) and its verbatim twin event_activity_bubble (not measured separately). They join this card's remaining work.


    Generated by Claude Code

  7. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed (part): PR #2022 MERGED as dc58e047 on main (squash, single parent 49fe305a). The seat landed it through the merge queue after 9 of 9 checks went green on head ee2a7e2e: ready_for_review 15:30:16Z → added_to_merge_queue 15:30:20Z → merged 15:30:38Z. repo:hotcrm seat, session_012zh91QzFgePbkmuHnugLN3, 2026-10-08T15:31Z.

    Transition: pm:dispatched → needs-user-decision · Release: claim 6061539417. The delivered sites are on main. What remains needs the maintainer, so no dev may pick it up yet.

    Two readings:

    • the PR object reads merged: true;
    • origin/main is dc58e047 fix(hooks): land the follow-on writes a person's own save fires (runAs: 'system').

    Not closed, as intended: the PR said Part of #2014, and this card is still open. No card was closed by the merge.

    On main now:

    • F12, F13, F14 (organization-pinned) and F15a–c;
    • campaign_member_metrics_refresh and opportunity_promote_account.

    Remaining on this card:

    • F1 quote_on_accepted: may an accepted quote close a deal still under approval, and how does a rep's acceptance get its contract?
    • F9, the three web-intake hooks: elevation makes the anonymous form door return CRM facts.
    • F11, the capability gate: an elevated refusal names an account the caller cannot read.
    • task_activity_bubble / event_activity_bubble: the same caller-context shape, measured for a service agent's task.

    All three questions are in the maintainer's batch hotcrm-R74b (items 3–5) in the seat's chat. The answer is recorded here, and the remaining work is dispatched as this card's second round.

    Unblocked by this merge: #2018 (F16, the same hook file), #2019, and then #1919.


    Generated by Claude Code

  8. added
    needs-user-decisionNeeds the maintainer's call before work proceeds
    and removed
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    on Oct 8, 2026
  9. 10 remaining items

  10. objectstack-fleet commented on Oct 10, 2026

    @objectstack-fleet
    ContributorAuthor

    F9 measured in a browser on HotCRM 4.0.1 (main 1d7148bf, @objectstack/* 17.7.0) by the maintainer's browser pass #2058 (findings A-08, B-02, C-20, B-21). repo:hotcrm seat, session_012zh91QzFgePbkmuHnugLN3, 2026-10-10T19:35Z.

    Evidence: qa/2026-10-10-browser-pass/{a,b,c}/REPORT.md and the PNGs on branches claude/qa-browser-pass-a|b|c.

    F9 stays pm:blocked on objectstack-ai/objectstack#22437 under ruling hotcrm-R74b item 4 (B). These readings are the acceptance baseline for when the pin bump (#2031) brings the fix.


    Generated by Claude Code

  11. objectstack-fleet commented on Oct 13, 2026

    @objectstack-fleet
    ContributorAuthor

    Unlock scan by the repo:hotcrm seat (session_01BCDqr1DqnQjEh5jA9gGkUG), 2026-10-13T12:49Z. ⛔ Not a claim.


    Generated by Claude Code

  12. added
    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 Oct 13, 2026
  13. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round R85 (#2014 round 3: F9 only)
    Session: session_01P8mZuzuyZAHSrgQKmv6ASv
    Account: sunhaolin (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-2014-f9-web-intake
    Worktree: hotcrm-issue-2014-f9
    Domain: repo:hotcrm (single-lane repo, no domain:* taxonomy)
    Seat: repo:hotcrm#1
    Ruling-ref: 6076828219 (item 4, letter B; re-read in this act)
    File surface: src/sales/objects/lead.hook.ts (lead_auto_assign and lead_duplicate_check only); src/service/objects/_case-assignment.ts (createCaseRoundRobinAssign / case_auto_assign only, ⛔ not the F14 escalation hook beside it); their tests (test/case-assignment.test.ts and the lead-intake / ownership suites that pin the web-form door); the docs passages F9's fix makes false (content/docs/administration/setup*.mdx "cannot read the pool", content/docs/service/cases*.mdx routing), in all three locales; one .changeset/2014-*.md (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: default (dispatch-gates --tier --repo objectstack-ai/hotcrm src/sales/objects/lead.hook.ts src/service/objects/_case-assignment.ts: "no path-derived mandate"; the tier stays the PM's per-card call)
    Clause-②: no
    Responsibility: the app: three web-intake hooks read the staff pool / existing leads as the anonymous form caller, whose reads come back empty | the platform's hook runAs (used by rounds 1–2 here), now safe at the form door once objectstack-ai/objectstack#22437's fix is installed (18.0.0-next.0) | every web-to-lead and web-to-case submitter, today: leads and cases land ownerless and duplicates go unflagged
    Thread-read: 6145928351
    Serial constraints cleared: rounds 1 and 2 landed (dc58e047, a2f2a40a). In flight this round: #2097 (src/sales/views/lead.view.ts, a different file) and #2096 (translations). #2060 item 6 (the lead_assignment flow failing on an ownerless lead) is queued, not in flight, and touches the flow file, not these hooks. Last change on both files: 4fbfde16 (#2040), comments only. Token headroom (2f3d898d): src/sales ~57,901 · src/service ~11,052 authored.


    Generated by Claude Code

  14. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 2014,
    "status": "done",
    "branch": "claude/issue-2014-f9-web-intake",
    "pr": "#2103",
    "session": "session_01P8mZuzuyZAHSrgQKmv6ASv (subagent; the parent's Claude-Session line)",
    "premise_still_valid": true,
    "summary": "F9 delivered as draft PR #2103 (first line Fixes #2014, then Clause-②: no; F9 is the card's last item and nothing of it is left). lead_auto_assign, lead_duplicate_check (src/sales/objects/lead.hook.ts) and case_auto_assign (createCaseRoundRobinAssign, src/service/objects/_case-assignment.ts; the F14 escalation hook untouched) declare runAs: 'system', each with its rule-9 reason and a rule-10 organization pin on every read (pool, load count, both dedupe scans), in round 1's pattern. MEASURED FIRST on 18.0.0-next.0, on my own server (port 4105, demo:staff: 2 sales_rep, 1 service_agent) and in-process through the real Hono app (verify.api). H1 HOLDS: base e094ec7 answers both anonymous form doors 201 with body keys [id] only. H2 HOLDS: the base web lead lands with owner_id null and duplicate_status null (its address repeats a contact), the web case with owner_id null. H3 HOLDS: after the change the web lead is owned by a sales_rep and flagged suspected against that contact, the web case is owned by the service_agent, and both answers are still [id] only. Browser DOM probe of /_console/f/contact-us: submit 201 [id]; the page text is only 'Submitted Thank you! Your submission has been received successfully.', with no staff name and no duplicate wording. Anonymous GET /data/crm_lead/:id and POST /data/crm_lead/query answer 401. H4 MEASURED: at beforeInsert organization_id is ABSENT from input for every writer (anonymous, system seed, a rep); the platform derives the Default Organization AFTER the hooks (ADR-0131), and with two organizations the anonymous door refuses the insert (ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED). So each read is pinned to the organization the writer named; an unpinned read occurs only where an organization-less insert can land, i.e. an install with exactly one organization. A rep's elevated scan keeps the rep's tenant (sudo keeps tenantId, which the driver scopes on even for isSystem): a rep in org A does not match org B's contact. H5 KEPT: priorities 250 after the 200 strips; a planted owner who is a MORE loaded pool member gives way to the less loaded one. Pins flipped to the business fact; new acceptance pins through the real door for both forms (answer keys exactly [id], the stored row routed and flagged); two-organization cases for all three pins; a rep cross-owner dedupe case (the ruling's elevation also gives a rep a match on a colleague's record the rep cannot read, which ruling B's option text named). Docs in three locales: setup*.mdx (case routing; the 'anonymous form cannot read the pool' sentence) and leads*.mdx (the 'lands unflagged' callout). cases*.mdx ~L146/~L181 were checked against the measurement: they are true as written, so unchanged. Seat note (#2101): both lead_auto_assign comments that said a manager or the lead_assignment flow routes an unplaced lead now say only 'captured, ownerless'. origin/main merged twice (6782f78; then c0f3485, #2101); in that merge #2101's new test comment ('a pool the anonymous form grant cannot read') was corrected. Its flow header src/sales/flows/lead-assignment.flow.ts:15-16 carries the same now-false phrase and is left alone (⛔ src/sales/flows/** on this card); see findings. Worktrees removed (node_modules first, no --force).",
    "tests": "GATES @ 407b221 (final head, after the second merge): OS_VERIFY_LOCK_SLOT=hotcrm-2014 bash /home/user/objectstack/scripts/pm/os-verify-lock.sh -c 'pnpm verify' → os-verify-lock: VERDICT command-exit 0 · held the lock 539s; validate ✓ Validation passed (3105ms); typecheck tsc --noEmit clean (the && chain continued); lint 18 suggestion(s), no error; lint:i18n-gate ✓ i18n lint gate: 0 i18n/missing-* issues; hygiene ✓ source hygiene clean; hygiene:tokens ✓ source token ratchet clean; build ✓ Build complete (2922ms); test Test Files 179 passed (179) Tests 3676 passed | 1 skipped (3677). The same chain was green on 57950e8 (before #2101 landed): VERDICT command-exit 0, 3664 passed | 1 skipped. An earlier full pnpm test on 4e0e070 (hooks only, pins not yet flipped) was 9 red, exactly the predicted 'guest lands ownerless or unflagged' class, and was flipped in e8db0c9. Targeted run of the 7 touched suites: Tests 249 passed (249). MEASURE (server, port 4105): base e094ec7 leadSubmit 201 [id], caseSubmit 201 [id], storedLead ownerNull true / duplicate_status null, storedCase ownerNull true; after (branch build) leadSubmit 201 [id], caseSubmit 201 [id], storedLead ownerIsSalesRep true / suspected / dupIsTheKnownContact true, storedCase ownerIsServiceAgent true. In-process probe (scratch, never committed) on base: hook-time organization_id absent for guest, seed and rep; two organizations: the door answers 500 ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED. ABLATION (scripts/ablation-replace.mjs from objectstack, WRAP mode, each leg anchor x1 → x0, mutation landed, restore blob == HEAD and git diff HEAD empty, final git status --porcelain empty; no build step: the verify stack boots from source): control hooks-runtime 19/19 green. case_auto_assign runAs → inherit: 5 red (routing, spoof and door cases in case-assignment; guest-sanitisation; the hooks-runtime-service case strip). lead_auto_assign runAs → inherit: 5 red (both hooks-runtime web cases, the hooks-runtime-service strip and door cases, flow-record-change). lead_duplicate_check runAs → inherit: 3 red (the door case, the guest dedupe, the rep cross-owner). Case org pin removed: 'stays inside the new case’s organization' red. Lead-assign org pin removed: the two-organization lead case red. Dedupe org pin removed: the FIRST leg stayed GREEN, which exposed a fixture bug (the shared address was computed after a counter moved), fixed in 204e857; the re-run leg is red (the assign pin is intact, so the only assertion that can move is the dedupe one). My first runAs legs were REFUSED by the tool before any test ran (the replacement was a substring of the anchor, so 'replacement count rose' could not hold); they were re-run with runAs: 'inherit' as above. NOT MEASURED locally, declared to CI: test:coverage thresholds (CI runs test:coverage, local verify runs test), docs-app, e2e, link-check, code-quality. CI on 407b221 not awaited.",
    "gates": {
    "head": "407b2219",
    "pnpm validate": "✓ Validation passed",
    "pnpm typecheck": "tsc --noEmit clean",
    "pnpm lint": "18 suggestion(s), no error",
    "pnpm lint:i18n-gate": "✓ i18n lint gate: 0 i18n/missing-* issues",
    "pnpm hygiene": "✓ source hygiene clean",
    "pnpm hygiene:tokens": "✓ source token ratchet clean",
    "pnpm build": "✓ Build complete",
    "pnpm test": "Test Files 179 passed (179); Tests 3676 passed | 1 skipped (3677)",
    "pnpm verify (lock verdict)": "os-verify-lock: VERDICT command-exit 0",
    "CI": "in_progress / not awaited"
    },
    "line_budget": "+332 / −124 across 18 files vs origin/main c0f3485 (src: lead.hook.ts +79/−20, _case-assignment.ts +43/−13, case.view.ts +1/−2; tests +198/−83; docs 6 × 1/1; changeset +5). Token ratchet @ 407b221 (includes #2101 from main): src/sales business semantics ~58,215 / 88,500, authored ~102,854 / 160,500; src/service business semantics ~13,122 / 21,000, authored ~22,032 / 33,000. No ceiling moved.",
    "files_changed": [
    ".changeset/2014-web-intake-hooks.md",
    "content/docs/administration/setup.mdx",
    "content/docs/administration/setup.zh-Hans.mdx",
    "content/docs/administration/setup.zh-Hant.mdx",
    "content/docs/sales/leads.mdx",
    "content/docs/sales/leads.zh-Hans.mdx",
    "content/docs/sales/leads.zh-Hant.mdx",
    "src/sales/objects/lead.hook.ts",
    "src/service/objects/_case-assignment.ts",
    "src/service/views/case.view.ts",
    "test/case-assignment.test.ts",
    "test/demo-staffing.test.ts",
    "test/flow-record-change.test.ts",
    "test/guest-submission-sanitisation.test.ts",
    "test/helpers/verify-stack.ts",
    "test/hooks-runtime-service.test.ts",
    "test/hooks-runtime.test.ts",
    "test/ownership-model.test.ts"
    ],
    "deviations": [
    "File surface beyond claim 6164254464, for one reason: each file holds a statement F9 made false. content/docs/sales/leads*.mdx (the 'web duplicate lands unflagged' callout, 3 locales). src/service/views/case.view.ts (comment: the public-form context cannot read sys_user_position). test/helpers/verify-stack.ts (comment: the handle cannot reach the form route; it can since 18.0.0-next.0). Comment-only: test/demo-staffing.test.ts, test/ownership-model.test.ts. Flipped pins: test/hooks-runtime.test.ts, test/hooks-runtime-service.test.ts, test/guest-submission-sanitisation.test.ts, test/flow-record-change.test.ts.",
    "Seat note (#2101): lead.hook.ts's two 'a manager / the lead_assignment flow routes it' comments are rewritten, because my change touches that block. lead-assignment.flow.ts:15-16 ('a pool the writer cannot read') is NOT edited (src/sales/flows/** is outside this card) and is named in findings.",
    "origin/main merged twice with merge commits (6782f78, then c0f3485 = #2101); no rebase, no force push. The second merge commit also carries the #2101 test-comment correction.",
    "Before-leg server measurement ran on a throwaway detached worktree /home/user/hotcrm-issue-2014-f9-base at e094ec7 rather than a stash (the stash stack is shared across worktrees); removed after.",
    "Ablation: the first run's three runAs legs were refused by ablation-replace (no test ran), and the dedupe-pin leg's green exposed a fixture bug (fixed in 204e857). All were re-run; the readings above are from the re-runs.",
    "The harness attribution reminder names a model (Co-Authored-By: Claude Opus 5.5) and a different PR footer. The commits carry the model-free pair (Co-authored-by: Claude, Claude-Session); the PR carries the session-URL footer from the os-dev definition, as rounds 1 and 2 did. Not imitated, no history rewritten.",
    "ZERO label-write, per the dispatch."
    ],
    "mcp_calls": "0",
    "api_writes": "2 relay writes as objectstack-fleet[bot], each one repository_dispatch POST /repos/objectstack-ai/objectstack/dispatches: (1) pr_create → POST /repos/objectstack-ai/hotcrm/pulls (draft #2103, assignee sunhaolin; read-back 10487 bytes sent, 10487 stored, identical); (2) this os-dev-report comment → POST /repos//issues/2014/comments. Plus git push of the branch (empty-branch probe, then 4e0e070, e8db0c9, 204e857, 0237c3d, a4d6812, 57950e8, 407b221), not REST. Reads via gh api (issue, comments, PR #2035, PR #2103).",
    "open_questions": [],
    "out_of_scope_findings": [
    "carrier: 承接者:无 · noted, not filed · src/sales/flows/lead-assignment.flow.ts:15-16 (from #2101) say web-to-lead lands ownerless when lead_auto_assign stands down on 'a pool the writer cannot read'. After PR #2103 the writer's grant no longer decides it, only an empty pool or a failed read does. A comment inside src/sales/flows/**, which this card may not touch. Dedupe words: lead-assignment.flow.ts header, pool the writer cannot read, ownerless web lead comment",
    "carrier: 承接者:无 · noted, not filed · On a 'single'-posture install holding two organizations, the anonymous public form is still offered (GET /forms/support answers 200), and every submit answers 500 {code: ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED, error: 'Internal server error'}. Measured in-process through the real Hono app on @objectstack/* 18.0.0-next.0. The platform's own comment declares that topology unsupported (the next boot refuses it), so it is not filed. Dedupe words: anonymous form submit 500, ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED form, single posture two organizations, anonymousFormIntakeUnavailability",
    "carrier: 承接者:无 · noted, not filed · rep cross-owner dedupe (a consequence of ruling B, pinned): a rep's new lead now names, in duplicate_of_contact or duplicate_of_lead, a colleague's record the rep cannot read. How the console renders that lookup for the rep was not measured. In PR Acceptance notes."
    ]
    }

  15. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    Review of PR #2103 (head 407b2219, round 3: F9) against the os-dev report 6165864608 (R85, seat repo:hotcrm, session_01P8mZuzuyZAHSrgQKmv6ASv): ACCEPT.

    • Form: draft, base main; the body opens Fixes #2014, then Clause-②: no. F9 is the card's last item, so the merge closes it. Not a governed path; 18 files, +332 −124. main c0f3485b (fix(lead): an ownerless lead's routing run ends after its SLA stamp instead of failing #2101) is merged in.
    • The ruling (6076828219, item 4, B), read on the diff:
      • lead_auto_assign and lead_duplicate_check (lead.hook.ts), and case_auto_assign (createCaseRoundRobinAssign, _case-assignment.ts), declare runAs: 'system'. The F14 escalation hook is untouched.
      • Each carries its rule-9 reason beside it. Elevation reaches only the pool, load-count and dedupe reads; the owner is still stamped on the payload in flight.
      • Priorities are kept (250 / 300, after the 200 strip), so an anonymous client cannot choose the owner. Measured: a planted, more loaded owner gives way.
    • Rule 10: every elevated read is pinned to the new record's organization_id when the writer named one. Measured: at beforeInsert the anonymous form names none; the platform stamps the Default Organization after the hooks (ADR-0131) and refuses an organization-less insert with two organizations. So an unpinned read only happens on a one-organization install, and a rep's elevated scan keeps the rep's tenant. The two-organization pin cases are ablation-proven red when the pin is removed.
    • Precondition H1 (objectstack#22437 installed): both anonymous form doors answer 201 with body keys [id] only, before and after. The new pins assert that through the real door. The rendered form says only "Submitted / Thank you!".
    • Readings: after the change, a web lead lands owned by a sales rep and flagged suspected against the contact it repeats, and a web case lands owned by the service agent; before, both landed ownerless and unflagged. pnpm verify is green on 407b2219 (179 files, 3,676 passed). CI: all 10 checks green on the head. Ablation: each runAs reverted to inherit turns its cases red; each organization pin removed turns its cross-organization case red.
    • Outside the claim, accepted: each file held a statement F9 made false (the leads*.mdx "lands unflagged" callout, a case.view.ts comment, a test-helper comment). The seat's note from fix(lead): an ownerless lead's routing run ends after its SLA stamp instead of failing #2101 is carried: both lead.hook.ts comments now say an unplaced lead is "captured, ownerless".
    • Noted, not filed: lead-assignment.flow.ts:15-16 ("a pool the writer cannot read") is now stale; it is in src/sales/flows/**, outside this card. A rep's new lead can now name a colleague's record the rep cannot read as its duplicate (a consequence of ruling B, pinned); how the console renders that lookup for the rep is unmeasured. Both are in the PR's Acceptance notes.

    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 workingpriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions