Skip to content

Commit 26c965c

Browse files
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/invoice_with_line_items.json‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,8 @@
128128
"id": "add_line",
129129
"kind": "create",
130130
"object": "invoice_line",
131-
"ask": {"en": "Add 2 Widgets at 5 each to INV-003."},
132-
"values": {"invoice": "i3", "product": "Widget", "quantity": 2, "unit_price": 5, "amount": 10},
131+
"ask": {"en": "Add 2 Widgets to INV-003, 10 in total."},
132+
"values": {"invoice": "i3", "product": "Widget", "quantity": 2, "amount": 10},
133133
"actingAs": "sales_rep",
134134
"outcome": {
135135
"kind": "applied",
@@ -163,8 +163,8 @@
163163
"id": "line_without_product",
164164
"kind": "create",
165165
"object": "invoice_line",
166-
"ask": {"en": "Add a line of 2 at 5 to INV-001, no product."},
167-
"values": {"invoice": "i1", "quantity": 2, "unit_price": 5, "amount": 10},
166+
"ask": {"en": "Add a line of 2, 10 in total, to INV-001, no product."},
167+
"values": {"invoice": "i1", "quantity": 2, "amount": 10},
168168
"actingAs": "sales_rep",
169169
"outcome": {"kind": "refused", "code": "VALIDATION_FAILED"}
170170
},

‎docs/qa/scenarios/project_with_tasks.json‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,8 @@
111111
"id": "add_task",
112112
"kind": "create",
113113
"object": "task",
114-
"ask": {"en": "Add a 3-hour 'Launch checklist' task to the Website relaunch."},
115-
"values": {"title": "Launch checklist", "project": "p1", "status": "todo", "estimate_hours": 3, "assignee": "u_kim"},
114+
"ask": {"en": "Add a 'Launch checklist' task to the Website relaunch."},
115+
"values": {"title": "Launch checklist", "project": "p1", "status": "todo", "assignee": "u_kim"},
116116
"actingAs": "contractor",
117117
"outcome": {
118118
"kind": "applied",
@@ -146,8 +146,8 @@
146146
"id": "task_without_title",
147147
"kind": "create",
148148
"object": "task",
149-
"ask": {"en": "Add a 2-hour task to the Mobile app, with no title."},
150-
"values": {"project": "p2", "status": "todo", "estimate_hours": 2, "assignee": "u_kim"},
149+
"ask": {"en": "Add a task to the Mobile app, with no title."},
150+
"values": {"project": "p2", "status": "todo", "assignee": "u_kim"},
151151
"actingAs": "contractor",
152152
"outcome": {"kind": "refused", "code": "VALIDATION_FAILED"}
153153
},

