@particle-academy/fancy-conformance 0.24.0 → 0.26.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 -0
- package/VERSION +1 -1
- package/package.json +1 -1
- package/schema/suite-manifest.schema.json +4 -3
- package/suites/flow/durable-dispatch/cases.json +1199 -0
- package/suites/flow/durable-dispatch/manifest.json +31 -0
- package/suites/shared/subscription-lease/cases.json +206 -0
- package/suites/shared/subscription-lease/manifest.json +41 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../../schema/suite-manifest.schema.json",
|
|
3
|
+
"suite": "flow/durable-dispatch",
|
|
4
|
+
"title": "A queued run hands out one node at a time unless the host asks for more",
|
|
5
|
+
"since": "0.25.0",
|
|
6
|
+
"caseFormat": "table",
|
|
7
|
+
"cases": "cases.json",
|
|
8
|
+
"contract": {
|
|
9
|
+
"function": "dispatchTrace(schema: WorkflowSchema, maxConcurrent: int, publishes: { [nodeId]: string[] }, pauses: string[]) -> { trace: string[], neverDispatched: string[] }",
|
|
10
|
+
"summary": "Simulate a durable (queued, per-node) run with the runtime's OWN frontier and dispatch-selection functions, and no engine execution. Import the schema leniently against a local built-in kind registry with the structural kinds. Let `limit` be null when `maxConcurrent` is 0 (unlimited), else `maxConcurrent`. Start with empty node state, an empty FIFO `inFlight` and an empty `trace`. Repeat: (1) compute the frontier from the state; record every `skipped` node as SKIPPED with no ports and append `skip <id>`; (2) SELECT from `ready` (declaration order): with a null limit take all of them, else take the first `limit - held`, where `held` counts nodes whose state is CLAIMED or PAUSED; mark each selected node CLAIMED, push it onto `inFlight` and append `dispatch <id>`; (3) if `inFlight` is empty, stop; (4) shift the first node off `inFlight`: when it is in `pauses`, record it PAUSED (it keeps its slot) and append `pause <id>`; otherwise record it COMPLETED publishing `publishes[id]` (default [\"out\"]) and append `complete <id>`. `neverDispatched` is every node, in declaration order, that never entered the state. The TRACE, not a list of batches, is pinned: a batch list cannot tell a node dispatched the moment a gate paused from one dispatched after a sibling settled, and that is the difference between counting a paused node as held and not.",
|
|
11
|
+
"reference": "php",
|
|
12
|
+
"referenceNote": "Goldens produced by fancy-flow-php 0.54.0's `FancyFlow\\Laravel\\Runs\\Frontier::compute` and `FancyFlow\\Laravel\\Runs\\DispatchLimit::select`, then reviewed row by row. Written for fancy-flow-php#17, which made serial the default.",
|
|
13
|
+
"implementations": [
|
|
14
|
+
{ "language": "php", "package": "particle-academy/fancy-flow-php", "symbol": "FancyFlow\\Laravel\\Runs\\DispatchLimit" },
|
|
15
|
+
{ "language": "node", "package": "@particle-academy/fancy-flow", "symbol": "selectDispatch" },
|
|
16
|
+
{ "language": "python", "package": "fancy-flow", "symbol": "fancy_flow.durable.select_dispatch" },
|
|
17
|
+
{ "language": "rust", "package": "fancy-flow", "symbol": "fancy_flow::durable::select_dispatch" }
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
"notes": [
|
|
21
|
+
"THE OWNER'S RULING, relayed from the estate's largest consumer: nodes never queue at the same time; a node is added to the worker queue only after the previous node finishes, and that is fancy-flow's DEFAULT. Parallel dispatch of a ready frontier is an explicit opt-in. Rows 0001, 0004, 0005, 0007, 0008, 0011, 0012 and 0013 are the default; 0002, 0003, 0006, 0009, 0010 and 0014 are what a host gets only by asking.",
|
|
22
|
+
"SERIAL IS maxConcurrent 1, UNLIMITED IS 0. Every coordinator defaults to 1. A positive integer caps how many of one run's nodes are held at once; 0 is the whole ready frontier, exported under a name (`DispatchLimit::UNLIMITED`, `UNLIMITED_CONCURRENCY`) so a host never writes a bare 0. A negative limit is refused where it is set: under a serial default, a typo that silently turned a run parallel is the failure to avoid.",
|
|
23
|
+
"HELD MEANS CLAIMED OR PAUSED. A node paused for a person keeps its slot, so a gate never opens a gap for a sibling to queue alongside it while the person decides (0008, and 0010 under a cap). On fancy-flow-php a pause also parks the whole run, so this changes nothing observable there; on the TS and Python coordinators a pause does NOT park the run, and without this rule a later advance would dispatch the gate's siblings.",
|
|
24
|
+
"ORDER IS DECLARATION ORDER AMONG WHAT IS READY NOW, not breadth-first. 0007 is the discriminating row: `c` becomes ready after `a` settles and is declared before the still-waiting `b`, so it goes first. An implementation that queues nodes in the order they became ready fails it. 0004 pins that the edge list's order does not matter.",
|
|
25
|
+
"THE BUDGET IS MEASURED AGAINST WORK ALREADY HELD, not the size of one batch. Two nodes settling at once each trigger an advance on a real queue; a per-batch cap lets each dispatch its own quota. The simulation cannot race, so 0014 and 0010 pin the held-count arithmetic directly, and each runtime's own suite pins the race.",
|
|
26
|
+
"WORKERS ARE FIFO AND SETTLE ONE AT A TIME. That makes every row deterministic without pretending to model a real queue's timing. It is a model of the decision, not of the transport.",
|
|
27
|
+
"SKIPS AND NOTES NEVER TAKE A SLOT (0011, 0012). The frontier settles a dead branch, everything below it, and a sticky note in the same pass, before selection.",
|
|
28
|
+
"RUST JOINED IN 0.26.0. fancy-flow-rs had no durable coordinator when this table was written; the owner ruled to build one rather than record its absence, and it passes all 14 rows against its own frontier and selection. It also runs flow/run-diagnostics through its coordinator, and matches flow/graph-runs durably and in a single process.",
|
|
29
|
+
"THERE IS A DISCRIMINATION PROBE (tests/discrimination-durable-dispatch.test.ts): a faithful frontier + selection passes every row, and four mutants each fail an exact set -- a paused node not held (0008, 0010), a per-batch cap (0008, 0010, 0014), the pre-#17 default (0001, 0004, 0005, 0007, 0008, 0013) and breadth-first order (0007). Writing it is what turned the goldens from dispatch batches into a trace: the batch shape let the first two mutants through."
|
|
30
|
+
]
|
|
31
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../../schema/case-table.schema.json",
|
|
3
|
+
"suite": "shared/subscription-lease",
|
|
4
|
+
"cases": [
|
|
5
|
+
{
|
|
6
|
+
"id": "0001-active-well-before-renewal",
|
|
7
|
+
"title": "Three days into a seven-day lease with a one-day margin is active, nothing to do",
|
|
8
|
+
"since": "0.26.0",
|
|
9
|
+
"input": {
|
|
10
|
+
"expiresAt": "2026-09-22T00:00:00Z",
|
|
11
|
+
"renewBeforeSeconds": 86400,
|
|
12
|
+
"renewOperation": "subscription_renew",
|
|
13
|
+
"now": "2026-09-18T00:00:00Z"
|
|
14
|
+
},
|
|
15
|
+
"expected": {
|
|
16
|
+
"renewAt": "2026-09-21T00:00:00.000Z",
|
|
17
|
+
"state": "active",
|
|
18
|
+
"action": "none"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"id": "0002-due-at-the-boundary",
|
|
23
|
+
"title": "Exactly at renewAt the lease is DUE — the boundary is inclusive",
|
|
24
|
+
"since": "0.26.0",
|
|
25
|
+
"input": {
|
|
26
|
+
"expiresAt": "2026-09-22T00:00:00Z",
|
|
27
|
+
"renewBeforeSeconds": 86400,
|
|
28
|
+
"renewOperation": "subscription_renew",
|
|
29
|
+
"now": "2026-09-21T00:00:00Z"
|
|
30
|
+
},
|
|
31
|
+
"expected": {
|
|
32
|
+
"renewAt": "2026-09-21T00:00:00.000Z",
|
|
33
|
+
"state": "due",
|
|
34
|
+
"action": "renew"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"id": "0003-one-second-before-due",
|
|
39
|
+
"title": "One second before renewAt is still active",
|
|
40
|
+
"since": "0.26.0",
|
|
41
|
+
"input": {
|
|
42
|
+
"expiresAt": "2026-09-22T00:00:00Z",
|
|
43
|
+
"renewBeforeSeconds": 86400,
|
|
44
|
+
"renewOperation": "subscription_renew",
|
|
45
|
+
"now": "2026-09-20T23:59:59Z"
|
|
46
|
+
},
|
|
47
|
+
"expected": {
|
|
48
|
+
"renewAt": "2026-09-21T00:00:00.000Z",
|
|
49
|
+
"state": "active",
|
|
50
|
+
"action": "none"
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"id": "0004-expired-at-the-boundary-beats-due",
|
|
55
|
+
"title": "Exactly at expiresAt the lease is EXPIRED, and expired wins over due",
|
|
56
|
+
"since": "0.26.0",
|
|
57
|
+
"input": {
|
|
58
|
+
"expiresAt": "2026-09-22T00:00:00Z",
|
|
59
|
+
"renewBeforeSeconds": 86400,
|
|
60
|
+
"renewOperation": "subscription_renew",
|
|
61
|
+
"now": "2026-09-22T00:00:00Z"
|
|
62
|
+
},
|
|
63
|
+
"expected": {
|
|
64
|
+
"renewAt": "2026-09-21T00:00:00.000Z",
|
|
65
|
+
"state": "expired",
|
|
66
|
+
"action": "resync"
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"id": "0005-one-second-before-expiry-is-still-renewable",
|
|
71
|
+
"title": "One second before expiry the lease is due, not expired",
|
|
72
|
+
"since": "0.26.0",
|
|
73
|
+
"input": {
|
|
74
|
+
"expiresAt": "2026-09-22T00:00:00Z",
|
|
75
|
+
"renewBeforeSeconds": 86400,
|
|
76
|
+
"renewOperation": "subscription_renew",
|
|
77
|
+
"now": "2026-09-21T23:59:59Z"
|
|
78
|
+
},
|
|
79
|
+
"expected": {
|
|
80
|
+
"renewAt": "2026-09-21T00:00:00.000Z",
|
|
81
|
+
"state": "due",
|
|
82
|
+
"action": "renew"
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"id": "0006-now-long-before-is-simply-active",
|
|
87
|
+
"title": "A now far before the lease (clock skew, or a lease issued for the future) is active — nothing clamps",
|
|
88
|
+
"since": "0.26.0",
|
|
89
|
+
"input": {
|
|
90
|
+
"expiresAt": "2026-09-22T00:00:00Z",
|
|
91
|
+
"renewBeforeSeconds": 86400,
|
|
92
|
+
"renewOperation": "subscription_renew",
|
|
93
|
+
"now": "2020-01-01T00:00:00Z"
|
|
94
|
+
},
|
|
95
|
+
"expected": {
|
|
96
|
+
"renewAt": "2026-09-21T00:00:00.000Z",
|
|
97
|
+
"state": "active",
|
|
98
|
+
"action": "none"
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
"id": "0007-missed-by-a-week-is-resync",
|
|
103
|
+
"title": "A lease nobody renewed is resync, however long ago it lapsed",
|
|
104
|
+
"since": "0.26.0",
|
|
105
|
+
"input": {
|
|
106
|
+
"expiresAt": "2026-09-22T00:00:00Z",
|
|
107
|
+
"renewBeforeSeconds": 86400,
|
|
108
|
+
"renewOperation": "subscription_renew",
|
|
109
|
+
"now": "2026-09-29T00:00:00Z"
|
|
110
|
+
},
|
|
111
|
+
"expected": {
|
|
112
|
+
"renewAt": "2026-09-21T00:00:00.000Z",
|
|
113
|
+
"state": "expired",
|
|
114
|
+
"action": "resync"
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"id": "0008-offset-and-zulu-are-one-instant",
|
|
119
|
+
"title": "An expiry with a +02:00 offset and a Zulu now compare as instants",
|
|
120
|
+
"since": "0.26.0",
|
|
121
|
+
"input": {
|
|
122
|
+
"expiresAt": "2026-09-22T02:00:00+02:00",
|
|
123
|
+
"renewBeforeSeconds": 3600,
|
|
124
|
+
"renewOperation": "channel_create",
|
|
125
|
+
"now": "2026-09-21T23:30:00Z"
|
|
126
|
+
},
|
|
127
|
+
"expected": {
|
|
128
|
+
"renewAt": "2026-09-21T23:00:00.000Z",
|
|
129
|
+
"state": "due",
|
|
130
|
+
"action": "renew"
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
"id": "0009-fractional-seconds-parse",
|
|
135
|
+
"title": "Fractional seconds do not break the comparison",
|
|
136
|
+
"since": "0.26.0",
|
|
137
|
+
"input": {
|
|
138
|
+
"expiresAt": "2026-09-22T00:00:00.500Z",
|
|
139
|
+
"renewBeforeSeconds": 60,
|
|
140
|
+
"renewOperation": "subscription_renew",
|
|
141
|
+
"now": "2026-09-21T23:59:00.400Z"
|
|
142
|
+
},
|
|
143
|
+
"expected": {
|
|
144
|
+
"renewAt": "2026-09-21T23:59:00.500Z",
|
|
145
|
+
"state": "active",
|
|
146
|
+
"action": "none"
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
"id": "0010-an-epoch-is-refused-not-guessed",
|
|
151
|
+
"title": "A Google-shaped epoch-milliseconds expiry is refused — the connector converts, the lease never guesses units",
|
|
152
|
+
"since": "0.26.0",
|
|
153
|
+
"input": {
|
|
154
|
+
"expiresAt": "1789430400000",
|
|
155
|
+
"renewBeforeSeconds": 86400,
|
|
156
|
+
"renewOperation": "channel_create",
|
|
157
|
+
"now": "2026-09-18T00:00:00Z"
|
|
158
|
+
},
|
|
159
|
+
"expected": {
|
|
160
|
+
"refused": "expiresAt"
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
"id": "0011-zero-margin-is-refused",
|
|
165
|
+
"title": "renewBeforeSeconds of 0 is refused: due would be unreachable, and nobody would renew",
|
|
166
|
+
"since": "0.26.0",
|
|
167
|
+
"input": {
|
|
168
|
+
"expiresAt": "2026-09-22T00:00:00Z",
|
|
169
|
+
"renewBeforeSeconds": 0,
|
|
170
|
+
"renewOperation": "subscription_renew",
|
|
171
|
+
"now": "2026-09-18T00:00:00Z"
|
|
172
|
+
},
|
|
173
|
+
"expected": {
|
|
174
|
+
"refused": "renewBeforeSeconds"
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
"id": "0012-negative-margin-is-refused",
|
|
179
|
+
"title": "A negative renewBeforeSeconds is refused",
|
|
180
|
+
"since": "0.26.0",
|
|
181
|
+
"input": {
|
|
182
|
+
"expiresAt": "2026-09-22T00:00:00Z",
|
|
183
|
+
"renewBeforeSeconds": -60,
|
|
184
|
+
"renewOperation": "subscription_renew",
|
|
185
|
+
"now": "2026-09-18T00:00:00Z"
|
|
186
|
+
},
|
|
187
|
+
"expected": {
|
|
188
|
+
"refused": "renewBeforeSeconds"
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
"id": "0013-no-renew-operation-is-refused",
|
|
193
|
+
"title": "A lease with no renew operation is refused — a due lease with nothing to call is one nobody renews",
|
|
194
|
+
"since": "0.26.0",
|
|
195
|
+
"input": {
|
|
196
|
+
"expiresAt": "2026-09-22T00:00:00Z",
|
|
197
|
+
"renewBeforeSeconds": 86400,
|
|
198
|
+
"renewOperation": "",
|
|
199
|
+
"now": "2026-09-18T00:00:00Z"
|
|
200
|
+
},
|
|
201
|
+
"expected": {
|
|
202
|
+
"refused": "renewOperation"
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
]
|
|
206
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../../schema/suite-manifest.schema.json",
|
|
3
|
+
"suite": "shared/subscription-lease",
|
|
4
|
+
"title": "A subscription that EXPIRES, and when the host must act on it",
|
|
5
|
+
"since": "0.26.0",
|
|
6
|
+
"caseFormat": "table",
|
|
7
|
+
"cases": "cases.json",
|
|
8
|
+
"contract": {
|
|
9
|
+
"summary": "A `subscription` trigger is a webhook the provider stops delivering unless somebody renews it. The lease is the provider's expiry plus the connector's declaration of how early to renew and which operation does it, so a host runs ONE renewal scheduler for every expiring trigger instead of one per connector. `state` says where the lease is; `action` says what the host does about it.",
|
|
10
|
+
"functions": {
|
|
11
|
+
"renewAt": "renewAt(lease) -> instant (expiresAt - renewBeforeSeconds)",
|
|
12
|
+
"state": "state(lease, now) -> active | due | expired",
|
|
13
|
+
"action": "action(lease, now) -> none | renew | resync"
|
|
14
|
+
},
|
|
15
|
+
"reference": "authored",
|
|
16
|
+
"referenceNote": "Boundaries are decided here, not measured: `due` is INCLUSIVE at renewAt, `expired` is INCLUSIVE at expiresAt and wins over `due` (Microsoft Graph refuses to renew a subscription that has expired; a Google Calendar channel simply stops). A missed lease is `resync`, never a quiet re-create: notifications during the gap are gone, so the host must re-list (sync token or full) AND re-subscribe.",
|
|
17
|
+
"implementations": [
|
|
18
|
+
{
|
|
19
|
+
"language": "node",
|
|
20
|
+
"package": "@particle-academy/fancy-connector-core",
|
|
21
|
+
"symbol": "subscriptionLease / leaseRenewAt / leaseState / leaseAction from \"@particle-academy/fancy-connector-core\""
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"language": "php",
|
|
25
|
+
"package": "particle-academy/fancy-connector-core",
|
|
26
|
+
"symbol": "ParticleAcademy\\Connectors\\SubscriptionLease (renewAt(), state(), action())"
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
"notes": [
|
|
31
|
+
"The lease carries the provider's expiry as an RFC 3339 INSTANT. Google Calendar hands back `expiration` as an epoch in MILLISECONDS as a string, Graph hands back `expirationDateTime` as ISO 8601 — the CONNECTOR converts on the way in; the lease refuses anything that is not an instant (case 0010) rather than guessing units.",
|
|
32
|
+
"`renewBeforeSeconds` must be positive (cases 0011, 0012). Zero would make `due` unreachable — the moment it applies is the moment `expired` wins — which is a lease nobody ever renews, declared in a way nothing would report.",
|
|
33
|
+
"`renewOperation` names the operation the host calls when the lease is due. For Graph that is a renew; for Google Calendar, whose channels cannot be renewed, it is the create again (the connector stops the old channel itself). The lease does not know the difference and does not need to: it says WHEN, the connector says WHAT.",
|
|
34
|
+
"`expired` beats `due` at the same instant (case 0004): a lease that has just expired cannot be renewed, so the only honest action is resync.",
|
|
35
|
+
"Clock skew clamps nothing here, unlike isReplaySafe: a `now` before renewAt is simply `active` (case 0006), because there is no earlier attempt to have forgotten.",
|
|
36
|
+
"LANDED AS AUTHORED. Taken from fancy-connector-core v0.7.0 `fixtures/subscription-lease/cases.json` (weaver.agi, owner-approved lease vocabulary 2026-09-14). Rows, contract and notes are unchanged; only the split into manifest.json + cases.json and a per-row `since` were added to fit this repository's format. Core deletes its local copy and reads this suite once it pins this release, so the table lives in one place.",
|
|
37
|
+
"EVERY ROW EXERCISES ALL THREE FUNCTIONS, so no row names an `fn`: a row pins `renewAt`, `state` and `action` together, or pins that constructing the lease is refused and names the field. A runner returns `{refused: <field>}` for a refusal and `{renewAt, state, action}` otherwise, with `renewAt` as an RFC 3339 instant in UTC with milliseconds (`...T00:00:00.000Z`).",
|
|
38
|
+
"PYTHON IS NOT LISTED: fancy-connector-core has no Python port yet. It joins the `implementations` when that package exists.",
|
|
39
|
+
"THERE IS A DISCRIMINATION PROBE (tests/discrimination-subscription-lease.test.ts): a faithful lease passes every row, and each mutant a competent author would write -- an exclusive `due`, an exclusive `expired`, `due` winning over `expired`, a missed lease re-created instead of resynced, an epoch guessed as milliseconds, a zero margin allowed, an offset dropped, fractional seconds truncated -- fails an exact set of rows."
|
|
40
|
+
]
|
|
41
|
+
}
|