Operant Studio
OPER-171

[OPER-QA-16] AgentWorker as QaTestProjectMember (agent + human fanout) — re-file

Suggested
Justin Cooke

Body

feature_id: FEAT-studio-qa-projects
verify_cmd: pnpm --filter @operant/qa test

## Context

Re-file of `OPER-QA-16` (CANCELED) and `OPER-152` (CANCELED). Both were landed by
the admission gate with the `admission-rejected` label — `OPER-QA-16` on
`missing_feature_id` (its body carried no `feature_id:` frontmatter). There is no
re-admission endpoint, so per `.claude/rules/feature-map-and-depmap.md` the only
recovery is cancel + fresh POST. This ticket is that fresh POST; it declares
`FEAT-studio-qa-projects`, which has been in `products/studio/feature-map.md` and
`products/studio/dependency-map.md` all along.

The re-file also fixes a second, unrelated gate. `OPER-QA-*` is invisible to the
OPER-D2 ticket-trailer check: `scripts/ticket-trailer-eval.sh` accepts
`(POR|PDX|OPER|ROST)-[0-9]{1,6}`, and the `QA` segment means no trailer spelling of
`OPER-QA-16` can ever match. Both implementation PRs were therefore red on `Check
ticket trailer` with no honest fix available — the earlier run declined to invent a
numeric parent, correctly. A fresh POST gets a sequential numeric identifier from
`nextIdentifier()`, which the gate does match, so the trailer on those PRs can now
name a real ticket.

**The implementation is already complete and open for review.** No agent should
re-plan or re-implement this ticket.

| PR | Scope | State |
|---|---|---|
| #449 | schema + migration `20260909170000_oper_qa_16_agent_members` + real-Postgres constraint proof | open, `size/L`, all checks green except the trailer gate above |
| #450 | `packages/qa/src/projects.ts` member-kind-agnostic fanout + 15 unit cases | open, draft, `[BLOCKED]` on #449, `size/M` |

