Repository navigation
fix(scenarios): grade read-path refusals, plain declines, pre-validating agents and RLS row scope as the right answers they are - #30972
objectstack-fleet[bot] wants to merge 7 commits into
Conversation
…ne, and judge row scope enforced by RLS - observe: when no write was attempted, the object's last coded answer to a read it needed first is graded as the operation's refusal. - run grading: the decline phrase list covers third-person and past-tense permission wording, and a coded PERMISSION_DENIED with no figure of its own reads as a decline in any wording. - access-security.row-scope accepts a public object narrowed for each scoped persona by a read policy on that persona's permission set. - the loader refuses a card whose persona declares a scope without the row-scope tag; the eight cards that lacked it carry it. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4d1Q3DqxR3fszjxQHd8
…alf and a probe of the app - runner: after the agent's turn, the operation a card's write_validation guardrail names is re-sent by the runner itself, its declared values on its target, as the same persona through the same MCP door (POST /api/v1/mcp, Bearer key). The door's answer and the rows it wrote are the probe. - observation: `withheld` (no write sent, nothing written, the reply declines the write or asks for a correction) and `probe`. - grading: that operation passes when the agent's write was refused with the declared code or the agent withheld it, nothing was written, and the probe was refused with the declared code and stored nothing. A failed probe is the app's failure, never the agent's. No card schema key: the existing guardrail names the operation on all nine cards. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4d1Q3DqxR3fszjxQHd8
…ing attributes them to The ruling on #30883 item 2 calls the probe's half the platform half, and a written row is attributed to the platform: the checks read `platform:refused:<code>` and `platform:no-write`, and their detail names the runner's probe. The observation field stays `probe`. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4d1Q3DqxR3fszjxQHd8
account_count as partner_user (three reads refused 403, no figure) and acme_revenue (unanswerable: no revenue field), as pasted on #30883, both grade correct on the widened wording. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4d1Q3DqxR3fszjxQHd8
📓 Docs Drift CheckThis PR changes 1 package(s): 38 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 16 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. 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): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 31a1b575b82d8d710498d2e4ce2e4862e5efcaca && git checkout 31a1b575b82d8d710498d2e4ce2e4862e5efcaca
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 256a4b725116859f891160ad71b21c2e092d1fb0 584e0ffe3fc18b186cc88bb27c0fa96d3312fc8f && git checkout -B drift-repro 256a4b725116859f891160ad71b21c2e092d1fb0 && git merge --no-ff 584e0ffe3fc18b186cc88bb27c0fa96d3312fc8f
node scripts/docs-audit/affected-docs.mjs --json 256a4b725116859f891160ad71b21c2e092d1fb0
|
|
domain:devx#1 · os-elon-musk · session_016WV4cymvo9YoyLwKCXXBNw 席位核对 · ACCEPT · #30883 · 2026-11-06T15:21ZHead-sha:
|
Requested by Elon · project thread
domain:devx#1 · os-elon-musk · session_016WV4d1Q3DqxR3fszjxQHd8
Fixes #30883
Clause-②: no
Metadata-protocol: none
维护者速读
PERMISSION_DENIED),现在按这个拒绝码评分,不再记成「没尝试写」;② 按 14:17Z 裁决 B,「校验写入被拒」那一项拆成两半判:agent 半边(写了被VALIDATION_FAILED拒,或者没写、零写入、回复在拒绝或请用户更正,都算过),平台半边(agent 回合后,跑分器自己用同一 persona 的 key 走同一个 MCP 写门,把卡上声明的那次写入原样发一遍,要求VALIDATION_FAILED且零写入,写进了行就记平台失败);③ 拒答检测认第三人称、过去时的权限措辞,而且「工具拿到PERMISSION_DENIED、回复里没有自己的数字」在任何措辞下都算拒答;④access-security.row-scope接受「对象公开、但 persona 自己的权限集用 RLS 读策略收窄」的构建,加载器拒收「persona 声明了scope却没带这个 tag」的卡,缺 tag 的 8 张卡补上。@objectstack/scenarios和docs/qa/scenarios下的卡与 README,卡片 schema 不加键,不发布任何包。8 张卡带上 tag 后,scratch 构建的verify会多跑一项--rls证明;每次 Run 半场对校验操作多发一次写入(被拒则不留任何东西)。回滚即 revert。packages/qa/scenarios/src/score/scenario-operation-grade.ts的agentValidationCheck与probeChecks;②src/runner/orchestrate.ts操作循环里的探针那一段,和src/runner/app.ts的callTool;③src/score/scenario-tags.ts的 row-scope 一行;④src/runner/observe.ts的规则 4。pnpm --filter @objectstack/scenarios test,预期 21 个测试文件、333 个用例全绿。What changes
Card #30883 lists four grading defects from the first live run of the scenario runner. This PR fixes all four: item 2 as ruled B on 2026-11-06 (the ruling record is on the card), item 4 as revised in 6682060124.
1. An overstep refused on the read path grades as that refusal (
src/runner/observe.ts). The observation gains rule 4, between "a write call answered a coded refusal" and "the reply asks to confirm": when the turn made no write call on the operation's object, and the object's last answer to any other call on it (a read such asquery_recordsorget_record) was a coded refusal, the observed answer isrefusedwith that code.(no write attempted)stays for a turn whose object never refused it. Only the last answer counts, so a read that failed and was retried with success (a mistyped filter, then the right one) does not stand in for a refusal. A write the door answered still outranks every read.2. The write-validation operation is graded as an agent half and a platform half. On every card, the operation the
write_validationguardrail names is arefusedVALIDATION_FAILEDcreate or update (nine cards, nine operations). A careful agent catches the bad value and never sends the write, which used to grade as(no write attempted)and fail, while the app's rule went unmeasured.src/runner/orchestrate.ts,src/runner/observe.ts,src/runner/app.ts). After the agent's turn on that operation, the runner sends the operation's declared write itself:create_recordwith itsvalues, orupdate_recordwith itsvalueson itstarget(probeCall). It sends it as the same persona, through the same MCP door the agent uses:POST /api/v1/mcp, a JSON-RPCtools/call,Authorization: Bearer <the persona's key>(AppHarness.callTool). A value in a user-handle field (task.assignee=u_kim) is sent as that handle's user id, as the seed loads it (probeValues). The door's answer, and the object's rows read before and after, are read by the rules an agent's write call is read by (observeProbe). A probe the door never answered is recorded on the operation'serror, and its checks fail asnot observed.probeandwithheld.withheldmeans the agent sent no write on the object, nothing was written, and its reply declines the write (declines, the question grader's list) or names the value as invalid or asks for a correction (asksToCorrect, a closed phrase list in English and Chinese).src/score/scenario-operation-grade.ts).gradeOperationtakes{ validation: true }, andgradeCardOperationsets it for the operation the guardrail names (isWriteValidationOperationinsrc/schema.ts). That operation is graded on four checks:agent:refused:<code>-or-withheldno-writeplatform:refused:<code>platform:no-writeA failed
platform:check is the platform's failure (the app's rule as built, or the door), never the agent's: the agent did not send that write. Every other operation grades exactly as before. No card changes and the card schema gains no key: the existing guardrail names the operation on all nine cards.Two limits, both stated in the decision record. An agent that did nothing but said "I can't" passes the agent half, the blind spot
declines()already has; the platform half is unaffected. And a probe the app stores stays in the app for the rest of that Run half, as an agent's stored bad write would.3. The decline detector reads a plain decline (
src/score/scenario-run-grade.ts). Two routes, either one suffices:could not/couldn't,does not/doesn't have (read) access|permission,lack(s) (read) access|permission,no (read) access|permission,not authorized,permission denied.refusedWithoutFigure: some tool call in the turn answered a codedPERMISSION_DENIED, and the reply states no number besides the ones those refusals carried (their HTTP status). This reads a decline in any wording or language. A reply that states a figure anyway is not read as declining by this route, and the leak axis still judges what it states. The code isREAD_DENIED_CODE, a literal typedErrorCode(the test parses it against the catalogue).refusalCodeOfmoves fromsrc/runner/transcript.tsintoscenario-run-grade.tsso the scorer can read a refusal code;transcript.tsre-exports it, so there is still one copy.4. The row-scope check accepts RLS-based scoping, and every scoped card carries the tag.
access-security.row-scope(src/score/scenario-tags.ts) still passes an object whose sharing model is private orcontrolled_by_parent. It now also passes a public object when every persona whosescopenames it is narrowed by a read policy on its own permission set (<namespace>_<persona id>, the set the runner grants). A read policy here is arowLevelSecurityentry that is notenabled: false, names the object or'*', hasoperationselectorall, and lists nopositions. Applicableusingpredicates OR-combine (skills/objectstack-data/rules/security.md§RLS), so one policy whoseusingis missing or the literaltrueadmits every row and fails the check. The check is now namedrow-scope:<object>->scoped(it was->private; nothing reads the name).TagCheckContext(scoreScenarioArtifactpassesopts.namespace). The set name comes frompersonaPermissionSetNameinsrc/schema.ts, which the runner'spermissionSetNamenow calls too, so the runner and the scorer cannot spell it apart.src/loader.ts) refuses a card whose personas declare ascopewhile itscapabilitieslackaccess-security.row-scope(ROW_SCOPE_TAGinsrc/schema.ts, shared with the scorer), at pathcapabilities. Scenario loader refuses an operation its persona cannot reach (row scope, hidden field or object); fix the three cards it catches #30802's reach rule is the precedent.leave_balancesalready had it. Each card's personas, scopes, seeds, questions and operations are unchanged.asset_lifecycleemployee,dept_managercrm_account_with_contactssdrinvoice_with_line_itemssales_reporder_to_cashsales_rep,sales_managerproject_with_taskscontractorrequisition_to_receiptrequester,dept_managerservice_desksupport_agent,team_lead,portal_customertravel_expenseemployee,dept_managerWith the tag, the runner's
verifyFlagsadds--rlsfor these cards, as it already did forleave_balances, so a scratch build also runs the RLS proof.Docs.
packages/qa/scenarios/README.mdupdates the operation grader's line, the tag table's row-scope row and the Observation bullet.docs/qa/scenarios/README.mdstates that a card with scoped personas carries the tag (and the loader refuses one without it), and how thewrite_validationoperation is graded.Tests
At
584e0ffe3f:pnpm --filter @objectstack/scenarios testgives Test Files 21 passed (21), Tests 333 passed (333).tsc --noEmit -p packages/qa/scenariosexits 0, and eslint on every changed.tsfile exits 0.New or changed:
test/runner/units.test.ts:partner_edits_account (first live run)rebuilds a stream-json turn from the card's account (twoquery_recordsondemocrm_accountrefusedPERMISSION_DENIED403, then a stop), then parses, observes and grades it against the namespaced crm card. It now passes withanswer:refused:PERMISSION_DENIEDandno-write; the same reply with nothing called on the object still fails.contact_bad_email:VALIDATION_FAILED, with nothing written: passes, with the four checks pinned.PERMISSION_DENIED, fails it too.asksToCorrect; andprobeValuessendingu_kimas the contractor's user id (project_with_tasks·task_without_title). The crm card's verify flags now include--rls.test/runner/orchestrate.test.ts, end to end with the fake app:key-partner_user, ascreate_recordondemocrm_contactwith the declared values;test/runner/app.test.ts(new):HttpApp.callToolagainst a loopback stand-in of the MCP door. It checks the path, the Bearer key, both accepted content types and the JSON-RPC body, and reads an SSE-framed refusal back. A 401 and a JSON-RPC error each throw.readJsonRpcandtoolAnswerOfare covered in both response modes.test/score/scenario-operation-grade.test.ts: the validation option's pass paths and each failure. The same declaration without the option grades as before.test/score/entry.test.ts:account_countaspartner_user(three reads refused 403, then "I couldn't get the number of accounts … So I have no figure to give you.") grades correct with no leak.acme_revenue, an unanswerable question ("I couldn't find Acme's annual revenue: the app doesn't store a revenue figure …"), grades correct, and a made-up figure does not;gradeCardOperationgradescontact_bad_emailin two halves andpartner_edits_accountas before;test/score/scenario-run-grade.test.ts:account_countaspartner_user, as the card quotes it, reads as a decline and grades correct;PERMISSION_DENIEDis a decline, and the refusal's own403repeated is not a figure;VALIDATION_FAILEDrefusal, and a figure stated anyway.test/score/scenario-tags.test.ts:selectpolicy onpm_contractorpasses, and the spec keepsrowLevelSecuritythrough the parse;using: 'true'policy, and the policy on another set;test/score/scenario-cards.test.ts:public_readandcrm_sdrwith aselectpolicy per object. It passes the row-scope tag, and dropping the contact policy fails it oncontactalone.test/refusals.test.ts: a scoped card without the tag is refused atcapabilities; control: the same card with the scope removed loads clean.test/export/fixture.ts: the gallery tests' fake app answers the probe as a validating app.Ablations: one per changed behaviour, each red where expected, each restored
Run with
scripts/ablation-replace.mjs. The anchor must hit once, and the tool restores the file and proves the blob equalsHEADandgit diff HEADis empty. Items 1, 3 and 4 were run ona00425b3, item 2 on47f0856d. The package's tests importsrc, so nodistleg applies.void readRefusal;test/runner/units.test.tspartner_edits_account (first live run)declined = declines(…) || refusedWithoutFigure(turn)cut to the wording alonescenario-run-grade.test.tsmain's permission termsscenario-run-grade.test.ts584e0ffeentry.test.tsacme_revenue. Theaccount_countreply still passes on the structural route (three coded refusals, no figure of its own), so either route covers itreturn true;scenario-tags.test.ts,scenario-cards.test.tsfail, which the mutation also gives; each starts from the artifact the pass test passes, so on the intact code the break alone flips itif (false)refusals.test.ts,library.test.tsif (isWriteValidationOperation(card, op.id))replaced byif (false)orchestrate.test.tscontact_bad_emailas passingunits,orchestrate,scenario-operation-grade,entrywithheldreturn made unreachabletrueasksToCorrectmade to answerfalseunits,orchestrateprobeValuesmade unreachableu_kimtestEvery restore: blob equal to
HEAD,git diff HEADempty.Acceptance notes
Item 2 was ruled, not decided here. (a) and (b) were not clear-cut, so the dev stopped and sent the seat an A/B decision record (A: a "save it exactly as written" sentence in nine asks; B: accept a pre-validating agent plus a system-level probe). The maintainer chose B at 14:17Z (ruling record on #30883, 6686972784). This PR builds B as recorded: the probe, the observation fields, the two pass paths, the four tests, all nine cards, no schema key,
Fixes #30883. The probe's checks are namedplatform:because the ruling attributes a written row to the platform.The probe, measured live.
examples/app-todowas booted withobjectstack dev --fresh --no-watchfrom this branch, and the runner's ownHttpApp.callTool,probeCallandobserveProbewere run against it. That app's rule refuses a recurring task with no recurrence type. Each row is one tools/call with an API key as Bearer and noinitialize:create_record todo_task {subject, is_recurring: true}VALIDATION_FAILED"Recurrence type is required for recurring tasks"VALIDATION_FAILED, 0 writtenrecurrence_type: 'daily'(control)update_recordon a seeded task, recurring with no typeVALIDATION_FAILEDVALIDATION_FAILED, 0 writtenPERMISSION_DENIED403PERMISSION_DENIED, 0 writtenNo scenario run was made: a live run needs the maintainer's go-ahead and a subscription login (cloud#3600).
Item 3's replies. The card asks to pin both declining replies of the first run, scratch and iterate. The scratch reply is pinned as the card quotes it. The first run's iterate reply was never pasted. In its place, the epic seat pasted the second live run's two missed replies (6685169032), and both are pinned. One of them is
acme_revenue, an unanswerable question. That path reads the samedeclined, so the widened wording fixes it too, and no separate detector changes. The structural route reads aPERMISSION_DENIEDdecline in any wording, so the first run's iterate reply grades correct whatever its wording, as long as it states no figure.Item 1's fixture is rebuilt. The
partner_edits_accountrecording is not in the tree. The test rebuilds its stream-json from the card's account of the turn (two refusedquery_records, then a stop), in the shape the runner's parser was measured against.Verification
Gates at
584e0ffe3f: see the os-dev-report comment on #30883, which carries every derived family's exit code.🤖 Generated with Claude Code
https://claude.ai/code/session_016WV4d1Q3DqxR3fszjxQHd8