@openwop/openwop-conformance 1.10.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +2 -2
  3. package/api/asyncapi.yaml +70 -0
  4. package/api/openapi.yaml +268 -1
  5. package/coverage.md +33 -2
  6. package/fixtures/oauth-providers/synthetic.json +38 -0
  7. package/fixtures.md +10 -0
  8. package/package.json +1 -1
  9. package/schemas/README.md +12 -0
  10. package/schemas/agent-deployment-transition.schema.json +49 -0
  11. package/schemas/agent-deployment.schema.json +54 -0
  12. package/schemas/agent-eval-suite.schema.json +140 -0
  13. package/schemas/agent-inventory-response.schema.json +25 -0
  14. package/schemas/agent-manifest.schema.json +5 -0
  15. package/schemas/agent-org-chart.schema.json +82 -0
  16. package/schemas/agent-ref.schema.json +12 -2
  17. package/schemas/agent-roster-entry.schema.json +81 -0
  18. package/schemas/agent-roster-response.schema.json +21 -0
  19. package/schemas/budget-policy.schema.json +18 -0
  20. package/schemas/capabilities.schema.json +277 -0
  21. package/schemas/credential-provenance.schema.json +18 -0
  22. package/schemas/eval-summary.schema.json +92 -0
  23. package/schemas/node-pack-manifest.schema.json +17 -0
  24. package/schemas/org-chart-responsibility-view.schema.json +26 -0
  25. package/schemas/run-event-payloads.schema.json +286 -3
  26. package/schemas/run-event.schema.json +19 -0
  27. package/schemas/tool-descriptor.schema.json +63 -0
  28. package/schemas/trigger-subscription.schema.json +26 -0
  29. package/src/lib/agentOrgChart.ts +82 -0
  30. package/src/lib/agentRoster.ts +76 -0
  31. package/src/lib/liveRuntime.ts +59 -0
  32. package/src/lib/profiles.ts +157 -0
  33. package/src/lib/runtimeRequires.ts +38 -0
  34. package/src/lib/safeFetch.ts +87 -0
  35. package/src/lib/triggerBridge.ts +74 -0
  36. package/src/scenarios/agent-deployment-shape.test.ts +139 -0
  37. package/src/scenarios/agent-eval-suite-shape.test.ts +167 -0
  38. package/src/scenarios/agent-live-allowlist-enforced.test.ts +53 -0
  39. package/src/scenarios/agent-live-invocation-bracket.test.ts +98 -0
  40. package/src/scenarios/agent-live-runtime-shape.test.ts +98 -0
  41. package/src/scenarios/agent-live-structured-output.test.ts +58 -0
  42. package/src/scenarios/agent-org-chart-scoping.test.ts +137 -0
  43. package/src/scenarios/agent-org-chart-shape.test.ts +127 -0
  44. package/src/scenarios/agent-platform-profile.test.ts +158 -0
  45. package/src/scenarios/agent-roster-attribution.test.ts +179 -0
  46. package/src/scenarios/agent-roster-shape.test.ts +146 -0
  47. package/src/scenarios/budget-policy-shape.test.ts +136 -0
  48. package/src/scenarios/egress-provenance-shape.test.ts +137 -0
  49. package/src/scenarios/memory-capability-model-shape.test.ts +186 -0
  50. package/src/scenarios/oauth-authorization-code-roundtrip.test.ts +145 -0
  51. package/src/scenarios/org-position-no-authority-escalation.test.ts +78 -0
  52. package/src/scenarios/runtime-requires-install-gate.test.ts +92 -0
  53. package/src/scenarios/runtime-requires-shape.test.ts +134 -0
  54. package/src/scenarios/safefetch-behavior.test.ts +99 -0
  55. package/src/scenarios/safefetch-live-audit.test.ts +175 -0
  56. package/src/scenarios/spec-corpus-validity.test.ts +19 -3
  57. package/src/scenarios/tool-descriptor-shape.test.ts +133 -0
  58. package/src/scenarios/trigger-bridge-delivery.test.ts +126 -0
  59. package/src/scenarios/trigger-bridge-shape.test.ts +135 -0
  60. package/src/scenarios/x-openwop-form-pack-manifest.test.ts +155 -0
