Skip to content

fix(scenarios): make the crm control card agree with its own personas - #30801

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-30770-crm-control-card-scope
Nov 6, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-30770-crm-control-card-scope

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #30770
epic:#3600#1 · hotlong · session_01Q4EjjPXwX1YfHyKJUh4agu

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:修正 crm_account_with_contacts 对照卡片里四个与自身 persona 设定矛盾的写操作(移动联系人、删除确认、新建联系人、邮箱校验),卡片的种子数据、问题和计算出的答案都不变。
  • 为什么改:卡片原本让 sdr 去改它看不见的记录、写它看不见的字段,忠实的构建只会拒绝,于是评分时卡片永远判错;现在每个操作都落在执行者能触及的范围内。
  • 风险与回滚:只动一张 QA 场景卡片和私有测试包的测试,不发布任何包;回滚即 revert 本 PR。
  • 先看:docs/qa/scenarios/crm_account_with_contacts.json(operations 段),packages/qa/scenarios/test/runner/units.test.ts(改名后的操作断言)。
  • 两分钟验证:pnpm --filter @objectstack/scenarios test,预期 15 个测试文件、203 个用例全绿。
  • 需要你做的:决定本卡「预防同类问题」的那一半(loader 拒收规则,已在暂存分支上实现并验证)要不要扩大文件面合入,它同时会拒收另外三张卡片里的五个同类操作,详见下方 Acceptance notes。

What changes

Every operation of docs/qa/scenarios/crm_account_with_contacts.json now acts on what its acting persona can reach. The card's intent per operation is kept; seed rows, questions and computed truths are unchanged.

