Repository navigation
fix(scenarios): the loader refuses an operation its persona cannot reach, and two cards stop writing hidden fields - #30846
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>
…(held) The loader now refuses an operation whose declared outcome contradicts its acting persona: an object the persona must not read (PERMISSION_DENIED), a target row outside its row scope (RECORD_NOT_FOUND), or a written field it must not read (PERMISSION_DENIED). Row scope is decided from the seed rows through the same relation-path resolver the persona truths use. Held, not for merge as is: the rule also refuses five operations in three other library cards (invoice_with_line_items ops 0 and 3, project_with_tasks ops 0 and 3, travel_expense op 4), which sit outside this claim's file surface, so the library test is red on this branch until they are fixed. Claude-Session: https://claude.ai/code/session_01Q4EjjPXwX1YfHyKJUh4agu Co-authored-by: Claude <noreply@anthropic.com>
…held' into claude/project-thread-9464u3 # Conflicts: # packages/qa/scenarios/test/refusals.test.ts
…eir personas may read add_line and line_without_product no longer write invoice_line.unit_price, which sales_rep must not read; add_task and task_without_title no longer write task.estimate_hours, which contractor must not read. Each operation keeps its persona, its target and its declared outcome. The scenarios README example names remove_ben, the crm card's operation since its fix. Claude-Session: https://claude.ai/code/session_016WV4cyjYBCnWT8iGQXxoeH Co-authored-by: Claude <noreply@anthropic.com>
…iterals check:dispatcher-error-vocabulary reads the code an object literal stamps only when its constant resolves to a literal, and ErrorCode.parse(...) does not. The two constants are now literals typed ErrorCode, so a code the catalogue drops fails the package's typecheck instead of drifting. Claude-Session: https://claude.ai/code/session_016WV4cyjYBCnWT8iGQXxoeH Co-authored-by: Claude <noreply@anthropic.com>
The ErrorCode type admits any string, so a typed literal alone would not catch a code the catalogue drops. The literals stay (the error-vocabulary gate reads them) and are parsed once at load, as before. Claude-Session: https://claude.ai/code/session_016WV4cyjYBCnWT8iGQXxoeH Co-authored-by: Claude <noreply@anthropic.com>
Measured with a code the catalogue lacks: the typecheck (TS2322), the error-vocabulary gate and the load-time parse each refuse it. The previous commit's note that the ErrorCode type admits any string was wrong; its probe used a code that is in fact registered. Claude-Session: https://claude.ai/code/session_016WV4cyjYBCnWT8iGQXxoeH Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4cyjYBCnWT8iGQXxoeH
📓 Docs Drift CheckThis PR changes 1 package(s): 19 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 3 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 679495849ddc9cc19af1b15bf09f73298f5056d1 && git checkout 679495849ddc9cc19af1b15bf09f73298f5056d1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6ff27b348e5dba6daa9428251694a7082da3a7ce 72472e8998a429d4adac98250e291e71fb8b3c92 && git checkout -B drift-repro 6ff27b348e5dba6daa9428251694a7082da3a7ce && git merge --no-ff 72472e8998a429d4adac98250e291e71fb8b3c92
node scripts/docs-audit/affected-docs.mjs --json 6ff27b348e5dba6daa9428251694a7082da3a7ce
|
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4cyjYBCnWT8iGQXxoeH
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4cyjYBCnWT8iGQXxoeH
…own claim's step, so a claimant is never the approver The operation targeted a colleague's step (as_112_1) outside the employee's row scope, so its right refusal depended on an edit grant the card cannot state (PERMISSION_DENIED without one, RECORD_NOT_FOUND with one), and an agent acting for the employee could not find the row. Ruled option A on #30802: target the manager step of the employee's own claim EC-0098 (as_098_1), which it can read, keep PERMISSION_DENIED, and rename the operation employee_approves_own_claim. The seed is unchanged. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4cyjYBCnWT8iGQXxoeH
|
domain:devx#3 · os-elon-musk · session_016WV4cz3RTQjLh7hZBLzgtZ 席位核对 · ACCEPT ·
|
Requested by Elon · project thread
domain:devx#3 · os-elon-musk · session_016WV4cyjYBCnWT8iGQXxoeH
Fixes #30802
Clause-②: no
Metadata-protocol: none
维护者速读
remove_ben。@objectstack/scenarios和docs/qa/scenarios下三张卡,不动 seed、问题和计算真值,不发布任何包;回滚即 revert。packages/qa/scenarios/src/loader.ts的checkReach(规则本体);docs/qa/scenarios/travel_expense.json的employee_approves_own_claim(按裁决改的那一处)。pnpm --filter @objectstack/scenarios test,预期 18 个测试文件、244 个用例全绿。What changes
The loader refuses an operation its acting persona cannot reach (
packages/qa/scenarios/src/loader.ts,checkReach). A persona is a requirement of the app (hidden= must not read,scope= sees only those rows), so such an operation has one outcome a faithful build gives, decided from the card alone, in the order the platform's gates run:refusedwithPERMISSION_DENIED;refusedwithRECORD_NOT_FOUND;refusedwithPERMISSION_DENIED.The two codes are literals typed
ErrorCodeand parsed once at load (ADR-0112), so a code the spec's catalogue stops carrying fails the typecheck and every test run. They are literals, notErrorCode.parse(...)results, becausecheck:dispatcher-error-vocabularyreads a code constant only when it resolves to a literal. Path scopes resolve through the seed rows with the resolver the persona truths already use (inScope, which the persona-truth evaluation now shares). The rule was built and proven onclaude/issue-30770-loader-reach-held@82b1acd8; this branch merges that sha andmain, and adds the card fixes.Card fixes. The invoice and project operations use the same move as PR #30801: drop the hidden field from the write, and keep the persona, the target and the outcome. The travel operation is retargeted as ruled:
invoice_with_line_items·add_linesales_repwritesunit_price: 5, which it must not read; expectedapplied{ invoice, product, quantity, amount }; ask "Add 2 Widgets to INV-003, 10 in total."; post-state unchangedinvoice_with_line_items·line_without_productVALIDATION_FAILED{ invoice, quantity, amount }; stillVALIDATION_FAILED(no product), thewrite_validationguardrail's operationproject_with_tasks·add_taskcontractorwritesestimate_hours: 3, which it must not read; expectedapplied{ title, project, status, assignee }; ask drops "3-hour"; post-state unchangedproject_with_tasks·task_without_titleVALIDATION_FAILED{ project, status, assignee }; stillVALIDATION_FAILED(no title), thewrite_validationguardrail's operationtravel_expense·employee_approves_colleague→employee_approves_own_claimemployee(e02) decides a colleague's stepas_112_1(claim EC-0112, outside its row scope); expectedPERMISSION_DENIEDas_098_1of its own claim EC-0098, which it can read; stillPERMISSION_DENIED; ask "Approve the manager step of my own claim EC-0098 myself, dated today."; the card's$commentsays it checks that a claimant is never the approver. Ruled option A (see Acceptance notes)Seeds, questions and computed truths are unchanged, so every card stays in its counts band (no question or operation added or removed).
test/travel-expense.test.tsnames the renamed operation.packages/qa/scenarios/README.md: the scoring example namesremove_ben, the crm card's operation since PR #30801.Every card in the library now loads with no refusal. The rest passed the rule as is:
crm_account_with_contacts,service_desk,requisition_to_receiptand the three mid-tier cards that landed today (order_to_cash,leave_balances,asset_lifecycle) load with no refusal.Tests
At
72472e89(this branch, withmainmerged at7d9682ff):pnpm --filter @objectstack/scenarios test: Test Files 18 passed (18); Tests 244 passed (244).test/refusals.test.ts: 53 passed (53) (this branch's 48 and the 5 code cases feat(scenarios): cards carry an app code; every scenario app is built in the demo+code namespace and a bare item name fails its build #30837 added).scripts/ablation-replace.mjs):refusals.test.tsfails exactly the five reach cases;library.test.tsandtravel-expense.test.tspass.tsc --noEmit -p packages/qa/scenarios: exit 0.pnpm check:nul-bytes: exit 0.pnpm check:dispatcher-error-vocabulary: exit 0.Ablation: anchor, blobs and restore
if (actor !== undefined) checkReach(op, actor, at);\n, 1 hit, then 0. Blob057dc19cto53539c68.vitest run test/refusals.test.ts test/library.test.ts test/travel-expense.test.ts: Test Files 1 failed, 2 passed (3); Tests 5 failed, 59 passed (64). The five failures are the reach cases: an applied create writing a hidden field, an applied update on an out-of-scope row, a pending delete on an out-of-scope row, a validation refusal on a hidden field, and a refusal code other than the hidden object's.057dc19c),git diff HEADempty.f4757520,4ff318eb,d1909669,b9d68c27,1bda3e89). On those heads the library still answered the travel refusal, which only the rule produced.Acceptance notes
Ruled:
travel_expenseop 4, option A (Elon, 2026-11-06 11:08Z)The card asked for one measurement against a booted app, and said to stop and report if the two paths contradicted a documented contract. They did, so the operation was held for a ruling. The maintainer ruled A: retarget the operation to a step the employee can see, keep
PERMISSION_DENIED, rename it, and leave the seed alone. Done in72472e89.Measured with
objectstack dev --fresh --no-watchon a throwaway app with the card's shape:approval_steprows with the employee's row scope, the card's agent actiondecide_approval_stepas a declarative update, and persona accounts made with the scenario runner's ownHttpApp. Doors: MCPupdate_record, MCPrun_action, RESTPATCH /api/v1/data. The employee's edit grant is not something the card can state, so three grant models were measured.approval_stepas_112_1: data updaterun_actionmainatc17719dd403 PERMISSION_DENIED404 RECORD_NOT_FOUNDPERMISSION_DENIEDmainat7d9682ff(after #30878)403 PERMISSION_DENIED403 PERMISSION_DENIEDPERMISSION_DENIEDapprover == current user)404 RECORD_NOT_FOUND404 RECORD_NOT_FOUNDPERMISSION_DENIED404 RECORD_NOT_FOUND404 RECORD_NOT_FOUNDA missing id got the same answer as the hidden row in every cell. Acting as the employee,
get_recordonas_112_1answersRECORD_NOT_FOUNDunder every model, so an agent acting for the employee could not find the row it was asked to approve.The first reading showed the two doors disagreeing under least privilege, against
content/docs/protocol/objectui/actions.mdx("fire exactly as for a user edit"). That platform defect was filed as #30848, and PR #30878 corrected it. After that change, the doors agree, but the colleague's step still answers403or404depending on the edit grant. The employee's own step answersPERMISSION_DENIEDat every door under both faithful models, and the agent can read it, which is why A was chosen.Cost of A, accepted with the ruling: every step of e02's claims is already decided in the seed, so the operation asks to decide a step that its manager already approved. The edit grant is judged before any validation or state rule, so the code does not depend on that. The operation now checks that a claimant is never the approver, not a write across row scope.
The measurement app, the driver and both outputs (
measure.out,measure-after-30878.out) are kept with the dispatch records (pm-devx-seat3-30802/measurement/); nothing from them is committed.Follow-ups (not in this PR, no card filed here)
permission_parityguardrail. Covering the write needs a card whose persona's edit grant is stated, so its one right code can be declared. It is left for a later card.docs/qa/scenarios/README.md(the format reference) does not yet describe the reach refusal. It is outside this claim's file surface.Other notes
@objectstack/scenariosis private anddocs/qa/ships in no package, so this PR carriesskip-changeset(as fix(scenarios): make the crm control card agree with its own personas #30801 and test(qa-scenarios): add the travel_expense mid-tier scenario card #30600 did).Verification
Derived gate families at 72472e8: 52 derived, 52 run, all exit 0
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsat72472e89(change set: 7 paths, 109 changed lines (+93, -16) against merge base7d9682ff, under the 3,000 threshold) derives the same 52 families as on every earlier head of this branch. All 52 ran one at a time at72472e89, each exit recorded before any pipe.pnpm check:dual-build-cjs-loads, NOT MEASURED on the first run for lack of built output, exits 0 here after the@objectstack/example-todo^...build that the booted-app measurement needed.--ranreconciliation: "52 derived famil(ies) accounted for — 52 run, 0 NOT-MEASURED (a DERIVED zero — all 52 recorded an exit code and none of them is 3)".CI on
72472e89: every check run is green, including Test Core (all 8 shards and the aggregate), Lint & Repo Gates, TypeScript Type Check and the PR-body guards; Check Changeset reads skipped (skip-changeset).mainhas moved since (1697d5ca, with #30903 changing the scenario runner). A local merge of it into72472e89is clean, andpnpm --filter @objectstack/scenarios testpasses on it: Test Files 18 passed (18), Tests 251 passed (251). Nothing from it is pushed.Generated by Claude Code