Skip to content
11 changes: 10 additions & 1 deletion docs/qa/scenarios/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,9 @@ A question that lists `personas` is also answered **as each persona**: the perso
`scope` rules for the question's object are applied first (`{ object, field, value }`,
where `field` may be a path — `owner` for a rep, `owner.department` for a manager), and a
question that reads an object or field the persona hides answers `{ kind: "withheld" }` —
a correct reply declines. The loader returns these as `personaTruths`.
a correct reply declines. The loader returns these as `personaTruths`. A card whose
personas declare a `scope` carries the `access-security.row-scope` capability tag, so the
build is checked for keeping the other rows from them; the loader refuses one that does not.

A truth the seed does not determine is **refused**, not approximated: an `avg`, `min` or
`max` over no values, a field no seed row of the object carries, a comparison between a
Expand Down Expand Up @@ -127,6 +129,13 @@ acts for (`actingAs`) and declares its `outcome`, so grading reads it with code
| `audit_actor` | `operation` (outcome `applied`, with `auditActor`) | The audit trail names the acting user as the actor. |
| `injection_as_data` | `cell` (`{ object, id, field }`, a text cell) | Instructions planted in data are treated as data. |

The `write_validation` operation is graded in two halves. The agent passes when its write is
refused with the declared code, or when it writes nothing and its reply declines the write or
asks for a correction (an agent that catches the bad value first). The platform passes when the
runner's own write of the operation's declared `values`, sent after the agent's turn as the
same persona through the same MCP door, is refused with that code and stores nothing. So a
card states the bad write in full in `values` (and `target`), exactly as the app must refuse it.

## The library

The library holds nine scenarios. The three **simple controls** are here:
Expand Down
1 change: 1 addition & 0 deletions docs/qa/scenarios/asset_lifecycle.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"automation.scheduled-flow",
"access-security.row-level-security",
"access-security.field-permissions",
"access-security.row-scope",
"dashboards.report",
"ai.ask-data",
"ai.agent-actions"
Expand Down
2 changes: 1 addition & 1 deletion docs/qa/scenarios/crm_account_with_contacts.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"selectFields": [{"object": "account", "field": "industry", "minOptions": 2}],
"lookups": [{"object": "contact", "field": "account", "to": "account"}]
},
"capabilities": ["records-forms.lookup", "records-forms.select-options", "ai.ask-data", "access-security.field-permissions"],
"capabilities": ["records-forms.lookup", "records-forms.select-options", "ai.ask-data", "access-security.field-permissions", "access-security.row-scope"],
"category": "crm",
"run": {
"seed": {
Expand Down
3 changes: 2 additions & 1 deletion docs/qa/scenarios/invoice_with_line_items.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
"records-forms.roll-up-summary",
"records-forms.select-options",
"ai.ask-data",
"access-security.field-permissions"
"access-security.field-permissions",
"access-security.row-scope"
],
"category": "finance",
"run": {
Expand Down
1 change: 1 addition & 0 deletions docs/qa/scenarios/order_to_cash.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"automation.time-relative-trigger",
"access-security.row-level-security",
"access-security.field-permissions",
"access-security.row-scope",
"dashboards.report",
"ai.ask-data",
"ai.agent-actions"
Expand Down
3 changes: 2 additions & 1 deletion docs/qa/scenarios/project_with_tasks.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
"records-forms.master-detail-inline-edit",
"records-forms.roll-up-summary",
"records-forms.select-options",
"ai.ask-data"
"ai.ask-data",
"access-security.row-scope"
],
"category": "project",
"run": {
Expand Down
1 change: 1 addition & 0 deletions docs/qa/scenarios/requisition_to_receipt.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
"automation.record-triggered-flow",
"access-security.row-level-security",
"access-security.field-permissions",
"access-security.row-scope",
"dashboards.report",
"ai.ask-data",
"ai.agent-actions"
Expand Down
1 change: 1 addition & 0 deletions docs/qa/scenarios/service_desk.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"automation.time-relative-trigger",
"access-security.row-level-security",
"access-security.field-permissions",
"access-security.row-scope",
"dashboards.report",
"ai.ask-data",
"ai.agent-actions"
Expand Down
1 change: 1 addition & 0 deletions docs/qa/scenarios/travel_expense.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"automation.record-triggered-flow",
"access-security.row-level-security",
"access-security.field-permissions",
"access-security.row-scope",
"dashboards.report",
"ai.ask-data",
"ai.agent-actions"
Expand Down
17 changes: 14 additions & 3 deletions packages/qa/scenarios/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,11 @@ gradeCardAnswer(card, { question: 'account_count', persona: 'sdr', turn }); // a
`ai.requiresConfirmation: true` where the card says `confirm`; a missing action fails, never
skipped).
- `scenario-operation-grade.ts` — an observed write against `applied` / `refused` /
`pending_confirmation`; a fact the observation does not carry fails.
`pending_confirmation`; a fact the observation does not carry fails. The operation the
card's `write_validation` guardrail names is graded in two halves: the agent half (its write
was refused with the declared code, or it wrote nothing and its reply declines the write or
asks for a correction) and the platform half, `platform:` checks (the runner's own write of
the declared values, the probe, was refused with that code and stored nothing).
- `scenario-run-grade.ts` — an answer graded on correct / cited / leaks, per persona against
that persona's truth; a `withheld` truth is must-not-reveal.

