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
20 changes: 20 additions & 0 deletions .changeset/status-monotonic-audience-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@adcp/client': minor
---

Extend the bundled `status.monotonic` default assertion to track the audience lifecycle alongside the seven resource types it already guards (adcontextprotocol/adcp#2836). `sync_audiences` responses carry per-audience `status` values (`processing | ready | too_small`) drawn from the newly-named spec enum at `/schemas/enums/audience-status.json`, and the assertion now rejects off-graph transitions across storyboard steps for every observed `audience_id`.

**Transition graph** — fully bidirectional across the three states, matching the spec's permissive "MAY transition" hedging:

- `processing → ready | too_small` on matching completion.
- `ready ↔ processing` on re-sync (new members → re-match).
- `too_small → processing | ready` on re-sync (more members → re-match, directly back to ready when the re-matched count clears the minimum).
- `ready ↔ too_small` as counts cross `minimum_size` across re-syncs.

**Observations** are drawn from `sync_audiences` responses only — discovery-only calls (request omits the `audiences[]` array) still return `audiences[]`, so the extractor covers both write and read paths under the single task name. No separate `list_audiences` task exists in the spec. Actions `deleted` and `failed` omit `status` entirely on the response envelope; the extractor's id+status guard makes those rows silent (nothing to observe, nothing to check).

**Resource scoping** is `(audience, audience_id)`, independent from the other tracked resources. Unknown enum values drift-reset the anchor rather than failing — `response_schema` remains the gate for enum conformance.

8 new unit tests cover the forward flow, the too_small → processing → ready re-sync path, bidirectional `ready ↔ too_small`, `ready → processing` on re-sync, self-edge silent pass, deleted/failed silent pass, per-audience-id scoping, and enum-drift tolerance. The assertion description now enumerates `audience` alongside the other resource types.

Follow-up: wiring `audience-sync/index.yaml` with `invariants: [status.monotonic]` in the adcp spec repo once this release lands.
41 changes: 40 additions & 1 deletion src/lib/testing/storyboard/default-invariants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,20 @@ const PROPOSAL_TRANSITIONS: TransitionGraph = {
]),
};

const AUDIENCE_TRANSITIONS: TransitionGraph = {
// See `static/schemas/source/enums/audience-status.json`. Fully bidirectional
// across the three states — sellers MAY re-enter `processing` on re-sync
// from `ready` or `too_small`, and `ready ↔ too_small` can happen as
// member counts cross `minimum_size` (spec hedges this as MAY, not MUST).
// No terminals: delete / fail omit `status` entirely via the envelope's
// `action` field, so there's nothing to record for them.
transitions: new Map<string, ReadonlySet<string>>([
['processing', new Set(['ready', 'too_small'])],
['ready', new Set(['processing', 'too_small'])],
['too_small', new Set(['processing', 'ready'])],
]),
};

