Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 0 additions & 14 deletions .changeset/30290-scenario-scorer-port.md

This file was deleted.

19 changes: 19 additions & 0 deletions .changeset/30291-scenario-tag-checks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/scenarios': patch
'@objectstack/cli': patch
---

feat(scenarios): the deterministic scenario scorer lives in `@objectstack/scenarios` and judges capability tags, agent-action exposure, write outcomes and per-persona answers against a loaded card

Clause-②: no
Metadata-protocol: none

- **`@objectstack/scenarios/score`** (private, unpublished): the entry the scenario runner calls. `scoreCardArtifact(card, artifact)` scores a built `dist/objectstack.json`; `gradeCardOperation(card, operationId, observation)` judges an observed write against its declared outcome; `gradeCardAnswer(card, { question, persona?, turn })` grades an agent's answer against the loader's computed truth for that persona. Each takes a card the loader returned, and the graders read the card schema's own types.
- **The scorer moved here from `@objectstack/cli`.** That covers the structural shape score, the artifact-to-graph reader and the Run grader, which were never released from the CLI. It now also carries:
- **capability-tag checks**: a closed table of nine tags, listed in the package README. A tag outside it, or a row with nothing in the card to judge, is reported `unscored` and counted nowhere;
- **agent-action exposure**: each declared action exists on its object, carries `ai.exposed: true` and, where the card says it asks first, `ai.requiresConfirmation: true`. A missing action fails;
- **a write-operation grader**: `applied`, `refused` or `pending_confirmation`. An unobserved fact fails;
- **per-persona Run grading**: a `withheld` truth is must-not-reveal, and an all-rows figure outside a scoped persona's rows is a leak.
- **`@objectstack/cli`**: `MetadataEvalCase` is now declared in `corpus.ts`, beside the golden corpus it types, and `metadata-eval.ts` re-exports it under the same name. No behaviour change, no new flag and no new package-entry export. The CLI carries no scenario code.

No model call anywhere.
15 changes: 14 additions & 1 deletion packages/cli/src/lint/corpus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
*/

import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel';
import type { MetadataEvalCase } from './metadata-eval.js';

// `id` is a reverse-domain package identifier and `namespace` is a snake_case
// metadata prefix — one project, two identifiers, contradictory rules
Expand All @@ -34,6 +33,20 @@ const manifest = (id: string, namespace: string, name: string) => ({
engines: { protocol: `^${PROTOCOL_MAJOR}` },
});

/** One case of the metadata-generation eval (`metadata-eval.ts` runs them). */
export interface MetadataEvalCase {
/** Stable id (snake_case). */
id: string;
/** The natural-language authoring goal a generator would receive. */
prompt: string;
/** Golden/representative stack used in offline mode. */
fixture: unknown;
/** Minimum score to pass this case (defaults to the runner's `minScore`). */
minScore?: number;
/** Optional human note about what the case exercises. */
note?: string;
}

