@particle-academy/fancy-conformance 0.25.0 → 0.27.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 CHANGED
@@ -70,7 +70,9 @@ verbatim.
70
70
  | `shared/flow-run-identity` | 25 | fancy-flow's run/step identity: the idempotency key a retrying connector sends, and when a retry may still reuse it |
71
71
  | `flow/graph-runs` | 23 | Whole-graph execution: the same `WorkflowSchema` in, the same `RunResult.outputs` out |
72
72
  | `flow/run-diagnostics` | 14 | The run-time warnings for a graph that delivers nothing: an edge naming a port its source can never publish, and a route taken on a path that did not resolve. Half the rows pin when to stay silent |
73
+ | `flow/port-activation` | 12 | Which output ports a node lights and what each carries: a chosen SUBSET via `__ports`, one port via `__port` / `branch`, and the declared-port fallbacks. One row records a real disagreement as a skip rather than omitting it |
73
74
  | `flow/durable-dispatch` | 14 | How a queued run hands out nodes: one at a time by default, in declaration order, with a paused gate keeping its slot; a cap and the whole frontier only when a host asks |
75
+ | `shared/subscription-lease` | 13 | A subscription that expires: when a renewal is due, when the lease has expired, and why a missed lease is a resync rather than a quiet re-create |
74
76
 
75
77
  Every case carries an `id`, a `title`, the suite version it arrived in, and —
76
78
  where it exists to catch something specific — a `notes` field saying what.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.25.0
1
+ 0.27.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@particle-academy/fancy-conformance",
3
- "version": "0.25.0",
3
+ "version": "0.27.0",
4
4
  "description": "Shared cross-language conformance fixtures for the Fancy suite. One contract, N implementations, and a single table that every implementation asserts in its own CI — so 'parity' is a test result rather than a claim. Ships the fixture data itself, so a Rust, Go or Python runner can consume it without a JavaScript toolchain.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -37,8 +37,8 @@
37
37
  },
38
38
  "runShape": { "type": "object" },
39
39
  "reference": {
40
- "description": "Which language's behaviour the goldens were taken from. Goldens are never what a value 'obviously' is.",
41
- "enum": ["php", "node", "rust", "python", "go"]
40
+ "description": "Which language's behaviour the goldens were taken from. Goldens are never what a value 'obviously' is. 'authored' means the suite's author decided them rather than capturing them from a runtime; referenceNote must say by whom and on what evidence.",
41
+ "enum": ["php", "node", "rust", "python", "go", "authored"]
42
42
  },
43
43
  "referenceNote": { "type": "string" },
44
44
  "implementations": {
@@ -50,7 +50,8 @@
50
50
  "properties": {
51
51
  "language": { "enum": ["php", "node", "rust", "python", "go"] },
52
52
  "package": { "type": "string", "minLength": 1 },
53
- "symbol": { "type": "string" }
53
+ "symbol": { "type": "string" },
54
+ "engine": { "type": "string", "description": "For an implementation that runs inside a workflow engine: the engine package and the range it is exercised against." }
54
55
  },
55
56
  "additionalProperties": false
56
57
  }
@@ -13,7 +13,8 @@
13
13
  "implementations": [
14
14
  { "language": "php", "package": "particle-academy/fancy-flow-php", "symbol": "FancyFlow\\Laravel\\Runs\\DispatchLimit" },
15
15
  { "language": "node", "package": "@particle-academy/fancy-flow", "symbol": "selectDispatch" },
16
- { "language": "python", "package": "fancy-flow", "symbol": "fancy_flow.durable.select_dispatch" }
16
+ { "language": "python", "package": "fancy-flow", "symbol": "fancy_flow.durable.select_dispatch" },
17
+ { "language": "rust", "package": "fancy-flow", "symbol": "fancy_flow::durable::select_dispatch" }
17
18
  ]
18
19
  },