Before After Why this fix and not the other
attach_dee: sdr edits ct4 (account: null, outside sdr's scope contact.account.owner = u_sam), expected applied move_eli: sdr re-parents ct5 (Eli Moss) from Bitworks (ac3) to Acme (ac1), both owned by u_sam; still applied, auditActor: sdr The operation exists for the audit_actor guardrail ("Re-parenting the contact is audited with the signed-in user as its actor"), which needs an applied write. A refused overstep would have dropped that guardrail's only operation. No unattached contact can ever be in sdr's scope (a null account resolves to no owner), so the edit moves to an in-scope re-parent. ct5 stays in scope after the move.
contact_bad_email: sdr creates a contact with a malformed email, expected VALIDATION_FAILED, but sdr hides contact.email Acts as partner_user, writes { full_name, email } only, still expected VALIDATION_FAILED The write_validation guardrail asserts the form's validation error. partner_user hides only the account object, so it can edit email. It writes no account lookup, so it never references the object it must not read. The live reading the runner recorded (VALIDATION_FAILED, "Email must be a valid email address") stays the expected answer.
remove_chen: sdr is asked to delete ct3 (Shopwell, owned by u_tia, outside sdr's scope), expected pending_confirmation remove_ben: sdr is asked to delete ct2 (Ben Ode, Acme), still pending_confirmation Same defect class as attach_dee: an agent acting as sdr cannot even find Chen Li.
add_contact: sdr creates a contact writing email, a field sdr hides, expected applied Writes { full_name, account }; the created row is read back by where: { full_name } Same defect class as contact_bad_email: a faithful build refuses a write to a field the persona must not read.

remove_chen and add_contact were not named on the card. The held loader rule (below) found them, and they are fixed here under the bounded in-place rule: same defect class, mechanical, in the file this claim holds, same gate family.

Tests that named the old operation ids now use the new ones (test/refusals.test.ts, test/runner/units.test.ts, test/runner/orchestrate.test.ts, test/score/entry.test.ts). The orchestrate test's fake built artifact now grants partner_user allowCreate on contact, to match the operation it now runs. The runner counts stay 8 questions / 9 persona turns / 5 operations / 2 personas.

Tests

At 9bfc83d2:

  • pnpm --filter @objectstack/scenarios test: Test Files 15 passed (15), Tests 203 passed (203); lock VERDICT command-exit 0.
  • pnpm --filter @objectstack/scenarios typecheck (tsc --noEmit): exit 0, after pnpm --filter '@objectstack/scenarios^...' build (exit 0).
  • eslint (--no-inline-config) on the four changed test files: exit 0.

Acceptance notes

The class guard (the card's "prevent the class" ask) is held, not in this PR. It is statically decidable, and it is implemented on claude/issue-30770-loader-reach-held @ 82b1acd8 (one commit on top of this branch). The loader refuses an operation whose declared outcome contradicts its acting persona:

  1. op object hidden from the persona: requires refused + PERMISSION_DENIED.
  2. target row outside the persona's row scope: requires refused + RECORD_NOT_FOUND. A by-id write on a row the caller cannot read is answered not-found before any field or validation gate (plugin-security authz middleware, the callerCannotReadAddressedRow branches).
  3. a written field hidden from the persona: requires refused + PERMISSION_DENIED. The field-write gate (computeForbiddenFieldWrites) runs before validation.

Codes go through ErrorCode.parse (ADR-0112). Path scopes are no obstacle: the loader already resolves relation paths through the seed rows (resolverFor, the same resolver the persona truths use). Proof on the held branch: test/refusals.test.ts gains 5 refusal cases plus a control. With the rule: 48/48 pass. With loader.ts restored to this branch's version (ablation): 43 pass / 5 fail, exactly the 5 new cases. Restore verified by blob hash 4310a418… equal to HEAD.

Why held: the same rule also refuses five operations in three cards outside this claim's file surface, so library.test.ts is red on the held branch:

  • invoice_with_line_items op 0 add_line and op 3 line_without_product: sales_rep writes invoice_line.unit_price, which it hides.
  • project_with_tasks op 0 add_task and op 3 task_without_title: contractor writes task.estimate_hours, which it hides.
  • travel_expense op 4 employee_approves_colleague: target as_112_1 is outside employee's approval_step scope, so the rule wants RECORD_NOT_FOUND, while the card declares PERMISSION_DENIED. If an approval decision travels through an approval door rather than a by-id data update, that card's code may be right and the rule's row-scope arm should not apply to it. This one wants a ruling, not a mechanical fix.

What the held rule deliberately does not decide (the platform's answer depends on the build's write policy, not on the card): a create whose values place the new row outside the persona's scope, an update whose values move the row out of scope, and a lookup value naming a row outside scope or of a hidden object.

packages/qa/scenarios/README.md still shows gradeCardOperation(card, 'remove_chen', observed) as an example. It is outside this claim's file surface; it is a one-token follow-up (remove_ben).

No changeset: @objectstack/scenarios is private and docs/qa/ ships in no package, so this PR carries skip-changeset.

Verification

Derived gate families: 50 derived, 49 run exit 0, 1 NOT MEASURED

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 9bfc83d2 (change set: 5 paths against merge base 612468cc2). Every family exits 0 except:

  • pnpm check:dual-build-cjs-loads :: exit 3, PREREQUISITE NOT MET (reads built output of 61 packages this run did not build). NOT MEASURED; CI's Lint & Repo Gates runs it.

--ran reconciliation with every exit recorded: "50 derived famil(ies) accounted for — 49 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)".

Held loader rule: what it reports on the current library

On this branch's card, plus the held loader.ts, the crm card loads clean; before this PR's card fix it answered four refusals (ops 0 to 3 of this card). The other five refusals are listed above.

signed: epic:#3600#1 · hotlong · session_01Q4EjjPXwX1YfHyKJUh4agu


Generated by Claude Code

Every operation of crm_account_with_contacts now acts on what its acting
persona can reach:

- attach_dee (sdr edits ct4, account null, outside sdr's row scope) becomes
  move_eli: sdr re-parents ct5 from Bitworks to Acme, both owned by u_sam,
  still applied with sdr as the audit actor, which is what the audit_actor
  guardrail asserts.
- remove_chen (sdr deletes ct3 at Shopwell, owned by u_tia, outside sdr's
  scope) becomes remove_ben: sdr is asked to delete ct2 at Acme, still
  pending_confirmation.
- add_contact no longer writes contact.email, a field sdr must not read; the
  created row is read back by full_name.
- contact_bad_email runs as partner_user, who can edit contact.email and
  writes no lookup to the account object it must not read, so the refusal
  is still the form's VALIDATION_FAILED the write_validation guardrail asserts.

Seed rows, questions and computed truths are unchanged. The runner and
grader tests that name the renamed operations follow the new ids.

Claude-Session: https://claude.ai/code/session_01Q4EjjPXwX1YfHyKJUh4agu
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet objectstack-fleet Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Nov 6, 2026
@github-actions

github-actions Bot commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

What this run could not see

Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 177f0ee22e4792a22b98a94494224f46d06dca60 → packageMentionDocs.

@github-actions

github-actions Bot commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 42162088042 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

分类:failure —— 按下面的日志分诊。

失败的 job(日志抽取,best effort):

  • Test Core (1/8) — 失败步骤: Run this shard's tests

    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — evidence pointers (#5623) > is green against a verbatim copy of the shipped ledgers
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — evidence pointers (#5623) > stays green when the missing path is attributed to ANOTHER repo
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — evidence pointers (#5623) > never bounds a citation attributed to ANOTHER repo
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — evidence pointers (#5623) > prints the citation count and how many are in range, in the documented two
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — symbol anchors (#12516) > stays GREEN on the drifted BEFORE-state — the honest residual this grammar e
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — symbol anchors (#12516) > prints the anchor count and how many resolve, equal on a green run
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — the evidence-scan population (#13041) > stays GREEN when a `dead` entry carries the SAME rotted pointe
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — the evidence-scan population (#13041) > declares every status either scanned or explicitly unscanned, 
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — the live-elsewhere criteria (#13483) > is green on the shipped ledgers and publishes the population be
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — the live-elsewhere criteria (#13483) > is green on a planted live-elsewhere row and counts it as carry
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — the README state table (#7257) > is green against a verbatim copy, and says how many rows it checked
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — the generated count artifact (#7377, sharded #20361) > is green against a verbatim copy, says the shar
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    

↳ 失败原因 是判读的关键:超时(Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言(AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️ 断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError。 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 10 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

epic:#3600#1 · hotlong · session_01Q4EjjPXwX1YfHyKJUh4agu

Triage of merge-queue build 42162088042 (red, comment 6677066108), before re-queueing once:


Generated by Claude Code

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

Labels

size/s skip-changeset PR has no user-facing published change; bypasses the changeset gate tests

Projects

None yet

1 participant