@openwop/openwop-conformance 1.57.0 → 1.62.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.
@@ -0,0 +1,172 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://openwop.dev/spec/v1/form-content-pack-manifest.schema.json",
4
+ "title": "FormContentPackManifest",
5
+ "description": "Manifest for a published OpenWOP form-content pack — `pack.json` at the pack root with `kind: \"form-content\"`. Peer to the node / workflow-chain / prompt / artifact-type / card pack manifests; disjoint via the `kind` discriminator. See `spec/v1/form-content-packs.md` for the canonical contract and RFC 0137 for the rationale.\n\nA form-content pack distributes **form templates**: a named, versioned set of typed input fields a host instantiates into an ordinary, fully editable form through its normal create path. It is purely declarative — no `runtime`, no executor bytecode, no entry point. The pack contributes the *shape* of a form; it does not define a submission surface, a handler, or any behavior the host would not otherwise accept.\n\n**A template carries NO submission routing, and MUST NOT gain any.** There is deliberately no `intakeBinding` / destination / webhook / list-id property on `FormTemplate`, and `additionalProperties: false` rejects one. Where a tenant's form submissions go is a decision about the OPERATOR's data; a third-party pack author does not get to make it by shipping a manifest. Enforced as the `form-content-template-no-submission-routing` SECURITY invariant — see `form-content-packs.md` §\"No submission routing\".\n\n**The wire vocabulary governs the WIRE, not host storage.** A host MAY store fields under any internal representation it likes and translate at the pack boundary; the spec constrains what crosses the wire, not what sits in a host's database (`positioning.md`).\n\n**Field types are the RFC 0071 portable subset, not a second vocabulary.** `fields[].type` reuses the closed portable subset defined in `chat-card-packs.md` §\"Input fields — a closed portable subset\" (RFC 0071 Phase 2, gap G9 resolved 2026-05-27) verbatim, including its `vendor.<org>.<kind>` / `x-<kind>` extension escape hatch. Two declarative pack kinds that both collect typed user input MUST agree on what a field type means.",
6
+ "type": "object",
7
+ "required": ["name", "version", "kind", "engines", "templates"],
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "kind": {
11
+ "type": "string",
12
+ "const": "form-content",
13
+ "description": "Pack kind discriminator. MUST be the literal string `\"form-content\"`."
14
+ },
15
+ "name": {
16
+ "type": "string",
17
+ "description": "Reverse-DNS pack name per `node-packs.md` §Naming. Reserved scopes are identical (`core.*` / `vendor.<org>.*` / `community.<author>.*` / `private.<host>.*`).",
18
+ "pattern": "^(core|vendor|community|private)\\.[a-z][a-z0-9_-]*(\\.[a-z][a-zA-Z0-9_-]*)+$",
19
+ "minLength": 1,
20
+ "maxLength": 256
21
+ },
22
+ "version": {
23
+ "type": "string",
24
+ "description": "Pack-level SemVer 2.0.0.",
25
+ "pattern": "^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?$"
26
+ },
27
+ "description": { "type": "string", "maxLength": 1024 },
28
+ "author": { "type": "string" },
29
+ "license": { "type": "string", "description": "SPDX license identifier (e.g., `Apache-2.0`, `MIT`)." },
30
+ "homepage": { "type": "string", "format": "uri" },
31
+ "repository": { "type": "string", "format": "uri" },
32
+ "keywords": {
33
+ "type": "array",
34
+ "items": { "type": "string", "maxLength": 64 },
35
+ "maxItems": 50
36
+ },
37
+ "engines": {
38
+ "type": "object",
39
+ "required": ["openwop"],
40
+ "properties": {
41
+ "openwop": { "type": "string", "description": "Semver range — which openwop protocol versions this pack works against." }
42
+ },
43
+ "additionalProperties": true
44
+ },
45
+ "dependencies": {
46
+ "type": "object",
47
+ "additionalProperties": { "type": "string" },
48
+ "description": "Other packs this pack depends on. Map of pack name → semver range."
49
+ },
50
+ "peerDependencies": {
51
+ "type": "object",
52
+ "additionalProperties": { "type": "string" },
53
+ "description": "Engine-supplied capabilities the pack consumes (e.g., `{ \"host.forms.contentPacks\": \"supported\" }`). Resolved against the host's advertised capabilities at register time."
54
+ },
55
+ "templates": {
56
+ "type": "array",
57
+ "minItems": 1,
58
+ "maxItems": 100,
59
+ "items": { "$ref": "#/$defs/FormTemplate" },
60
+ "description": "Form template definitions this pack contributes. Each MUST have a unique `templateId` within the pack — duplicates MUST be refused at registry `PUT` / install (`form-content-packs.md` §\"Unique identifiers\"); JSON Schema cannot express uniqueness-by-property, so this is a normative prose requirement, as it is for every peer pack kind. `maxItems` is an OUTER resource bound against a render bomb, not product policy — a host MAY enforce a tighter limit locally."
61
+ },
62
+ "signing": { "$ref": "#/$defs/Signing" }
63
+ },
64
+ "$defs": {
65
+ "FormTemplate": {
66
+ "type": "object",
67
+ "required": ["templateId", "version", "label", "title", "fields"],
68
+ "additionalProperties": false,
69
+ "properties": {
70
+ "templateId": {
71
+ "type": "string",
72
+ "description": "Reverse-DNS template identifier. Same pattern and reserved scopes as a pack `name`. This is the id a registry denormalizes for discovery (`registry-operations.md` §\"Type-ID indexing\") and the id a host resolves when instantiating. Third parties MUST NOT publish under `core.*`.",
73
+ "pattern": "^(core|vendor|community|private)\\.[a-z][a-z0-9_-]*(\\.[a-z][a-zA-Z0-9_-]*)+$",
74
+ "minLength": 1,
75
+ "maxLength": 256
76
+ },
77
+ "version": {
78
+ "type": "string",
79
+ "description": "Per-template SemVer 2.0.0 — the SAME axis as the pack `version`, NOT the non-negative-integer schema-version axis used by `chat-card-pack-manifest.schema.json` `schemaVersion` / `artifact-type-pack-manifest.schema.json` `schemaVersion`. A form template is a distributable artifact whose shape changes are author-meaningful (a removed field is a breaking change to anything storing its submissions), so it carries the same SemVer contract as the pack.",
80
+ "pattern": "^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?$"
81
+ },
82
+ "label": {
83
+ "type": "string",
84
+ "minLength": 1,
85
+ "maxLength": 200,
86
+ "description": "Short human-readable name for a picker / gallery listing. UNTRUSTED pack-authored content — see `form-content-packs.md` §\"Trust boundary\". The length bound is a resource guard, NOT a trust boundary."
87
+ },
88
+ "title": {
89
+ "type": "string",
90
+ "minLength": 1,
91
+ "maxLength": 200,
92
+ "description": "Heading rendered above the instantiated form. UNTRUSTED pack-authored content — see `form-content-packs.md` §\"Trust boundary\"."
93
+ },
94
+ "description": {
95
+ "type": "string",
96
+ "maxLength": 300,
97
+ "description": "Optional explanatory copy. UNTRUSTED pack-authored content — see `form-content-packs.md` §\"Trust boundary\"."
98
+ },
99
+ "category": {
100
+ "type": "string",
101
+ "maxLength": 64,
102
+ "description": "Optional grouping hint for a picker. Advisory — the spec assigns no meaning to any particular value and a host MUST NOT treat it as an authorization or visibility boundary."
103
+ },
104
+ "fields": {
105
+ "type": "array",
106
+ "minItems": 1,
107
+ "maxItems": 200,
108
+ "items": { "$ref": "#/$defs/FormField" },
109
+ "description": "Typed fields the template contributes, in presentation order. Each MUST have a unique `id` within the template — duplicates MUST be refused (`form-content-packs.md` §\"Unique identifiers\"). Two fields sharing an `id` silently overwrite one another in the submission value bag, losing data with no error, so this is a data-integrity requirement rather than a style rule. `maxItems` is an OUTER resource bound; a host MAY enforce a tighter limit locally."
110
+ }
111
+ }
112
+ },
113
+ "FormField": {
114
+ "type": "object",
115
+ "required": ["id", "type", "label"],
116
+ "additionalProperties": false,
117
+ "description": "One typed field. `id` (not `key`) matches `chat-card-pack-manifest.schema.json#/$defs/InputField` so a host renders both declarative kinds through the same machinery.",
118
+ "properties": {
119
+ "id": {
120
+ "type": "string",
121
+ "minLength": 1,
122
+ "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$",
123
+ "description": "Field identifier, unique within the template. Mirrors `chat-card-pack-manifest.schema.json` `InputField.id`."
124
+ },
125
+ "type": {
126
+ "type": "string",
127
+ "description": "Closed portable subset OR a `vendor.<org>.<kind>` / `x-<kind>` host extension other hosts MUST ignore (degrading to a plain text input). The subset is IDENTICAL to `chat-card-pack-manifest.schema.json` `InputField.type` (RFC 0071 G9, resolved 2026-05-27): `text`, `longtext`, `number`, `boolean`, `select`, `multiselect`, `file`, `artifact-ref`. Named by DATA KIND, not widget — a host renders `boolean` as whatever toggle/checkbox it likes, and `longtext` as whatever multi-line control it likes. Validation formats (`email`, `uri`, …) are NOT types; use `format`.",
128
+ "pattern": "^(text|longtext|number|boolean|select|multiselect|file|artifact-ref|vendor\\.[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*|x-[a-z][a-z0-9-]*)$"
129
+ },
130
+ "format": {
131
+ "type": "string",
132
+ "description": "Optional validation-format constraint on the field's value, following the JSON Schema `format` idiom used throughout this corpus. Spec-reserved core identifiers: `email`, `uri`, `date`, `date-time`, `time`. Host extensions MUST be `x-<format>`-prefixed. Only meaningful for `type: \"text\"` / `\"longtext\"`; a host MUST ignore it on any other type. A host that recognizes the format SHOULD apply it as an input-validation constraint; a host that does NOT recognize it MUST ignore it and accept the value as plain text (the same degradation contract as an unrecognized `type`). `format` is a CONSTRAINT on a `text` field — it is deliberately not a member of the `type` vocabulary, so the portable type set stays a stable set of data kinds instead of accreting widgets.",
133
+ "pattern": "^(email|uri|date|date-time|time|x-[a-z][a-z0-9-]*)$"
134
+ },
135
+ "label": {
136
+ "type": "string",
137
+ "minLength": 1,
138
+ "maxLength": 1000,
139
+ "description": "Human-readable field label. Bounded at 1000 rather than 200 because a lawful consent label (GDPR/marketing opt-in) is legitimately long-form. UNTRUSTED pack-authored content — see `form-content-packs.md` §\"Trust boundary\"."
140
+ },
141
+ "description": {
142
+ "type": "string",
143
+ "maxLength": 300,
144
+ "description": "Optional help text. UNTRUSTED pack-authored content — see `form-content-packs.md` §\"Trust boundary\"."
145
+ },
146
+ "required": {
147
+ "type": "boolean",
148
+ "description": "Whether the host SHOULD require a value before accepting the instantiated form. Absent ⇒ `false`."
149
+ },
150
+ "default": {
151
+ "description": "Optional default value. Its JSON type SHOULD match the field `type` (string for `text`/`longtext`, number for `number`, boolean for `boolean`, string for `select`, array of string for `multiselect`); a host MUST ignore a default whose type does not match rather than fail the instantiation."
152
+ },
153
+ "options": {
154
+ "type": "array",
155
+ "items": { "type": "string", "maxLength": 200 },
156
+ "maxItems": 250,
157
+ "description": "Choices for `type: \"select\"` / `\"multiselect\"`. UNTRUSTED pack-authored content — see `form-content-packs.md` §\"Trust boundary\". Mirrors `chat-card-pack-manifest.schema.json` `InputField.options`."
158
+ }
159
+ }
160
+ },
161
+ "Signing": {
162
+ "type": "object",
163
+ "description": "Optional signing metadata. See node-packs.md §signing.",
164
+ "additionalProperties": false,
165
+ "properties": {
166
+ "publicKeyRef": { "type": "string" },
167
+ "signatureRef": { "type": "string" },
168
+ "method": { "type": "string", "enum": ["manual", "sigstore"] }
169
+ }
170
+ }
171
+ }
172
+ }
@@ -12,13 +12,14 @@
12
12
  { "required": ["provider"] },
