@openwop/openwop-conformance 1.58.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.
- package/README.md +2 -2
- package/coverage.md +4 -0
- package/package.json +1 -1
- package/schemas/README.md +1 -0
- package/schemas/form-content-pack-manifest.schema.json +172 -0
- package/schemas/registry-version-manifest.schema.json +11 -5
- package/src/lib/artifactTypes.ts +24 -3
- package/src/lib/cardPacks.ts +10 -3
- package/src/lib/formContentPacks.ts +133 -0
- package/src/lib/workflow-chain-expansion.ts +27 -0
- package/src/scenarios/form-content-instantiation.test.ts +168 -0
- package/src/scenarios/form-content-packs.test.ts +415 -0
|
@@ -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"],
|
package/src/lib/artifactTypes.ts
CHANGED
|
@@ -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
|
|
40
|
-
? (doc.capabilities as Record<string, unknown>)
|
|
60
|
+
const caps = doc?.capabilities && typeof doc.capabilities === 'object'
|
|
61
|
+
? (doc.capabilities as Record<string, unknown>)
|
|
41
62
|
: undefined;
|
|
42
|
-
const cap =
|
|
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
|
|
package/src/lib/cardPacks.ts
CHANGED
|
@@ -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
|
-
//
|
|
33
|
-
|
|
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 =
|
|
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
|
+
});
|