@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.
- package/CHANGELOG.md +48 -0
- package/README.md +2 -2
- package/api/asyncapi.yaml +70 -0
- package/api/openapi.yaml +268 -1
- package/coverage.md +33 -2
- package/fixtures/oauth-providers/synthetic.json +38 -0
- package/fixtures.md +10 -0
- package/package.json +1 -1
- package/schemas/README.md +12 -0
- package/schemas/agent-deployment-transition.schema.json +49 -0
- package/schemas/agent-deployment.schema.json +54 -0
- package/schemas/agent-eval-suite.schema.json +140 -0
- package/schemas/agent-inventory-response.schema.json +25 -0
- package/schemas/agent-manifest.schema.json +5 -0
- package/schemas/agent-org-chart.schema.json +82 -0
- package/schemas/agent-ref.schema.json +12 -2
- package/schemas/agent-roster-entry.schema.json +81 -0
- package/schemas/agent-roster-response.schema.json +21 -0
- package/schemas/budget-policy.schema.json +18 -0
- package/schemas/capabilities.schema.json +277 -0
- package/schemas/credential-provenance.schema.json +18 -0
- package/schemas/eval-summary.schema.json +92 -0
- package/schemas/node-pack-manifest.schema.json +17 -0
- package/schemas/org-chart-responsibility-view.schema.json +26 -0
- package/schemas/run-event-payloads.schema.json +286 -3
- package/schemas/run-event.schema.json +19 -0
- package/schemas/tool-descriptor.schema.json +63 -0
- package/schemas/trigger-subscription.schema.json +26 -0
- package/src/lib/agentOrgChart.ts +82 -0
- package/src/lib/agentRoster.ts +76 -0
- package/src/lib/liveRuntime.ts +59 -0
- package/src/lib/profiles.ts +157 -0
- package/src/lib/runtimeRequires.ts +38 -0
- package/src/lib/safeFetch.ts +87 -0
- package/src/lib/triggerBridge.ts +74 -0
- package/src/scenarios/agent-deployment-shape.test.ts +139 -0
- package/src/scenarios/agent-eval-suite-shape.test.ts +167 -0
- package/src/scenarios/agent-live-allowlist-enforced.test.ts +53 -0
- package/src/scenarios/agent-live-invocation-bracket.test.ts +98 -0
- package/src/scenarios/agent-live-runtime-shape.test.ts +98 -0
- package/src/scenarios/agent-live-structured-output.test.ts +58 -0
- package/src/scenarios/agent-org-chart-scoping.test.ts +137 -0
- package/src/scenarios/agent-org-chart-shape.test.ts +127 -0
- package/src/scenarios/agent-platform-profile.test.ts +158 -0
- package/src/scenarios/agent-roster-attribution.test.ts +179 -0
- package/src/scenarios/agent-roster-shape.test.ts +146 -0
- package/src/scenarios/budget-policy-shape.test.ts +136 -0
- package/src/scenarios/egress-provenance-shape.test.ts +137 -0
- package/src/scenarios/memory-capability-model-shape.test.ts +186 -0
- package/src/scenarios/oauth-authorization-code-roundtrip.test.ts +145 -0
- package/src/scenarios/org-position-no-authority-escalation.test.ts +78 -0
- package/src/scenarios/runtime-requires-install-gate.test.ts +92 -0
- package/src/scenarios/runtime-requires-shape.test.ts +134 -0
- package/src/scenarios/safefetch-behavior.test.ts +99 -0
- package/src/scenarios/safefetch-live-audit.test.ts +175 -0
- package/src/scenarios/spec-corpus-validity.test.ts +19 -3
- package/src/scenarios/tool-descriptor-shape.test.ts +133 -0
- package/src/scenarios/trigger-bridge-delivery.test.ts +126 -0
- package/src/scenarios/trigger-bridge-shape.test.ts +135 -0
- 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
|
+
}
|
package/src/lib/profiles.ts
CHANGED
|
@@ -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'];
|