/**
* Extractor record per resource type. For each response shape we recognize,
* describe how to walk the body and emit `(resource_id, status)` pairs.
Expand Down Expand Up @@ -693,6 +707,15 @@ function extractStatusObservations(task: string, body: Record<string, unknown>):
pushProposal(obs, body.proposal);
}

// Audience lifecycle: sync_audiences is both the write and discovery path
// (discovery-only calls omit the request `audiences` array but still return
// `audiences[]`). No separate list_audiences task exists.
if (task === 'sync_audiences') {
for (const a of asArray(body.audiences)) {
if (isObject(a)) pushAudience(obs, a);
}
}

return obs;
}

Expand Down Expand Up @@ -799,6 +822,22 @@ function pushProposal(obs: StatusObservation[], record: Record<string, unknown>)
}
}

function pushAudience(obs: StatusObservation[], record: Record<string, unknown>): void {
// `status` is absent when `action` is `deleted` or `failed` — spec
// envelope intentionally omits the field rather than emitting a terminal
// value. The `&& status` guard below makes those observations silent.
const id = asString(record.audience_id);
const status = asString(record.status);
if (id && status) {
obs.push({
resource_type: 'audience',
resource_id: id,
status,
graph: AUDIENCE_TRANSITIONS,
});
}
}

interface MonotonicState {
stepId: string;
status: string;
Expand All @@ -807,7 +846,7 @@ interface MonotonicState {
registerOnce('status.monotonic', {
id: 'status.monotonic',
description:
'Observed resource statuses (media_buy, creative, account, si_session, catalog_item, proposal, creative_approval) MUST only transition along edges in the spec lifecycle graph.',
'Observed resource statuses (media_buy, creative, account, si_session, catalog_item, proposal, creative_approval, audience) MUST only transition along edges in the spec lifecycle graph.',
onStart: ctx => {
// `${resource_type}:${resource_id}` → last-observed state. Tuple key
// disambiguates the unlikely `media_buy_id` / `creative_id` collision.
Expand Down
109 changes: 109 additions & 0 deletions test/lib/storyboard-default-invariants.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1046,4 +1046,113 @@ describe('default-invariants: status.monotonic', () => {
assert.strictEqual(out[0].output[0].passed, false);
assert.match(out[0].output[0].error, /media_buy mb-1: active → pending_creatives/);
});

// ── audience ───────────────────────────────────────────────

const audienceOf = (id, status, extra = {}) => ({ audience_id: id, status, ...extra });

test('audience: processing → ready forward flow passes', () => {
const out = run([
step({ step_id: 's1', task: 'sync_audiences', response: { audiences: [audienceOf('aud-1', 'processing')] } }),
step({
step_id: 's2',
task: 'sync_audiences',
response: { audiences: [audienceOf('aud-1', 'ready', { matched_count: 1200 })] },
}),
]);
assert.ok(out.every(r => r.output.every(o => o.passed)));
});

test('audience: too_small → processing → ready re-sync path passes', () => {
const out = run([
step({
step_id: 's1',
task: 'sync_audiences',
response: { audiences: [audienceOf('aud-1', 'too_small', { minimum_size: 1000 })] },
}),
step({ step_id: 's2', task: 'sync_audiences', response: { audiences: [audienceOf('aud-1', 'processing')] } }),
step({
step_id: 's3',
task: 'sync_audiences',
response: { audiences: [audienceOf('aud-1', 'ready', { matched_count: 1500 })] },
}),
]);
assert.ok(out.every(r => r.output.every(o => o.passed)));
});

test('audience: ready ↔ too_small is bidirectional (counts cross minimum_size)', () => {
const out = run([
step({ step_id: 's1', task: 'sync_audiences', response: { audiences: [audienceOf('aud-1', 'ready')] } }),
step({ step_id: 's2', task: 'sync_audiences', response: { audiences: [audienceOf('aud-1', 'too_small')] } }),
step({ step_id: 's3', task: 'sync_audiences', response: { audiences: [audienceOf('aud-1', 'ready')] } }),
]);
assert.ok(out.every(r => r.output.every(o => o.passed)));
});

test('audience: ready → processing is allowed on re-sync', () => {
const out = run([
step({ step_id: 's1', task: 'sync_audiences', response: { audiences: [audienceOf('aud-1', 'ready')] } }),
step({ step_id: 's2', task: 'sync_audiences', response: { audiences: [audienceOf('aud-1', 'processing')] } }),
]);
assert.ok(out[1].output.every(o => o.passed));
});

test('audience: self-edge (same status re-read) is silent pass', () => {
const out = run([
step({ step_id: 's1', task: 'sync_audiences', response: { audiences: [audienceOf('aud-1', 'ready')] } }),
step({ step_id: 's2', task: 'sync_audiences', response: { audiences: [audienceOf('aud-1', 'ready')] } }),
]);
// Two passes, no failures. `prev.status === ob.status` is a no-op path.
assert.ok(out.every(r => r.output.every(o => o.passed)));
});

test('audience: action deleted / failed omits status — observations are silent', () => {
// Spec envelope omits `status` entirely when `action` is `deleted` or
// `failed`. pushAudience requires both id and status, so these rows
// contribute no observations — the assertion can't see absence.
const out = run([
step({ step_id: 's1', task: 'sync_audiences', response: { audiences: [audienceOf('aud-1', 'ready')] } }),
step({
step_id: 's2',
task: 'sync_audiences',
response: { audiences: [{ audience_id: 'aud-1', action: 'deleted' }] },
}),
step({
step_id: 's3',
task: 'sync_audiences',
response: { audiences: [{ audience_id: 'aud-1', action: 'failed' }] },
}),
]);
// s2/s3 carry no status → no observations → assertion doesn't emit.
assert.ok(out.every(r => r.output.every(o => o.passed)));
});

test('audience: observations are scoped per audience_id', () => {
// aud-1 and aud-2 have independent histories. A ready on aud-1 doesn't
// anchor aud-2, so aud-2 starting at too_small isn't a regression.
const out = run([
step({
step_id: 's1',
task: 'sync_audiences',
response: { audiences: [audienceOf('aud-1', 'ready'), audienceOf('aud-2', 'too_small')] },
}),
step({
step_id: 's2',
task: 'sync_audiences',
response: { audiences: [audienceOf('aud-1', 'processing'), audienceOf('aud-2', 'ready')] },
}),
]);
assert.ok(out.every(r => r.output.every(o => o.passed)));
});

test('audience: unknown status value is treated as enum drift (not a fail)', () => {
// Matches the existing drift behaviour on media_buy — unknown prev.status
// resets the anchor instead of failing; response_schema is the gate for
// enum conformance.
const out = run([
step({ step_id: 's1', task: 'sync_audiences', response: { audiences: [audienceOf('aud-1', 'xx_unknown')] } }),
step({ step_id: 's2', task: 'sync_audiences', response: { audiences: [audienceOf('aud-1', 'ready')] } }),
]);
assert.ok(out[1].output.every(o => o.passed));
});
});
Loading