@@ -0,0 +1,26 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://openwop.dev/spec/v1/trigger-subscription.schema.json",
4
+ "title": "TriggerSubscription",
5
+ "description": "RFC 0083 §B. A durable inbound-trigger subscription record (a webhook registration, a schedule, a queue consumer) with a standardized four-state machine layered over the existing per-source registration. Composes RFC 0052/0053/0017 + webhooks.md + RFC 0040 causation; the channel wire format stays a vendor extension (§E). Content-free of inbound payloads/credentials (SR-1).",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["subscriptionId", "source", "state"],
9
+ "properties": {
10
+ "subscriptionId": { "type": "string", "minLength": 1, "description": "Stable host-unique id for the subscription. Correlates the §C delivery events + the management surface." },
11
+ "source": { "type": "string", "enum": ["webhook", "schedule", "queue", "email", "form"], "description": "Which trigger source backs the subscription. Channels beyond these (Slack/Discord/SMS) bridge as a vendor extension by registering a subscription of the closest source kind (§E)." },
12
+ "state": { "type": "string", "enum": ["active", "paused", "failed", "dead-lettered"], "description": "The §B state. `active`: accepting + delivering; `paused`: retained, not delivering (operator-held); `failed`: delivery failing past policy (the webhooks.md circuit-breaker generalized); `dead-lettered`: terminal, deliveries routed to the RFC 0053 sink." },
13
+ "dedupEnabled": { "type": "boolean", "description": "When true, the host de-duplicates inbound events by `dedupKey` within the retention window (§C-1; the idempotency.md Layer-1 model applied to inbound triggers)." },
14
+ "retryPolicy": {
15
+ "type": "object",
16
+ "additionalProperties": false,
17
+ "description": "Delivery retry policy (§C-2). On exhaustion the subscription/delivery transitions to `dead-lettered` (RFC 0053).",
18
+ "properties": {
19
+ "maxAttempts": { "type": "integer", "minimum": 1, "description": "Maximum delivery attempts before dead-lettering." },
20
+ "backoff": { "type": "string", "enum": ["none", "fixed", "exponential"], "description": "Backoff strategy between attempts." }
21
+ }
22
+ },
23
+ "webhookId": { "type": "string", "minLength": 1, "description": "MAY — for `source: \"webhook\"`, the existing webhooks.md register key (unchanged; the state machine layers over it)." },
24
+ "secretFingerprint": { "type": "string", "minLength": 1, "maxLength": 32, "description": "MAY — for `source: \"webhook\"`, an identifier for the signing secret (the `(webhookId, secretFingerprint)` register key). It MUST be a **salted or host-keyed, TRUNCATED** one-way digest (e.g. the first 8–16 hex of `HMAC(hostKey, secret)`) — NOT the raw secret (SR-1) and NOT a full unsalted `SHA256(secret)` (a full unsalted hash of a low-entropy secret is an offline brute-force / confirmation oracle). The `maxLength: 32` ceiling structurally rejects a full 64-hex digest." }
25
+ }
26
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Shared helpers for the RFC 0087 `agents.orgChart` conformance scenarios.
3
+ * Lives in lib/ (not a `*.test.ts`) so scenarios import it via
4
+ * `../lib/agentOrgChart.js`.
5
+ *
6
+ * The org-chart is structure + a read (like the RFC 0072 inventory), not an
7
+ * event surface — so these helpers wrap the two NORMATIVE reads
8
+ * (`GET /v1/agents/org-chart` + `GET /v1/agents/org-chart/{departmentId}`),
9
+ * exercised black-box against any conformant host. Tenant scoping (RFC 0074)
10
+ * is probed with the `OPENWOP_CROSS_TENANT_ORG_CHART_DEPARTMENT_ID` env var (a
11
+ * department id outside the caller's owner triple), the org-chart analog of the
12
+ * roster scenario's `OPENWOP_CROSS_TENANT_ROSTER_ID`.
13
+ *
14
+ * @see RFCS/0087-agent-org-chart.md
15
+ * @see spec/v1/agent-org-chart.md
16
+ */
17
+ import { driver } from './driver.js';
18
+ import { readCapabilityFamily } from './discovery-capabilities.js';
19
+
20
+ /** Reads `agents.orgChart` from discovery (root-first per RFC 0073); null when
21
+ * unadvertised. */
22
+ export async function readOrgChartCap(): Promise<Record<string, unknown> | null> {
23
+ const agents = await readCapabilityFamily<{ orgChart?: unknown }>('agents');
24
+ const oc = agents?.orgChart;
25
+ return oc && typeof oc === 'object' ? (oc as Record<string, unknown>) : null;
26
+ }
27
+
28
+ export interface OrgDepartment {
29
+ departmentId?: string;
30
+ parentDepartmentId?: string | null;
31
+ [k: string]: unknown;
32
+ }
33
+
34
+ export interface OrgMember {
35
+ rosterId?: string;
36
+ departmentId?: string;
37
+ roleId?: string;
38
+ reportsTo?: string | null;
39
+ [k: string]: unknown;
40
+ }
41
+
42
+ export interface OrgChart {
43
+ owner?: { tenantId?: string; workspaceId?: string };
44
+ departments?: OrgDepartment[];
45
+ members?: OrgMember[];
46
+ }
47
+
48
+ export interface ResponsibilityView {
49
+ department?: { departmentId?: string; [k: string]: unknown };
50
+ members?: OrgMember[];
51
+ responsibilities?: string[];
52
+ }
53
+
54
+ /** GET the NORMATIVE org-chart (RFC 0087 §A `GET /v1/agents/org-chart`);
55
+ * null when the host doesn't serve it (404/405/501). */
56
+ export async function getOrgChart(): Promise<OrgChart | null> {
57
+ const res = await driver.get('/v1/agents/org-chart');
58
+ if (res.status === 404 || res.status === 405 || res.status === 501) return null;
59
+ return (res.json as OrgChart | undefined) ?? {};
60
+ }
61
+
62
+ /** GET a department's §D responsibility roll-up. `recursive` defaults to the
63
+ * host default (true) when undefined. Returns `{ status, view }` so a caller
64
+ * can distinguish a cross-tenant 404 from a served view. */
65
+ export async function getDepartmentView(
66
+ departmentId: string,
67
+ recursive?: boolean,
68
+ ): Promise<{ status: number; view: ResponsibilityView | undefined }> {
69
+ const qs = recursive === undefined ? '' : `?recursive=${recursive ? 'true' : 'false'}`;
70
+ const res = await driver.get(`/v1/agents/org-chart/${encodeURIComponent(departmentId)}${qs}`);
71
+ return { status: res.status, view: res.json as ResponsibilityView | undefined };
72
+ }
73
+
74
+ /** The descriptive key set a member object is allowed to carry on the wire
75
+ * (RFC 0087 §A). Anything outside this — in particular an authority-bearing
76
+ * field — is a §B `org-position-no-authority-escalation` violation. */
77
+ export const MEMBER_DESCRIPTIVE_KEYS = new Set(['rosterId', 'departmentId', 'roleId', 'reportsTo']);
78
+
79
+ /** Authority-bearing field names that MUST NEVER appear on an org-chart wire
80
+ * object (member / department / responsibility view) — position confers no
81
+ * authority (RFC 0087 §B). */
82
+ export const AUTHORITY_FIELDS = ['scopes', 'canDispatch', 'permissions', 'authority', 'roleGrants', 'capabilities'];
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Shared helpers for the RFC 0086 `agents.roster` conformance scenarios.
3
+ * Lives in lib/ (not a `*.test.ts`) so scenarios import it via
4
+ * `../lib/agentRoster.js`.
5
+ *
6
+ * Two surfaces:
7
+ * - the NORMATIVE read (`GET /v1/agents/roster[/{rosterId}]`, RFC 0086 §B),
8
+ * exercised black-box against any conformant host; and
9
+ * - the host-sample fire seam (`POST /v1/host/sample/roster/fire`), used to
10
+ * drive a portfolio trigger so the `roster.run.initiated` attribution +
11
+ * ordering can be asserted against the test event-log seam. The fire seam
12
+ * is OPTIONAL — scenarios soft-skip on 404/405 (the reference roster store
13
+ * is deferred per RFC 0086 §Conformance).
14
+ *
15
+ * @see RFCS/0086-standing-agent-roster-and-workflow-portfolio.md
16
+ * @see spec/v1/agent-roster.md
17
+ */
18
+ import { driver } from './driver.js';
19
+ import { readCapabilityFamily } from './discovery-capabilities.js';
20
+
21
+ /** Reads `agents.roster` from discovery (root-first per RFC 0073); null when
22
+ * unadvertised. */
23
+ export async function readRosterCap(): Promise<Record<string, unknown> | null> {
24
+ const agents = await readCapabilityFamily<{ roster?: unknown }>('agents');
25
+ const r = agents?.roster;
26
+ return r && typeof r === 'object' ? (r as Record<string, unknown>) : null;
27
+ }
28
+
29
+ export interface RosterEntry {
30
+ rosterId?: string;
31
+ persona?: string;
32
+ agentRef?: { agentId?: string; version?: string; channel?: string };
33
+ workflows?: string[];
34
+ owner?: { tenantId?: string; workspaceId?: string };
35
+ [k: string]: unknown;
36
+ }
37
+
38
+ export interface RosterResponse {
39
+ roster?: RosterEntry[];
40
+ total?: number;
41
+ }
42
+
43
+ /** GET the NORMATIVE standing roster (RFC 0086 §B `GET /v1/agents/roster`);
44
+ * null when the host doesn't serve it (404/405/501). */
45
+ export async function listRoster(): Promise<RosterResponse | null> {
46
+ const res = await driver.get('/v1/agents/roster');
47
+ if (res.status === 404 || res.status === 405 || res.status === 501) return null;
48
+ return (res.json as RosterResponse | undefined) ?? {};
49
+ }
50
+
51
+ /** GET a single roster entry by id. Returns `{ status, entry }` so a caller can
52
+ * distinguish a 404 (cross-tenant / unknown) from a served entry. */
53
+ export async function getRosterEntry(
54
+ rosterId: string,
55
+ ): Promise<{ status: number; entry: RosterEntry | undefined }> {
56
+ const res = await driver.get(`/v1/agents/roster/${encodeURIComponent(rosterId)}`);
57
+ return { status: res.status, entry: res.json as RosterEntry | undefined };
58
+ }
59
+
60
+ export interface RosterFireResult {
61
+ runId?: string;
62
+ rosterId?: string;
63
+ triggerSubscriptionId?: string;
64
+ }
65
+
66
+ /** Drive a portfolio trigger for a roster member via the host-sample fire seam.
67
+ * `asWorkItem:true` requests the RFC 0083 durable-work-item path (carries a
68
+ * `triggerSubscriptionId` + run `causationId`). Returns null when the seam is
69
+ * unwired (404/405). */
70
+ export async function fireRosterPortfolio(
71
+ body: { rosterId?: string; triggerSource?: string; asWorkItem?: boolean } = {},
72
+ ): Promise<RosterFireResult | null> {
73
+ const res = await driver.post('/v1/host/sample/roster/fire', body);
74
+ if (res.status === 404 || res.status === 405) return null;
75
+ return (res.json as RosterFireResult | undefined) ?? {};
76
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Shared helpers for the RFC 0077 `agents.liveRuntime` conformance scenarios.
3
+ * Lives in lib/ (not a `*.test.ts`) so scenarios import it via
4
+ * `../lib/liveRuntime.js`.
5
+ *
6
+ * RFC 0077 adds NO new endpoint — a live manifest invocation rides the existing
7
+ * run surface (agent as root of `POST /v1/runs`, a `WorkflowNode.agent` step, or
8
+ * a chat `@mention`) and brackets the existing `agent.*` family with
9
+ * `agent.invocation.started` / `agent.invocation.completed`. To drive one
10
+ * deterministically in conformance, the host exposes the OPTIONAL sample seam
11
+ * `POST /v1/host/sample/agents/live-invoke` returning `{ runId, invocationId }`;
12
+ * the bracketed events are read back via the test event-log seam. The seam is
13
+ * deferred per RFC 0077 §Conformance, so scenarios soft-skip on 404/405.
14
+ *
15
+ * @see RFCS/0077-agent-run-lifecycle-and-live-manifest-dispatch.md
16
+ * @see spec/v1/multi-agent-execution.md §"Live manifest dispatch"
17
+ */
18
+ import { driver } from './driver.js';
19
+ import { readCapabilityFamily } from './discovery-capabilities.js';
20
+
21
+ /** Reads `agents.liveRuntime` from discovery (root-first per RFC 0073); null
22
+ * when unadvertised. */
23
+ export async function readLiveRuntimeCap(): Promise<Record<string, unknown> | null> {
24
+ const agents = await readCapabilityFamily<{ liveRuntime?: unknown }>('agents');
25
+ const lr = agents?.liveRuntime;
26
+ return lr && typeof lr === 'object' ? (lr as Record<string, unknown>) : null;
27
+ }
28
+
29
+ export interface LiveInvokeResult {
30
+ runId?: string;
31
+ invocationId?: string;
32
+ outcome?: string;
33
+ }
34
+
35
+ /**
36
+ * Drive one live manifest invocation via the host-sample seam. Body fields:
37
+ * - `agentId` (optional): the manifest agent to invoke; host picks a default
38
+ * when omitted.
39
+ * - `source` (optional): `workflow-node` | `run-api` | `chat-mention`.
40
+ * - `returnSchemaRef` (optional) + `forceInvalidResult` (optional): exercise
41
+ * the §B step-6 structured-output enforcement — force a result that violates
42
+ * the handoff schema so a `structuredOutput` host fails the run.
43
+ * - `attemptTool` (optional): the id of a tool OUTSIDE the agent's
44
+ * `toolAllowlist` the invocation should attempt (the §F-1 allowlist floor).
45
+ * Returns null when the seam is unwired (404/405).
46
+ */
47
+ export async function invokeLive(
48
+ body: {
49
+ agentId?: string;
50
+ source?: string;
51
+ returnSchemaRef?: string;
52
+ forceInvalidResult?: boolean;
53
+ attemptTool?: string;
54
+ } = {},
55
+ ): Promise<LiveInvokeResult | null> {
56
+ const res = await driver.post('/v1/host/sample/agents/live-invoke', body);
57
+ if (res.status === 404 || res.status === 405) return null;
58
+ return (res.json as LiveInvokeResult | undefined) ?? {};
59
+ }
@@ -30,6 +30,8 @@ export const PROFILE_NAMES = [
30
30
  'openwop-node-packs',
31
31
  'openwop-replay-fork',
32
32
  'openwop-fixtures',
33
+ 'openwop-memory',
34
+ 'openwop-trigger-bridge',
33
35
  ] as const;
34
36
 
35
37
  export type ProfileName = (typeof PROFILE_NAMES)[number];
@@ -211,6 +213,155 @@ export function isFixtures(c: DiscoveryPayload): boolean {
211
213
  return c.fixtures.every((id) => typeof id === 'string' && id.length > 0);
212
214
  }
213
215
 
216
+ /**
217
+ * `openwop-memory` predicate (RFC 0080). Host implements the reconciled
218
+ * memory-capability model at the core tier: a read/write `MemoryAdapter`
219
+ * (`memory.supported: true` and `memory.writable !== false`) plus a cross-run
220
+ * durable store (`agents.memoryBackends` includes `'long-term'`). Capability
221
+ * families are document-root properties of the discovery payload (RFC 0073),
222
+ * so this reads `c.memory` / `c.agents`, matching `isReplayFork`.
223
+ *
224
+ * @see spec/v1/profiles.md §`openwop-memory`
225
+ * @see spec/v1/agent-memory.md §"Memory capability model"
226
+ */
227
+ export function isMemory(c: DiscoveryPayload): boolean {
228
+ if (!isCore(c)) return false;
229
+ const memory = c.memory as { supported?: unknown; writable?: unknown } | undefined;
230
+ if (memory == null || typeof memory !== 'object') return false;
231
+ if (memory.supported !== true) return false;
232
+ if (memory.writable === false) return false;
233
+ const agents = c.agents as { memoryBackends?: unknown } | undefined;
234
+ if (agents == null || !isStringArray(agents.memoryBackends)) return false;
235
+ return agents.memoryBackends.includes('long-term');
236
+ }
237
+
238
+ /**
239
+ * `openwop-trigger-bridge` predicate (RFC 0083). Host composes the durable
240
+ * inbound-work contract: advertises the `triggerBridge`, has a `deadLetter`
241
+ * sink for exhausted deliveries, and has at least one durable inbound source
242
+ * (queue bus, durable webhooks, or scheduling). Capability families are
243
+ * document-root properties (RFC 0073), so this reads `c.triggerBridge` /
244
+ * `c.deadLetter` / `c.queueBus` / `c.webhooks` / `c.scheduling`.
245
+ *
246
+ * @see spec/v1/profiles.md §`openwop-trigger-bridge`
247
+ * @see spec/v1/trigger-bridge.md
248
+ */
249
+ export function isTriggerBridge(c: DiscoveryPayload): boolean {
250
+ if (!isCore(c)) return false;
251
+ const supported = (v: unknown): boolean =>
252
+ v != null && typeof v === 'object' && (v as { supported?: unknown }).supported === true;
253
+ if (!supported(c.triggerBridge)) return false;
254
+ if (!supported(c.deadLetter)) return false;
255
+ const webhooks = c.webhooks as { durable?: unknown } | undefined;
256
+ const durableSource =
257
+ supported(c.queueBus) ||
258
+ supported(c.scheduling) ||
259
+ (webhooks != null && typeof webhooks === 'object' && webhooks.durable === true);
260
+ return durableSource;
261
+ }
262
+
263
+ // ─────────────────────────────────────────────────────────────────────────────
264
+ // Operational annex: openwop-agent-platform (RFC 0085).
265
+ //
266
+ // NOT part of the closed `profiles.md` predicate catalog (PROFILE_NAMES /
267
+ // deriveProfiles above) — it is an operational ANNEX (the production-profile.md /
268
+ // auth-profiles.md pattern) combining a discovery predicate with required runtime
269
+ // conformance evidence + documentation + a badge. These helpers compute only the
270
+ // discovery-PREDICATE part; the live aggregate-evidence assertion (does every
271
+ // constituent scenario actually pass?) lives in agent-platform-profile.test.ts.
272
+ //
273
+ // @see spec/v1/agent-platform-profile.md
274
+ // ─────────────────────────────────────────────────────────────────────────────
275
+
276
+ /** Narrow helper: a capability sub-block with `supported === true`. */
277
+ function blockSupported(v: unknown): boolean {
278
+ return v != null && typeof v === 'object' && (v as { supported?: unknown }).supported === true;
279
+ }
280
+
281
+ /** The `openwop-agent-platform` FLOOR (`partial`) discovery predicate — RFC 0085 §B. */
282
+ export function isAgentPlatformPartial(c: DiscoveryPayload): boolean {
283
+ if (!isCore(c)) return false;
284
+ const agents = c.agents as { manifestRuntime?: unknown; liveRuntime?: unknown } | undefined;
285
+ const httpClient = c.httpClient as { safeFetch?: unknown; egressPolicy?: unknown } | undefined;
286
+ const replay = c.replay as { supported?: unknown } | undefined;
287
+ const nondet = c.nondeterminismPolicy as { declared?: unknown } | undefined;
288
+ return (
289
+ blockSupported(agents?.manifestRuntime) &&
290
+ blockSupported(agents?.liveRuntime) &&
291
+ blockSupported(c.toolCatalog) &&
292
+ blockSupported(c.toolHooks) &&
293
+ blockSupported(httpClient?.safeFetch) &&
294
+ blockSupported(c.providerUsage) &&
295
+ blockSupported(c.prompts) &&
296
+ blockSupported(c.memory) &&
297
+ blockSupported(c.feedback) &&
298
+ (replay?.supported === true || nondet?.declared === true)
299
+ );
300
+ }
301
+
302
+ /** The `openwop-agent-platform` `full` discovery predicate (floor + governance tier) — RFC 0085 §B. */
303
+ export function isAgentPlatformFull(c: DiscoveryPayload): boolean {
304
+ if (!isAgentPlatformPartial(c)) return false;
305
+ const agents = c.agents as { manifestRuntime?: { installScope?: unknown } } | undefined;
306
+ const memory = c.memory as { attribution?: unknown } | undefined;
307
+ // Debug bundle is advertised at `capabilities.debugBundle.supported` (debug-bundle.md /
308
+ // RFC 0009), NOT under `production.*` — the production block only adds stricter truncation MUSTs.
309
+ const httpClient = c.httpClient as { egressPolicy?: unknown } | undefined;
310
+ return (
311
+ blockSupported(c.authorization) &&
312
+ agents?.manifestRuntime?.installScope === 'tenant' &&
313
+ blockSupported(memory?.attribution) &&
314
+ blockSupported(c.debugBundle) &&
315
+ blockSupported(c.triggerBridge) &&
316
+ blockSupported(httpClient?.egressPolicy)
317
+ );
318
+ }
319
+
320
+ /** The host-reported annex status: `full` ⊃ `partial` ⊃ `none` (discovery-predicate only). */
321
+ export function agentPlatformStatus(c: DiscoveryPayload): 'none' | 'partial' | 'full' {
322
+ if (isAgentPlatformFull(c)) return 'full';
323
+ if (isAgentPlatformPartial(c)) return 'partial';
324
+ return 'none';
325
+ }
326
+
327
+ /**
328
+ * The per-term satisfaction breakdown (RFC 0085 §D) — the richer interop signal
329
+ * alongside the flat `none`/`partial`/`full` ladder. Adoption is NON-CONTIGUOUS:
330
+ * a real host built feature-by-feature can satisfy `full`-tier terms (RBAC,
331
+ * memory-attribution, tenant-scoping) while still failing `floor` terms, so the
332
+ * flat status would understate it (reads identical to a do-nothing host). This
333
+ * returns exactly the term ids a host satisfies, so a `none` host honoring 6/16
334
+ * terms is distinguishable from one honoring 0/16.
335
+ */
336
+ export function agentPlatformSatisfiedTerms(c: DiscoveryPayload): readonly string[] {
337
+ const agents = c.agents as { manifestRuntime?: { installScope?: unknown }; liveRuntime?: unknown } | undefined;
338
+ const httpClient = c.httpClient as { safeFetch?: unknown; egressPolicy?: unknown } | undefined;
339
+ const memory = c.memory as { attribution?: unknown } | undefined;
340
+ const replay = c.replay as { supported?: unknown } | undefined;
341
+ const nondet = c.nondeterminismPolicy as { declared?: unknown } | undefined;
342
+ const checks: ReadonlyArray<readonly [string, boolean]> = [
343
+ // floor
344
+ ['floor:agents.manifestRuntime', blockSupported(agents?.manifestRuntime)],
345
+ ['floor:agents.liveRuntime', blockSupported(agents?.liveRuntime)],
346
+ ['floor:toolCatalog', blockSupported(c.toolCatalog)],
347
+ ['floor:toolHooks', blockSupported(c.toolHooks)],
348
+ ['floor:httpClient.safeFetch', blockSupported(httpClient?.safeFetch)],
349
+ ['floor:providerUsage', blockSupported(c.providerUsage)],
350
+ ['floor:prompts', blockSupported(c.prompts)],
351
+ ['floor:memory', blockSupported(c.memory)],
352
+ ['floor:feedback', blockSupported(c.feedback)],
353
+ ['floor:replay-or-nondeterminism', replay?.supported === true || nondet?.declared === true],
354
+ // full (governance)
355
+ ['full:authorization', blockSupported(c.authorization)],
356
+ ['full:tenant-installScope', agents?.manifestRuntime?.installScope === 'tenant'],
357
+ ['full:memory.attribution', blockSupported(memory?.attribution)],
358
+ ['full:debugBundle', blockSupported(c.debugBundle)],
359
+ ['full:triggerBridge', blockSupported(c.triggerBridge)],
360
+ ['full:egressPolicy', blockSupported(httpClient?.egressPolicy)],
361
+ ];
362
+ return checks.filter(([, ok]) => ok).map(([id]) => id);
363
+ }
364
+
214
365
  /**
215
366
  * Derive the full profile set from a discovery payload.
216
367
  *
@@ -228,6 +379,8 @@ export function deriveProfiles(c: DiscoveryPayload): readonly ProfileName[] {
228
379
  if (isNodePacksDiscovery(c)) result.push('openwop-node-packs');
229
380
  if (isReplayFork(c)) result.push('openwop-replay-fork');
230
381
  if (isFixtures(c)) result.push('openwop-fixtures');
382
+ if (isMemory(c)) result.push('openwop-memory');
383
+ if (isTriggerBridge(c)) result.push('openwop-trigger-bridge');
231
384
  return result;
232
385
  }
233
386
 
@@ -254,5 +407,9 @@ export function hasProfile(c: DiscoveryPayload, profile: ProfileName): boolean {
254
407
  return isReplayFork(c);
255
408
  case 'openwop-fixtures':
256
409
  return isFixtures(c);
410
+ case 'openwop-memory':
411
+ return isMemory(c);
412
+ case 'openwop-trigger-bridge':
413
+ return isTriggerBridge(c);
257
414
  }
258
415
  }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Shared helper for the RFC 0076 §A `runtime.requires[]` install-gate
3
+ * conformance scenarios. Lives in lib/ (not a *.test.ts) so scenarios import it
4
+ * via `../lib/runtimeRequires.js`.
5
+ *
6
+ * Drives the conformance-only host seam specified in host-sample-test-seams.md
7
+ * §"Open seams": `POST /v1/host/sample/packs/install-gate`. The seam evaluates a
8
+ * manifest's `runtime.requires[]` against a simulated host grant-set and returns
9
+ * the install-time outcome the host would produce — letting a single seam
10
+ * exercise the grant / refuse / non-sandbox-projection behaviors deterministically.
11
+ */
12
+ import { driver } from './driver.js';
13
+
14
+ export interface InstallGateRequest {
15
+ /** The candidate pack manifest (carrying runtime.requires[]). */
16
+ manifest: Record<string, unknown>;
17
+ /** Primitives the simulated sandbox grants. Ignored when `gating === false`. */
18
+ grantSet?: string[];
19
+ /** Whether the simulated host gates platform access. Default true (sandbox host). */
20
+ gating?: boolean;
21
+ }
22
+
23
+ export interface InstallGateResponse {
24
+ /** HTTP status the seam returned (200 install, 400 refuse). */
25
+ status: number;
26
+ /** Parsed response body. */
27
+ body: Record<string, unknown>;
28
+ }
29
+
30
+ /**
31
+ * Drives one install-gate evaluation via the host-sample seam, or null
32
+ * (soft-skip) when the host doesn't expose it.
33
+ */
34
+ export async function installGate(req: InstallGateRequest): Promise<InstallGateResponse | null> {
35
+ const res = await driver.post('/v1/host/sample/packs/install-gate', req as unknown as Record<string, unknown>);
36
+ if (res.status === 404 || res.status === 405) return null; // seam absent — soft-skip
37
+ return { status: res.status, body: (res.json as Record<string, unknown> | undefined) ?? {} };
38
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Shared helper for the RFC 0076 §B `ctx.http.safeFetch` conformance scenarios.
3
+ * Lives in lib/ (not a *.test.ts) so scenarios import it via `../lib/safeFetch.js`.
4
+ *
5
+ * Reads `capabilities.httpClient.safeFetch` (root-first, wrapper-fallback) and
6
+ * drives the conformance-only host seam `POST /v1/host/sample/http/safe-fetch`
7
+ * (host-sample-test-seams.md §"Open seams").
8
+ */
9
+ import { driver } from './driver.js';
10
+ import { capabilityFamily } from './discovery-capabilities.js';
11
+
12
+ interface HttpClientCap {
13
+ supported?: boolean;
14
+ safeFetch?: { supported?: boolean };
15
+ }
16
+
17
+ /** True when the host advertises `capabilities.httpClient.safeFetch.supported`. */
18
+ export async function isSafeFetchSupported(): Promise<boolean> {
19
+ const disco = await driver.get('/.well-known/openwop');
20
+ return capabilityFamily<HttpClientCap>(disco.json, 'httpClient')?.safeFetch?.supported === true;
21
+ }
22
+
23
+ /** True when the host also advertises `capabilities.toolHooks.prePostEvents`. */
24
+ export async function isToolHookAuditOn(): Promise<boolean> {
25
+ const disco = await driver.get('/.well-known/openwop');
26
+ return capabilityFamily<{ prePostEvents?: boolean }>(disco.json, 'toolHooks')?.prePostEvents === true;
27
+ }
28
+
29
+ export interface SafeFetchResult {
30
+ outcome?: 'fetched' | 'blocked';
31
+ status?: number;
32
+ blocked?: 'ssrf' | 'upgrade' | string;
33
+ toolCalled?: Record<string, unknown>;
34
+ toolReturned?: Record<string, unknown>;
35
+ }
36
+
37
+ /**
38
+ * Drives one safeFetch evaluation via the host-sample seam, or null (soft-skip)
39
+ * when the host doesn't expose it.
40
+ */
41
+ export async function safeFetch(body: Record<string, unknown>): Promise<SafeFetchResult | null> {
42
+ const res = await driver.post('/v1/host/sample/http/safe-fetch', body);
43
+ if (res.status === 404 || res.status === 405) return null; // seam absent — soft-skip
44
+ return (res.json as SafeFetchResult | undefined) ?? {};
45
+ }
46
+
47
+ /**
48
+ * True when the host advertises BOTH `httpClient.safeFetch.supported` AND
49
+ * `toolHooks.prePostEvents` — the co-advertisement that, per
50
+ * `host-capabilities.md` §host.http + RFC 0076 §B, makes live audit-pair
51
+ * emission a MUST. One discovery fetch (the two single-flag helpers above each
52
+ * fetch; this avoids the double round-trip for the live-audit gate).
53
+ */
54
+ export async function isSafeFetchLiveAuditAdvertised(): Promise<boolean> {
55
+ const disco = await driver.get('/.well-known/openwop');
56
+ const safeFetchOn =
57
+ capabilityFamily<HttpClientCap>(disco.json, 'httpClient')?.safeFetch?.supported === true;
58
+ const auditOn =
59
+ capabilityFamily<{ prePostEvents?: boolean }>(disco.json, 'toolHooks')?.prePostEvents === true;
60
+ return safeFetchOn && auditOn;
61
+ }
62
+
63
+ /** Result of the live-run safe-fetch seam: the host executed one
64
+ * `ctx.http.safeFetch` call inside a real run via the production injection
65
+ * path, and returns the run's id so the caller can read the durable event
66
+ * log. `null` ⇒ the run seam is unwired (soft-skip, host-pending). */
67
+ export interface SafeFetchRunResult {
68
+ runId?: string;
69
+ outcome?: 'fetched' | 'blocked';
70
+ }
71
+
72
+ /**
73
+ * Drives one `ctx.http.safeFetch` call **inside a real run** via the open seam
74
+ * `POST /v1/host/sample/http/safe-fetch-run`, returning `{ runId, outcome }`,
75
+ * or null (soft-skip) when the run seam isn't wired. Distinct from `safeFetch`
76
+ * (which returns the audit pair INLINE from the seam): this exercises the
77
+ * production per-ctx `ctx.http.safeFetch` path so the caller can assert the
78
+ * `agent.toolCalled`/`agent.toolReturned` pair landed in the DURABLE run event
79
+ * log — closing the seam-vs-production gap in `safefetch-behavior.test.ts`.
80
+ */
81
+ export async function safeFetchViaRun(
82
+ body: Record<string, unknown>,
83
+ ): Promise<SafeFetchRunResult | null> {
84
+ const res = await driver.post('/v1/host/sample/http/safe-fetch-run', body);
85
+ if (res.status === 404 || res.status === 405) return null; // run seam unwired — soft-skip
86
+ return (res.json as SafeFetchRunResult | undefined) ?? {};
87
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Shared helpers for the RFC 0083 `triggerBridge` conformance scenario.
3
+ * Lives in lib/ (not a `*.test.ts`) so scenarios import it via
4
+ * `../lib/triggerBridge.js`.
5
+ *
6
+ * Two surfaces:
7
+ * - the NORMATIVE read (`GET /v1/trigger-subscriptions[/{subscriptionId}]`,
8
+ * RFC 0083 §A), exercised black-box; and
9
+ * - the host-sample delivery seam (`POST /v1/host/sample/trigger-bridge/deliver`),
10
+ * used to drive the §C delivery model (dedup → retry → dead-letter →
11
+ * causation) so the two `trigger.*` events can be asserted against the test
12
+ * event-log seam. The seam is OPTIONAL — scenarios soft-skip on 404/405
13
+ * (reference durable-delivery is deferred per RFC 0083 §Conformance).
14
+ *
15
+ * Gating uses the `openwop-trigger-bridge` PROFILE derived from the live
16
+ * discovery doc (the bridge + a dead-letter sink + a durable source, §D), not a
17
+ * bare capability flag.
18
+ *
19
+ * @see RFCS/0083-durable-trigger-and-channel-bridge-profile.md
20
+ * @see spec/v1/trigger-bridge.md
21
+ * @see spec/v1/profiles.md (§openwop-trigger-bridge)
22
+ */
23
+ import { driver } from './driver.js';
24
+ import { deriveProfiles, type DiscoveryPayload } from './profiles.js';
25
+
26
+ /** True when the live host's discovery derives the `openwop-trigger-bridge`
27
+ * profile (RFC 0083 §D predicate: bridge advertised + dead-letter sink + a
28
+ * durable source). */
29
+ export async function isTriggerBridgeProfileAdvertised(): Promise<boolean> {
30
+ const disco = await driver.get('/.well-known/openwop');
31
+ if (disco.status !== 200 || !disco.json) return false;
32
+ return deriveProfiles(disco.json as DiscoveryPayload).includes('openwop-trigger-bridge');
33
+ }
34
+
35
+ export interface TriggerSubscription {
36
+ subscriptionId?: string;
37
+ source?: string;
38
+ state?: string;
39
+ [k: string]: unknown;
40
+ }
41
+
42
+ /** GET the NORMATIVE subscription read surface (RFC 0083 §A
43
+ * `GET /v1/trigger-subscriptions`); null when not served (404/405/501). */
44
+ export async function listTriggerSubscriptions(): Promise<{ subscriptions?: TriggerSubscription[] } | null> {
45
+ const res = await driver.get('/v1/trigger-subscriptions');
46
+ if (res.status === 404 || res.status === 405 || res.status === 501) return null;
47
+ return (res.json as { subscriptions?: TriggerSubscription[] } | undefined) ?? {};
48
+ }
49
+
50
+ export interface DeliveryResult {
51
+ runId?: string;
52
+ subscriptionId?: string;
53
+ outcome?: string;
54
+ deliveredCount?: number;
55
+ }
56
+
57
+ /**
58
+ * Drive one delivery through the host-sample bridge seam. `scenario`:
59
+ * - `dedup` — deliver the same `dedupKey` twice; effectively-once (§C-1).
60
+ * - `exhaust` — exhaust the retry policy → `dead-lettered` (§C-2 + RFC 0053).
61
+ * - `deliver` — a single successful delivery whose run's `run.started`
62
+ * carries the delivery `causationId` (§C / RFC 0040).
63
+ * Returns null when the seam is unwired (404/405).
64
+ */
65
+ export async function driveDelivery(
66
+ body: { scenario: 'dedup' | 'exhaust' | 'deliver'; dedupKey?: string; source?: string },
67
+ ): Promise<DeliveryResult | null> {
68
+ const res = await driver.post('/v1/host/sample/trigger-bridge/deliver', body);
69
+ if (res.status === 404 || res.status === 405) return null;
70
+ return (res.json as DeliveryResult | undefined) ?? {};
71
+ }
72
+
73
+ export const SUBSCRIPTION_STATES = ['active', 'paused', 'failed', 'dead-lettered'];
74
+ export const DELIVERY_OUTCOMES = ['delivered', 'retrying', 'dead-lettered'];