13
13
  { "properties": { "chains": { "type": "array", "minItems": 1 } }, "required": ["chains"] },
14
14
  { "properties": { "prompts": { "type": "array", "minItems": 1 } }, "required": ["prompts"] },
15
- { "properties": { "cards": { "type": "array", "minItems": 1 } }, "required": ["cards"] }
15
+ { "properties": { "cards": { "type": "array", "minItems": 1 } }, "required": ["cards"] },
16
+ { "properties": { "templates": { "type": "array", "minItems": 1 } }, "required": ["templates"] }
16
17
  ],
17
18
  "allOf": [
18
19
  {
19
- "$comment": "RFC 0107 — runtime is required for EXECUTABLE kinds (node, or kind absent) and MUST be absent for DECLARATIVE kinds (artifact-type, connection, workflow-chain, prompt, card). NOTE: the chat-card-pack kind string is `card` per RFC 0071 (corrected 2026-06-23; an earlier RFC 0107 draft said `chat-card`).",
20
+ "$comment": "RFC 0107 — runtime is required for EXECUTABLE kinds (node, or kind absent) and MUST be absent for DECLARATIVE kinds (artifact-type, connection, workflow-chain, prompt, card, form-content). NOTE: the chat-card-pack kind string is `card` per RFC 0071 (corrected 2026-06-23; an earlier RFC 0107 draft said `chat-card`). `form-content` added by RFC 0137.",
20
21
  "if": {
21
- "properties": { "kind": { "enum": ["artifact-type", "connection", "workflow-chain", "prompt", "card"] } },
22
+ "properties": { "kind": { "enum": ["artifact-type", "connection", "workflow-chain", "prompt", "card", "form-content"] } },
22
23
  "required": ["kind"]
23
24
  },
24
25
  "then": { "not": { "required": ["runtime"] } },
@@ -58,9 +59,9 @@
58
59
  },
59
60
  "kind": {
60
61
  "type": "string",
61
- "enum": ["node", "artifact-type", "connection", "workflow-chain", "prompt", "card"],
62
+ "enum": ["node", "artifact-type", "connection", "workflow-chain", "prompt", "card", "form-content"],
62
63
  "default": "node",
63
- "description": "Pack-kind discriminator (RFC 0107). ABSENT ≡ `node` (the original, executable kind — backward compatible). Executable kinds (`node`) carry `runtime` + `nodes[]`/`agents[]`. DECLARATIVE kinds carry their own payload and NO `runtime`: `artifact-type` → `artifactTypes[]` (RFC 0075); `connection` → `provider` (RFC 0095); `workflow-chain` → `chains[]` (RFC 0013); `prompt` → `prompts[]`; `card` → `cards[]` (the chat-card pack, RFC 0071 — kind string is `card`, NOT `chat-card`; an earlier RFC 0107 draft used `chat-card`, corrected 2026-06-23). The registry's runtime-support check (registry-operations.md §Validation flow #7) is skipped for declarative kinds."
64
+ "description": "Pack-kind discriminator (RFC 0107). ABSENT ≡ `node` (the original, executable kind — backward compatible). Executable kinds (`node`) carry `runtime` + `nodes[]`/`agents[]`. DECLARATIVE kinds carry their own payload and NO `runtime`: `artifact-type` → `artifactTypes[]` (RFC 0075); `connection` → `provider` (RFC 0095); `workflow-chain` → `chains[]` (RFC 0013); `prompt` → `prompts[]`; `card` → `cards[]` (the chat-card pack, RFC 0071 — kind string is `card`, NOT `chat-card`; an earlier RFC 0107 draft used `chat-card`, corrected 2026-06-23); `form-content` → `templates[]` (RFC 0137). The registry's runtime-support check (registry-operations.md §Validation flow #7) is skipped for declarative kinds."
64
65
  },
65
66
  "artifactTypes": {
66
67
  "type": "array",
@@ -87,6 +88,11 @@
87
88
  "description": "Present iff `kind == \"card\"` (the chat-card pack, RFC 0071; the registry denormalizes `cards[].cardTypeId` into the per-pack index). Carried loosely; source schema authoritative.",
88
89
  "items": { "type": "object", "additionalProperties": true }
89
90
  },
91
+ "templates": {
92
+ "type": "array",
93
+ "description": "Present iff `kind == \"form-content\"` (RFC 0137). Form templates the pack contributes (each: `templateId`, `version`, `label`, `title`, `fields[]`). Carried loosely here per the RFC 0107 G1 pattern — `form-content-pack-manifest.schema.json` is the authoritative source contract, and the registry denormalizes `templates[].templateId` into the per-pack index for discovery (`registry-operations.md` §\"Type-ID indexing\").",
94
+ "items": { "type": "object", "additionalProperties": true }
95
+ },
90
96
  "runtime": {
91
97
  "type": "object",
92
98
  "required": ["language"],
@@ -86,6 +86,10 @@
86
86
  "type": "string",
87
87
  "description": "One-paragraph description of what the chain produces. Surfaced in host editor tile hover-text."
88
88
  },
89
+ "internal": {
90
+ "type": "boolean",
91
+ "description": "RFC 0135 — when true, this chain is a composition-only fragment (typically an RFC 0133 sub-chain child): hosts MUST omit it from default template-gallery/picker listings while keeping it loadable, resolvable by id, composable, and expandable exactly like any other chain. Presentational/discovery semantics only — NOT an authorization boundary (a host MUST NOT treat it as access control). Absent ⇒ false (listed). See workflow-chain-packs.md §\"Chain visibility (RFC 0135)\"."
92
+ },
89
93
  "parameters": {
90
94
  "type": "object",
91
95
  "description": "JSON Schema 2020-12 fragment describing the parameter values the host editor MUST collect from the author at drop time. Authors-supplied values are validated against this schema before expansion proceeds; invalid input MUST be rejected with `chain_parameter_invalid`. RECOGNIZED EXTENSION KEY (RFC 0124 / WCP4): a property MAY carry `x-openwop-sensitive: true` to declare the parameter secret-class. A host that recognizes it MUST NOT expansion-time-freeze that parameter (plaintext secret-at-rest leak, SR-1) and MUST NOT materialize it as a plaintext `source:\"variable\"` binding (the value would land in the run-scoped bag / `RunSnapshot.variables` — the same SR-1 leak one layer down). Instead, per the 2026-07-04 §Security amendment: it is deferrable ONLY in a prompt-body position, where it MUST be materialized as a `source:\"secret\"` `PromptVariable` (BYOK-resolved via `capabilities.secrets`, redacted to `[REDACTED:<secretId>]` in `prompt.composed`, never bagged); in ANY other position (whole-value `node.inputs`, embedded non-prompt `config`, or a host lacking deferred / `secrets` support) it MUST fail closed with `sensitive_param_not_deferrable` (422). Per-run supply is a `credentialRef` secret reference, never plaintext. See `workflow-chain-packs.md` §\"Deferred-parameter expansion\" / RFC 0124 §Security.",
@@ -33,13 +33,34 @@ interface DiscoveryDoc {
33
33
  }
34
34
 
35
35
  /** Reads `host.artifactTypes` from discovery (capabilities block or top-level); null when unadvertised. */
36
+ /**
37
+ * Capability-key lookup order (RFC 0137 G16, resolved 2026-08-05).
38
+ *
39
+ * The canonical discovery key for a `host.*` capability family is the PLAIN
40
+ * family name at the document root — NOT the dotted identifier. Evidence:
41
+ * `schemas/capabilities.schema.json` declares 82 properties and **zero** dotted
42
+ * `host.*` keys, and five host capabilities are already declared plainly there —
43
+ * `fs`, `kvStorage`, `tableStorage`, `queueBus`, `scheduling` — each mapping to a
44
+ * `§host.<name>` section in `host-capabilities.md` (`§host.fs` ↔ `fs`, and so on).
45
+ * The `host.` prefix is the capability IDENTIFIER notation used in prose headings,
46
+ * pack `peerDependencies`, and `error.capability` — not the discovery-document key.
47
+ *
48
+ * These helpers previously read the dotted key ONLY, which made them the outlier
49
+ * against five established precedents and rendered a schema-following host
50
+ * INVISIBLE to the scenario — a silent soft-skip to green.
51
+ *
52
+ * Order: plain at root (canonical) → dotted at root → plain under the deprecated
53
+ * `capabilities` wrapper → dotted under the wrapper. Root before wrapper per
54
+ * `capabilities.md` §"Document-root layout (normative — RFC 0073)"; the wrapper
55
+ * arms retire with the migration window at v2.0.
56
+ */
36
57
  export async function readArtifactTypesCap(): Promise<Record<string, unknown> | null> {
37
58
  const res = await driver.get('/.well-known/openwop');
38
59
  const doc = res.json as DiscoveryDoc | undefined;
39
- const fromCaps = doc?.capabilities && typeof doc.capabilities === 'object'
40
- ? (doc.capabilities as Record<string, unknown>)['host.artifactTypes']
60
+ const caps = doc?.capabilities && typeof doc.capabilities === 'object'
61
+ ? (doc.capabilities as Record<string, unknown>)
41
62
  : undefined;
42
- const cap = fromCaps ?? doc?.['host.artifactTypes'];
63
+ const cap = doc?.['artifactTypes'] ?? doc?.['host.artifactTypes'] ?? caps?.['artifactTypes'] ?? caps?.['host.artifactTypes'];
43
64
  return cap && typeof cap === 'object' ? (cap as Record<string, unknown>) : null;
44
65
  }
45
66
 
@@ -29,10 +29,17 @@ export async function readCardPacksCap(): Promise<Record<string, unknown> | null
29
29
  const res = await driver.get('/.well-known/openwop');
30
30
  const doc = res.json as DiscoveryDoc | undefined;
31
31
  const caps = doc?.capabilities && typeof doc.capabilities === 'object' ? (doc.capabilities as Record<string, unknown>) : undefined;
32
- // Accept either a discrete `host.chat.cardPacks` key or a `cardPacks` facet under `host.chat`.
33
- const direct = caps?.['host.chat.cardPacks'] ?? doc?.['host.chat.cardPacks'];
32
+ // RFC 0137 G16 (resolved 2026-08-05): the canonical discovery key is the PLAIN
33
+ // family name at the document root. capabilities.schema.json declares 82 properties
34
+ // and ZERO dotted host.* keys, and already declares five host capabilities plainly
35
+ // (fs, kvStorage, tableStorage, queueBus, scheduling), each mapping to a §host.<name>
36
+ // section. The `host.` prefix is the capability IDENTIFIER (peerDependencies,
37
+ // error.capability), not the discovery key. Order: plain-root → dotted-root →
38
+ // plain-wrapper → dotted-wrapper (root before wrapper per RFC 0073).
39
+ // Accept either a discrete cardPacks key or a `cardPacks` facet under the chat block.
40
+ const direct = doc?.['chat.cardPacks'] ?? doc?.['host.chat.cardPacks'] ?? caps?.['chat.cardPacks'] ?? caps?.['host.chat.cardPacks'];
34
41
  if (direct && typeof direct === 'object') return direct as Record<string, unknown>;
35
- const chat = caps?.['host.chat'] ?? doc?.['host.chat'];
42
+ const chat = doc?.['chat'] ?? doc?.['host.chat'] ?? caps?.['chat'] ?? caps?.['host.chat'];
36
43
  const facet = chat && typeof chat === 'object' ? (chat as Record<string, unknown>)['cardPacks'] : undefined;
37
44
  return facet && typeof facet === 'object' ? (facet as Record<string, unknown>) : null;
38
45
  }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Shared helpers for the RFC 0137 `host.forms.contentPacks` conformance scenarios.
3
+ * Lives in lib/ so scenarios import it via `../lib/formContentPacks.js`.
4
+ *
5
+ * Hosts wiring form-content packs expose a documented host-extension seam:
6
+ *
7
+ * POST /v1/host/sample/formcontent/instantiate
8
+ * body: { templateId: string }
9
+ * → 2xx {
10
+ * formId?: string,
11
+ * fields?: Array<{
12
+ * id?: string,
13
+ * control?: string, // the control kind the host chose for this field
14
+ * declaredType?: string, // the wire `fields[].type` the template declared
15
+ * editable?: boolean, // the instantiating user may rename/remove it
16
+ * locked?: boolean, // inverse spelling some hosts prefer
17
+ * }>,
18
+ * viaCreatePath?: boolean, // instantiation went through the host's NORMAL form-create path
19
+ * routing?: unknown, // MUST be absent/empty — a template cannot bind a destination
20
+ * refused?: boolean, // the host refused the template outright
21
+ * }
22
+ *
23
+ * A 404/405 means the host hasn't wired the seam → soft-skip. Every leg in the
24
+ * scenario is additionally gated on the `host.forms.contentPacks` advertisement,
25
+ * so a host that does not implement RFC 0137 skips cleanly and stays v1-compliant.
26
+ *
27
+ * @see spec/v1/form-content-packs.md §"Instantiation", §"No submission routing"
28
+ * @see spec/v1/host-capabilities.md §host.forms
29
+ */
30
+ import { driver } from './driver.js';
31
+
32
+ interface DiscoveryDoc {
33
+ capabilities?: Record<string, unknown>;
34
+ [k: string]: unknown;
35
+ }
36
+
37
+ /** A field as reported by the instantiation seam. */
38
+ export interface SeamField {
39
+ id?: string;
40
+ control?: string;
41
+ declaredType?: string;
42
+ editable?: boolean;
43
+ locked?: boolean;
44
+ }
45
+
46
+ export interface InstantiateResult {
47
+ status: number;
48
+ json: Record<string, unknown>;
49
+ fields: SeamField[];
50
+ }
51
+
52
+ /**
53
+ * Reads `host.forms.contentPacks` from discovery; null when unadvertised.
54
+ *
55
+ * **The key is the DOTTED `host.forms`, at the DOCUMENT ROOT.** Two normative
56
+ * rules pin this and a host must satisfy both:
57
+ *
58
+ * - `host-capabilities.md` §"How a capability is consumed" step 1 — a host
59
+ * advertises `host.<name>: { supported: true, … }`. The literal key carries
60
+ * the `host.` prefix; a bare `forms` is a different (undefined) key.
61
+ * - `capabilities.md` §"Document-root layout (normative — RFC 0073)" — every
62
+ * capability family MUST appear at the **document root**. A top-level
63
+ * `capabilities` wrapper is a "deprecated legacy shape", and a host serving
64
+ * families exclusively under the wrapper "is non-conformant and is graded as
65
+ * such".
66
+ *
67
+ * So the canonical advertisement is a root-level `"host.forms": { … }`. Per RFC
68
+ * 0073, clients SHOULD read the root FIRST and MAY fall back to the wrapper, so
69
+ * that is the order here — the wrapper fallback exists only for the v1.x
70
+ * migration window and retires at v2.0.
71
+ *
72
+ * Accepts either a discrete `host.forms.contentPacks` key or a `contentPacks`
73
+ * facet under a `host.forms` block (mirrors how `host.chat.cardPacks` is read).
74
+ */
75
+ export async function readFormContentCap(): Promise<unknown> {
76
+ const res = await driver.get('/.well-known/openwop');
77
+ const doc = res.json as DiscoveryDoc | undefined;
78
+ // Root first (RFC 0073 MUST); the `capabilities` wrapper is the deprecated
79
+ // legacy fallback, tolerated only through the v1.x migration window.
80
+ const caps = doc?.capabilities && typeof doc.capabilities === 'object' ? (doc.capabilities as Record<string, unknown>) : undefined;
81
+ // RFC 0137 G16 (resolved 2026-08-05): the canonical discovery key is the PLAIN
82
+ // family name at the document root. capabilities.schema.json declares 82 properties
83
+ // and ZERO dotted host.* keys, and already declares five host capabilities plainly
84
+ // (fs, kvStorage, tableStorage, queueBus, scheduling), each mapping to a §host.<name>
85
+ // section. The `host.` prefix is the capability IDENTIFIER (peerDependencies,
86
+ // error.capability), not the discovery key. Order: plain-root → dotted-root →
87
+ // plain-wrapper → dotted-wrapper (root before wrapper per RFC 0073).
88
+ const direct = doc?.['forms.contentPacks'] ?? doc?.['host.forms.contentPacks'] ?? caps?.['forms.contentPacks'] ?? caps?.['host.forms.contentPacks'];
89
+ if (direct !== undefined) return direct;
90
+ const forms = doc?.['forms'] ?? doc?.['host.forms'] ?? caps?.['forms'] ?? caps?.['host.forms'];
91
+ return forms && typeof forms === 'object' ? (forms as Record<string, unknown>)['contentPacks'] : null;
92
+ }
93
+
94
+ /** True when the host advertises it resolves + instantiates form-content templates. */
95
+ export function formContentSupported(cap: unknown): boolean {
96
+ if (cap === true) return true;
97
+ return typeof cap === 'object' && cap !== null && (cap as Record<string, unknown>)['supported'] === true;
98
+ }
99
+
100
+ /** Instantiates a registered template via the host-sample seam, or null (soft-skip) when absent. */
101
+ export async function instantiateTemplate(templateId: string): Promise<InstantiateResult | null> {
102
+ const res = await driver.post('/v1/host/sample/formcontent/instantiate', { templateId });
103
+ if (res.status === 404 || res.status === 405) return null;
104
+ const json = (res.json ?? {}) as Record<string, unknown>;
105
+ const raw = Array.isArray(json['fields']) ? (json['fields'] as unknown[]) : [];
106
+ const fields = raw.filter((f): f is SeamField => typeof f === 'object' && f !== null);
107
+ return { status: res.status, json, fields };
108
+ }
109
+
110
+ /** Finds a seam field by its wire `id`. */
111
+ export function fieldById(fields: SeamField[], id: string): SeamField | undefined {
112
+ return fields.find((f) => f.id === id);
113
+ }
114
+
115
+ /**
116
+ * A field is editable unless the host explicitly says otherwise. Accepts both
117
+ * spellings (`editable: false` / `locked: true`); absent ⇒ treated as editable,
118
+ * because §Instantiation #3 makes editability the default expectation and a host
119
+ * that does not report the facet is not asserting a lock.
120
+ */
121
+ export function isEditable(field: SeamField | undefined): boolean {
122
+ if (!field) return false;
123
+ if (field.locked === true) return false;
124
+ return field.editable !== false;
125
+ }
126
+
127
+ /**
128
+ * The set of control kinds a host may legitimately choose for a degraded
129
+ * (unrecognized `vendor.*` / `x-`) field type. §Instantiation #2 says "plain text
130
+ * input"; hosts spell that control differently, so accept the obvious synonyms
131
+ * rather than pinning one host's vocabulary onto the wire.
132
+ */
133
+ export const PLAIN_TEXT_CONTROLS: ReadonlySet<string> = new Set(['text', 'string', 'plaintext', 'plain-text', 'input', 'textbox']);
@@ -280,6 +280,32 @@ export function expandChain(chain: WorkflowChain, ctx: ExpansionContext): Expand
280
280
  return { nodes: expandedNodes, edges: expandedEdges, idMap };
281
281
  }
