Repository navigation
Commit eddc3ab
fix(scenarios): the seed owns a row only by a person the row names, and fills each persona record’s user (#31309)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"}
-->
_Requested by **Leehom** · [project
thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X3qrKgMTXcji1SpNaYQq7KK)_
Fixes #30913
Clause-②: yes (widening) — `@objectstack/scenarios/runner` gains two
exports, `personaRecords` (the record binding) and `rowOwner` (the
persona a seed row is owned by). `personaHandles` keeps its signature
and is now the handle map only (a value from a direct scope rule on a
declared user-handle field); it no longer holds row ids or option values
from other scope rules. On the three control cards its answer is
unchanged. `seedApp` sets `owner_id` by the ruled rule and fills each
persona record's `user` field.
Metadata-protocol: none
## 维护者速读
- 改了什么:场景跑测的种子行属主(`owner_id`)只由"这一行指向的那个人"决定:行本身就是某角色的
`record`、行上已声明的查找字段指向该 `record`,或用户句柄字段写着该角色的句柄;同一行指向多人时按 `run.personas`
顺序取第一个。每个角色的人员行会把该角色的用户 id 写进它的 `user` 字段。构建提示的"Row
ownership"一节重写,加一行平台边界说明(开源版没有层级解析器)。
- 为什么改:原来任何直接范围值都会认领属主,`service_desk` 上 237 条公开评论全归门户客户(其中 197
条是别的客户的工单),按 `group` 把 78 张工单给了组长,权限题因此在给卡片没要求的归属打分。这是维护者 2026-11-07
13:34 对 #30913 的裁决 Q1=A′ / Q2=C 的跑测器那一半。
- 风险与回滚:只动 `@objectstack/scenarios`(QA
工具包)。三张对照卡(crm、invoice、project)种子属主前后完全相同;其余六张按新规则变化,前后对照见下表。回滚即 revert
本 PR。
-
先看:`packages/qa/scenarios/src/runner/prompts.ts`(`rowOwner`、`personaRecords`、`rowOwnership`)、`packages/qa/scenarios/src/runner/orchestrate.ts`(种子循环)、`packages/qa/scenarios/test/runner/orchestrate.test.ts`(service_desk
钉子)。
- 两分钟验证:`pnpm --filter @objectstack/scenarios exec vitest run
test/runner/orchestrate.test.ts -t service_desk`,预期 5 个用例全绿,其中"no public
comment is the portal customer's"一条断言门户客户拥有 0 条评论。
- 需要你做的:无,席位落地。
## What changes
**Owner rule (`rowOwner`, `src/runner/prompts.ts`).** A seed row is
owned by a persona's user only when the row names that person:
- it IS the persona's `record` (`customer` `cus_03`);
- a declared direct lookup on it (`expect.lookups`, target = the
record's object) holds the record's id (`ticket.assignee` = `a02`,
`ticket_comment.author` = `a02`, `department.manager` = `e13`);
- a declared user-handle field on it holds the persona's handle
(`account.owner` = `u_sam`, the control cards' route, kept).
The first persona in `run.personas` order the row names owns it. An
option value (`ticket_comment.visibility` = `public`), a row id scope
(`department.id` = `dep_sales`) and a lookup to a row no persona is
(`ticket.group`, `employee.department`) name nobody, so those rows keep
the inserter, the admin.
**Handle map and record binding.** `personaHandles` is now the handle
map only, so `probeValues` and `unheldHandles` keep their meaning;
`personaRecords` is the record binding (`object/id` → persona, first in
order). `personLookups` and `rowOwnership` are module-private.
**Seed loop (`seedApp`, `src/runner/orchestrate.ts`).** Handle fields
are rewritten exactly as before (`seed.rewritten`, `handlesAsAdmin`
unchanged). Then each persona's record row gets that persona's user id
in its `user` field (`PERSON_USER_FIELD`), and `owner_id` is set from
`rowOwner`. With no persona accounts (the gallery's screenshot boot) no
`user` and no record-bound owner is set.
**Build prompt (`rowOwnership`).** The Row ownership section now says:
which seed rows are persons; that the runner loads each one's `user`
field, so the build gives each person object an optional `user`-type
field named `user`; which lookups name a person; the persona order for a
row naming two; that every other row is the admin's; and one
platform-boundary line (the open-source server has no hierarchy
resolver: `plugin-sharing` registers none, so hierarchy read scopes fail
closed to own, `sharing-plugin.ts` "Open edition: not registered →
hierarchy scopes fail closed to own"), so a person who must see rows
another person owns gets them through what the build models (a read
policy, a parent-controlled child, a field naming them). The old line
that listed `public` = `portal_customer` is gone.
**Docs.** `packages/qa/scenarios/README.md` (Persona contract) and
`docs/qa/scenarios/README.md` (the `record` paragraph) describe the
rule. One `patch` changeset.
### Seed owners before and after, all nine cards
Measured by replaying `seedApp` over each namespaced card with one
account per persona (a fake app that keeps what it is sent), on base
`7c523aa1` and on this head. Counts are rows per object per owning
persona; rows with no `owner_id` are left out.
| Card | Before | After |
|:--|:--|:--|
| `asset_lifecycle` | department dept_manager 1; employee dept_manager
4; asset dept_manager 28, employee 4; checkout employee 7; repair_order
employee 2; disposal_request employee 1 | department dept_manager 1;
employee dept_manager 1, employee 1; asset dept_manager 4, employee 4;
checkout dept_manager 5, employee 7; repair_order employee 2,
dept_manager 1; disposal_request dept_manager 3, employee 1 |
| `crm_account_with_contacts` | account sdr 2, admin 1 | same |
| `invoice_with_line_items` | invoice sales_rep 2, admin 1 | same |
| `leave_balances` | employee dept_manager 4; leave_balance employee 2;
leave_request employee 10 | department dept_manager 1; employee
dept_manager 1, employee 1; leave_balance dept_manager 2, employee 2;
leave_request dept_manager 10, employee 10; approval_step dept_manager
28 |
| `order_to_cash` | account sales_rep 2; sales_order sales_rep 20 |
employee sales_manager 3, sales_rep 1; account sales_rep 2; sales_order
sales_rep 20; activity sales_rep 8 |
| `project_with_tasks` | task contractor 3, admin 3 | same |
| `requisition_to_receipt` | department dept_manager 1;
purchase_requisition requester 10 | department dept_manager 1; employee
requester 1, dept_manager 1; purchase_requisition requester 10;
approval_step dept_manager 36; goods_receipt requester 10 |
| `service_desk` | customer portal_customer 1; agent team_lead 4; ticket
support_agent 15, team_lead 78, portal_customer 5; ticket_comment
portal_customer 237 | customer portal_customer 1; agent_group team_lead
1; agent support_agent 1, team_lead 1; ticket support_agent 15,
portal_customer 15; ticket_comment support_agent 15 |
| `travel_expense` | department dept_manager 1; trip_request employee
12; expense_claim employee 10 | department dept_manager 1; employee
dept_manager 1, employee 1; trip_request dept_manager 10, employee 12;
expense_claim dept_manager 10, employee 10; approval_step dept_manager
27 |
The new owners all come from a lookup to a person row
(`approval_step.approver`, `checkout.employee`, `employee.manager`,
`goods_receipt.received_by`, `activity.owner`), the person rows
themselves, or a manager's own requests (`leave_request.employee` =
`e05`).
## Tests
- `test/runner/orchestrate.test.ts`, new `seedApp — the service_desk
seed owns a row only by a person it names`: no public comment is the
portal customer's (was 237), comments are owned only through `author`
(15 to support_agent); t009 (assignee a02 + customer cus_03) goes to
support_agent by persona order, t008 (group grp_tech + cus_03) to
portal_customer; team_lead owns no ticket through `group` (was 78);
`cus_03`, `a02`, `a04` carry owner and `user` = their persona's user,
and no other row carries `user`; with no accounts nothing is owned; on
`requisition_to_receipt`, `dep_sales` is the dept_manager's (through
`manager` = e13) and only the two person rows among its employees are
owned.
- `test/runner/units.test.ts`: `personaHandles` holds only `u_sam` on
crm and nothing on service_desk; `personaRecords` on service_desk;
`rowOwner` cases (record, lookup, two persons, option, group, handle,
unheld handle); the Row ownership section on service_desk names the
person rows, the `user` field, the person lookups, the order and the
platform boundary, and no `public` or `grp_tech`; a card with no person
bound has no section. One existing pin moved with the rewritten text
(`u_sam` = `sdr` → `u_sam` → the `sdr` user).
**Ablations** (`scripts/ablation-replace.mjs` on committed HEAD
`dba2b955`; the tests import `src/` directly, so no `dist/` leg). Each:
mutation landed (anchor 1 → 0, blob changed), suites red, restored to
the HEAD blob, `git diff HEAD` empty.
| Leg | Mutation | Red |
|:--|:--|:--|
| A1 | `rowOwner` takes any direct scope value again (the handle-field
condition replaced by a marker) | 4 failed: the public-comment pin, the
two-persona/group pin, the person-row pin, the `rowOwner` unit cases |
| A2 | the record's `user` field write deleted | 1 failed: the
person-row pin |
## Acceptance notes
- **"The 15 tickets follow the persona order."** The ruling's item 4
says service_desk's 15 two-persona tickets go to support_agent. Its item
3 (a group is not a person) decides first: of those 15, 10 name
team_lead only through `group` = `grp_tech` and portal_customer through
`customer` = `cus_03`, so they name one person and go to
portal_customer; the other 5 name support_agent (`assignee` = `a02`) and
portal_customer, and go to support_agent by persona order. Item 3 is
applied as written; the tests pin both halves (t008, t009).
- **Which lookups name a person.** A lookup declared in `expect.lookups`
whose target is the object of some persona's `record`, the
declared-never-inferred rule #30914 set for scope fields. An undeclared
seed field holding a record id names nobody.
- **Global personas** carry no `record` on the nine cards, so they own
nothing beyond the handle route; the rule reads a `record` on any
persona if a card declares one.
- **No `user` without accounts.** The gallery's screenshot boot passes
no persona accounts, so no `user` field is set; the prompt asks for the
field as optional because every non-person row of that object carries
none.
- **Clause-② reading.** The claim said `no` unless the diff adds an
export; it adds `personaRecords` and `rowOwner` to `./runner` (it
re-exports `prompts.ts`), so this PR declares `yes (widening)`.
`personaHandles` returns a smaller map on six cards; that is a changed
answer of an exported function, not a refused input or a compile break,
so no `(narrowing)` arm is declared. The seat's contract review reads
it.
- Grading is unchanged (Q2 = C): no scorer file moves.
## Verification
- Head `dba2b955`, base `7c523aa1`. `origin/main` has moved three
commits since (`7fe019f4`, `007fdb1a`, `0f254f47`), none in
`packages/qa/scenarios`.
- Dependency closure: `pnpm --filter "@objectstack/scenarios..." build`
under the lock, VERDICT command-exit 0.
- `pnpm --filter @objectstack/scenarios build && pnpm --filter
@objectstack/scenarios exec vitest run --maxWorkers=2` under the lock:
Test Files 30/30, Tests 599/599, VERDICT command-exit 0.
- `pnpm --filter @objectstack/scenarios typecheck` (`tsc --noEmit`, its
program includes `test/**`): exit 0.
- `node --stack-size=4000 node_modules/eslint/bin/eslint.js
packages/qa/scenarios --no-inline-config` (the root `pnpm lint`, scoped;
the package has no lint script): exit 0.
- **Not measured:** a live model run (paused by the maintainer) and a
recorded-driver replay (no recording in this container); the seed is
pinned through `seedApp` over the real `service_desk` and
`requisition_to_receipt` cards with a fake app.
<details><summary>Derived gates (dispatch-gates --commands, 63
commands)</summary>
```text
node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0
node scripts/check-adr-0087-registration.mjs --self-test :: exit 0
node scripts/check-adr-links.mjs :: exit 0
node scripts/check-adr-links.mjs --self-test :: exit 0
node scripts/check-changeset-no-major.mjs --base origin/main :: 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-empty-changeset.mjs --base origin/main :: exit 0
node scripts/check-empty-changeset.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/ci/snapshot-next.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
node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0
node scripts/release-pending-publish.mjs --self-test :: 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:changeset-gate-self-tests :: exit 0
pnpm check:ci-filter-parity :: exit 0
pnpm check:cli-command-ids :: exit 0
pnpm check:closed-ceremony-text :: exit 0
pnpm check:cross-package-test-inputs :: 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 3
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:optional-error-sink :: 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:vouch-for-reference-callers :: exit 0
pnpm check:watch-hint-literal :: exit 0
pnpm check:where-matcher :: exit 0
```
- `dispatch-gates --ran`: "63 derived famil(ies) accounted for — 62 run,
1 NOT-MEASURED (1 DERIVED from a recorded exit 3)".
- NOT MEASURED: `pnpm check:dual-build-cjs-loads`, exit 3 PREREQUISITE
NOT MET (it loads every package's `dist/`; only the scenarios closure
was built). CI's lint job runs it on a full build.
- `check-plugin-teardown-shape.mjs --self-test` ran after `git fetch
--depth=1 origin 621a487…` (the shallow-clone prerequisite).
- `check:type-check-debt` re-measured under the lock (342 s): "0 raw tsc
error(s) total, none above its recorded number".
</details>
<details><summary>Outside the derived set (artifact-roster /
declared-wide rows this diff could touch)</summary>
```text
pnpm --filter @objectstack/spec run check:error-code-provenance :: exit 0
node scripts/check-platform-checklist-watchdog.mjs :: exit 0
node scripts/check-published-list-mirrors.mjs :: exit 0
pnpm check:published-readme-exports :: exit 0
pnpm check:changeset-fixed :: exit 0
```
- `check:error-code-provenance` exit 0: the diff adds no error-code
constant.
</details>
Every CI family is read from this PR's check-runs, not re-run here.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01FEkQ5mHhWW8Wwnm4Sn6oSV)_
Co-authored-by: Claude <noreply@anthropic.com>1 parent 12ef21a commit eddc3ab
7 files changed
Lines changed: 291 additions & 56 deletions
File tree
- .changeset
- docs/qa/scenarios
- packages/qa/scenarios
- src/runner
- test/runner
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
175 | 175 | | |
176 | 176 | | |
177 | 177 | | |
178 | | - | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
179 | 185 | | |
180 | 186 | | |
181 | 187 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
253 | 253 | | |
254 | 254 | | |
255 | 255 | | |
256 | | - | |
257 | | - | |
258 | | - | |
259 | | - | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
260 | 269 | | |
261 | 270 | | |
262 | 271 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
32 | 32 | | |
33 | 33 | | |
34 | 34 | | |
35 | | - | |
| 35 | + | |
36 | 36 | | |
37 | 37 | | |
38 | 38 | | |
| |||
48 | 48 | | |
49 | 49 | | |
50 | 50 | | |
51 | | - | |
| 51 | + | |
52 | 52 | | |
53 | 53 | | |
54 | 54 | | |
| |||
520 | 520 | | |
521 | 521 | | |
522 | 522 | | |
523 | | - | |
524 | | - | |
525 | | - | |
526 | | - | |
527 | | - | |
528 | | - | |
529 | | - | |
530 | | - | |
| 523 | + | |
| 524 | + | |
| 525 | + | |
| 526 | + | |
| 527 | + | |
| 528 | + | |
| 529 | + | |
| 530 | + | |
| 531 | + | |
| 532 | + | |
| 533 | + | |
| 534 | + | |
| 535 | + | |
| 536 | + | |
| 537 | + | |
531 | 538 | | |
532 | 539 | | |
533 | 540 | | |
| |||
546 | 553 | | |
547 | 554 | | |
548 | 555 | | |
549 | | - | |
| 556 | + | |
550 | 557 | | |
| 558 | + | |
551 | 559 | | |
552 | 560 | | |
553 | 561 | | |
| |||
561 | 569 | | |
562 | 570 | | |
563 | 571 | | |
564 | | - | |
565 | | - | |
566 | | - | |
567 | | - | |
568 | | - | |
569 | | - | |
570 | | - | |
571 | | - | |
572 | | - | |
573 | | - | |
574 | | - | |
| 572 | + | |
| 573 | + | |
| 574 | + | |
| 575 | + | |
| 576 | + | |
| 577 | + | |
| 578 | + | |
575 | 579 | | |
| 580 | + | |
| 581 | + | |
| 582 | + | |
| 583 | + | |
576 | 584 | | |
577 | 585 | | |
578 | 586 | | |
| |||
0 commit comments