19
20
  "notes": [
@@ -24,7 +25,7 @@
24
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.",
25
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.",
26
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.",
27
- "RUST IS NOT LISTED: fancy-flow-rs has no durable coordinator. Whether it gets one is an open decision recorded in the envelope's .ai/plans/fancy-flow-serial-dispatch.md.",
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.",
28
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."
29
30
  ]
30
31
  }
@@ -0,0 +1,169 @@
1
+ {
2
+ "$schema": "../../../schema/case-table.schema.json",
3
+ "suite": "flow/port-activation",
4
+ "cases": [
5
+ {
6
+ "id": "0101-ports-list-lights-the-named-subset",
7
+ "title": "A `__ports` LIST lights exactly those ports, each carrying the shared value.",
8
+ "since": "0.27.0",
9
+ "tags": ["subset"],
10
+ "input": {
11
+ "declaredOutputs": ["a", "b", "c", "d", "e"],
12
+ "result": { "__ports": ["a", "c"], "value": "matched" }
13
+ },
14
+ "expected": [
15
+ { "port": "a", "value": "matched" },
16
+ { "port": "c", "value": "matched" }
17
+ ],
18
+ "notes": "The rule fancy-flow-php#18 added. Before it the engine knew two answers — one port, or every port — so a router that matched two of five lanes had to drop the rest of the work or wake lanes nobody asked for. Ports `b`, `d` and `e` are declared and stay DARK, which is the half of this row that matters."
19
+ },
20
+ {
21
+ "id": "0102-ports-map-gives-each-port-its-own-payload",
22
+ "title": "A `__ports` MAP lights its keys, each carrying its own payload.",
23
+ "since": "0.27.0",
24
+ "tags": ["subset"],
25
+ "input": {
26
+ "declaredOutputs": ["a", "b", "c", "d", "e"],
27
+ "result": { "__ports": { "a": { "queue": "billing" }, "c": { "queue": "abuse" } } }
28
+ },
29
+ "expected": [
30
+ { "port": "a", "value": { "queue": "billing" } },
31
+ { "port": "c", "value": { "queue": "abuse" } }
32
+ ],
33
+ "notes": "One classifier, two lanes, two different payloads. Sharing one payload across the lit ports would force every lane to re-derive which part of the result was addressed to it."
34
+ },
35
+ {
36
+ "id": "0103-per-port-null-is-a-payload",
37
+ "title": "A per-port payload that is present and NULL is delivered as null.",
38
+ "since": "0.27.0",
39
+ "tags": ["subset", "null"],
40
+ "input": {
41
+ "declaredOutputs": ["a", "b"],
42
+ "result": { "__ports": { "a": null } }
43
+ },
44
+ "expected": [{ "port": "a", "value": null }],
45
+ "notes": "Key presence, not truthiness. `values[port] ?? value` reads this as 'absent' and hands the port the shared value instead — which is `undefined`/null here, so it would PASS by accident. It is asserted anyway because the same lookup is what row 0102 depends on, and the version of this bug that shipped in `branch` was invisible for exactly this reason."
46
+ },
47
+ {
48
+ "id": "0104-an-empty-ports-list-lights-nothing",
49
+ "title": "An explicitly empty `__ports` lights NOTHING.",
50
+ "since": "0.27.0",
51
+ "tags": ["subset", "empty"],
52
+ "input": {
53
+ "declaredOutputs": ["a", "b", "c"],
54
+ "result": { "__ports": [], "value": "unrouted" }
55
+ },
56
+ "expected": [],
57
+ "notes": "The honest answer for a router that matched no rule. Falling through to 'every declared port' here would wake all three lanes at precisely the moment the node decided none applied — and the run would look successful."
58
+ },
59
+ {
60
+ "id": "0105-map-order-is-emission-order",
61
+ "title": "A `__ports` map lights its ports in the order the map declares them.",
62
+ "since": "0.27.0",
63
+ "tags": ["subset", "order"],
64
+ "input": {
65
+ "declaredOutputs": ["a", "b", "c"],
66
+ "result": { "__ports": { "c": 1, "a": 2 } }
67
+ },
68
+ "expected": [
69
+ { "port": "c", "value": 1 },
70
+ { "port": "a", "value": 2 }
71
+ ],
72
+ "notes": "Declaration order, not the declared-outputs order and not sorted. A runtime that sorted these would deliver every payload correctly and still be wrong: two lanes touching one resource would interleave differently on different runtimes, which is the class of bug that only reproduces in production."
73
+ },
74
+ {
75
+ "id": "0106-a-malformed-ports-falls-through",
76
+ "title": "A `__ports` that is neither a list nor a map falls through to every declared port.",
77
+ "since": "0.27.0",
78
+ "tags": ["subset", "malformed"],
79
+ "input": {
80
+ "declaredOutputs": ["a", "b"],
81
+ "result": { "__ports": "a" }
82
+ },
83
+ "expected": [
84
+ { "port": "a", "value": { "__ports": "a" } },
85
+ { "port": "b", "value": { "__ports": "a" } }
86
+ ],
87
+ "notes": "A string is not a subset instruction. Reading an unreadable `__ports` as 'no ports' would turn one typo into a silently truncated run; falling through publishes the whole result, which is loud downstream. Note what each port carries: the WHOLE result, wrapper included, because no subset rule fired."
88
+ },
89
+ {
90
+ "id": "0201-port-sugar-lights-one-port",
91
+ "title": "`__port` lights exactly one port, carrying `value`.",
92
+ "since": "0.27.0",
93
+ "tags": ["single"],
94
+ "input": {
95
+ "declaredOutputs": ["a", "b", "c"],
96
+ "result": { "__port": "b", "value": "only-b" }
97
+ },
98
+ "expected": [{ "port": "b", "value": "only-b" }],
99
+ "notes": "Unchanged by #18 and asserted here so it stays that way. `__ports` was added as a sibling rule, not a replacement, and is checked AFTER `__port`."
100
+ },
101
+ {
102
+ "id": "0202-branch-with-an-explicit-null-payload",
103
+ "title": "`branch` with `value` present and null delivers null, not the wrapper.",
104
+ "since": "0.27.0",
105
+ "tags": ["single", "null"],
106
+ "input": {
107
+ "declaredOutputs": ["a", "b"],
108
+ "result": { "branch": "b", "value": null }
109
+ },
110
+ "expected": [{ "port": "b", "value": null }],
111
+ "notes": "ALL FOUR RUNTIMES SHIPPED THIS WRONG, IDENTICALLY, so no parity table caught it — they agreed on being wrong. `value ?? result` cannot tell 'no value key' (row 0203, where the whole result IS the payload) from 'value is null' (this row), so a branch whose payload was null leaked `{branch, value}` downstream: two fields no kind declares, while the declared ones were absent. The reachable path is an upstream `transform` whose dot-path did not resolve."
112
+ },
113
+ {
114
+ "id": "0203-branch-with-no-value-key-carries-the-whole-result",
115
+ "title": "`branch` with NO `value` key carries the entire result.",
116
+ "since": "0.27.0",
117
+ "tags": ["single"],
118
+ "input": {
119
+ "declaredOutputs": ["a", "b"],
120
+ "result": { "branch": "b", "verdict": "approved" }
121
+ },
122
+ "expected": [{ "port": "b", "value": { "branch": "b", "verdict": "approved" } }],
123
+ "notes": "The other half of 0202, and the reason the lookup must ask about key presence. An executor that returns its own object and names a branch inside it gets the object delivered; the wrapper is the payload here by design."
124
+ },
125
+ {
126
+ "id": "0301-a-plain-result-lights-every-declared-port",
127
+ "title": "A result naming no ports lights EVERY declared output port, each carrying the whole result.",
128
+ "since": "0.27.0",
129
+ "tags": ["fallback"],
130
+ "input": {
131
+ "declaredOutputs": ["a", "b", "c"],
132
+ "result": { "ok": true }
133
+ },
134
+ "expected": [
135
+ { "port": "a", "value": { "ok": true } },
136
+ { "port": "b", "value": { "ok": true } },
137
+ { "port": "c", "value": { "ok": true } }
138
+ ],
139
+ "notes": "The default, and what `__ports` narrows. Declared order."
140
+ },
141
+ {
142
+ "id": "0302-a-node-declaring-no-ports-lights-out",
143
+ "title": "A node that declares NO outputs lights the lone `out` port.",
144
+ "since": "0.27.0",
145
+ "tags": ["fallback"],
146
+ "input": {
147
+ "declaredOutputs": null,
148
+ "result": { "ok": true }
149
+ },
150
+ "expected": [{ "port": "out", "value": { "ok": true } }],
151
+ "notes": "`null` is 'undeclared', which is not the same as 'explicitly none' — see row 0303. The `out` fallback is what lets a hand-written document omit ports entirely and still chain."
152
+ },
153
+ {
154
+ "id": "0303-an-explicitly-empty-outputs-lights-nothing",
155
+ "title": "A node whose outputs are an explicitly EMPTY list lights nothing.",
156
+ "since": "0.27.0",
157
+ "tags": ["fallback", "empty", "divergence"],
158
+ "input": {
159
+ "declaredOutputs": [],
160
+ "result": { "ok": true }
161
+ },
162
+ "expected": [],
163
+ "skip": {
164
+ "node": "DIVERGENCE, not an exemption: @particle-academy/fancy-flow collapses an explicitly-empty outputs to ['out'] because its fallback tests `declared?.length` and [] is falsy, so the three states (undeclared / explicitly none / declared) become two. Measured on 0.74.1. PHP, Python and Rust all publish nothing. Tracked as an issue on fancy-flow; this row starts passing there the day it is fixed."
165
+ },
166
+ "notes": "THREE STATES, NOT TWO. `null` falls back to `out` (row 0302); `[]` means a terminal node that genuinely publishes nothing. Collapsing them is how a terminal node starts publishing — and downstream of that, an edge nobody drew starts delivering. Kept as a skipped row rather than deleted so every runner prints the disagreement: a table that omits the one case its implementations disagree on is a table that agrees by saying less."
167
+ }
168
+ ]
169
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "$schema": "../../../schema/suite-manifest.schema.json",
3
+ "suite": "flow/port-activation",
4
+ "title": "Which output ports a node lights, and what each one carries",
5
+ "since": "0.27.0",
6
+ "caseFormat": "table",
7
+ "cases": "cases.json",
8
+ "contract": {
9
+ "function": "run(node with declaredOutputs, executor returning result) -> ordered [{ port, value }]",
10
+ "summary": "Run a ONE-NODE graph whose node declares `declaredOutputs` as its output ports and whose executor returns `result`, and report the `node-output` events that node emitted, in order, as `[{ port, value }]`. The answer is the events rather than a private function's return value because every runtime keeps this logic private (`activatedPorts`, `_activated_ports`, `Walk::activated_ports`) and the events are what a consumer — and the durable layer — actually observes. A row with an empty `expected` asserts the node emitted NOTHING, which is a different claim from emitting one port with a null payload and is the claim several of these rows are about.",
11
+ "reference": "node",
12
+ "referenceNote": "The result-shape rules are `@particle-academy/fancy-flow`'s and predate this table; the `__ports` rows were added with fancy-flow-php#18 across all four runtimes in the same week and the goldens were captured by running each. Row 0303 is the one place the four do NOT agree, and it is recorded as a skip rather than removed — see the notes.",
13
+ "implementations": [
14
+ { "language": "node", "package": "@particle-academy/fancy-flow", "symbol": "activatedPorts" },
15
+ { "language": "php", "package": "particle-academy/fancy-flow-php", "symbol": "FancyFlow\\Engine\\FlowRunner::activatedPorts" },
16
+ { "language": "python", "package": "fancy-flow", "symbol": "fancy_flow.engine.runner.FlowRunner._activated_ports" },
17
+ { "language": "rust", "package": "fancy-flow", "symbol": "fancy_flow::engine::walk::Walk::activated_ports" }
18
+ ]
19
+ },
20
+ "notes": [
21
+ "`declaredOutputs` is a LIST OF PORT IDS, or null for a node that declares none. Each runtime puts it where its own FlowNode keeps ports: TypeScript's is an xyflow node so they live in `data.outputs` as `{ id }` descriptors, while PHP, Python and Rust have a flattened `node.outputs`. The table asks about the ENGINE's rule, not about where a document stores it.",
22
+ "The node's kind must NOT be registered in the runtime running these rows. The declared-port fallback consults the kind's ports before falling back to `out`, so a row that accidentally named a builtin would be asserting the builtin's ports instead of the rule under test. Every row uses `hostRouter`, which no runtime ships.",
23
+ "THE ORDER OF THE EXPECTED LIST IS PART OF THE ASSERTION. A map-shaped `__ports` lights its ports in the order the object declares them (row 0105), which every one of the four preserves — JavaScript for string keys, PHP for array keys, Python for dicts since 3.7, and Rust because `fancy_json::Map` is insertion-ordered. A runtime that sorted them would still deliver every payload correctly and would still be wrong, because a downstream node reading a shared resource would see a different interleaving.",
24
+ "PER-PORT PAYLOADS ARE READ BY KEY PRESENCE, NEVER BY TRUTHINESS (rows 0103, 0202). A payload that is present and null is a payload. `values[port] ?? value` and `$ports[$port] ?? $result` cannot tell 'no key' from 'null', and all four runtimes shipped exactly that confusion in `branch` — every downstream node received the `{branch, value}` WRAPPER, two fields no kind declares, while the fields it does declare were absent. No parity table caught it because the four agreed on being wrong; these rows exist so the next one cannot hide the same way.",
25
+ "AN EMPTY `__ports` LIGHTS NOTHING, DELIBERATELY (row 0104). It is the honest answer for a router that matched no rule, and it is the same answer an explicitly empty `outputs` gives in three of the four runtimes. Lighting every port instead would wake every lane precisely when the node decided none applied.",
26
+ "A MALFORMED `__ports` FALLS THROUGH to the every-declared-port rule rather than lighting nothing (row 0106). `__ports` that is a string, a number or null is not a subset instruction, and treating an unreadable one as 'no ports' would turn a typo into a silently truncated run — the failure mode this whole table is written against.",
27
+ "ROW 0303 IS THE DIVERGENCE, AND IT IS RECORDED RATHER THAN REMOVED. `declaredOutputs: []` means 'this node explicitly has no output ports'. PHP, Python and Rust honour it and publish nothing; `@particle-academy/fancy-flow` collapses it to `[\"out\"]`, because its fallback tests `declared?.length` and an empty array is falsy — so the three-state distinction (undeclared / explicitly none / declared) becomes two-state there. Measured on fancy-flow 0.74.1, not inferred. It is skipped for node with that reason so every runner PRINTS it, which is the only way a known disagreement stays visible; deleting the row would make the table agree by saying less. Tracked as an issue on fancy-flow.",
28
+ "Nothing here asserts that a lit port must be a DECLARED one. All four runtimes publish whatever `__port` / `__ports` / `branch` names, declared or not, and an edge from a port that does not exist simply matches nothing. That is deliberate slack for hosts whose ports are config-driven, and pinning it either way belongs in its own row once someone decides it — not smuggled in through a row about something else."
29
+ ]
30
+ }
@@ -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
+ }