282
282
 
283
+ // ─── End of the MIRRORED CORE ───────────────────────────────────────────────
284
+ //
285
+ // Everything ABOVE this line is the base chain-expansion algorithm every host
286
+ // that loads workflow-chain packs implements, and it is mirrored verbatim by
287
+ // the in-memory reference host (`examples/hosts/in-memory/src/
288
+ // workflow-chain-expansion.ts`, in the `openwop-examples` repo), which cannot
289
+ // import this package under its zero-runtime-deps policy. That mirror is
290
+ // enforced byte-for-byte by `scripts/check-workflow-chain-expansion-sync.mjs`.
291
+ //
292
+ // Everything BELOW is CAPABILITY-GATED surface added after the mirror was
293
+ // established, and is deliberately NOT mirrored:
294
+ //
295
+ // • RFC 0124 deferred-parameter expansion (`expandChainDeferred` and its
296
+ // types) — the host-side deferral path.
297
+ // • RFC 0133 sub-chain co-expansion + produced variables (`expandChainTree`,
298
+ // `mintChildWorkflowId`, the `SubChain*` / `VariableUndeclared` errors) —
299
+ // gated on `capabilities.workflowChainPacks.subChains`. A host that does
300
+ // not advertise it MUST REFUSE a `subChains`-bearing chain with
301
+ // `sub_chain_unsupported`, never silently flatten — so a minimal host is
302
+ // required to reject this surface, not to implement it.
303
+ //
304
+ // Do NOT move the sentinel to "fix" a drift failure. If a change belongs to the
305
+ // base algorithm every host must share, it goes above and the mirror follows.
306
+ // If it is gated on an advertised capability, it goes below.
307
+ // ─────────────────────────────────────────────────────────────────────────────
308
+
283
309
  // ---------------------------------------------------------------------------