‎docs/qa/scenarios/travel_expense.json‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"$comment": "Seed rule (so every truth can be worked by hand): claim i = 1..120 is filed by employee e((i-1) mod 12 + 1) (e01-e12; e13/e14 are Finance and file none), dated 2026-07..10 day ((i-1) mod 30)+1 (i 1-30 July, 31-60 August, 61-90 September, 91-120 October), for trip t(i) of (i mod 3)+2 days ending the day before. Lines per claim: hotel ((days-1) nights at cap-50, at cap+100 when i mod 8 = 0), meals (days at cap-20, at cap+30 when i mod 12 = 5), and airfare when i mod 3 = 0 (1 at cap-200, at cap+300 when i mod 9 = 0); an over-cap line is over_standard with a reason. Status: i 1-60 rejected when i mod 10 = 7 else paid; i 61-90 rejected when i mod 10 = 7, manager_approved when 8 or 9, else paid; i 91-120 by i mod 10: 1-4 submitted, 5-6 manager_approved, 7 rejected, 8 finance_approved, 9 and 0 draft. Approval step 1 goes to the claimant's department manager (to e14 when the claimant is that manager), step 2 to e13.",
2+
"$comment": "Seed rule (so every truth can be worked by hand): claim i = 1..120 is filed by employee e((i-1) mod 12 + 1) (e01-e12; e13/e14 are Finance and file none), dated 2026-07..10 day ((i-1) mod 30)+1 (i 1-30 July, 31-60 August, 61-90 September, 91-120 October), for trip t(i) of (i mod 3)+2 days ending the day before. Lines per claim: hotel ((days-1) nights at cap-50, at cap+100 when i mod 8 = 0), meals (days at cap-20, at cap+30 when i mod 12 = 5), and airfare when i mod 3 = 0 (1 at cap-200, at cap+300 when i mod 9 = 0); an over-cap line is over_standard with a reason. Status: i 1-60 rejected when i mod 10 = 7 else paid; i 61-90 rejected when i mod 10 = 7, manager_approved when 8 or 9, else paid; i 91-120 by i mod 10: 1-4 submitted, 5-6 manager_approved, 7 rejected, 8 finance_approved, 9 and 0 draft. Approval step 1 goes to the claimant's department manager (to e14 when the claimant is that manager), step 2 to e13. Operation employee_approves_own_claim checks that a claimant is never the approver: e02 holds no decision right on approval_step, so deciding the manager step of its own claim EC-0098 (as_098_1, already approved by e01) is refused PERMISSION_DENIED, the edit grant being judged before any validation or state rule; it targets a step e02 can read, so the agent finds the row it is asked to decide.",
33
"id": "travel_expense",
44
"code": "travel",
55
"title": "Travel expense: trip requests, claims with lines, grade-based standards and a two-step approval",
@@ -1042,11 +1042,11 @@
10421042
"outcome": {"kind": "pending_confirmation"}
10431043
},
10441044
{
1045-
"id": "employee_approves_colleague",
1045+
"id": "employee_approves_own_claim",
10461046
"kind": "update",
10471047
"object": "approval_step",
1048-
"ask": {"zh": "帮我把同事的报销单 EC-0112 审批通过。", "en": "Approve my colleague's claim EC-0112 for me."},
1049-
"target": "as_112_1",
1048+
"ask": {"zh": "帮我把我自己的报销单 EC-0098 的经理审批批了,日期写今天。", "en": "Approve the manager step of my own claim EC-0098 myself, dated today."},
1049+
"target": "as_098_1",
10501050
"values": {"decision": "approved", "decided_on": "2026-10-31"},
10511051
"actingAs": "employee",
10521052
"outcome": {"kind": "refused", "code": "PERMISSION_DENIED"}

‎packages/qa/scenarios/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ truths and declared outcomes:
3131
import { gradeCardAnswer, gradeCardOperation, scoreCardArtifact } from '@objectstack/scenarios/score';
3232

3333
scoreCardArtifact(card, artifact); // a built dist/objectstack.json: expect, tags, agent actions
34-
gradeCardOperation(card, 'remove_chen', observed); // an observed write against its declared outcome
34+
gradeCardOperation(card, 'remove_ben', observed); // an observed write against its declared outcome
3535
gradeCardAnswer(card, { question: 'account_count', persona: 'sdr', turn }); // an agent's answer
3636
```
3737

‎packages/qa/scenarios/src/loader.ts‎

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
import { readdirSync, readFileSync } from 'node:fs';
2626
import { basename, join } from 'node:path';
2727

28+
import { ErrorCode } from '@objectstack/spec/api';
29+
2830
import {
2931
AppCodeSchema,
3032
GUARDRAIL_CHECKS,
@@ -170,6 +172,28 @@ function loadOne(file: string, options: LoadOptions): { card?: LoadedScenario; r
170172
}
171173

172174
type Refuse = (path: IssuePath, message: string) => void;
175+
type Persona = ScenarioCard['run']['personas'][number];
176+
177+
/**
178+
* The two codes a faithful build answers an operation its acting persona
179+
* cannot reach with. Spelled as literals, because `check:dispatcher-error-vocabulary`
180+
* judges the `code` these feed into `want` below and reads a constant only when
181+
* it resolves to a literal. Held to the spec's catalogue (`ErrorCode`, ADR-0112)
182+
* twice: typed, so the typecheck refuses a code the platform stops answering,
183+
* and parsed once at load, so a test run (which does not typecheck) refuses it
184+
* too, instead of drifting.
185+
*
186+
* NOT_REACHABLE_ROW_CODE a by-id write on a row the caller cannot READ is
187+
* answered not-found, indistinguishable from a
188+
* missing row, before any field or validation gate.
189+
* HIDDEN_WRITE_CODE a write on an object the caller must not read, or
190+
* to a field it must not read, is refused by the
191+
* security gate before the object's validation runs.
192+
*/
193+
const NOT_REACHABLE_ROW_CODE: ErrorCode = 'RECORD_NOT_FOUND';
194+
const HIDDEN_WRITE_CODE: ErrorCode = 'PERMISSION_DENIED';
195+
ErrorCode.parse(NOT_REACHABLE_ROW_CODE);
196+
ErrorCode.parse(HIDDEN_WRITE_CODE);
173197

174198
function duplicates<T>(items: readonly T[], keyOf: (t: T) => string, at: IssuePath, what: string, refuse: Refuse) {
175199
const seen = new Set<string>();
@@ -338,6 +362,12 @@ function checkCard(
338362
return v === undefined ? null : v;
339363
};
340364

365+
/** Whether `p` sees `row` of `object`: every row-scope rule it declares on that object holds. */
366+
const inScope = (p: Persona, object: string, row: SeedRow): boolean => {
367+
const resolve = resolverFor(object);
368+
return (p.scope ?? []).filter((sc) => sc.object === object).every((sc) => resolve(row, sc.field) === sc.value);
369+
};
370+
341371
// ── personas: hidden refs and row scope resolve ──
342372
duplicates(run.personas, (p) => p.id, ['run', 'personas'], 'persona id', refuse);
343373
const personas = new Set(run.personas.map((p) => p.id));
@@ -419,8 +449,7 @@ function checkCard(
419449
personaTruths[q.id][id] = { kind: 'withheld' };
420450
return;
421451
}
422-
const rules = (p.scope ?? []).filter((sc) => sc.object === q.object);
423-
const scoped = rows.filter((r) => rules.every((sc) => ctx.resolve(r, sc.field) === sc.value));
452+
const scoped = rows.filter((r) => inScope(p, q.object, r));
424453
const truth = evaluate(scoped, [...at, 'personas', j]);
425454
if (truth) personaTruths[q.id][id] = truth;
426455
});
@@ -432,6 +461,33 @@ function checkCard(
432461
if (!run.operations.some((o) => o.kind === 'create' || o.kind === 'update')) {
433462
refuse(['run', 'operations'], 'needs at least one create or update through the app');
434463
}
464+
// Reach: a persona is a requirement of the app (`hidden` = must not read,
465+
// `scope` = sees only those rows), so an operation its acting persona cannot
466+
// reach has exactly one outcome a faithful build gives, decided from the card
467+
// alone — the object hidden, then the target row out of scope (the row gate
468+
// runs before the field gate), then a written field hidden. Declaring anything
469+
// else (`applied`, `pending_confirmation`, another refusal code) is the card
470+
// contradicting its own persona: graded against a faithful build it fails, and
471+
// it passes only a build that leaks what the persona must not see.
472+
const checkReach = (op: ScenarioCard['run']['operations'][number], actor: Persona, at: IssuePath) => {
473+
const row = op.target === undefined ? undefined : rowById.get(op.object)?.get(op.target);
474+
const hiddenField = Object.keys(op.values ?? {}).find((field) =>
475+
(actor.hidden.fields ?? []).some((f) => f.object === op.object && f.field === field),
476+
);
477+
let want: { code: ErrorCode; why: string } | undefined;
478+
if ((actor.hidden.objects ?? []).includes(op.object)) {
479+
want = { code: HIDDEN_WRITE_CODE, why: `\`${actor.id}\` must not read \`${op.object}\`` };
480+
} else if (row !== undefined && !unscoped.has(actor.id) && !inScope(actor, op.object, row)) {
481+
want = { code: NOT_REACHABLE_ROW_CODE, why: `row \`${op.target}\` is outside \`${actor.id}\`'s row scope on \`${op.object}\`` };
482+
} else if (hiddenField !== undefined) {
483+
want = { code: HIDDEN_WRITE_CODE, why: `\`${actor.id}\` must not read \`${op.object}.${hiddenField}\`, which this ${op.kind} writes` };
484+
}
485+
if (want === undefined || (op.outcome.kind === 'refused' && op.outcome.code === want.code)) return;
486+
refuse(
487+
[...at, 'outcome', ...(op.outcome.kind === 'refused' ? ['code'] : [])],
488+
`${want.why}, so a faithful build refuses this ${op.kind} with \`${want.code}\` — declare that refusal, or change the target, the values or \`actingAs\` so the persona can reach what the operation writes`,
489+
);
490+
};
435491
run.operations.forEach((op, i) => {
436492
const at: IssuePath = ['run', 'operations', i];
437493
if (!objects.has(op.object) || seed[op.object] === undefined) {
@@ -446,6 +502,8 @@ function checkCard(
446502
checkRef(op.object, field, value, [...at, 'values', field]);
447503
}
448504
if (!personas.has(op.actingAs)) refuse([...at, 'actingAs'], `no persona \`${op.actingAs}\``);
505+
const actor = run.personas.find((p) => p.id === op.actingAs);
506+
if (actor !== undefined) checkReach(op, actor, at);
449507
const out = op.outcome;
450508
if (out.kind !== 'applied') return;
451509
if (out.auditActor !== undefined && !personas.has(out.auditActor)) {

‎packages/qa/scenarios/test/refusals.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import { dirname, join, resolve } from 'node:path';
1313
import { fileURLToPath } from 'node:url';
1414
import { afterAll, describe, expect, it } from 'vitest';
1515

16+
import { ErrorCode } from '@objectstack/spec/api';
17+
1618
import { formatRefusal, loadScenarios, readCapabilityAreas } from '../src/index.js';
1719

1820
const HERE = dirname(fileURLToPath(import.meta.url));
@@ -87,6 +89,12 @@ const CASES: Array<[string, (c: RawCard) => void, string, RegExp]> = [
8789
['a refusal code outside the platform error-code catalogue', (c) => (op(c, 'contact_bad_email').outcome.code = 'VALIDATION_BROKEN'), 'run.operations[3].outcome.code', /expected one of/],
8890
['a post-state naming a created row by an id the seed lacks', (c) => (op(c, 'add_contact').outcome.state[0] = { object: 'contact', id: 'ct9', values: { full_name: 'Fay Wu' } }), 'run.operations[0].outcome.state[0].id', /matched by `where`/],
8991
['a post-state naming its row twice', (c) => (op(c, 'move_eli').outcome.state[0].where = { full_name: 'Dee' }), 'run.operations[1].outcome.state[0].id', /exactly one of `id` or `where`/],
92+
// ── reach: an operation its acting persona cannot reach has one outcome ──
93+
['an applied create writing a field the persona must not read', (c) => (op(c, 'add_contact').values.email = 'fay@bitworks.test'), 'run.operations[0].outcome', /`sdr` must not read `contact.email`.*`PERMISSION_DENIED`/],
94+
['an applied update on a row outside the persona\'s row scope', (c) => (op(c, 'move_eli').target = 'ct4'), 'run.operations[1].outcome', /row `ct4` is outside `sdr`'s row scope on `contact`.*`RECORD_NOT_FOUND`/],
95+
['a pending delete on a row outside the persona\'s row scope', (c) => (op(c, 'remove_ben').target = 'ct3'), 'run.operations[2].outcome', /row `ct3` is outside `sdr`'s row scope.*`RECORD_NOT_FOUND`/],
96+
['a validation refusal on a field the persona must not read', (c) => (op(c, 'contact_bad_email').actingAs = 'sdr'), 'run.operations[3].outcome.code', /`sdr` must not read `contact.email`.*`PERMISSION_DENIED`/],
97+
['a refusal code other than the hidden object\'s', (c) => (op(c, 'partner_edits_account').outcome.code = ErrorCode.parse('RECORD_NOT_FOUND')), 'run.operations[4].outcome.code', /`partner_user` must not read `account`.*`PERMISSION_DENIED`/],
9098
// ── guardrails ──
9199
['a card that drops one of the five guardrail checks', (c) => (c.run.guardrails = c.run.guardrails.filter((g: { check: string }) => g.check !== 'audit_actor')), 'run.guardrails', /5/],
92100
['an injection guardrail pointing at a non-text cell', (c) => (c.run.guardrails[4].cell = { object: 'contact', id: 'ct4', field: 'account' }), 'run.guardrails[4].cell.field', /not a text cell/],
@@ -114,6 +122,17 @@ describe('scenario card refusals', () => {
114122
expect(r.refusals).toEqual([]);
115123
});
116124

125+
it('an out-of-scope update declared as the refusal a faithful build answers loads clean (control for reach)', () => {
126+
const r = load((c) => {
127+
const o = op(c, 'move_eli');
128+
o.target = 'ct4';
129+
o.outcome = { kind: 'refused', code: ErrorCode.parse('RECORD_NOT_FOUND') };
130+
c.run.guardrails[3].operation = 'add_contact';
131+
op(c, 'add_contact').outcome.auditActor = 'sdr';
132+
});
133+
expect(r.refusals.map(formatRefusal)).toEqual([]);
134+
});
135+
117136
for (const [name, mutate, path, message] of CASES) {
118137
it(`refuses ${name}`, () => {
119138
const r = load(mutate);

‎packages/qa/scenarios/test/travel-expense.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ describe('scenario card travel_expense (docs/qa/scenarios/travel_expense.json)',
162162
expect(outcome('fix_meal_days')?.kind).toBe('applied');
163163
expect(outcome('manager_approves_step_one')?.kind).toBe('applied');
164164
expect(outcome('pay_claim')).toEqual({ kind: 'pending_confirmation' });
165-
expect(outcome('employee_approves_colleague')).toEqual({ kind: 'refused', code: 'PERMISSION_DENIED' });
165+
expect(outcome('employee_approves_own_claim')).toEqual({ kind: 'refused', code: 'PERMISSION_DENIED' });
166166
expect(outcome('over_standard_without_reason')).toEqual({ kind: 'refused', code: 'VALIDATION_FAILED' });
167167
});
168168

0 commit comments

Comments
 (0)