@particle-academy/fancy-conformance 0.5.0 → 0.9.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,27 @@
1
+ {
2
+ "$schema": "../../../schema/suite-manifest.schema.json",
3
+ "suite": "flow/graph-runs",
4
+ "title": "Whole-graph execution: the same WorkflowSchema in, the same outputs out",
5
+ "since": "0.6.0",
6
+ "caseFormat": "table",
7
+ "cases": "cases.json",
8
+ "contract": {
9
+ "function": "runGraph(schema: WorkflowSchema, initialInputs: object) -> { ok: true, outputs: object } | { ok: false, error: string }",
10
+ "summary": "Import a WorkflowSchema v1 document leniently against the built-in kind registry (including the structural kinds), run it through the engine with the built-in offline executors, and return the run's outputs. This is the end-to-end parity contract for the fancy-flow engine: Kahn topological order, the three port-activation conventions, branch routing, dead-edge handling at merge points, cycle detection, and the closed failure of an unregistered kind.",
11
+ "reference": "php",
12
+ "referenceNote": "The goldens were produced by running particle-academy/fancy-flow-php, where they lived as tests/Parity/fixtures/ before being promoted here. Two were CORRECTED rather than transliterated during the promotion; each says so in its own notes.",
13
+ "implementations": [
14
+ { "language": "php", "package": "particle-academy/fancy-flow-php", "symbol": "FancyFlow\\Engine\\FlowRunner" },
15
+ { "language": "python", "package": "fancy-flow", "symbol": "fancy_flow.engine.FlowRunner" },
16
+ { "language": "rust", "package": "fancy-flow", "symbol": "fancy_flow::engine::FlowRunner" },
17
+ { "language": "node", "package": "@particle-academy/fancy-flow", "symbol": "runFlow" }
18
+ ]
19
+ },
20
+ "notes": [
21
+ "These 23 cases spent their whole life as a COPY. They were fancy-flow-php's own test fixtures; fancy-flow-py duplicated the directory byte for byte and held the two together with a provenance test, and its fixtures/SOURCE.md said plainly that the real fix was to promote them here. A fourth runtime is what made a third copy indefensible.",
22
+ "The reference implementation asserting these rows is the PHP twin. `@particle-academy/fancy-flow` — the runtime whose run-flow.ts is the behavioural source of truth for all of them — did NOT run them at any point while they were private fixtures, so the goldens describing its behaviour had zero implementations checking them on its own side. Wiring the Node runner is the remaining half of this promotion.",
23
+ "The run is deliberately fully specified rather than left to the runner: LENIENT import, a LOCAL kind registry with the structural kinds registered, and the built-in offline executors. A runner that populates the SHARED kind registry instead gets different declared output ports for `for_each` and disagrees on a case nobody changed.",
24
+ "Two goldens were corrected on promotion. `0014` recorded PHP's ENCODING of an empty header map (`[]`) rather than its value; the shared golden is `{}`, which PHP still satisfies because its loader decodes to assoc arrays. `0021` and `0022` asserted a SUBSTRING (`errorContains`) and now assert the exact message — the substring is what hid a live divergence in which Python emitted an ASCII hyphen where PHP and TypeScript emit an em dash.",
25
+ "There is NO discrimination probe for this suite, and that is a real gap rather than an oversight. Every other suite here ships deliberately-wrong implementations that must fail an exact set of ids; the implementation under test here is an entire workflow engine, which this package does not carry and should not grow. Until a probe exists, these rows guard DRIFT between four runtimes but do not prove they discriminate — read the green tick as the weaker claim it is."
26
+ ]
27
+ }
@@ -0,0 +1,126 @@
1
+ {
2
+ "$schema": "../../../schema/cases.schema.json",
3
+ "suite": "flow/subflow-registry",
4
+ "cases": [
5
+ {
6
+ "id": "0001-host-kind-survives-nesting",
7
+ "title": "A kind the HOST registered is still resolvable inside the child.",
8
+ "since": "0.8.0",
9
+ "tags": ["inheritance", "regression"],
10
+ "input": {
11
+ "parent": { "@particle-academy/output": "builtin", "host_kind": "host" },
12
+ "injected": null,
13
+ "graphConfig": null
14
+ },
15
+ "expected": { "@particle-academy/output": "builtin", "host_kind": "host" }
16
+ },
17
+ {
18
+ "id": "0002-bare-builtins-are-not-enough",
19
+ "title": "A parent carrying several host kinds passes all of them down, not just the builtins.",
20
+ "since": "0.8.0",
21
+ "tags": ["inheritance", "regression"],
22
+ "input": {
23
+ "parent": {
24
+ "@particle-academy/output": "builtin",
25
+ "@particle-academy/transform": "builtin",
26
+ "agent": "host",
27
+ "invoice_lookup": "host",
28
+ "tenant_notify": "host"
29
+ },
30
+ "injected": null,
31
+ "graphConfig": null
32
+ },
33
+ "expected": {
34
+ "@particle-academy/output": "builtin",
35
+ "@particle-academy/transform": "builtin",
36
+ "agent": "host",
37
+ "invoice_lookup": "host",
38
+ "tenant_notify": "host"
39
+ }
40
+ },
41
+ {
42
+ "id": "0003-host-override-of-a-builtin-reaches-the-child",
43
+ "title": "A host that REPLACED a builtin keeps its own version inside the child.",
44
+ "since": "0.8.0",
45
+ "tags": ["inheritance", "override", "regression"],
46
+ "input": {
47
+ "parent": { "@particle-academy/llm_call": "host", "@particle-academy/output": "builtin" },
48
+ "injected": null,
49
+ "graphConfig": null
50
+ },
51
+ "expected": { "@particle-academy/llm_call": "host", "@particle-academy/output": "builtin" }
52
+ },
53
+ {
54
+ "id": "0004-graph-config-adds-to-the-inherited-set",
55
+ "title": "A registry the graph declares on the node is layered ON TOP of the inherited one.",
56
+ "since": "0.8.0",
57
+ "tags": ["config"],
58
+ "input": {
59
+ "parent": { "@particle-academy/output": "builtin", "host_kind": "host" },
60
+ "injected": null,
61
+ "graphConfig": { "child_only": "child-specific" }
62
+ },
63
+ "expected": {
64
+ "@particle-academy/output": "builtin",
65
+ "host_kind": "host",
66
+ "child_only": "child-specific"
67
+ }
68
+ },
69
+ {
70
+ "id": "0005-graph-config-wins-on-a-conflict",
71
+ "title": "An executor the graph names for its child beats the inherited one of the same kind.",
72
+ "since": "0.8.0",
73
+ "tags": ["config", "precedence"],
74
+ "input": {
75
+ "parent": { "@particle-academy/output": "builtin", "host_kind": "host" },
76
+ "injected": null,
77
+ "graphConfig": { "host_kind": "child-specific" }
78
+ },
79
+ "expected": { "@particle-academy/output": "builtin", "host_kind": "child-specific" }
80
+ },
81
+ {
82
+ "id": "0006-an-injected-registry-replaces-the-inherited-one",
83
+ "title": "A registry handed to the executor at construction is used INSTEAD of the parent's.",
84
+ "since": "0.8.0",
85
+ "tags": ["injection", "precedence"],
86
+ "input": {
87
+ "parent": { "@particle-academy/output": "builtin", "host_kind": "host" },
88
+ "injected": { "@particle-academy/output": "builtin", "sandboxed_kind": "host" },
89
+ "graphConfig": null
90
+ },
91
+ "expected": { "@particle-academy/output": "builtin", "sandboxed_kind": "host" }
92
+ },
93
+ {
94
+ "id": "0007-graph-config-still-layers-over-an-injected-registry",
95
+ "title": "Injection replaces the inherited registry; the graph's own still layers on top of that.",
96
+ "since": "0.8.0",
97
+ "tags": ["injection", "config", "precedence"],
98
+ "input": {
99
+ "parent": { "host_kind": "host" },
100
+ "injected": { "@particle-academy/output": "builtin" },
101
+ "graphConfig": { "child_only": "child-specific" }
102
+ },
103
+ "expected": { "@particle-academy/output": "builtin", "child_only": "child-specific" }
104
+ },
105
+ {
106
+ "id": "0008-an-empty-graph-config-changes-nothing",
107
+ "title": "An empty config registry is not the same as an absent one, and neither empties the child.",
108
+ "since": "0.8.0",
109
+ "tags": ["config", "edge"],
110
+ "input": {
111
+ "parent": { "@particle-academy/output": "builtin", "host_kind": "host" },
112
+ "injected": null,
113
+ "graphConfig": {}
114
+ },
115
+ "expected": { "@particle-academy/output": "builtin", "host_kind": "host" }
116
+ },
117
+ {
118
+ "id": "0009-an-empty-parent-yields-an-empty-child",
119
+ "title": "Inheritance copies what is there; it never invents builtins the parent did not have.",
120
+ "since": "0.8.0",
121
+ "tags": ["edge"],
122
+ "input": { "parent": {}, "injected": null, "graphConfig": null },
123
+ "expected": {}
124
+ }
125
+ ]
126
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "$schema": "../../../schema/suite-manifest.schema.json",
3
+ "suite": "flow/subflow-registry",
4
+ "title": "What executors a subflow's child runs against",
5
+ "since": "0.8.0",
6
+ "caseFormat": "table",
7
+ "cases": "cases.json",
8
+ "contract": {
9
+ "function": "childRegistry(parent: object, injected: object|null, graphConfig: object|null) -> object",
10
+ "summary": "Compose the executor registry a `subflow` node runs its CHILD graph against. `parent` is the registry the parent run is executing with, keyed by kind id, whose values stand in for executors. `injected` is a registry handed to the subflow executor at construction, or null. `graphConfig` is the registry the graph itself declares on the subflow node's `config.executors`, or null. The result is the registry the child must resolve against, keyed by kind id.",
11
+ "reference": "node",
12
+ "referenceNote": "Designed here rather than lifted from an implementation, because at the time this suite was written all four runtimes were WRONG in the same way and none could serve as the reference. `node` is recorded as the reference because @particle-academy/fancy-flow's run-flow.ts is the behavioural source of truth for the engine generally.",
13
+ "implementations": [
14
+ { "language": "node", "package": "@particle-academy/fancy-flow", "symbol": "subflowExecutor" },
15
+ { "language": "php", "package": "particle-academy/fancy-flow-php", "symbol": "FancyFlow\\Nodes\\Structural\\SubflowExecutor" },
16
+ { "language": "python", "package": "fancy-flow", "symbol": "fancy_flow.nodes.structural.SubflowExecutor" },
17
+ { "language": "rust", "package": "fancy-flow", "symbol": "fancy_flow::nodes::structural::SubflowExecutor" }
18
+ ]
19
+ },
20
+ "notes": [
21
+ "This table exists because ALL FOUR runtimes had the same defect at once, and each of the three that were fixed carried its own hand-written test for it. Three copies of an assertion agree right up until someone changes one of them, and nothing anywhere reports the divergence — which is the same shape as the bug the table describes.",
22
+ "Reported against the PHP twin as fancy-flow-php#7 by a consumer running a real graph: a child containing a host-registered kind failed with `No executor registered for kind=<host-kind>`, while the identical graph run at top level succeeded. Same workflow, two behaviours depending on nesting depth.",
23
+ "The TypeScript engine had it with a WORSE default. Its child ran against `config.executors ?? {}` — an EMPTY registry unless the graph happened to carry one — so nesting a graph did not merely lose the host's kinds, it lost everything. PHP and Python fell back to the bare builtins. None of the three warned, because an unregistered kind fails closed with no outputs, which is the correct default and exactly what makes this silent.",
24
+ "The costly case is not a MISSING kind but a REPLACED one. A host that overrides `llm_call` to add tenancy, budgeting or token accounting gets its own version in the parent and the package's version in the child: the same graph billing two different ways depending on where it was invoked from, with nothing failing. Case 0003 is that case.",
25
+ "The contract is deliberately a function over REGISTRIES rather than a graph run. `flow/graph-runs` cannot ship a discrimination probe because the implementation under test there is an entire workflow engine, which this package does not carry and should not grow. Registry composition is a pure function over maps, so this suite CAN be probed — and is, in tests/discrimination.test.ts, by the exact mutant that shipped in production: a child that sees only the builtins.",
26
+ "Values in these maps are marker strings, not executors. `builtin`, `host` and `child-specific` stand for whichever callable a runtime would bind; what the table pins is WHICH one survives into the child, not what it does."
27
+ ]
28
+ }
@@ -0,0 +1,226 @@
1
+ {
2
+ "$schema": "../../../schema/cases.schema.json",
3
+ "suite": "flow/workflow-props",
4
+ "cases": [
5
+ {
6
+ "id": "0001-supplied-value-passes-through",
7
+ "title": "A declared, supplied value arrives in the resolved map unchanged.",
8
+ "since": "0.9.0",
9
+ "tags": ["happy-path"],
10
+ "input": {
11
+ "declared": [{ "name": "topic", "type": "string" }],
12
+ "passed": { "topic": "otters" }
13
+ },
14
+ "expected": { "ok": true, "props": { "topic": "otters" } }
15
+ },
16
+ {
17
+ "id": "0002-default-fills-an-omitted-value",
18
+ "title": "An omitted input with a default resolves to the default.",
19
+ "since": "0.9.0",
20
+ "tags": ["defaults"],
21
+ "input": {
22
+ "declared": [{ "name": "limit", "type": "number", "default": 10 }],
23
+ "passed": {}
24
+ },
25
+ "expected": { "ok": true, "props": { "limit": 10 } }
26
+ },
27
+ {
28
+ "id": "0003-supplied-beats-default",
29
+ "title": "An explicitly supplied value wins over the declared default.",
30
+ "since": "0.9.0",
31
+ "tags": ["defaults"],
32
+ "input": {
33
+ "declared": [{ "name": "limit", "type": "number", "default": 10 }],
34
+ "passed": { "limit": 25 }
35
+ },
36
+ "expected": { "ok": true, "props": { "limit": 25 } }
37
+ },
38
+ {
39
+ "id": "0004-explicit-zero-is-not-replaced-by-a-default",
40
+ "title": "A supplied 0 survives a non-zero default.",
41
+ "since": "0.9.0",
42
+ "tags": ["defaults", "falsy", "trap"],
43
+ "input": {
44
+ "declared": [{ "name": "limit", "type": "number", "default": 10 }],
45
+ "passed": { "limit": 0 }
46
+ },
47
+ "expected": { "ok": true, "props": { "limit": 0 } }
48
+ },
49
+ {
50
+ "id": "0005-explicit-false-is-not-replaced-by-a-default",
51
+ "title": "A supplied false survives a true default.",
52
+ "since": "0.9.0",
53
+ "tags": ["defaults", "falsy", "trap"],
54
+ "input": {
55
+ "declared": [{ "name": "dryRun", "type": "boolean", "default": true }],
56
+ "passed": { "dryRun": false }
57
+ },
58
+ "expected": { "ok": true, "props": { "dryRun": false } }
59
+ },
60
+ {
61
+ "id": "0006-explicit-empty-string-is-not-replaced-by-a-default",
62
+ "title": "A supplied empty string survives a non-empty default.",
63
+ "since": "0.9.0",
64
+ "tags": ["defaults", "falsy", "trap"],
65
+ "input": {
66
+ "declared": [{ "name": "note", "type": "string", "default": "unset" }],
67
+ "passed": { "note": "" }
68
+ },
69
+ "expected": { "ok": true, "props": { "note": "" } }
70
+ },
71
+ {
72
+ "id": "0007-absent-optional-is-absent-not-null",
73
+ "title": "An optional input with no default and no value is missing from the map entirely.",
74
+ "since": "0.9.0",
75
+ "tags": ["absence"],
76
+ "input": {
77
+ "declared": [
78
+ { "name": "topic", "type": "string" },
79
+ { "name": "note", "type": "string" }
80
+ ],
81
+ "passed": { "topic": "otters" }
82
+ },
83
+ "expected": { "ok": true, "props": { "topic": "otters" } }
84
+ },
85
+ {
86
+ "id": "0008-untyped-declaration-accepts-anything",
87
+ "title": "An input declaring no type accepts a nested object.",
88
+ "since": "0.9.0",
89
+ "tags": ["types"],
90
+ "input": {
91
+ "declared": [{ "name": "payload" }],
92
+ "passed": { "payload": { "nested": [1, 2] } }
93
+ },
94
+ "expected": { "ok": true, "props": { "payload": { "nested": [1, 2] } } }
95
+ },
96
+ {
97
+ "id": "0009-required-is-satisfied-by-its-default",
98
+ "title": "A required input carrying a default does not need the caller to supply it.",
99
+ "since": "0.9.0",
100
+ "tags": ["required", "defaults"],
101
+ "input": {
102
+ "declared": [{ "name": "limit", "type": "number", "required": true, "default": 5 }],
103
+ "passed": {}
104
+ },
105
+ "expected": { "ok": true, "props": { "limit": 5 } }
106
+ },
107
+ {
108
+ "id": "0010-array-satisfies-array-not-object",
109
+ "title": "A list satisfies a declared array.",
110
+ "since": "0.9.0",
111
+ "tags": ["types", "trap"],
112
+ "input": {
113
+ "declared": [{ "name": "tags", "type": "array" }],
114
+ "passed": { "tags": ["a", "b"] }
115
+ },
116
+ "expected": { "ok": true, "props": { "tags": ["a", "b"] } }
117
+ },
118
+ {
119
+ "id": "0011-object-satisfies-object",
120
+ "title": "A map satisfies a declared object.",
121
+ "since": "0.9.0",
122
+ "tags": ["types"],
123
+ "input": {
124
+ "declared": [{ "name": "meta", "type": "object" }],
125
+ "passed": { "meta": { "k": "v" } }
126
+ },
127
+ "expected": { "ok": true, "props": { "meta": { "k": "v" } } }
128
+ },
129
+ {
130
+ "id": "0012-no-declaration-and-no-props",
131
+ "title": "A workflow that declares nothing, called with nothing, resolves to an empty map.",
132
+ "since": "0.9.0",
133
+ "tags": ["happy-path", "absence"],
134
+ "input": { "declared": null, "passed": null },
135
+ "expected": { "ok": true, "props": {} }
136
+ },
137
+ {
138
+ "id": "0101-an-unknown-key-fails",
139
+ "title": "A misspelled input name FAILS rather than sitting unread.",
140
+ "since": "0.9.0",
141
+ "tags": ["validation", "regression", "silent-failure"],
142
+ "input": {
143
+ "declared": [{ "name": "topic", "type": "string" }],
144
+ "passed": { "topik": "otters" }
145
+ },
146
+ "expected": { "ok": false, "code": "unknown_input" }
147
+ },
148
+ {
149
+ "id": "0102-props-passed-to-a-workflow-declaring-none-fails",
150
+ "title": "Passing anything to a workflow that declares no inputs FAILS.",
151
+ "since": "0.9.0",
152
+ "tags": ["validation", "silent-failure"],
153
+ "input": {
154
+ "declared": null,
155
+ "passed": { "topic": "otters" }
156
+ },
157
+ "expected": { "ok": false, "code": "unknown_input" }
158
+ },
159
+ {
160
+ "id": "0103-unknown-is-reported-before-missing-required",
161
+ "title": "A caller who misspells a required input is told about the word they typed.",
162
+ "since": "0.9.0",
163
+ "tags": ["validation", "ordering"],
164
+ "input": {
165
+ "declared": [{ "name": "topic", "type": "string", "required": true }],
166
+ "passed": { "topik": "otters" }
167
+ },
168
+ "expected": { "ok": false, "code": "unknown_input" }
169
+ },
170
+ {
171
+ "id": "0104-missing-required-fails",
172
+ "title": "A required input with no default and no value FAILS.",
173
+ "since": "0.9.0",
174
+ "tags": ["validation", "required"],
175
+ "input": {
176
+ "declared": [{ "name": "topic", "type": "string", "required": true }],
177
+ "passed": {}
178
+ },
179
+ "expected": { "ok": false, "code": "missing_required" }
180
+ },
181
+ {
182
+ "id": "0105-wrong-type-fails",
183
+ "title": "A string supplied where a number is declared FAILS.",
184
+ "since": "0.9.0",
185
+ "tags": ["validation", "types"],
186
+ "input": {
187
+ "declared": [{ "name": "limit", "type": "number" }],
188
+ "passed": { "limit": "ten" }
189
+ },
190
+ "expected": { "ok": false, "code": "type_mismatch" }
191
+ },
192
+ {
193
+ "id": "0106-object-does-not-satisfy-array",
194
+ "title": "A map supplied where an array is declared FAILS.",
195
+ "since": "0.9.0",
196
+ "tags": ["validation", "types", "trap"],
197
+ "input": {
198
+ "declared": [{ "name": "tags", "type": "array" }],
199
+ "passed": { "tags": { "0": "a" } }
200
+ },
201
+ "expected": { "ok": false, "code": "type_mismatch" }
202
+ },
203
+ {
204
+ "id": "0107-array-does-not-satisfy-object",
205
+ "title": "A list supplied where an object is declared FAILS.",
206
+ "since": "0.9.0",
207
+ "tags": ["validation", "types", "trap"],
208
+ "input": {
209
+ "declared": [{ "name": "meta", "type": "object" }],
210
+ "passed": { "meta": ["a"] }
211
+ },
212
+ "expected": { "ok": false, "code": "type_mismatch" }
213
+ },
214
+ {
215
+ "id": "0108-null-does-not-satisfy-a-declared-type",
216
+ "title": "An explicit null supplied where a string is declared FAILS.",
217
+ "since": "0.9.0",
218
+ "tags": ["validation", "types"],
219
+ "input": {
220
+ "declared": [{ "name": "topic", "type": "string" }],
221
+ "passed": { "topic": null }
222
+ },
223
+ "expected": { "ok": false, "code": "type_mismatch" }
224
+ }
225
+ ]
226
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "$schema": "../../../schema/suite-manifest.schema.json",
3
+ "suite": "flow/workflow-props",
4
+ "title": "Resolving a caller's props against what a workflow declares",
5
+ "since": "0.9.0",
6
+ "caseFormat": "table",
7
+ "cases": "cases.json",
8
+ "contract": {
9
+ "function": "resolveWorkflowProps(declared: array|null, passed: object|null) -> {ok: true, props: object} | {ok: false, code: string}",
10
+ "summary": "Check the flat, by-name object a caller passed against the `inputs` a workflow declares, and fill in declared defaults. `declared` is the list of input declarations ({name, type?, required?, default?}) or null when the workflow declares none. `passed` is what the caller supplied, or null. On success the result carries the RESOLVED map -- supplied values plus defaults for anything omitted, and nothing else. On failure it carries a stable `code`; the human-readable message is deliberately NOT part of the contract.",
11
+ "reference": "node",
12
+ "referenceNote": "@particle-academy/fancy-flow's src/runtime/workflow-props.ts is the behavioural source of truth, and the rules were designed there with this table written alongside rather than after.",
13
+ "implementations": [
14
+ { "language": "node", "package": "@particle-academy/fancy-flow", "symbol": "resolveWorkflowProps" },
15
+ { "language": "php", "package": "particle-academy/fancy-flow-php", "symbol": "FancyFlow\\Runtime\\WorkflowProps::resolve" },
16
+ { "language": "python", "package": "fancy-flow", "symbol": "fancy_flow.runtime.workflow_props.resolve_workflow_props" }
17
+ ]
18
+ },
19
+ "notes": [
20
+ "The CODE is asserted, never the message. Each runtime words its errors idiomatically and an English sentence is not a behaviour -- pinning the prose would hold three implementations to a translation, and would go red on a wording improvement that changed nothing.",
21
+ "The reason this feature exists is case 0101: passing a MISSPELLED key. Before workflow props, `initialInputs` was keyed by node id and an unrecognised key was not an error -- the value simply sat unread, the node saw nothing, and the run reported success with output that was quietly wrong. Every implementation must fail that case, because the silent version is what shipped.",
22
+ "Cases 0004-0006 are the falsy-value trap and they are the ones a re-implementation gets wrong. `0`, `false` and `\"\"` are values a caller MEANT to pass. A default applied with `||`, or with `??` on the wrong side, silently replaces them -- and a declared limit of 0 quietly becoming 10 is not an error anybody observes.",
23
+ "Absent is ABSENT. An input that is not supplied, has no default and is not required must not appear in the resolved map at all -- not as null, not as an empty string. PHP has one absent value and JS has two; letting that difference leak would make `{{ $props.x }}` disagree across runtimes for no useful reason.",
24
+ "`type` is optional and an omitted type accepts ANYTHING (case 0008). 'I am not asserting a shape' must not degrade into 'nothing is allowed', which is the failure mode of a validator written defensively.",
25
+ "Arrays are checked before objects (case 0010). `typeof []` is `\"object\"` in JavaScript, and a declaration saying `array` that accepts `{}` is a check that passes while meaning nothing.",
26
+ "Unknown keys are checked BEFORE required ones (case 0103). A caller who typed `topik` instead of `topic` should be told about `topik` -- the word they wrote -- rather than that `topic` is missing, which describes a key they believe they supplied."
27
+ ]
28
+ }