Branch: `agent/oper-qa-16-oper-qa-16-agentworker-as-qatestprojectmember-agent-human-fanout`
(#449) stacked under `agent/oper-qa-16-fanout` (#450).

### Two premises in the original ticket that do not hold on `main`

Both were resolved in the implementation and both survive into this re-file; the
acceptance criteria below are restated against the code that exists rather than the
code the original assumed.

1. **There is no `userId` column on `QaTestProjectMember`.** OPER-100 shipped a
   deliberately untyped `memberId`, mirroring `TestRun.assigneeId`, and OPER-128's
   invite endpoint keys idempotency on the live `@@unique([projectId, memberId])`.
   Renaming it would be a destructive rewrite of a shipped uniqueness contract for
   no behavioural gain. The XOR the original asked for is therefore expressed
   against the columns that exist: `memberKind = 'USER'` iff `agentWorkerId IS
   NULL`, `memberKind = 'AGENT_WORKER'` iff `agentWorkerId = memberId`. Both shapes
   the original wanted rejected still are.

2. **`resolveAssignmentsForProject()` has never been on `main`.** The original opens
   by crediting it to OPER-127; `packages/qa/src/projects.ts` is a new file in #450,
   and `scripts/check-uat-loop-blockers.ts` step 7 still reports the DRAFT →
   IN_PROGRESS transition blocked. "Unchanged in signature" was vacuous. #450 ships
   the fanout the resolver will call; the transition itself remains OPER-115's.

### Tenant boundary — the one thing a reviewer should read closely

An agent seat is `QaTestProjectMember`'s first reference to a tenant-scoped row
outside the `QaTestProject` parent chain, i.e. the first chance to seat team A's
worker on team B's project. OPER-103 denormalized `teamId` onto the table, which is
what makes the composite FK expressible:

```sql
FOREIGN KEY ("teamId", "agentWorkerId") REFERENCES "AgentWorker"("teamId", "id")
  ON DELETE CASCADE ON UPDATE NO ACTION
```

A cross-team seat is unrepresentable rather than merely discouraged, and the FK reads
the same `teamId` column `getScopedDb` filters on. `ON UPDATE NO ACTION` is
deliberate: cascading would rewrite a seat's `teamId` to follow a worker that changed
teams, moving a live seat across the boundary. The general rule was lifted into
`.claude/rules/multi-tenant-rls.md` § Required setup.

## Acceptance Criteria

- [x] Migration `20260909170000_oper_qa_16_agent_members` adds `memberKind`
      (`QaTestMemberKind`, NOT NULL DEFAULT `'USER'`) and nullable `agentWorkerId`,
      plus CHECK `QaTestProjectMember_member_kind_xor` and CHECK
      `QaTestProjectMember_agent_member_id_match`. Additive; exact rollback in the
      migration header.
- [x] The FK is composite — `(teamId, agentWorkerId)` → `AgentWorker (teamId, id)` —
      with the unique index Postgres requires on the target, so a cross-team seat is
      refused by the database.
- [x] `qa.prisma` carries `memberKind`, `agentWorkerId String?`, the composite
      `agentWorker AgentWorker?` relation, and the `[projectId, memberKind]` index.
      `memberId` stays required and stays the single assignee identifier.
- [x] `TestAssignment` schema unchanged.
- [x] The fanout in `packages/qa/src/projects.ts` never branches on `memberKind`; a
      human seat and an agent seat with the same id produce byte-identical output,
      asserted by a test so a later `memberKind` branch fails CI. Live seats only
      (`removedAt`), duplicate (member, case) pairs collapse.
- [x] `assertAgentWorkerSeatable()` rejects a cross-team seat before the FK does,
      naming both team ids, so a route handler can map it to 403.
- [x] `packages/qa/src/__tests__/projects.agent-members.test.ts` — 15 cases, no
      database. Includes the arithmetic the original asked for: 2 human + 3 agent
      members × 4 cases = 20 assignments, asserted per-seat and for uniqueness, not
      just by length.
- [x] `packages/database/src/__tests__/qa-agent-members.integration.test.ts` — 13
      cases against real Postgres. CHECK and FK constraints are invisible to the
      Prisma datamodel, so the XOR-rejects-both-set, XOR-rejects-neither-set,
      cross-team-FK, and cascade-on-deregister criteria are proved here rather than
      against a mock. Assertions are on SQLSTATE (`23514` / `23503`) plus constraint
      name.
- [x] Team scoping proved both ways — the denormalized `teamId` column and the parent
      traversal — and asserted to agree.
- [x] No change to the POR-QA-1 BFF contract; agent members surface as generic
      `member` shapes.
- [x] `products/studio/dependency-map.md` gains the
      `FEAT-studio-qa-projects BLOCKED_BY FEAT-studio-agent-workers` edge that this
      ticket's composite FK introduces, with `Audited-At:` bumped and the matching
      `feature-map.md` note. Docs-only PR, filed separately from #449 so the LOC
      exemption applies.
- [x] Both PRs carry a `Closes` trailer naming this ticket, so the Ledger↔GitHub
      reconciler can close it on merge.

## Why this matters

Unblocks agent-side execution of the QA fleet at scale. Mass-assigning to N agents
becomes N `QaTestProjectMember` rows with `agentWorkerId` set, the case rows added to
a phase, and one phase transition — no second code path, no parallel resolver, no
`AgentTestProjectMember` model. OPER-127 (DONE) supplied the project/phase/member
layer; OPER-115 owns the transition that will call this fanout.


## Landed

All three PRs merged to `main` on [REDACTED-DOB].

| PR | Commit | Scope |
|---|---|---|
| #449 | `182a9be` | schema + migration `20260909170000_oper_qa_16_agent_members` + 13-case real-Postgres constraint proof |
| #450 | `8881bab` | `packages/qa/src/projects.ts` member-kind-agnostic fanout + 15 unit cases |
| #478 | `3a8b42e` | `FEAT-studio-qa-projects BLOCKED_BY FEAT-studio-agent-workers` depmap edge (docs-only) |

`sync-product-checksums` ran green on `3a8b42e`, so `Product.featureMapChecksum`
tracks the edited feature map and the admission gate will not report `map_drift`.

`Check ticket trailer` is green on all three — the first time any PR in this ticket's
family satisfied that gate, and the reason this re-file exists.

Not in scope and still open: the DRAFT -> IN_PROGRESS phase transition that will call
`fanOutAssignments` is OPER-115's, and `scripts/check-uat-loop-blockers.ts` step 7
still reports it blocked. The trailer gate's inability to match any `OPER-QA-*`
identifier is tracked separately (OPER-170 is already looking at that gate).

Attachments

Loading attachments…

Comments

Loading comments…