@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,415 @@
1
+ /**
2
+ * Form-content packs (RFC 0137, `Active`).
3
+ *
4
+ * A form-content pack (`kind: "form-content"`) distributes FORM TEMPLATES — a
5
+ * named, versioned, ordered set of typed input fields a host instantiates into
6
+ * an ordinary, fully editable form through its normal create path. It is the
7
+ * sixth declarative pack kind under RFC 0107 and is purely inert: no `runtime`,
8
+ * no entry point, no handler, no submission surface the host would not
9
+ * otherwise accept.
10
+ *
11
+ * Always-on + server-free. Three parts:
12
+ *
13
+ * PART 1 — contract present. `form-content-packs.md` carries the
14
+ * instantiation rules + the F1 trust boundary; `registry-operations.md`
15
+ * §"Validation flow" selects the per-kind source schema for `form-content`
16
+ * and skips the runtime check for it. Guards against the requirement being
17
+ * silently dropped.
18
+ *
19
+ * PART 2 — the version-manifest schema admits the kind and still rejects
20
+ * malformed ones. Includes an explicit leg for the `anyOf` payload gate:
21
+ * extending the `kind` enum and declaring `templates` is NECESSARY BUT NOT
22
+ * SUFFICIENT — without a `templates` branch in `anyOf`, every form-content
23
+ * manifest is rejected. That omission is believed to be the second face of
24
+ * the CI failure that motivated RFC 0137, so it gets its own assertion.
25
+ *
26
+ * PART 3 — the field vocabulary is SHARED, not forked. `fields[].type` in
27
+ * `form-content-pack-manifest.schema.json` MUST be byte-identical to
28
+ * `InputField.type` in `chat-card-pack-manifest.schema.json`. Two declarative
29
+ * kinds that both collect typed user input, rendered by the same host
30
+ * machinery, MUST agree on what a field type means. This is the regression
31
+ * guard for RFC 0137 R2: it fails the moment either kind's vocabulary is
32
+ * widened alone.
33
+ *
34
+ * @see spec/v1/form-content-packs.md
35
+ * @see spec/v1/chat-card-packs.md §"Input fields — a closed portable subset"
36
+ * @see spec/v1/registry-operations.md §"Validation flow"
37
+ * @see schemas/form-content-pack-manifest.schema.json
38
+ * @see RFCS/0137-form-content-packs.md, RFCS/0107-publishable-declarative-pack-kinds.md
39
+ */
40
+
41
+ import { describe, it, expect } from 'vitest';
42
+ import { readFileSync } from 'node:fs';
43
+ import { join } from 'node:path';
44
+ import Ajv2020 from 'ajv/dist/2020.js';
45
+ import addFormats from 'ajv-formats';
46
+ import { SCHEMAS_DIR, V1_DIR } from '../lib/paths.js';
47
+
48
+ const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
49
+
50
+ const readSchema = (name: string): Record<string, unknown> =>
51
+ JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8'));
52
+
53
+ describe('form-content-packs: contract present in the corpus (RFC 0137, server-free)', () => {
54
+ const registryDoc = V1_DIR ? readFileSync(join(V1_DIR, 'registry-operations.md'), 'utf8') : '';
55
+ const formDoc = V1_DIR ? readFileSync(join(V1_DIR, 'form-content-packs.md'), 'utf8') : '';
56
+
57
+ it.skipIf(V1_DIR === null)('registry-operations.md §Validation flow selects the form-content source schema by `kind`', () => {
58
+ expect(
59
+ /form-content[\s\S]{0,160}form-content-pack-manifest\.schema\.json/.test(registryDoc),
60
+ why('registry-operations.md §Validation flow #3', '`kind: "form-content"` validates against its own source schema (RFC 0137)'),
61
+ ).toBe(true);
62
+ });
63
+
64
+ it.skipIf(V1_DIR === null)('registry-operations.md skips the runtime-support check for form-content', () => {
65
+ expect(
66
+ /declarative[\s\S]{0,200}form-content/.test(registryDoc),
67
+ why('registry-operations.md §Validation flow #7', 'form-content is a declarative kind — the runtime check is skipped'),
68
+ ).toBe(true);
69
+ });
70
+
71
+ it.skipIf(V1_DIR === null)('registry-operations.md extends the declarative-id denormalization to templateId', () => {
72
+ expect(
73
+ /templates\[\]\.templateId/.test(registryDoc),
74
+ why('registry-operations.md §Type-ID indexing', 'a registry SHOULD denormalize `templates[].templateId` (RFC 0137)'),
75
+ ).toBe(true);
76
+ });
77
+
78
+ it.skipIf(V1_DIR === null)('form-content-packs.md requires the host to use its NORMAL create path and execute nothing', () => {
79
+ expect(
80
+ /MUST[\s\S]{0,120}normal[\s\S]{0,40}create path/i.test(formDoc),
81
+ why('form-content-packs.md §Instantiation', 'the host MUST instantiate through its normal create path'),
82
+ ).toBe(true);
83
+ expect(
84
+ /MUST NOT execute anything from the pack/i.test(formDoc),
85
+ why('form-content-packs.md §Instantiation', 'the host MUST NOT execute anything from the pack — the kind is inert'),
86
+ ).toBe(true);
87
+ });
88
+
89
+ it.skipIf(V1_DIR === null)('form-content-packs.md carries the F1 trust boundary, incl. "a signature is not content trust"', () => {
90
+ expect(
91
+ /untrusted/i.test(formDoc) && /contentTrust/.test(formDoc),
92
+ why('form-content-packs.md §Trust boundary', 'pack-authored strings are untrusted; prompts propagate meta.contentTrust'),
93
+ ).toBe(true);
94
+ expect(
95
+ /signature proves[\s\S]{0,80}not[\s\S]{0,60}trustworthy|MUST NOT treat pack provenance as content trust/i.test(formDoc),
96
+ why('form-content-packs.md §Trust boundary', 'a signature proves authorship, NOT that the authored bytes are safe'),
97
+ ).toBe(true);
98
+ expect(
99
+ /Length bounds are not a trust boundary/i.test(formDoc),
100
+ why('form-content-packs.md §Trust boundary', 'maxLength is a resource guard, NOT sanitization'),
101
+ ).toBe(true);
102
+ });
103
+
104
+ it.skipIf(V1_DIR === null)('the spec distinguishes DEGRADE (well-formed extension) from REFUSE (malformed value)', () => {
105
+ expect(
106
+ /Degrade applies to \*extensions\*, not to malformed values/i.test(formDoc),
107
+ why('form-content-packs.md §Instantiation', 'MUST-degrade is scoped to vendor.*/x- extensions, not bare unknowns'),
108
+ ).toBe(true);
109
+ expect(
110
+ /MUST NOT collapse these into one rule in either direction/i.test(formDoc),
111
+ why(
112
+ 'form-content-packs.md §Instantiation',
113
+ 'refusing a well-formed extension breaks forward compat; degrading a malformed value hides an authoring error',
114
+ ),
115
+ ).toBe(true);
116
+ });
117
+
118
+ it.skipIf(V1_DIR === null)('form-content-packs.md forbids minting a second field-type vocabulary', () => {
119
+ expect(
120
+ /MUST NOT\W{0,4}\s*define its own field-type vocabulary/i.test(formDoc),
121
+ why('form-content-packs.md §Field types', 'the kind reuses the RFC 0071 portable subset rather than defining its own'),
122
+ ).toBe(true);
123
+ });
124
+ });
125
+
126
+ describe('form-content-packs: version-manifest schema admits the kind (RFC 0137, server-free)', () => {
127
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
128
+ addFormats(ajv);
129
+ const versionManifestSchema = readSchema('registry-version-manifest.schema.json');
130
+ const validate = ajv.compile(versionManifestSchema);
131
+
132
+ const base = { name: 'core.openwop.forms', version: '1.0.0', engines: { openwop: '>=1.0.0' }, integrity: 'sha256-abc=' };
133
+ const template = {
134
+ templateId: 'core.openwop.form.rsvp',
135
+ version: '1.0.0',
136
+ label: 'RSVP',
137
+ title: 'Will you be joining us?',
138
+ fields: [{ id: 'guestName', type: 'text', label: 'Your name' }],
139
+ };
140
+
141
+ it('`form-content` is in the kind enum and `templates` is a declared property', () => {
142
+ const props = (versionManifestSchema.properties ?? {}) as Record<string, { enum?: string[] }>;
143
+ expect(props.kind?.enum, why('registry-version-manifest.schema.json', '`form-content` joins the kind enum (RFC 0137)')).toEqual(
144
+ expect.arrayContaining(['node', 'artifact-type', 'connection', 'card', 'form-content']),
145
+ );
146
+ expect(!!props.templates, why('registry-version-manifest.schema.json', '`templates` payload property declared (additionalProperties:false)')).toBe(true);
147
+ });
148
+
149
+ it('the `anyOf` payload gate carries a `templates` branch (NECESSARY — enum + property alone are not sufficient)', () => {
150
+ const anyOf = (versionManifestSchema.anyOf ?? []) as Array<{ required?: string[] }>;
151
+ expect(
152
+ anyOf.some((branch) => (branch.required ?? []).includes('templates')),
153
+ why('registry-version-manifest.schema.json §anyOf', 'without a `templates` branch every form-content manifest is rejected (RFC 0137 §Proposal 1)'),
154
+ ).toBe(true);
155
+ });
156
+
157
+ it('a published form-content version manifest validates (kind + templates, no runtime)', () => {
158
+ const ok = validate({ ...base, kind: 'form-content', templates: [template] });
159
+ expect(ok, why('registry-operations.md §Validation flow', 'form-content manifest publishes (RFC 0137)')).toBe(true);
160
+ });
161
+
162
+ it('a form-content manifest carrying `runtime` is REJECTED (declarative kinds carry no runtime)', () => {
163
+ const ok = validate({ ...base, kind: 'form-content', templates: [template], runtime: { language: 'javascript' } });
164
+ expect(ok, why('registry-version-manifest.schema.json §allOf', 'a declarative kind MUST NOT carry runtime')).toBe(false);
165
+ });
166
+
167
+ it('an unchanged node version manifest still validates (RFC 0137 is additive)', () => {
168
+ const ok = validate({
169
+ ...base,
170
+ runtime: { language: 'javascript' },
171
+ nodes: [{ typeId: 'core.openwop.x.n', version: '1.0.0', category: 'data', role: 'pure' }],
172
+ });
173
+ expect(ok, why('COMPATIBILITY.md §2.1', 'RFC 0137 is additive — node manifests validate unchanged')).toBe(true);
174
+ });
175
+ });
176
+
177
+ describe('form-content-packs: source manifest contract (RFC 0137, server-free)', () => {
178
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
179
+ addFormats(ajv);
180
+ const sourceSchema = readSchema('form-content-pack-manifest.schema.json');
181
+ const validate = ajv.compile(sourceSchema);
182
+
183
+ const pack = (templates: unknown[]): Record<string, unknown> => ({
184
+ name: 'core.openwop.forms.starters',
185
+ version: '1.0.0',
186
+ kind: 'form-content',
187
+ engines: { openwop: '>=1.1.0 <2.0.0' },
188
+ templates,
189
+ });
190
+ const field = (over: Record<string, unknown> = {}): Record<string, unknown> => ({
191
+ id: 'guestName',
192
+ type: 'text',
193
+ label: 'Your name',
194
+ ...over,
195
+ });
196
+ const template = (over: Record<string, unknown> = {}): Record<string, unknown> => ({
197
+ templateId: 'core.openwop.form.rsvp',
198
+ version: '1.0.0',
199
+ label: 'RSVP',
200
+ title: 'Will you be joining us?',
201
+ fields: [field()],
202
+ ...over,
203
+ });
204
+
205
+ it('schema discipline: draft 2020-12, canonical $id, closed objects', () => {
206
+ expect(sourceSchema.$schema, why('CONTRIBUTING.md §JSON Schemas', 'draft 2020-12')).toBe(
207
+ 'https://json-schema.org/draft/2020-12/schema',
208
+ );
209
+ expect(sourceSchema.$id, why('CONTRIBUTING.md §JSON Schemas', 'canonical $id URL')).toBe(
210
+ 'https://openwop.dev/spec/v1/form-content-pack-manifest.schema.json',
211
+ );
212
+ expect(sourceSchema.additionalProperties, why('CONTRIBUTING.md §JSON Schemas', 'additionalProperties:false')).toBe(false);
213
+ });
214
+
215
+ it('a well-formed form-content pack validates', () => {
216
+ expect(validate(pack([template()])), why('form-content-packs.md §Manifest format', 'the canonical example validates')).toBe(true);
217
+ });
218
+
219
+ it('the full portable field-type subset is accepted', () => {
220
+ for (const type of ['text', 'longtext', 'number', 'boolean', 'select', 'multiselect', 'file', 'artifact-ref']) {
221
+ expect(
222
+ validate(pack([template({ fields: [field({ type })] })])),
223
+ why('chat-card-packs.md §Input fields', `portable type \`${type}\` is accepted (RFC 0071 G9 subset)`),
224
+ ).toBe(true);
225
+ }
226
+ });
227
+
228
+ it('vendor.* and x- host extensions are accepted (other hosts degrade to plain text)', () => {
229
+ for (const type of ['vendor.myndhyve.color', 'x-signature-pad']) {
230
+ expect(
231
+ validate(pack([template({ fields: [field({ type })] })])),
232
+ why('form-content-packs.md §Field types', `host extension \`${type}\` is accepted`),
233
+ ).toBe(true);
234
+ }
235
+ });
236
+
237
+ it('`email` and `textarea` are REJECTED as field types (they are a format and a widget)', () => {
238
+ expect(
239
+ validate(pack([template({ fields: [field({ type: 'email' })] })])),
240
+ why('form-content-packs.md §Validation formats are not types', '`email` is a format constraint, not a data kind — use text + format'),
241
+ ).toBe(false);
242
+ expect(
243
+ validate(pack([template({ fields: [field({ type: 'textarea' })] })])),
244
+ why('chat-card-packs.md §Input fields', '`textarea` is a widget name — the portable data kind is `longtext`'),
245
+ ).toBe(false);
246
+ });
247
+
248
+ it('`format: "email"` on a text field IS accepted (the supported spelling)', () => {
249
+ expect(
250
+ validate(pack([template({ fields: [field({ id: 'email', type: 'text', format: 'email' })] })])),
251
+ why('form-content-packs.md §Validation formats are not types', 'email validation rides `format`, not `type`'),
252
+ ).toBe(true);
253
+ });
254
+
255
+ it('a field using `key` instead of `id` is REJECTED (aligns with chat-card InputField.id)', () => {
256
+ const bad = { key: 'guestName', type: 'text', label: 'Your name' };
257
+ expect(
258
+ validate(pack([template({ fields: [bad] })])),
259
+ why('form-content-packs.md §Manifest format', 'the field identifier is `id`, not `key`'),
260
+ ).toBe(false);
261
+ });
262
+
263
+ it('an integer `templates[].version` is REJECTED (SemVer axis, not the integer schemaVersion axis)', () => {
264
+ expect(
265
+ validate(pack([template({ version: 3 })])),
266
+ why('form-content-packs.md §Manifest format', '`templates[].version` is SemVer 2.0.0'),
267
+ ).toBe(false);
268
+ });
269
+
270
+ it('an empty `fields[]` and an empty `templates[]` are REJECTED', () => {
271
+ expect(validate(pack([template({ fields: [] })])), why('form-content-pack-manifest.schema.json', 'a template MUST declare ≥1 field')).toBe(false);
272
+ expect(validate(pack([])), why('form-content-pack-manifest.schema.json', 'a pack MUST declare ≥1 template')).toBe(false);
273
+ });
274
+
275
+ it('a `runtime` block is REJECTED at the source manifest too (the kind is inert)', () => {
276
+ expect(
277
+ validate({ ...pack([template()]), runtime: { language: 'javascript' } }),
278
+ why('form-content-packs.md §Pack kind', 'a form-content pack carries no runtime'),
279
+ ).toBe(false);
280
+ });
281
+ });
282
+
283
+ describe('form-content-packs: a template carries NO submission routing (RFC 0137 §F2, invariant)', () => {
284
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
285
+ addFormats(ajv);
286
+ const sourceSchema = readSchema('form-content-pack-manifest.schema.json') as {
287
+ $defs: { FormTemplate: { properties: Record<string, unknown>; additionalProperties?: boolean } };
288
+ };
289
+ const validate = ajv.compile(sourceSchema);
290
+ const formDoc = V1_DIR ? readFileSync(join(V1_DIR, 'form-content-packs.md'), 'utf8') : '';
291
+
292
+ const withTemplateKey = (key: string, value: unknown): Record<string, unknown> => ({
293
+ name: 'core.openwop.forms.starters',
294
+ version: '1.0.0',
295
+ kind: 'form-content',
296
+ engines: { openwop: '>=1.1.0 <2.0.0' },
297
+ templates: [
298
+ {
299
+ templateId: 'core.openwop.form.rsvp',
300
+ version: '1.0.0',
301
+ label: 'RSVP',
302
+ title: 'RSVP',
303
+ fields: [{ id: 'a', type: 'text', label: 'A' }],
304
+ [key]: value,
305
+ },
306
+ ],
307
+ });
308
+
309
+ it('FormTemplate declares NO routing-shaped property and is closed', () => {
310
+ const props = Object.keys(sourceSchema.$defs.FormTemplate.properties);
311
+ for (const banned of ['intakeBinding', 'destination', 'webhook', 'webhookUrl', 'listId', 'mailbox', 'crmObject', 'routing', 'submitTo']) {
312
+ expect(props, why('form-content-packs.md §No submission routing', `FormTemplate MUST NOT declare a \`${banned}\` property`)).not.toContain(banned);
313
+ }
314
+ expect(
315
+ sourceSchema.$defs.FormTemplate.additionalProperties,
316
+ why('form-content-packs.md §No submission routing', 'FormTemplate is closed, so an undeclared routing key is rejected'),
317
+ ).toBe(false);
318
+ });
319
+
320
+ it('a template attempting to carry routing config is REJECTED', () => {
321
+ for (const [key, value] of [
322
+ ['intakeBinding', { listId: 'abc' }],
323
+ ['destination', 'https://attacker.example/collect'],
324
+ ['webhookUrl', 'https://attacker.example/hook'],
325
+ ] as Array<[string, unknown]>) {
326
+ expect(
327
+ validate(withTemplateKey(key, value)),
328
+ why('form-content-packs.md §No submission routing', `a pack MUST NOT bind a submission destination via \`${key}\``),
329
+ ).toBe(false);
330
+ }
331
+ });
332
+
333
+ it.skipIf(V1_DIR === null)('the spec scopes the routing ban to the PACK, leaving operator-configured routing free', () => {
334
+ expect(
335
+ /This constrains the pack, not the host/i.test(formDoc),
336
+ why('form-content-packs.md §No submission routing', 'a host MAY route wherever its OPERATOR configures; only pack-declared routing is banned'),
337
+ ).toBe(true);
338
+ });
339
+
340
+ it.skipIf(V1_DIR === null)('the spec binds FUTURE routing surfaces to operator consent, not pack declaration', () => {
341
+ expect(
342
+ /MUST NOT let a pack bind a destination unilaterally|routing MUST be a host-side decision/i.test(formDoc),
343
+ why('form-content-packs.md §No submission routing', 'a later routing RFC MUST keep the decision host-side, behind operator consent'),
344
+ ).toBe(true);
345
+ });
346
+ });
347
+
348
+ describe('form-content-packs: identifier uniqueness + resource bounds (RFC 0137 amendment)', () => {
349
+ const formDoc = V1_DIR ? readFileSync(join(V1_DIR, 'form-content-packs.md'), 'utf8') : '';
350
+ const sourceSchema = readSchema('form-content-pack-manifest.schema.json') as {
351
+ properties: { templates: { maxItems: number } };
352
+ $defs: {
353
+ FormTemplate: { properties: { fields: { maxItems: number } } };
354
+ FormField: { properties: { label: { maxLength: number }; options: { maxItems: number } } };
355
+ };
356
+ };
357
+
358
+ it.skipIf(V1_DIR === null)('duplicate `fields[].id` is a normative refusal, framed as data integrity', () => {
359
+ expect(
360
+ /each `fields\[\]\.id` MUST be unique within its template/i.test(formDoc),
361
+ why('form-content-packs.md §Unique identifiers', 'duplicate field ids MUST be refused'),
362
+ ).toBe(true);
363
+ expect(
364
+ /silently overwrite/i.test(formDoc),
365
+ why('form-content-packs.md §Unique identifiers', 'the rationale is silent data loss, not style'),
366
+ ).toBe(true);
367
+ });
368
+
369
+ it('resource bounds admit real-world content (long consent labels, a country list)', () => {
370
+ expect(
371
+ sourceSchema.$defs.FormField.properties.label.maxLength,
372
+ why('form-content-pack-manifest.schema.json', 'a lawful consent label is legitimately long-form'),
373
+ ).toBeGreaterThanOrEqual(1000);
374
+ expect(
375
+ sourceSchema.$defs.FormField.properties.options.maxItems,
376
+ why('form-content-pack-manifest.schema.json', 'a country list is ~195 entries — the cap must clear it'),
377
+ ).toBeGreaterThanOrEqual(250);
378
+ });
379
+
380
+ it('outer resource caps exist on both arrays (render-bomb guard, not product policy)', () => {
381
+ expect(sourceSchema.properties.templates.maxItems, why('form-content-pack-manifest.schema.json', 'templates[] carries an outer cap')).toBeGreaterThan(0);
382
+ expect(sourceSchema.$defs.FormTemplate.properties.fields.maxItems, why('form-content-pack-manifest.schema.json', 'fields[] carries an outer cap')).toBeGreaterThan(0);
383
+ });
384
+ });
385
+
386
+ describe('form-content-packs: the field vocabulary is SHARED with chat-card packs, not forked (RFC 0137 R2)', () => {
387
+ const formSchema = readSchema('form-content-pack-manifest.schema.json') as {
388
+ $defs: { FormField: { properties: { type: { pattern: string } } } };
389
+ };
390
+ const cardSchema = readSchema('chat-card-pack-manifest.schema.json') as {
391
+ $defs: { InputField: { properties: { type: { pattern: string } } } };
392
+ };
393
+
394
+ it('`fields[].type` and `inputs[].type` share one byte-identical pattern', () => {
395
+ const formPattern = formSchema.$defs.FormField.properties.type.pattern;
396
+ const cardPattern = cardSchema.$defs.InputField.properties.type.pattern;
397
+ expect(
398
+ formPattern,
399
+ why(
400
+ 'form-content-packs.md §Field types',
401
+ 'RFC 0137 reuses the RFC 0071 portable subset VERBATIM — two input-collecting declarative kinds MUST agree on what a field type means. If this fails, one kind\'s vocabulary was widened without the other and the wire contract has forked.',
402
+ ),
403
+ ).toBe(cardPattern);
404
+ });
405
+
406
+ it('the shared pattern still admits every portable data kind and both extension prefixes', () => {
407
+ const pattern = new RegExp(formSchema.$defs.FormField.properties.type.pattern);
408
+ for (const type of ['text', 'longtext', 'number', 'boolean', 'select', 'multiselect', 'file', 'artifact-ref']) {
409
+ expect(pattern.test(type), why('chat-card-packs.md §Input fields', `\`${type}\` is in the portable subset`)).toBe(true);
410
+ }
411
+ expect(pattern.test('vendor.acme.rating'), why('chat-card-packs.md §Input fields', 'vendor.<org>.<kind> extensions are admitted')).toBe(true);
412
+ expect(pattern.test('x-rating'), why('chat-card-packs.md §Input fields', 'x-<kind> extensions are admitted')).toBe(true);
413
+ expect(pattern.test('textarea'), why('chat-card-packs.md §Input fields', 'widget names are NOT in the subset')).toBe(false);
414
+ });
415
+ });
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Workflow-chain gallery visibility — `internal` chains (RFC 0135).
3
+ *
4
+ * Server-free corpus legs (always-on): the manifest schema's §WorkflowChain carries
5
+ * a boolean `internal` property; a chain declaring `internal: true` validates; a
6
+ * non-boolean `internal` is rejected; the spec documents the MUST-omit-from-default-
7
+ * gallery rule and the not-an-authorization-boundary rule.
8
+ *
9
+ * The gallery-omission behavior itself is host-catalog presentation with no
10
+ * normative wire listing endpoint, so it is witnessed at the reference host (host
11
+ * regression test), not over the wire — see RFC 0135 §Conformance.
12
+ *
13
+ * @see spec/v1/workflow-chain-packs.md §"Chain visibility (RFC 0135)"
14
+ * @see schemas/workflow-chain-pack-manifest.schema.json §WorkflowChain
15
+ * @see RFCS/0135-workflow-chain-internal-visibility.md
16
+ */
17
+
18
+ import { describe, it, expect } from 'vitest';
19
+ import { readFileSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ import Ajv2020 from 'ajv/dist/2020.js';
22
+ import addFormats from 'ajv-formats';
23
+ import { SCHEMAS_DIR } from '../lib/paths.js';
24
+
25
+ const cite = (section: string, requirement: string): string => `${section} — ${requirement}`;
26
+ const MANIFEST = join(SCHEMAS_DIR, 'workflow-chain-pack-manifest.schema.json');
27
+ const CHAIN_DOC = join(SCHEMAS_DIR, '..', 'spec', 'v1', 'workflow-chain-packs.md');
28
+
29
+ function packWith(internal: unknown): Record<string, unknown> {
30
+ return {
31
+ name: 'vendor.acme.factory',
32
+ version: '1.0.0',
33
+ kind: 'workflow-chain',
34
+ engines: { openwop: '^1' },
35
+ chains: [
36
+ {
37
+ chainId: 'acme.child-batch',
38
+ version: '1.0.0',
39
+ label: 'Child Batch (Factory child)',
40
+ description: 'Composition-only child fragment; composed by acme.factory.',
41
+ ...(internal !== undefined ? { internal } : {}),
42
+ parameters: {},
43
+ dag: { nodes: [{ id: 'build', typeId: 'core.ai.callPrompt', config: {} }] },
44
+ },
45
+ ],
46
+ };
47
+ }
48
+
49
+ describe('workflow-chain-internal-flag §A: corpus (RFC 0135, always-on)', () => {
50
+ it('the manifest §WorkflowChain declares a boolean `internal` property', () => {
51
+ const schema = JSON.parse(readFileSync(MANIFEST, 'utf8')) as {
52
+ $defs?: Record<string, { properties?: Record<string, { type?: string }> }>;
53
+ };
54
+ const internal = schema.$defs?.WorkflowChain?.properties?.internal;
55
+ expect(internal, cite('§WorkflowChain', 'internal property present')).toBeTruthy();
56
+ expect(internal?.type, cite('§WorkflowChain', 'internal is boolean')).toBe('boolean');
57
+ });
58
+
59
+ it('a chain declaring internal: true validates; a non-boolean internal is rejected', () => {
60
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
61
+ addFormats(ajv);
62
+ const validate = ajv.compile(JSON.parse(readFileSync(MANIFEST, 'utf8')) as Record<string, unknown>);
63
+ expect(validate(packWith(true)), cite('§WorkflowChain', `internal:true validates: ${ajv.errorsText(validate.errors)}`)).toBe(true);
64
+ expect(validate(packWith(undefined)), cite('§WorkflowChain', 'absent internal stays valid (absent ⇒ false)')).toBe(true);
65
+ expect(validate(packWith('yes')), cite('§WorkflowChain', 'non-boolean internal rejected')).toBe(false);
66
+ });
67
+
68
+ it('the spec documents the MUST-omit-from-default-gallery + not-an-authz-boundary rules', () => {
69
+ const doc = readFileSync(CHAIN_DOC, 'utf8');
70
+ expect(doc.includes('Chain visibility (RFC 0135)'), cite('§Chain visibility', 'section present')).toBe(true);
71
+ expect(
72
+ /internal[\s\S]{0,600}MUST omit/i.test(doc),
73
+ cite('§Chain visibility', 'documents MUST omit from default listing'),
74
+ ).toBe(true);
75
+ expect(
76
+ /NOT an authorization boundary/i.test(doc),
77
+ cite('§Chain visibility', 'documents internal is not an authz boundary'),
78
+ ).toBe(true);
79
+ });
80
+ });