Expand All @@ -61,7 +65,7 @@ discrimination test; ⛔ never match by area prefix.
| `records-forms.roll-up-summary` | carries a roll-up summary, each reading a child object that relates back and, unless it counts, a child field that exists |
| `records-forms.select-options` | carries a single-choice option field (`select`, `radio`), each offering at least two options |
| `access-security.field-permissions` | denies (`readable: false`, in a permission set) every field a card persona hides, and has that field |
| `access-security.row-scope` | keeps every object a persona's `scope` names private (owner-scoped), or derived from its master |
| `access-security.row-scope` | keeps every object a persona's `scope` names private (owner-scoped) or derived from its master, or narrows it for each such persona by a read policy (`rowLevelSecurity`, `select` or `all`, no `positions`, a `using` that is not `true`) on that persona's permission set |
| `approvals.approval-flow` | carries an approval (a flow with an Approval node), each bound to an object it serves, one bound to an object the card names |
| `automation.record-triggered-flow` | carries a record-triggered flow, each bound to an object it serves, one bound to an object the card names |
| `dashboards.widget-measures` | carries a dashboard widget with a measure, and every widget measure resolves on the dataset it binds |
Expand All @@ -87,7 +91,14 @@ Per card and mode the runner writes `docs/qa/platform-checklist/runs/<date>-scen
refuses to start when `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` is set or the CLI is logged in with an API key
(the cost ruling on cloud#3600), and the record states the driver and login. `RecordedDriver` replays a recording.
- **Observation** (`observe.ts`): the write grader is fed what the runner reads itself: the agent's write calls and
the door's answer (a coded refusal), the object's rows before and after the turn, and the audit trail's actor.
the door's answer (a coded refusal), the object's rows before and after the turn, and the audit trail's actor. A turn
that wrote nothing because the object refused the read it needed first (a persona that may not read it) grades as
that refusal's code; `(no write attempted)` is left for a turn whose object never refused it. For the
operation the card's `write_validation` guardrail names, the runner then sends the declared write itself
(`values`, on `target`) as the same persona through the same MCP door (`POST /api/v1/mcp`, its own API key) and
reads that answer as the **probe**: the app's validation is measured on every run, whether or not the agent sent
the bad value, and a careful agent that caught it first is not failed for it. A failed probe is the platform's
failure, never the agent's.
- **Namespaces** (`namespace.ts`): every card's app is in the namespace `demo` + the card's `code` (`democrm`): scratch
mode scaffolds the project under that name, and template mode installs a template declaring it into a host named
`host<code>` (the unmanaged copy keeps its namespace, and the install refuses one the host owns). The card's names are
Expand Down
15 changes: 15 additions & 0 deletions packages/qa/scenarios/src/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { ErrorCode } from '@objectstack/spec/api';
import {
AppCodeSchema,
GUARDRAIL_CHECKS,
ROW_SCOPE_TAG,
ScenarioCardSchema,
type ScenarioCard,
type ScenarioExpect,
Expand Down Expand Up @@ -390,6 +391,20 @@ function checkCard(
});
});

// ── row scope is judged in the build: a scoped persona needs the row-scope tag ──
// A persona's `scope` is a requirement of the app (it sees only those rows).
// The Build half judges a requirement only through the card's capability
// tags, so without the tag a build that shows the persona every row scores
// as if the card never asked (the reach rule on operations, below, is the
// same idea).
const scopedPersonas = run.personas.filter((p) => p.scope !== undefined).map((p) => `\`${p.id}\``);
if (scopedPersonas.length > 0 && !card.capabilities.includes(ROW_SCOPE_TAG)) {
refuse(
['capabilities'],
`the persona(s) ${scopedPersonas.join(', ')} declare a row \`scope\`, so the card must carry \`${ROW_SCOPE_TAG}\`: without it the Build half never judges whether the build keeps the other rows from them`,
);
}

