diff --git a/.changeset/status-monotonic-audience-lifecycle.md b/.changeset/status-monotonic-audience-lifecycle.md new file mode 100644 index 000000000..35560f7c1 --- /dev/null +++ b/.changeset/status-monotonic-audience-lifecycle.md @@ -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. diff --git a/src/lib/testing/storyboard/default-invariants.ts b/src/lib/testing/storyboard/default-invariants.ts index 84b056fd8..c3a78465d 100644 --- a/src/lib/testing/storyboard/default-invariants.ts +++ b/src/lib/testing/storyboard/default-invariants.ts @@ -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>([ + ['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. @@ -693,6 +707,15 @@ function extractStatusObservations(task: string, body: Record): 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; } @@ -799,6 +822,22 @@ function pushProposal(obs: StatusObservation[], record: Record) } } +function pushAudience(obs: StatusObservation[], record: Record): 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; @@ -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. diff --git a/test/lib/storyboard-default-invariants.test.js b/test/lib/storyboard-default-invariants.test.js index 241c7ddd1..89b968014 100644 --- a/test/lib/storyboard-default-invariants.test.js +++ b/test/lib/storyboard-default-invariants.test.js @@ -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)); + }); });