284
310
  // RFC 0124 (WCP4) — Portable per-run parameter deferral.
285
311
  //
@@ -293,6 +319,7 @@ export function expandChain(chain: WorkflowChain, ctx: ExpansionContext): Expand
293
319
  // §"Deferred-parameter expansion (RFC 0124)".
294
320
  // ---------------------------------------------------------------------------
295
321
 
322
+
296
323
  /** The parameter JSON Schema fragment (`chain.parameters`), narrowed to the
297
324
  * fields deferred expansion reads: each property's `type`, `description`, and
298
325
  * the RFC 0124 `x-openwop-sensitive` extension key. */
@@ -0,0 +1,168 @@
1
+ /**
2
+ * form-content-instantiation — RFC 0137 `form-content-packs.md` §"Instantiation",
3
+ * §"No submission routing" (F2).
4
+ *
5
+ * **This is the behavioral half of RFC 0137, and it exists because the other half
6
+ * could not witness anything.** `form-content-packs.test.ts` is entirely
7
+ * server-free: it proves the corpus agrees with itself — the schema carries the
8
+ * kind, the `anyOf` branch is present, the two field-type vocabularies are
9
+ * byte-identical. Every one of those legs passes identically against a host that
10
+ * never implemented RFC 0137, including one that advertises
11
+ * `host.forms.contentPacks` and does nothing. Running that suite with
12
+ * `--base-url` and calling the green a witness would be vacuous. This file is
13
+ * what `OPENWOP_REQUIRE_BEHAVIOR=true` is supposed to make non-vacuous.
14
+ *
15
+ * Gated on the `host.forms.contentPacks` advertisement AND the host-sample
16
+ * instantiate seam, so a host that does not implement RFC 0137 skips cleanly and
17
+ * stays v1-compliant.
18
+ *
19
+ * **The advertisement gate is `behaviorGate`, not a bare `return`.** That is the
20
+ * difference between a skip you can see and one you cannot: under
21
+ * `OPENWOP_REQUIRE_BEHAVIOR=true` an unadvertised capability FAILS with a message
22
+ * naming the profile, instead of quietly skipping to green. A host that
23
+ * mis-spells its advertisement — serving `capabilities.forms.contentPacks`
24
+ * instead of a root-level `"host.forms"` — otherwise gets four silent skips and a
25
+ * green run that witnesses nothing. That is the exact vacuity this scenario
26
+ * exists to prevent, so the gate must be loud in strict mode.
27
+ *
28
+ * The canonical advertisement is a ROOT-LEVEL dotted `"host.forms": { … }`:
29
+ * `host-capabilities.md` §"How a capability is consumed" pins the `host.`-prefixed
30
+ * key, and `capabilities.md` §"Document-root layout (normative — RFC 0073)" pins
31
+ * the document root — a `capabilities` wrapper is a deprecated legacy shape and a
32
+ * host serving families only under it "is non-conformant and is graded as such".
33
+ *
34
+ * What is asserted over the wire (each maps to a numbered §Instantiation rule):
35
+ *
36
+ * #1 — instantiation goes through the host's NORMAL create path, and the
37
+ * resulting form carries no routing destination (§F2: a pack MUST NOT
38
+ * bind where submissions go; the operator configures that afterward).
39
+ * #2 — an unrecognized `vendor.*` / `x-` field type DEGRADES to a plain text
40
+ * input rather than failing the instantiation. This is the leg that
41
+ * matters most: refuse-everything is a natural implementation instinct
42
+ * and it is non-conformant here.
43
+ * #3 — pack-authored fields are FULLY EDITABLE, with no privilege over a
44
+ * hand-added field.
45
+ *
46
+ * F1 (the trust boundary) is deliberately NOT asserted here. Its observable —
47
+ * a `contentTrust` tag on a composed prompt — is not visible to a black-box
48
+ * client for a kind that composes no prompt of its own. Claiming to witness it
49
+ * over HTTP would be the same vacuity this file exists to avoid; it stays a
50
+ * host-side guarantee backed by the schema/corpus legs and the host's own tests.
51
+ *
52
+ * @see spec/v1/form-content-packs.md §"Instantiation", §"No submission routing"
53
+ * @see spec/v1/host-capabilities.md §host.forms
54
+ * @see RFCS/0137-form-content-packs.md
55
+ */
56
+
57
+ import { describe, it, expect } from 'vitest';
58
+ import { driver } from '../lib/driver.js';
59
+ import { behaviorGate } from '../lib/behavior-gate.js';
60
+ import {
61
+ readFormContentCap,
62
+ formContentSupported,
63
+ instantiateTemplate,
64
+ fieldById,
65
+ isEditable,
66
+ PLAIN_TEXT_CONTROLS,
67
+ } from '../lib/formContentPacks.js';
68
+
69
+ /**
70
+ * Conformance fixture templates a host wires the seam against.
71
+ * `…form.basic` uses only portable-subset types; `…form.extended` additionally
72
+ * declares one `vendor.*` field the host is not expected to recognize.
73
+ */
74
+ /** Profile name for the strict-mode gate (`OPENWOP_REQUIRE_BEHAVIOR=true`). */
75
+ const PROFILE = 'host.forms.contentPacks';
76
+
77
+ const BASIC_TEMPLATE = 'vendor.conformance.form.basic';
78
+ const EXTENDED_TEMPLATE = 'vendor.conformance.form.extended';
79
+ const VENDOR_FIELD_ID = 'vendorExtended';
80
+
81
+ describe('form-content-instantiation: a host instantiates a registered template (RFC 0137 §Instantiation)', () => {
82
+ it('#1 instantiation goes through the host NORMAL create path', async () => {
83
+ if (!behaviorGate(PROFILE, formContentSupported(await readFormContentCap()))) return;
84
+ const res = await instantiateTemplate(BASIC_TEMPLATE);
85
+ if (res === null) return; // seam absent — soft-skip
86
+
87
+ expect(
88
+ res.status >= 200 && res.status < 300,
89
+ driver.describe('form-content-packs.md §Instantiation', 'a registered template MUST instantiate'),
90
+ ).toBe(true);
91
+
92
+ if (res.json['viaCreatePath'] !== undefined) {
93
+ expect(
94
+ res.json['viaCreatePath'],
95
+ driver.describe(
96
+ 'form-content-packs.md §Instantiation #1',
97
+ 'the host MUST create the form through the SAME path that serves a hand-authored form',
98
+ ),
99
+ ).toBe(true);
100
+ }
101
+
102
+ expect(
103
+ res.fields.length,
104
+ driver.describe('form-content-packs.md §Instantiation', 'the instantiated form MUST carry the template fields'),
105
+ ).toBeGreaterThan(0);
106
+ });
107
+
108
+ it('#1/F2 the instantiated form carries NO pack-bound submission destination', async () => {
109
+ if (!behaviorGate(PROFILE, formContentSupported(await readFormContentCap()))) return;
110
+ const res = await instantiateTemplate(BASIC_TEMPLATE);
111
+ if (res === null) return;
112
+
113
+ const routing = res.json['routing'];
114
+ const bound =
115
+ routing !== undefined &&
116
+ routing !== null &&
117
+ !(typeof routing === 'object' && Object.keys(routing as Record<string, unknown>).length === 0);
118
+
119
+ expect(
120
+ bound,
121
+ driver.describe(
122
+ 'form-content-packs.md §No submission routing (F2)',
123
+ 'a template MUST NOT bind a submission destination — the operator configures routing afterward, as for any hand-authored form',
124
+ ),
125
+ ).toBe(false);
126
+ });
127
+
128
+ it('#2 an unrecognized vendor.* field type DEGRADES to plain text, and does NOT fail the instantiation', async () => {
129
+ if (!behaviorGate(PROFILE, formContentSupported(await readFormContentCap()))) return;
130
+ const res = await instantiateTemplate(EXTENDED_TEMPLATE);
131
+ if (res === null) return;
132
+
133
+ expect(
134
+ res.status >= 200 && res.status < 300 && res.json['refused'] !== true,
135
+ driver.describe(
136
+ 'form-content-packs.md §Instantiation #2',
137
+ 'a well-formed but unrecognized vendor.*/x- type MUST degrade, NOT fail the instantiation — refusing a valid extension breaks forward compatibility',
138
+ ),
139
+ ).toBe(true);
140
+
141
+ const field = fieldById(res.fields, VENDOR_FIELD_ID);
142
+ if (field === undefined) return; // host doesn't report per-field controls on the seam — soft-skip
143
+ expect(
144
+ field.control !== undefined && PLAIN_TEXT_CONTROLS.has(field.control),
145
+ driver.describe(
146
+ 'form-content-packs.md §Instantiation #2',
147
+ `an unrecognized field type MUST render as a plain text input (got control ${JSON.stringify(field.control)})`,
148
+ ),
149
+ ).toBe(true);
150
+ });
151
+
152
+ it('#3 pack-authored fields are FULLY EDITABLE — no privilege over a hand-added field', async () => {
153
+ if (!behaviorGate(PROFILE, formContentSupported(await readFormContentCap()))) return;
154
+ const res = await instantiateTemplate(BASIC_TEMPLATE);
155
+ if (res === null) return;
156
+
157
+ const reporting = res.fields.filter((f) => f.editable !== undefined || f.locked !== undefined);
158
+ if (reporting.length === 0) return; // host doesn't report editability — soft-skip
159
+
160
+ expect(
161
+ reporting.every((f) => isEditable(f)),
162
+ driver.describe(
163
+ 'form-content-packs.md §Instantiation #3',
164
+ 'the host MUST NOT treat a pack-authored field as immutable or privileged relative to a hand-added one',
165
+ ),
166
+ ).toBe(true);
167
+ });
168
+ });