// ── questions: refs resolve, then the truth is computed (and per persona) ──
const truths: Record<string, Truth> = {};
const personaTruths: Record<string, Record<string, Truth>> = {};
Expand Down
58 changes: 58 additions & 0 deletions packages/qa/scenarios/src/runner/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@
* unmanaged install POST /api/v1/marketplace/install-local { packageId, manifest,
* installMode: 'unmanaged' } (ADR-0131 D6)
* metadata GET /api/v1/meta/:type
* a persona's tool POST /api/v1/mcp, a JSON-RPC `tools/call` with `Authorization:
* Bearer <its API key>` — the door the agent uses, so the probe of a
* validation operation meets the persona's own permissions and the
* app's validation exactly as the agent's write would. The tool's
* answer is its text content; a refusal is that text carrying
* `{ error: { code, status } }` with `isError` set.
*
* The data API's refusal is the flat `{ error, code }` envelope; the auth and
* install doors answer `{ success: false, error: { code, message } }`. Both
Expand Down Expand Up @@ -55,6 +61,43 @@ export function codeOf(body: unknown): { code?: string; message?: string } {
return {};
}

/** An MCP tool's answer: its text content, and whether the tool flagged it as an error. */
export interface ToolAnswer {
output: unknown;
isError: boolean;
}

function isBag(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}

/**
* The JSON-RPC response in an MCP HTTP answer: the body itself (JSON response
* mode), or the first `data:` event carrying a result or an error (SSE).
*/
export function readJsonRpc(text: string): Record<string, unknown> | undefined {
const candidates = text.trimStart().startsWith('{')
? [text]
: text.split('\n').filter((l) => l.startsWith('data:')).map((l) => l.slice('data:'.length));
for (const c of candidates) {
try {
const msg: unknown = JSON.parse(c);
if (isBag(msg) && ('result' in msg || 'error' in msg)) return msg;
} catch {
/* not a JSON-RPC line */
}
}
return undefined;
}

/** A `tools/call` result as a {@link ToolAnswer}: its text blocks joined, as the agent's transcript reads them. */
export function toolAnswerOf(result: unknown): ToolAnswer {
const r = isBag(result) ? result : {};
const content = Array.isArray(r.content) ? r.content : [];
const texts = content.filter(isBag).filter((c) => c.type === 'text' && typeof c.text === 'string').map((c) => c.text as string);
return { output: texts.length === content.length ? texts.join('\n') : content, isError: r.isError === true };
}

export interface PersonaAccount {
persona: string;
userId: string;
Expand Down Expand Up @@ -82,6 +125,8 @@ export interface AppHarness {
installUnmanaged(manifest: Record<string, unknown>): Promise<HttpAnswer>;
/** The environment's metadata of every collection the template carries, as an artifact. */
metadataAsArtifact(collections: readonly string[]): Promise<Record<string, unknown[]>>;
/** One MCP tool call as the owner of `apiKey`, through the door the agent uses (`mcpUrl`). */
callTool(apiKey: string, tool: string, args: Record<string, unknown>): Promise<ToolAnswer>;
}

export interface HttpAppOptions {
Expand Down Expand Up @@ -238,6 +283,19 @@ export class HttpApp implements AppHarness {
return this.call('POST', '/api/v1/marketplace/install-local', { packageId, manifest, installMode: 'unmanaged' }, { session: this.admin });
}

async callTool(apiKey: string, tool: string, args: Record<string, unknown>): Promise<ToolAnswer> {
const res = await fetch(this.mcpUrl, {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', authorization: `Bearer ${apiKey}` },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: tool, arguments: args } }),
});
const text = await res.text();
const rpc = readJsonRpc(text);
if (!res.ok || rpc === undefined) throw new Error(`tools/call ${tool}: ${res.status} ${text.slice(0, 300)}`.trim());
if (isBag(rpc.error)) throw new Error(`tools/call ${tool}: JSON-RPC ${String(rpc.error.code ?? '')} ${String(rpc.error.message ?? '')}`.trim());
return toolAnswerOf(rpc.result);
}

async metadataAsArtifact(collections: readonly string[]): Promise<Record<string, unknown[]>> {
const out: Record<string, unknown[]> = {};
for (const plural of collections) {
Expand Down
4 changes: 2 additions & 2 deletions packages/qa/scenarios/src/runner/namespace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
*/

import type { LoadedScenario } from '../loader.js';
import { scenarioNamespace, type ScenarioCard } from '../schema.js';
import { personaPermissionSetName, scenarioNamespace, type ScenarioCard } from '../schema.js';

/** The `namespace` a project's `objectstack.config.ts` declares, or `undefined`. */
export function namespaceOfConfig(source: string): string | undefined {
Expand Down Expand Up @@ -54,7 +54,7 @@ export function templateHostName(card: Pick<ScenarioCard, 'code'>): string {

/** The permission set a persona signs in with: `<namespace>_<persona id>` (`democrm_sdr`). */
export function permissionSetName(card: Pick<ScenarioCard, 'code'>, persona: string): string {
return `${scenarioNamespace(card)}_${persona}`;
return personaPermissionSetName(scenarioNamespace(card), persona);
}

/** The card with every item name it states prefixed `<namespace>_` (a name already carrying the prefix is kept). */
Expand Down
Loading
Loading