Repository navigation
Commit 26c965c
fix(scenarios): the loader refuses an operation its persona cannot reach, and two cards stop writing hidden fields (#30846)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} -->
_Requested by **Elon** · [project
thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DSVshHrq9aY7Erng1Qe4Go3)_
domain:devx#3 · os-elon-musk · session_016WV4cyjYBCnWT8iGQXxoeH
Fixes #30802
Clause-②: no
Metadata-protocol: none
## 维护者速读
- **改了什么**:场景卡片 loader 新增一条拒收规则:操作的执行 persona
看不到的对象、行或字段,卡片必须声明忠实构建会给出的那个拒绝;同时修好这条规则在 main 上拦下的全部 5
个操作:invoice、project 两张卡不再写 persona 看不见的字段;travel_expense 的「员工审批同事报销单」按裁决
A 改为「员工审批自己报销单的经理步骤」,测的是申请人永远不是审批人。README 示例改名 `remove_ben`。
- **为什么改**:卡片让 persona 写它看不见的字段、改它看不见的行,忠实的构建只会拒绝,评分就把正确的 app 判成失败。
- **风险与回滚**:只动私有测试包 `@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:
1. the operation's object is hidden from the persona: the outcome must
be `refused` with `PERMISSION_DENIED`;
2. the target row is outside the persona's row scope: `refused` with
`RECORD_NOT_FOUND`;
3. a written field is hidden from the persona: `refused` with
`PERMISSION_DENIED`.
The two codes are literals typed `ErrorCode` and 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, not
`ErrorCode.parse(...)` results, because
`check:dispatcher-error-vocabulary` reads 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 on
`claude/issue-30770-loader-reach-held` @ `82b1acd8`; this branch merges
that sha and `main`, 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:
| Card · operation | Before | After |
|:---|:---|:---|
| `invoice_with_line_items` · `add_line` | `sales_rep` writes
`unit_price: 5`, which it must not read; expected `applied` | writes `{
invoice, product, quantity, amount }`; ask "Add 2 Widgets to INV-003, 10
in total."; post-state unchanged |
| `invoice_with_line_items` · `line_without_product` | same hidden
write; expected `VALIDATION_FAILED` | writes `{ invoice, quantity,
amount }`; still `VALIDATION_FAILED` (no product), the
`write_validation` guardrail's operation |
| `project_with_tasks` · `add_task` | `contractor` writes
`estimate_hours: 3`, which it must not read; expected `applied` | writes
`{ title, project, status, assignee }`; ask drops "3-hour"; post-state
unchanged |
| `project_with_tasks` · `task_without_title` | same hidden write;
expected `VALIDATION_FAILED` | writes `{ project, status, assignee }`;
still `VALIDATION_FAILED` (no title), the `write_validation` guardrail's
operation |
| `travel_expense` · `employee_approves_colleague` →
`employee_approves_own_claim` | `employee` (e02) decides a colleague's
step `as_112_1` (claim EC-0112, outside its row scope); expected
`PERMISSION_DENIED` | decides the manager step `as_098_1` of its own
claim EC-0098, which it can read; still `PERMISSION_DENIED`; ask
"Approve the manager step of my own claim EC-0098 myself, dated today.";
the card's `$comment` says 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.ts` names the renamed operation.
`packages/qa/scenarios/README.md`: the scoring example names
`remove_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_receipt` and the three mid-tier cards that landed today
(`order_to_cash`, `leave_balances`, `asset_lifecycle`) load with no
refusal.
## Tests
At `72472e89` (this branch, with `main` merged at `7d9682ff`):
- `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 #30837 added).
- Ablation (the rule's call site deleted with
`scripts/ablation-replace.mjs`): `refusals.test.ts` fails exactly the
five reach cases; `library.test.ts` and `travel-expense.test.ts` pass.
- `tsc --noEmit -p packages/qa/scenarios`: exit 0. `pnpm
check:nul-bytes`: exit 0. `pnpm check:dispatcher-error-vocabulary`: exit
0.
<details><summary>Ablation: anchor, blobs and restore</summary>
- Anchor ` if (actor !== undefined) checkReach(op, actor, at);\n`, 1
hit, then 0. Blob `057dc19c` to `53539c68`.
- Ran `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.
- Restored: blob equal to HEAD (`057dc19c`), `git diff HEAD` empty.
- The same five-case ablation was read on every head before this one
(`f4757520`, `4ff318eb`, `d1909669`, `b9d68c27`, `1bda3e89`). On those
heads the library still answered the travel refusal, which only the rule
produced.
</details>
## Acceptance notes
### Ruled: `travel_expense` op 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 in
`72472e89`.
Measured with `objectstack dev --fresh --no-watch` on a throwaway app
with the card's shape: `approval_step` rows with the employee's row
scope, the card's agent action `decide_approval_step` as a declarative
update, and persona accounts made with the scenario runner's own
`HttpApp`. Doors: MCP `update_record`, MCP `run_action`, REST `PATCH
/api/v1/data`. The employee's edit grant is not something the card can
state, so three grant models were measured.
| Employee's grant on `approval_step` | colleague's step `as_112_1`:
data update | colleague's step: `run_action` | own claim's step: every
door |
|:---|:---|:---|:---|
| read only (least privilege), on `main` at `c17719dd` | `403
PERMISSION_DENIED` | `404 RECORD_NOT_FOUND` | `PERMISSION_DENIED` |
| read only, on `main` at `7d9682ff` (after #30878) | `403
PERMISSION_DENIED` | `403 PERMISSION_DENIED` | `PERMISSION_DENIED` |
| edit, narrowed to the steps it holds (an update rule `approver ==
current user`) | `404 RECORD_NOT_FOUND` | `404 RECORD_NOT_FOUND` |
`PERMISSION_DENIED` |
| edit with row scope only | `404 RECORD_NOT_FOUND` | `404
RECORD_NOT_FOUND` | applied (the employee approves its own claim, which
the card's prompt rules out) |
A missing id got the same answer as the hidden row in every cell. Acting
as the employee, `get_record` on `as_112_1` answers `RECORD_NOT_FOUND`
under 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 answers `403` or `404` depending on the edit
grant. The employee's own step answers `PERMISSION_DENIED` at 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)
- **A write across row scope is no longer covered by any operation on
this card.** Reads across row scope stay covered by the card's
`permission_parity` guardrail. 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
- What the 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.
- No changeset: `@objectstack/scenarios` is private and `docs/qa/` ships
in no package, so this PR carries `skip-changeset` (as #30801 and #30600
did).
## Verification
<details><summary>Derived gate families at 72472e8: 52 derived, 52 run,
all exit 0</summary>
`node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` at `72472e89` (change set: 7 paths, 109 changed lines (+93,
-16) against merge base `7d9682ff`, 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 at `72472e89`, 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.
`--ran` reconciliation: "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)".
```text
node scripts/check-adr-links.mjs :: exit 0
node scripts/check-adr-links.mjs --self-test :: exit 0
node scripts/check-closing-keyword-parity.mjs :: exit 0
node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0
node scripts/check-comment-mask-adoption.mjs :: exit 0
node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0
node scripts/check-comment-mask-corpus.mjs :: exit 0
node scripts/check-dts-emitted.mjs --self-test :: exit 0
node scripts/check-issue-citations.mjs :: exit 0
node scripts/check-plugin-teardown-shape.mjs :: exit 0
node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0
node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0
node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0
node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0
node scripts/docs-audit/check-affected-docs.mjs :: exit 0
node scripts/docs-audit/check-drift-comment.mjs :: exit 0
pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 0
pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0
pnpm check:ci-filter-parity :: exit 0
pnpm check:closed-ceremony-text :: exit 0
pnpm check:cross-package-test-inputs :: exit 0
pnpm check:dispatcher-error-vocabulary :: exit 0
pnpm check:doc-authoring :: exit 0
pnpm check:driver-memory-census :: exit 0
pnpm check:dts-closure :: exit 0
pnpm check:dual-build-cjs-loads :: exit 0
pnpm check:engine-double-contract :: exit 0
pnpm check:gitlink-declared :: exit 0
pnpm check:issue-citations :: exit 0
pnpm check:keyed-text-bounds :: exit 0
pnpm check:lean-entry-closure :: exit 0
pnpm check:logger-receiver-detach :: exit 0
pnpm check:nul-bytes :: exit 0
pnpm check:objectql-double-limit :: exit 0
pnpm check:org-identifier :: exit 0
pnpm check:page-declaration-shape :: exit 0
pnpm check:platform-object-tenancy-census :: exit 0
pnpm check:published-files :: exit 0
pnpm check:query-options-erasure :: exit 0
pnpm check:refd-timer-probe :: exit 0
pnpm check:registry-log-declared :: exit 0
pnpm check:rest-log-spy-declared :: exit 0
pnpm check:slot-lookup :: exit 0
pnpm check:sourcemap-no-sources-content :: exit 0
pnpm check:system-context-census :: exit 0
pnpm check:test-source-alias :: exit 0
pnpm check:tier-file-adoption :: exit 0
pnpm check:type-check-coverage :: exit 0
pnpm check:type-check-debt :: exit 0
pnpm check:undeclared-dep-imports :: exit 0
pnpm check:watch-hint-literal :: exit 0
pnpm check:where-matcher :: exit 0
```
</details>
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`).
`main` has moved since (`1697d5ca`, with #30903 changing the scenario
runner). A local merge of it into `72472e89` is clean, and `pnpm
--filter @objectstack/scenarios test` passes on it: Test Files 18 passed
(18), Tests 251 passed (251). Nothing from it is pushed.
---
_Generated by [Claude
Code](https://claude.ai/code/session_016WV4cyjYBCnWT8iGQXxoeH)_
<!-- signed: domain:devx#3 · os-elon-musk ·
session_016WV4cz3RTQjLh7hZBLzgtZ -->
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 0e88208 commit 26c965c
7 files changed
Lines changed: 93 additions & 16 deletions
File tree
- docs/qa/scenarios
- packages/qa/scenarios
- src
- test
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
128 | 128 | | |
129 | 129 | | |
130 | 130 | | |
131 | | - | |
132 | | - | |
| 131 | + | |
| 132 | + | |
133 | 133 | | |
134 | 134 | | |
135 | 135 | | |
| |||
163 | 163 | | |
164 | 164 | | |
165 | 165 | | |
166 | | - | |
167 | | - | |
| 166 | + | |
| 167 | + | |
168 | 168 | | |
169 | 169 | | |
170 | 170 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
111 | 111 | | |
112 | 112 | | |
113 | 113 | | |
114 | | - | |
115 | | - | |
| 114 | + | |
| 115 | + | |
116 | 116 | | |
117 | 117 | | |
118 | 118 | | |
| |||
146 | 146 | | |
147 | 147 | | |
148 | 148 | | |
149 | | - | |
150 | | - | |
| 149 | + | |
| 150 | + | |
151 | 151 | | |
152 | 152 | | |
153 | 153 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | | - | |
| 2 | + | |
3 | 3 | | |
4 | 4 | | |
5 | 5 | | |
| |||
1042 | 1042 | | |
1043 | 1043 | | |
1044 | 1044 | | |
1045 | | - | |
| 1045 | + | |
1046 | 1046 | | |
1047 | 1047 | | |
1048 | | - | |
1049 | | - | |
| 1048 | + | |
| 1049 | + | |
1050 | 1050 | | |
1051 | 1051 | | |
1052 | 1052 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
31 | 31 | | |
32 | 32 | | |
33 | 33 | | |
34 | | - | |
| 34 | + | |
35 | 35 | | |
36 | 36 | | |
37 | 37 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
25 | 25 | | |
26 | 26 | | |
27 | 27 | | |
| 28 | + | |
| 29 | + | |
28 | 30 | | |
29 | 31 | | |
30 | 32 | | |
| |||
170 | 172 | | |
171 | 173 | | |
172 | 174 | | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
173 | 197 | | |
174 | 198 | | |
175 | 199 | | |
| |||
338 | 362 | | |
339 | 363 | | |
340 | 364 | | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
341 | 371 | | |
342 | 372 | | |
343 | 373 | | |
| |||
419 | 449 | | |
420 | 450 | | |
421 | 451 | | |
422 | | - | |
423 | | - | |
| 452 | + | |
424 | 453 | | |
425 | 454 | | |
426 | 455 | | |
| |||
432 | 461 | | |
433 | 462 | | |
434 | 463 | | |
| 464 | + | |
| 465 | + | |
| 466 | + | |
| 467 | + | |
| 468 | + | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
| 472 | + | |
| 473 | + | |
| 474 | + | |
| 475 | + | |
| 476 | + | |
| 477 | + | |
| 478 | + | |
| 479 | + | |
| 480 | + | |
| 481 | + | |
| 482 | + | |
| 483 | + | |
| 484 | + | |
| 485 | + | |
| 486 | + | |
| 487 | + | |
| 488 | + | |
| 489 | + | |
| 490 | + | |
435 | 491 | | |
436 | 492 | | |
437 | 493 | | |
| |||
446 | 502 | | |
447 | 503 | | |
448 | 504 | | |
| 505 | + | |
| 506 | + | |
449 | 507 | | |
450 | 508 | | |
451 | 509 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
13 | 13 | | |
14 | 14 | | |
15 | 15 | | |
| 16 | + | |
| 17 | + | |
16 | 18 | | |
17 | 19 | | |
18 | 20 | | |
| |||
87 | 89 | | |
88 | 90 | | |
89 | 91 | | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
90 | 98 | | |
91 | 99 | | |
92 | 100 | | |
| |||
114 | 122 | | |
115 | 123 | | |
116 | 124 | | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
117 | 136 | | |
118 | 137 | | |
119 | 138 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
162 | 162 | | |
163 | 163 | | |
164 | 164 | | |
165 | | - | |
| 165 | + | |
166 | 166 | | |
167 | 167 | | |
168 | 168 | | |
| |||
0 commit comments