export const DEFAULT_METADATA_EVAL_CORPUS: MetadataEvalCase[] = [
{
id: 'invoice_with_line_items',
Expand Down
18 changes: 6 additions & 12 deletions packages/cli/src/lint/metadata-eval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,14 @@
* same bar. The seam is injected so this package keeps no LLM dependency.
*/

import type { MetadataEvalCase } from './corpus.js';
import { scoreMetadata, type MetadataScore } from './score.js';

export interface MetadataEvalCase {
/** Stable id (snake_case). */
id: string;
/** The natural-language authoring goal a generator would receive. */
prompt: string;
/** Golden/representative stack used in offline mode. */
fixture: unknown;
/** Minimum score to pass this case (defaults to the runner's `minScore`). */
minScore?: number;
/** Optional human note about what the case exercises. */
note?: string;
}
// Declared beside the corpus it types (`corpus.ts`), so the corpus module
// imports nothing from this runner: @objectstack/scenarios' suite reads the
// corpus, and a type import from here would pull the whole lint command into
// that package's type-check program.
export type { MetadataEvalCase } from './corpus.js';

export interface MetadataEvalCaseResult {
id: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ const CALLERS: Record<string, Classification> = {
"each target the decision before the engine reads it: the data door's `refuseUnservedRelationTarget`, the MCP " +
"stdio data bridge's `enforceRelationTargetExposure` and the runtime `callData`'s `refuseUnservedRelationTargets`",
},
'packages/cli/src/lint/scenario-graph.ts': {
'packages/qa/scenarios/src/score/scenario-graph.ts': {
kind: 'not-a-served-read',
reason:
"the scenario scorer's artifact reader (`artifactToEvalGraph`): it reads a field's declared target out of a " +
Expand Down
45 changes: 45 additions & 0 deletions packages/qa/scenarios/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,50 @@ for (const r of refusals) console.error(formatRefusal(r)); // FILE: FIELD.PATH:
seed does not determine.
- `src/loader.ts` — reads a directory of cards, validates shape and cross-references, and
returns typed cards or located refusals.
- `src/score/` — the deterministic scorer, entry `@objectstack/scenarios/score` (below).

No build step (`exports` points at `src`), and nothing here calls a model or the network.

## Scoring a run against a card — `@objectstack/scenarios/score`

Every function takes a card the loader returned, so it judges against the loader's computed
truths and declared outcomes:

```ts
import { gradeCardAnswer, gradeCardOperation, scoreCardArtifact } from '@objectstack/scenarios/score';

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

- `scenario-score.ts` — `expect` scored by exact name and by shape; `scenario-graph.ts` reads
the artifact into the graph both score.
- `scenario-tags.ts` — the capability-tag checks and the agent-action exposure check (each
declared action exists on its object, carries `ai.exposed: true`, and carries
`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.
- `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.

### The closed capability-tag table

A card's `capabilities` tag scores only when it is one of these, spelled exactly. Any other
tag is reported **`unscored`** — listed, counted neither as passed nor in the total, never a
pass — and so is a row whose card gives it nothing to judge (a field-permission tag on a card
whose personas hide no field). To score a new tag, add a row to `CAPABILITY_TAG_CHECKS` with a
discrimination test; ⛔ never match by area prefix.

| Tag | Passes when the built artifact… |
|:---|:---|
| `records-forms.lookup` | carries a lookup field, and every lookup field resolves to an object the build or the platform serves |
| `records-forms.master-detail-inline-edit` | has every master-detail field resolving, and one of them edited inline on its master (`inlineEdit`) |
| `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 |
| `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 |
8 changes: 6 additions & 2 deletions packages/qa/scenarios/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,20 @@
"version": "0.1.0",
"private": true,
"license": "Apache-2.0",
"description": "Scenario-card schema and loader for the open scenario library under docs/qa/scenarios/. A card states an authoring prompt, the structural expectation a generated app must meet, seed rows, data questions whose truth the loader COMPUTES from a declarative spec over those rows, personas, write operations with their guardrail checks, and one iteration request. Not published, no build step, no model calls: a devx instrument, not a protocol (packages/spec carries no obligation for this format).",
"description": "Scenario-card schema and loader for the open scenario library under docs/qa/scenarios/. A card states an authoring prompt, the structural expectation a generated app must meet, seed rows, data questions whose truth the loader COMPUTES from a declarative spec over those rows, personas, write operations with their guardrail checks, and one iteration request — plus the deterministic scorer that judges a built artifact, an observed write or an agent's answer against a loaded card (`@objectstack/scenarios/score`). Not published, no build step, no model calls: a devx instrument, not a protocol (packages/spec carries no obligation for this format).",
"type": "module",
"exports": {
".": "./src/index.ts"
".": "./src/index.ts",
"./score": "./src/score/index.ts"
},
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@objectstack/core": "workspace:*",
"@objectstack/objectql": "workspace:*",
"@objectstack/plugin-sharing": "workspace:*",
"@objectstack/spec": "workspace:*",
"zod": "^4.6.6"
},
Expand Down
95 changes: 95 additions & 0 deletions packages/qa/scenarios/src/score/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `@objectstack/scenarios/score` — score a LOADED card against what a run
* produced: a built artifact (`dist/objectstack.json`), an observed write, or
* an agent's answer. The entry the scenario runner calls; private, like the
* rest of this package.
*
* Every function takes a {@link LoadedScenario} (what `loadScenarios` /
* `loadScenarioFile` return), so the card it judges against has already passed
* the schema and the semantic pass, and its truths are the loader's computed
* ones — a card the loader refused cannot be scored here at all.
*
* Deterministic: no model call, no network (the cost ruling on cloud#3600).
*/

import type { LoadedScenario } from '../loader.js';
import { gradeOperation, type OperationGrade, type OperationObservation } from './scenario-operation-grade.js';
import { gradePersonaRun, type HiddenScope, type PersonaRunGrade, type RunTranscript } from './scenario-run-grade.js';
import { scoreScenarioArtifact, type ScenarioArtifactScore } from './scenario-tags.js';

export * from './scenario-graph.js';
export * from './scenario-operation-grade.js';
export * from './scenario-run-grade.js';
export * from './scenario-score.js';
export * from './scenario-tags.js';

/** Score one built artifact against one loaded card: `expect`, capability tags and agent actions. */
export function scoreCardArtifact(scenario: LoadedScenario, artifact: unknown): ScenarioArtifactScore {
return scoreScenarioArtifact(artifact, scenario.card);
}

/** Judge one observed write against the outcome the card declares for operation `operationId`. */
export function gradeCardOperation(scenario: LoadedScenario, operationId: string, observation: OperationObservation): OperationGrade {
const op = scenario.card.run.operations.find((o) => o.id === operationId);
if (!op) throw new RangeError(`${scenario.card.id}: no operation \`${operationId}\``);
return gradeOperation(op.outcome, observation);
}

export interface CardAnswer {
/** The question's id. */
question: string;
/** The persona the agent answered as; absent ⇒ graded against the all-rows truth. */
persona?: string;
turn: RunTranscript;
/**
* Values and aggregates the asker may not receive, computed by the caller
* (see {@link HiddenScope}); the hidden objects and fields are read from the
* persona and need not be passed.
*/
hidden?: Pick<HiddenScope, 'values' | 'aggregates'>;
/** Names an answer may cite an object by besides its machine name. */
objectLabels?: Readonly<Record<string, readonly string[]>>;
/** Relative tolerance for every figure; absent ⇒ exact. */
tol?: number;
}

/**
* Grade one agent answer to a card question — as a persona against that
* persona's computed truth (a `withheld` truth is must-not-reveal), or, with
* no persona, against the truth over every row.
*/
export function gradeCardAnswer(scenario: LoadedScenario, answer: CardAnswer): PersonaRunGrade {
const { card } = scenario;
const q = card.run.questions.find((x) => x.id === answer.question);
if (!q) throw new RangeError(`${card.id}: no question \`${answer.question}\``);
const truth = scenario.truths[q.id];
if (!truth) throw new RangeError(`${card.id}: question \`${q.id}\` has no computed truth`);

const hidden: HiddenScope = { ...answer.hidden };
let personaTruth = truth;
if (answer.persona !== undefined) {
const persona = card.run.personas.find((p) => p.id === answer.persona);
if (!persona) throw new RangeError(`${card.id}: no persona \`${answer.persona}\``);
const scoped = scenario.personaTruths[q.id]?.[persona.id];
if (!scoped) throw new RangeError(`${card.id}: question \`${q.id}\` is not answered as persona \`${persona.id}\``);
personaTruth = scoped;
hidden.objects = persona.hidden.objects ?? [];
const fields: Record<string, string[]> = {};
for (const f of persona.hidden.fields ?? []) (fields[f.object] ??= []).push(f.field);
hidden.fields = fields;
}

return gradePersonaRun(
{
object: q.object,
truth,
personaTruth,
hidden,
...(answer.objectLabels ? { objectLabels: answer.objectLabels } : {}),
...(answer.tol !== undefined ? { tol: answer.tol } : {}),
},
answer.turn,
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
*
* A multi-package artifact may carry its collections only inside
* `packages[i].manifest` (ADR-0130 D4, option B). Every collection here is read
* through `resolveStackCollection` — the rule `@objectstack/cli` and
* `@objectstack/verify` already share (`utils/stack-collections.ts`): the
* through `resolveStackCollection`, imported from its owner `@objectstack/core`
* — the rule `@objectstack/cli` and `@objectstack/verify` share too: the
* top-level array when present, otherwise every package body's, in the
* dependency order `resolveArtifactPackageOrder` decides. ⛔ No local walk of
* `packages[]`.
Expand All @@ -35,7 +35,7 @@

import { REFERENCE_VALUE_TYPES, referenceTargetOf } from '@objectstack/spec/data';

import { resolveStackCollection } from '../utils/stack-collections.js';
import { resolveStackCollection } from '@objectstack/core';
import { PLATFORM_OBJECT_PREFIX, type EvalGraph, type EvalGraphField, type EvalGraphNavEntry } from './scenario-score.js';

type Bag = Record<string, unknown>;
Expand Down
Loading
Loading