Repository navigation
fix(scenarios): make the crm control card agree with its own personas - #30801
Conversation
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>
📓 Docs Drift CheckNothing 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): |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 42162088042 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 分类: 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Fixes #30770
epic:#3600#1 · hotlong · session_01Q4EjjPXwX1YfHyKJUh4agu
Clause-②: no
Metadata-protocol: none
维护者速读
crm_account_with_contacts对照卡片里四个与自身 persona 设定矛盾的写操作(移动联系人、删除确认、新建联系人、邮箱校验),卡片的种子数据、问题和计算出的答案都不变。docs/qa/scenarios/crm_account_with_contacts.json(operations 段),packages/qa/scenarios/test/runner/units.test.ts(改名后的操作断言)。pnpm --filter @objectstack/scenarios test,预期 15 个测试文件、203 个用例全绿。What changes
Every operation of
docs/qa/scenarios/crm_account_with_contacts.jsonnow acts on what its acting persona can reach. The card's intent per operation is kept; seed rows, questions and computed truths are unchanged.attach_dee:sdreditsct4(account: null, outsidesdr's scopecontact.account.owner = u_sam), expectedappliedmove_eli:sdrre-parentsct5(Eli Moss) from Bitworks (ac3) to Acme (ac1), both owned byu_sam; stillapplied,auditActor: sdraudit_actorguardrail ("Re-parenting the contact is audited with the signed-in user as its actor"), which needs anappliedwrite. A refused overstep would have dropped that guardrail's only operation. No unattached contact can ever be insdr's scope (a null account resolves to no owner), so the edit moves to an in-scope re-parent.ct5stays in scope after the move.contact_bad_email:sdrcreates a contact with a malformedemail, expectedVALIDATION_FAILED, butsdrhidescontact.emailpartner_user, writes{ full_name, email }only, still expectedVALIDATION_FAILEDwrite_validationguardrail asserts the form's validation error.partner_userhides only theaccountobject, so it can editemail. It writes noaccountlookup, 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:sdris asked to deletect3(Shopwell, owned byu_tia, outsidesdr's scope), expectedpending_confirmationremove_ben:sdris asked to deletect2(Ben Ode, Acme), stillpending_confirmationattach_dee: an agent acting assdrcannot even find Chen Li.add_contact:sdrcreates a contact writingemail, a fieldsdrhides, expectedapplied{ full_name, account }; the created row is read back bywhere: { full_name }contact_bad_email: a faithful build refuses a write to a field the persona must not read.remove_chenandadd_contactwere 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 grantspartner_userallowCreateoncontact, 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); lockVERDICT command-exit 0.pnpm --filter @objectstack/scenarios typecheck(tsc --noEmit): exit 0, afterpnpm --filter '@objectstack/scenarios^...' build(exit 0).--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:refused+PERMISSION_DENIED.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, thecallerCannotReadAddressedRowbranches).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.tsgains 5 refusal cases plus a control. With the rule: 48/48 pass. Withloader.tsrestored to this branch's version (ablation): 43 pass / 5 fail, exactly the 5 new cases. Restore verified by blob hash4310a418…equal to HEAD.Why held: the same rule also refuses five operations in three cards outside this claim's file surface, so
library.test.tsis red on the held branch:invoice_with_line_itemsop 0add_lineand op 3line_without_product:sales_repwritesinvoice_line.unit_price, which it hides.project_with_tasksop 0add_taskand op 3task_without_title:contractorwritestask.estimate_hours, which it hides.travel_expenseop 4employee_approves_colleague: targetas_112_1is outsideemployee'sapproval_stepscope, so the rule wantsRECORD_NOT_FOUND, while the card declaresPERMISSION_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.mdstill showsgradeCardOperation(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/scenariosis private anddocs/qa/ships in no package, so this PR carriesskip-changeset.Verification
Derived gate families: 50 derived, 49 run exit 0, 1 NOT MEASURED
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsat9bfc83d2(change set: 5 paths against merge base612468cc2). 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.--ranreconciliation 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