Skip to content

Commit eddc3ab

Browse files
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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/scenarios': patch
3+
---
4+
5+
fix(scenarios): the scenario runner's seed owns a row only by a person the row names, and loads each persona's person row with its user
6+
7+
Clause-②: yes (widening) — `./runner` gains two exports, `personaRecords` (the record binding: each persona's `record` row, `object/id` → persona) and `rowOwner` (the persona a seed row is owned by). `personaHandles` keeps its signature and is now the handle map only: it holds a value from a direct scope rule on a declared user-handle field (`account.owner` = `u_sam`), no longer a row id or option value from any other direct scope rule (`e06`, `public`); on the three control cards its answer is unchanged. `seedApp` sets `owner_id` by the new rule and writes the persona's user id into its record row's `user` field.
8+
Metadata-protocol: none
9+
10+
The seed used to set a row's owner (`owner_id`) from any direct scope value that matched a persona's: an option (`ticket_comment.visibility = public` gave all 237 public comments on `service_desk` to the portal customer, 197 of them on other customers' tickets) or a lookup to a row that is no person (`ticket.group`, `employee.department`), and the last key read won a row two personas matched.
11+
12+
- **Owner rule.** A row is owned by a persona's user only when it IS that persona's `record`, when a declared direct lookup on it (`expect.lookups`) holds that record's id (`ticket.assignee = a02`), or when a declared user-handle field on it holds the persona's handle (the control cards' route, unchanged). The first persona in `run.personas` order the row names owns it. Every other row keeps the inserter, the admin.
13+
- **Person binding.** Each persona's `record` row is loaded with that persona's user id in its `user` field (`PERSON_USER_FIELD`). Without persona accounts (the gallery's screenshot boot) no `user` field and no record-bound owner is set.
14+
- **Build prompt.** The Row ownership section names the person rows, asks for an optional `user`-type field named `user` on each person object, lists the lookups that name a person, states the persona order for a row naming two, and adds a platform-boundary line: the open-source server has no hierarchy resolver, so a person who must see rows another person owns gets them through what the build models.
15+
16+
On `service_desk` the portal customer now owns no comment (was 237), the 5 tickets that name both the support agent and the portal customer go to the support agent, the team lead owns no ticket through `group` (was 78), and `cus_03`, `a02`, `a04` are their personas' rows.

‎docs/qa/scenarios/README.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,13 @@ is the seed row that is this person (an employee, an agent, a customer); the loa
175175
one naming no seeded row. It is required on a persona with a `scope`, unless that persona is
176176
named through a handle instead (a direct scope rule on a declared user-handle field,
177177
`owner: u_sam`, as on the three controls), and optional on a global one. The person object's
178-
user field is `user`, fixed by name: no card chooses it. An operation is a `create` (with `values`), an
178+
user field is `user`, fixed by name: no card chooses it. The runner loads that row's `user` with the
179+
persona's user id, and owns (`owner_id`) a seed row by a persona's user only when the row is that
180+
persona's `record`, a declared lookup on it holds the record's id, or a handle field on it holds the
181+
persona's handle; the first persona in `run.personas` order the row names wins. A scope on an
182+
option (`visibility: public`) or on a lookup to a row that is no person (a department, a group)
183+
gives that persona no rows: the build reaches them by its own means (a read policy, a
184+
parent-controlled child). An operation is a `create` (with `values`), an
179185
`update` (with `target` and `values`) or a `delete` (with `target`); a target is a seeded row
180186
id, and every written field is one the seed carries. Every operation names the persona it
181187
acts for (`actingAs`) and declares its `outcome`, so grading reads it with code only:

‎packages/qa/scenarios/README.md‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -253,10 +253,19 @@ npx @objectstack/scenarios help # the comma
253253
refuse bare object names. The build prompt asks for the prefix on EVERY item, and the scorer reports each bare
254254
non-object item name as a failing Build check (`bare-name:<type>:<name>`), so such a build is never verified.
255255
- **Persona contract**: the build is asked for one permission set per persona, named `<namespace>_<persona id>`. The runner
256-
grants it to that persona's user, and makes a seed row whose scope field names a persona's handle (`owner: u_sam`)
257-
owned by that persona's user. A field the card declares in `run.userHandleFields` (`userHandleFields` in
258-
`prompts.ts` reads the declaration, never infers it) is loaded with the user's id in place of the handle text, and
259-
the record's `seed.rewritten` lists each field so rewritten.
256+
grants it to that persona's user, and loads a seed row as owned (`owner_id`) by a persona's user only when the row
257+
names that person (`rowOwner` in `prompts.ts`): it IS the persona's `record`, a declared direct lookup on it holds
258+
that record's id (`ticket.assignee` = `a02`), or a declared user-handle field on it holds the persona's handle
259+
(`owner: u_sam`). The first persona in `run.personas` order the row names owns it. An option value
260+
(`visibility` = `public`), a row id scope and a lookup to a row no persona is (a department, a group) name nobody,
261+
so those rows keep the inserter, the admin. Each persona's `record` row is loaded with that persona's user id in its
262+
`user` field, which the build prompt asks for as an optional `user`-type field. `personaHandles` is the handle map
263+
only; `personaRecords` is the record binding. A field the card declares in `run.userHandleFields`
264+
(`userHandleFields` in `prompts.ts` reads the declaration, never infers it) is loaded with the user's id in place of
265+
the handle text, and the record's `seed.rewritten` lists each field so rewritten. The build prompt's Row ownership
266+
section states all of this, and a platform-boundary line: the open-source server has no hierarchy resolver, so a
267+
person who must see rows another person owns gets them through what the build models (a read policy, a
268+
parent-controlled child, a field naming them).
260269
- **Structure contract** (`prompts.ts`): every Build check traces to text the build prompt carries, so a faithful build
261270
never fails for not guessing a name. Besides the seed (objects and fields) and the persona sets, the contract states
262271
each `expect` row the exact-name scorer checks by name: every lookup's target, every single-choice field with its

‎packages/qa/scenarios/src/runner/orchestrate.ts‎

Lines changed: 30 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
*/
3333

3434
import { applyExpectDelta, truthsOver, type LoadedScenario } from '../loader.js';
35-
import { isProbedOperation, scenarioNamespace, type CardTitle } from '../schema.js';
35+
import { isProbedOperation, PERSON_USER_FIELD, scenarioNamespace, type CardTitle } from '../schema.js';
3636
import {
3737
gradeCardAnswer,
3838
gradeCardOperation,
@@ -48,7 +48,7 @@ import { servedAsRequested, type AgentDriver, type StepRef } from './driver.js';
4848
import { citationLabels, namespaceOfArtifact, namespacedScenario, permissionSetName, templateHostName } from './namespace.js';
4949
import { agentSetOf, clockDates, observeOperation, observeProbe, probeCall, touchedRowId, type Row } from './observe.js';
5050
import type { CommandResult } from './project.js';
51-
import { buildPrompt, iteratePrompt, operationPrompt, personaHandles, questionPrompt, templateIteratePrompt, userHandleFields, verifyArgs } from './prompts.js';
51+
import { buildPrompt, iteratePrompt, operationPrompt, personaHandles, personaRecords, questionPrompt, rowOwner, templateIteratePrompt, userHandleFields, verifyArgs } from './prompts.js';
5252
import { lookupKey, requiredLookups, seedPlan, type SeedLookup } from './seed.js';
5353
import type { ParsedTurn } from './transcript.js';
5454

@@ -520,14 +520,21 @@ async function buildHalf(
520520
* ({@link requiredLookups}), so a cycle is broken on a side the build left
521521
* optional; without it any side may wait.
522522
*
523-
* Row ownership is mapped from the persona handles. A row whose direct scope
524-
* field names a persona's handle is owned (`owner_id`, the platform's record
525-
* owner) by that persona's user. A user-handle field (`account.owner`,
526-
* `userHandleFields`) is itself loaded with a user id — the persona's, or the
527-
* admin's for a handle no persona holds (`u_tia`), and every handle is the
528-
* admin's when no persona account is passed (the gallery's screenshot boot) —
529-
* because a faithful build models it as a user lookup, which refuses the
530-
* handle text. Any other scope field names a row or an option and is sent
523+
* Row ownership follows the persons the card declares ({@link rowOwner}). A
524+
* row is owned (`owner_id`, the platform's record owner) by a persona's user
525+
* when it IS that persona's `record`, when a declared direct lookup on it holds
526+
* that record's id, or when a user-handle field on it holds that persona's
527+
* handle; the first persona in `run.personas` order the row names wins. An
528+
* option value, a row id scope and a lookup to a row no persona is name
529+
* nobody, so those rows keep the inserter (the admin). A persona's own record
530+
* is also loaded with that persona's user id in its `user` field
531+
* (`PERSON_USER_FIELD`), the binding the build prompt asks the build to model.
532+
* A user-handle field (`account.owner`, `userHandleFields`) is itself loaded
533+
* with a user id — the persona's, or the admin's for a handle no persona holds
534+
* (`u_tia`), and every handle is the admin's when no persona account is passed
535+
* (the gallery's screenshot boot, which also leaves every `user` field and
536+
* every record-bound owner unset) — because a faithful build models it as a
537+
* user lookup, which refuses the handle text. Every other value is sent
531538
* verbatim.
532539
*
533540
* The tally is kept in `seed` as each row is loaded, and `seed` is what is
@@ -546,8 +553,9 @@ export async function seedApp(
546553
): Promise<RunHalf['seed']> {
547554
const { card } = scenario;
548555
const handles = personaHandles(card);
549-
const ownerFields = new Set(card.run.personas.flatMap((p) => (p.scope ?? []).filter((s) => !s.field.includes('.')).map((s) => `${s.object}.${s.field}`)));
556+
const records = personaRecords(card);
550557
const userFields = userHandleFields(card);
558+
const accountOf = (persona: string | undefined) => (persona === undefined ? undefined : accounts.find((a) => a.persona === persona));
551559
const plan = seedPlan(card, required);
552560
/** `object/id` → the lookup values its second-pass update sets. */
553561
const later = new Map<string, { object: string; id: string; patch: Record<string, string> }>();
@@ -561,18 +569,18 @@ export async function seedApp(
561569
for (const row of card.run.seed[object] ?? []) {
562570
const body: Record<string, unknown> = { ...row };
563571
for (const [k, v] of Object.entries(row)) {
564-
if (typeof v !== 'string' || !ownerFields.has(`${object}.${k}`)) continue;
565-
const persona = handles.get(v);
566-
const acct = persona ? accounts.find((a) => a.persona === persona) : undefined;
567-
if (acct) body.owner_id = acct.userId;
568-
if (userFields.has(`${object}.${k}`)) {
569-
const userId = acct?.userId ?? app.adminUserId();
570-
body[k] = userId;
571-
body.owner_id = userId;
572-
seed.rewritten = [...new Set([...(seed.rewritten ?? []), `${object}.${k}`])].sort();
573-
if (!acct) seed.handlesAsAdmin = [...new Set([...(seed.handlesAsAdmin ?? []), v])].sort();
574-
}
572+
if (typeof v !== 'string' || !userFields.has(`${object}.${k}`)) continue;
573+
const acct = accountOf(handles.get(v));
574+
const userId = acct?.userId ?? app.adminUserId();
575+
body[k] = userId;
576+
body.owner_id = userId;
577+
seed.rewritten = [...new Set([...(seed.rewritten ?? []), `${object}.${k}`])].sort();
578+
if (!acct) seed.handlesAsAdmin = [...new Set([...(seed.handlesAsAdmin ?? []), v])].sort();
575579
}
580+
const self = accountOf(records.get(`${object}/${row.id}`));
581+
if (self) body[PERSON_USER_FIELD] = self.userId;
582+
const owner = accountOf(rowOwner(card, object, row));
583+
if (owner) body.owner_id = owner.userId;
576584
const waits = later.get(`${object}/${row.id}`);
577585
for (const field of Object.keys(waits?.patch ?? {})) delete body[field];
578586
const r = await app.insertRow(object, body);

0 commit comments

Comments
 (0)