@particle-academy/fancy-connector-core 0.0.0 → 0.1.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/CHANGELOG.md CHANGED
@@ -1,103 +1,103 @@
1
- # Changelog
2
-
3
- All notable changes to `fancy-connector-core` are documented here, in
4
- [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format.
5
-
6
- **This package is pre-1.0, so breaking changes land in MINOR releases.** The
7
- version number is not a promise it can keep yet; the entries below are.
8
-
9
- ## [Unreleased]
10
-
11
- ## [0.1.0] — unreleased
12
-
13
- First cut. Extracted and generalised from the `_connector` shared runtime in the
14
- fancy-flow node marketplace, then rebuilt around the requirements in The Ripple
15
- Effect's packager brief, which came from five adapters dry-verified against real
16
- APIs.
17
-
18
- The connector **catalogue** lives in `fancy-connectors` and releases on its own
19
- clock; this package is the runtime it is written on.
20
-
21
- ### Added
22
-
23
- - **Core runtime, TypeScript and PHP, with zero runtime dependencies in either
24
- ecosystem.** WebCrypto and `fetch` on Node; `hash()` and cURL behind a
25
- swappable `Transport` in PHP.
26
- - **`delivery` — a four-way failure classification.** `unreachable` /
27
- `refused-explicitly` / `ambiguous` / `rejected`, with retryability a function
28
- of the kind **and** the connector's declared idempotency. An unrecognised
29
- error falls to `ambiguous`, never to safe.
30
- - **`DeliveryDeclaration`** — `idempotent` + a cited `why` + `minIntervalMs` +
31
- `rateSource` (`documented` | `self-imposed`) + a dated `Citation`.
32
- - **`seam` — `ProviderAdapter` and `Connector`**, deliberately separate, plus
33
- the written list of everything the package never owns: approval, liveness, the
34
- approved-bytes comparison, consent, second review, journals, credential
35
- storage.
36
- - **`text`** — `measure(text, unit)` over graphemes / characters / UTF-8 bytes,
37
- and a `ByteRange` type producible only by a function that encoded the string.
38
- - **`render`** — pure, versioned (version **inside** the payload hash), loss
39
- reported rather than applied, driven by a declarative `RenderRules` so most
40
- providers need no rendering code at all.
41
- - **`chain`** — root fixed, parent advancing, with the post function as an
42
- argument so threading is testable without a live session.
43
- - **`metrics`** — declared metric shapes, `compareShape()`,
44
- `capabilityProblems()`, and `reported()` so *absent stays absent* is a
45
- property of the code.
46
- - **`probe`** — dry-verify against the real API with a deliberately invalid
47
- credential, per-provider `authStatuses`, a 2xx counted as a failure, and
48
- offline reported as **skipped** rather than failed.
49
- - **`drift`** — API-drift *reporting*: a narrow projection of an OpenAPI
50
- document against the operations a connector actually calls, a recorded-shape
51
- fallback for the majority of providers that publish nothing, and `unchecked`
52
- as a first-class outcome that is never collapsed into `clean`.
53
- - **Explicit-credentials resolution.** Credentials passed straight in bypass the
54
- connection registry entirely; an incomplete set is a loud failure rather than
55
- a silent fall-through.
56
- - **`CONNECTOR_API_VERSION` and `assertConnectorApi`** (`ConnectorApi` in PHP) —
57
- the compatibility number that makes a separate release clock safe for a
58
- catalogue whose connectors ship as VENDORED source. It moves only when the
59
- surface a connector is written against moves, the window is the current
60
- version and the one before, and a mismatch names which side is behind rather
61
- than reporting "incompatible".
62
-
63
- #### Two things the PHP core does differently, and why
64
-
65
- - **A custom `Transport` must throw `TransportException`** — via
66
- `unreachable()`, `ambiguous()` or `fromCurlErrno()` — to get the *always safe
67
- to retry* case. PHP has no error-code vocabulary the way Node does, and
68
- matching on message text is not an option: it is localised and changes between
69
- HTTP-client versions. **What a consumer must do:** nothing if you use the
70
- bundled `CurlTransport`. If you bind your own, throw `TransportException`
71
- rather than a bare exception — a bare one is classified `Ambiguous`, which is
72
- conservative and correct but gives up the retry a refused connection deserves.
73
- - **`RunIdentity` is bridged rather than implemented.** PHP interfaces are
74
- nominal, so `FancyFlow\Runtime\RunIdentity` — which matches the shape exactly —
75
- is not an instance of ours. `ForeignRunIdentity::adapt()` wraps any object
76
- carrying the five members, and every `Idempotency` entry point runs its
77
- argument through it. **What a consumer must do: nothing.** Pass `$ctx->run`
78
- straight in, exactly as the TypeScript side does.
79
-
80
- ### Fixed
81
-
82
- - **An ambiguous failure was retried on a non-idempotent connector.** The
83
- runtime this was extracted from classified a thrown transport as
84
- `ConnectorTransient` with `retryable = true` and retried it twice. A thrown
85
- transport includes a timeout, which may be a request the provider already
86
- acted on — so any connector without an idempotency key could be retried into a
87
- silent double write. **What a consumer must do: nothing, unless you relied on
88
- that behaviour.** A connector that genuinely is safe to repeat now says so
89
- with `idempotent: true` and keeps its retries; everything else becomes
90
- conservative, which is the correct direction.
91
- - **The sentence splitter broke URLs.** `splitToFit` treated every `.` as a
92
- sentence terminator, so `https://example.test/x` was a candidate break point
93
- and a link could land across two messages at a low limit. A terminator now
94
- only ends a sentence when whitespace or the end follows it. **What a consumer
95
- must do: nothing** — but any payload hash computed under the old splitting
96
- will differ, so an approval recorded against it will correctly refuse.
97
- - **The core no longer imports a workflow engine.** The run identity used for
98
- idempotency keys is declared structurally, so `fancy-flow`'s `RunIdentity`
99
- satisfies it with no dependency in either direction and a host that has never
100
- heard of a workflow engine can implement it.
101
-
102
- [Unreleased]: https://github.com/Particle-Academy/fancy-connector-core/compare/v0.1.0...HEAD
103
- [0.1.0]: https://github.com/Particle-Academy/fancy-connector-core/releases/tag/v0.1.0
1
+ # Changelog
2
+
3
+ All notable changes to `fancy-connector-core` are documented here, in
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format.
5
+
6
+ **This package is pre-1.0, so breaking changes land in MINOR releases.** The
7
+ version number is not a promise it can keep yet; the entries below are.
8
+
9
+ ## [Unreleased]
10
+
11
+ ## [0.1.0] — unreleased
12
+
13
+ First cut. Extracted and generalised from the `_connector` shared runtime in the
14
+ fancy-flow node marketplace, then rebuilt around the requirements in The Ripple
15
+ Effect's packager brief, which came from five adapters dry-verified against real
16
+ APIs.
17
+
18
+ The connector **catalogue** lives in `fancy-connectors` and releases on its own
19
+ clock; this package is the runtime it is written on.
20
+
21
+ ### Added
22
+
23
+ - **Core runtime, TypeScript and PHP, with zero runtime dependencies in either
24
+ ecosystem.** WebCrypto and `fetch` on Node; `hash()` and cURL behind a
25
+ swappable `Transport` in PHP.
26
+ - **`delivery` — a four-way failure classification.** `unreachable` /
27
+ `refused-explicitly` / `ambiguous` / `rejected`, with retryability a function
28
+ of the kind **and** the connector's declared idempotency. An unrecognised
29
+ error falls to `ambiguous`, never to safe.
30
+ - **`DeliveryDeclaration`** — `idempotent` + a cited `why` + `minIntervalMs` +
31
+ `rateSource` (`documented` | `self-imposed`) + a dated `Citation`.
32
+ - **`seam` — `ProviderAdapter` and `Connector`**, deliberately separate, plus
33
+ the written list of everything the package never owns: approval, liveness, the
34
+ approved-bytes comparison, consent, second review, journals, credential
35
+ storage.
36
+ - **`text`** — `measure(text, unit)` over graphemes / characters / UTF-8 bytes,
37
+ and a `ByteRange` type producible only by a function that encoded the string.
38
+ - **`render`** — pure, versioned (version **inside** the payload hash), loss
39
+ reported rather than applied, driven by a declarative `RenderRules` so most
40
+ providers need no rendering code at all.
41
+ - **`chain`** — root fixed, parent advancing, with the post function as an
42
+ argument so threading is testable without a live session.
43
+ - **`metrics`** — declared metric shapes, `compareShape()`,
44
+ `capabilityProblems()`, and `reported()` so *absent stays absent* is a
45
+ property of the code.
46
+ - **`probe`** — dry-verify against the real API with a deliberately invalid
47
+ credential, per-provider `authStatuses`, a 2xx counted as a failure, and
48
+ offline reported as **skipped** rather than failed.
49
+ - **`drift`** — API-drift *reporting*: a narrow projection of an OpenAPI
50
+ document against the operations a connector actually calls, a recorded-shape
51
+ fallback for the majority of providers that publish nothing, and `unchecked`
52
+ as a first-class outcome that is never collapsed into `clean`.
53
+ - **Explicit-credentials resolution.** Credentials passed straight in bypass the
54
+ connection registry entirely; an incomplete set is a loud failure rather than
55
+ a silent fall-through.
56
+ - **`CONNECTOR_API_VERSION` and `assertConnectorApi`** (`ConnectorApi` in PHP) —
57
+ the compatibility number that makes a separate release clock safe for a
58
+ catalogue whose connectors ship as VENDORED source. It moves only when the
59
+ surface a connector is written against moves, the window is the current
60
+ version and the one before, and a mismatch names which side is behind rather
61
+ than reporting "incompatible".
62
+
63
+ #### Two things the PHP core does differently, and why
64
+
65
+ - **A custom `Transport` must throw `TransportException`** — via
66
+ `unreachable()`, `ambiguous()` or `fromCurlErrno()` — to get the *always safe
67
+ to retry* case. PHP has no error-code vocabulary the way Node does, and
68
+ matching on message text is not an option: it is localised and changes between
69
+ HTTP-client versions. **What a consumer must do:** nothing if you use the
70
+ bundled `CurlTransport`. If you bind your own, throw `TransportException`
71
+ rather than a bare exception — a bare one is classified `Ambiguous`, which is
72
+ conservative and correct but gives up the retry a refused connection deserves.
73
+ - **`RunIdentity` is bridged rather than implemented.** PHP interfaces are
74
+ nominal, so `FancyFlow\Runtime\RunIdentity` — which matches the shape exactly —
75
+ is not an instance of ours. `ForeignRunIdentity::adapt()` wraps any object
76
+ carrying the five members, and every `Idempotency` entry point runs its
77
+ argument through it. **What a consumer must do: nothing.** Pass `$ctx->run`
78
+ straight in, exactly as the TypeScript side does.
79
+
80
+ ### Fixed
81
+
82
+ - **An ambiguous failure was retried on a non-idempotent connector.** The
83
+ runtime this was extracted from classified a thrown transport as
84
+ `ConnectorTransient` with `retryable = true` and retried it twice. A thrown
85
+ transport includes a timeout, which may be a request the provider already
86
+ acted on — so any connector without an idempotency key could be retried into a
87
+ silent double write. **What a consumer must do: nothing, unless you relied on
88
+ that behaviour.** A connector that genuinely is safe to repeat now says so
89
+ with `idempotent: true` and keeps its retries; everything else becomes
90
+ conservative, which is the correct direction.
91
+ - **The sentence splitter broke URLs.** `splitToFit` treated every `.` as a
92
+ sentence terminator, so `https://example.test/x` was a candidate break point
93
+ and a link could land across two messages at a low limit. A terminator now
94
+ only ends a sentence when whitespace or the end follows it. **What a consumer
95
+ must do: nothing** — but any payload hash computed under the old splitting
96
+ will differ, so an approval recorded against it will correctly refuse.
97
+ - **The core no longer imports a workflow engine.** The run identity used for
98
+ idempotency keys is declared structurally, so `fancy-flow`'s `RunIdentity`
99
+ satisfies it with no dependency in either direction and a host that has never
100
+ heard of a workflow engine can implement it.
101
+
102
+ [Unreleased]: https://github.com/Particle-Academy/fancy-connector-core/compare/v0.1.0...HEAD
103
+ [0.1.0]: https://github.com/Particle-Academy/fancy-connector-core/releases/tag/v0.1.0
package/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Particle Academy
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Particle Academy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,175 +1,175 @@
1
- # fancy-connector-core
2
-
3
- **The runtime under every Fancy connector.** Modes and estates, connections, one
4
- call path, a failure taxonomy that knows the difference between *it never
5
- arrived* and *nobody can tell*, deterministic fakers, webhook verification, pure
6
- versioned rendering, chain posting, declared metric shapes, credential-free
7
- probes, and API-drift reporting.
8
-
9
- Matched **TypeScript + PHP**, one repository, **zero runtime dependencies in
10
- either ecosystem**.
11
-
12
- ```bash
13
- npm install @particle-academy/fancy-connector-core
14
- composer require particle-academy/fancy-connector-core
15
- ```
16
-
17
- ---
18
-
19
- ## It owns the wire. It never owns a gate.
20
-
21
- Approval, liveness, the approved-bytes comparison, consent, second review and
22
- every journal belong to the **host** — because each is enforced in one place and
23
- every connector inherits it from the host's dispatch path rather than
24
- implementing it.
25
-
26
- ```ts
27
- await connector.call(target, { dryRun, credentials });
28
- // ^^^^^^ ^^^^^^^^^^^
29
- // yours yours
30
- ```
31
-
32
- Three of those promises are **tests**, not intentions:
33
-
34
- 1. **Nothing here reads the environment.** No `process.env`, no `getenv`. Your
35
- credential storage discipline — a gitignored file behind a whitelist, a secret
36
- manager, Laravel config — stays yours, because the package cannot reach past
37
- it.
38
- 2. **Nothing here contacts a URL of its own.** No telemetry, no phone-home, no
39
- default endpoint. Even the drift checker does not fetch: you fetch, and pass
40
- the document in.
41
- 3. **Nothing here retries an ambiguous failure** unless the connector declared
42
- that repeating a request is harmless.
43
-
44
- ---
45
-
46
- ## The one that matters: retry, without ever sending twice
47
-
48
- A network hiccup should not lose a call. A retry after a request that succeeded
49
- and whose response was lost is a **double write** — the exact failure an
50
- idempotency gate exists to prevent, arriving through the door marked
51
- *reliability*.
52
-
53
- Nearly every retry helper reconciles those the same way and loses: it catches an
54
- error, waits, and tries again, because from the caller's side all failures look
55
- alike.
56
-
57
- | what happened | did the provider receive it? | retry? |
58
- |---|---|---|
59
- | DNS failure, connection refused | **no** | always safe |
60
- | HTTP 429 / 5xx | **yes**, and it explicitly refused | safe — it says it did nothing |
61
- | timeout, abort, **anything unrecognised** | **unknown** | only where the connector is idempotent |
62
- | other 4xx | yes, and the answer was a real no | never |
63
-
64
- ```ts
65
- import { classifyError, shouldRetry } from "@particle-academy/fancy-connector-core";
66
-
67
- shouldRetry(classifyError(err).kind, { idempotent: connector.delivery.idempotent });
68
- ```
69
-
70
- `idempotent` defaults to **false**, and `delivery.why` must cite the mechanism —
71
- `Idempotency-Key`, or the absence of one — because `idempotent: true` is the one
72
- claim whose failure is a public duplicate.
73
-
74
- ---
75
-
76
- ## Text, and two one-line bugs that ASCII hides
77
-
78
- ```ts
79
- import { measure, linkRanges, sliceByteRange } from "@particle-academy/fancy-connector-core";
80
-
81
- measure("👍", "graphemes"); // 1 — "👍".length is 2
82
- linkRanges("👍 https://example.test"); // UTF-8 BYTE ranges, not char offsets
83
- ```
84
-
85
- `ByteRange` is a **type**, producible only by a function that encoded the string.
86
- A character-offset implementation is correct for ASCII and silently corrupts the
87
- *link* on any post containing an emoji — the post looks fine and goes somewhere
88
- wrong.
89
-
90
- ---
91
-
92
- ## Rendering: pure, versioned, honest about loss
93
-
94
- ```ts
95
- const payload = render(copy, {
96
- limit: 300, unit: "graphemes", thread: true, links: true, label: "Bluesky",
97
- });
98
-
99
- payload.segments; // one entry per message; more than one means a thread
100
- payload.problems; // what could NOT be fitted. Never silently applied.
101
- await payloadHash(payload); // RENDERER_VERSION is INSIDE the hash
102
- ```
103
-
104
- Show the payload before you approve it, recompute it at dispatch, refuse on
105
- mismatch. That is the only way "what was approved is what was sent" survives the
106
- existence of adaptation — and the renderer version being inside the hash is what
107
- stops it being checked separately and forgotten.
108
-
109
- ---
110
-
111
- ## Threading
112
-
113
- ```ts
114
- await postChain(payload.segments.map((s) => s.text), answering, post);
115
- ```
116
-
117
- Root fixed at the top of the chain; parent advancing to whatever was just posted.
118
- Reverse them and every message attaches to the first — a fan, not a thread — and
119
- the provider's response looks identical either way. The post function is an
120
- **argument** so that is reachable by a test.
121
-
122
- ---
123
-
124
- ## Probes: an auth error proves the transport
125
-
126
- A connector written from documentation can be wrong in three ways that look
127
- identical until a credential arrives: the URL is wrong, the request shape is
128
- wrong, or the error handling never runs. **All three are invisible to a fake
129
- server, because a fake server agrees with whatever your code does.**
130
-
131
- So call the real API with a deliberately invalid credential and require an
132
- auth-shaped refusal.
133
-
134
- - `authStatuses` is declared per provider — a Discord webhook answers `404` for
135
- an unknown id, so 404 IS its auth answer.
136
- - **A 2xx is a failure.** The credential is reaching nothing.
137
- - **Offline is `skip`, never `fail`.** A check that goes red on a train gets
138
- ignored, and is then worth nothing when it goes red for real.
139
-
140
- ---
141
-
142
- ## Drift: it reports, it never adapts
143
-
144
- A connector's worst failure is silent — the provider changes its API and nothing
145
- says so until something breaks.
146
-
147
- **And the fix is not a connector that reshapes itself.** That connector cannot be
148
- reviewed, its behaviour depends on when it ran, and adaptation is only ever a
149
- guess at intent. So:
150
-
151
- > Drift reports. The provider decides. A person acts.
152
-
153
- Drift also never changes runtime behaviour: it runs out of band, and a check that
154
- failed a working call because a *document* changed would be a self-inflicted
155
- outage.
156
-
157
- What is checked is a narrow projection, not a diff — *for the operations this
158
- connector actually calls, do the path, method, sent fields and read fields still
159
- exist?* Where a provider publishes no spec (most do not), the fallback is a
160
- recorded shape: field **names** only.
161
-
162
- `outcome` is `clean` | `drifted` | **`unchecked`**. The third is never collapsed
163
- into the first — a checker that could not see is not a checker that saw nothing
164
- wrong.
165
-
166
- ---
167
-
168
- ## Documentation
169
-
170
- - [`AGENTS.md`](./AGENTS.md) — the invariants, the traps, and what a change here
171
- breaks.
172
- - `.ai/plans/fancy-connectors.md` in the envelope — the architecture, the
173
- Socialite seam, the drift research, and every decision with its reason.
174
-
175
- MIT.
1
+ # fancy-connector-core
2
+
3
+ **The runtime under every Fancy connector.** Modes and estates, connections, one
4
+ call path, a failure taxonomy that knows the difference between *it never
5
+ arrived* and *nobody can tell*, deterministic fakers, webhook verification, pure
6
+ versioned rendering, chain posting, declared metric shapes, credential-free
7
+ probes, and API-drift reporting.
8
+
9
+ Matched **TypeScript + PHP**, one repository, **zero runtime dependencies in
10
+ either ecosystem**.
11
+
12
+ ```bash
13
+ npm install @particle-academy/fancy-connector-core
14
+ composer require particle-academy/fancy-connector-core
15
+ ```
16
+
17
+ ---
18
+
19
+ ## It owns the wire. It never owns a gate.
20
+
21
+ Approval, liveness, the approved-bytes comparison, consent, second review and
22
+ every journal belong to the **host** — because each is enforced in one place and
23
+ every connector inherits it from the host's dispatch path rather than
24
+ implementing it.
25
+
26
+ ```ts
27
+ await connector.call(target, { dryRun, credentials });
28
+ // ^^^^^^ ^^^^^^^^^^^
29
+ // yours yours
30
+ ```
31
+
32
+ Three of those promises are **tests**, not intentions:
33
+
34
+ 1. **Nothing here reads the environment.** No `process.env`, no `getenv`. Your
35
+ credential storage discipline — a gitignored file behind a whitelist, a secret
36
+ manager, Laravel config — stays yours, because the package cannot reach past
37
+ it.
38
+ 2. **Nothing here contacts a URL of its own.** No telemetry, no phone-home, no
39
+ default endpoint. Even the drift checker does not fetch: you fetch, and pass
40
+ the document in.
41
+ 3. **Nothing here retries an ambiguous failure** unless the connector declared
42
+ that repeating a request is harmless.
43
+
44
+ ---
45
+
46
+ ## The one that matters: retry, without ever sending twice
47
+
48
+ A network hiccup should not lose a call. A retry after a request that succeeded
49
+ and whose response was lost is a **double write** — the exact failure an
50
+ idempotency gate exists to prevent, arriving through the door marked
51
+ *reliability*.
52
+
53
+ Nearly every retry helper reconciles those the same way and loses: it catches an
54
+ error, waits, and tries again, because from the caller's side all failures look
55
+ alike.
56
+
57
+ | what happened | did the provider receive it? | retry? |
58
+ |---|---|---|
59
+ | DNS failure, connection refused | **no** | always safe |
60
+ | HTTP 429 / 5xx | **yes**, and it explicitly refused | safe — it says it did nothing |
61
+ | timeout, abort, **anything unrecognised** | **unknown** | only where the connector is idempotent |
62
+ | other 4xx | yes, and the answer was a real no | never |
63
+
64
+ ```ts
65
+ import { classifyError, shouldRetry } from "@particle-academy/fancy-connector-core";
66
+
67
+ shouldRetry(classifyError(err).kind, { idempotent: connector.delivery.idempotent });
68
+ ```
69
+
70
+ `idempotent` defaults to **false**, and `delivery.why` must cite the mechanism —
71
+ `Idempotency-Key`, or the absence of one — because `idempotent: true` is the one
72
+ claim whose failure is a public duplicate.
73
+
74
+ ---
75
+
76
+ ## Text, and two one-line bugs that ASCII hides
77
+
78
+ ```ts
79
+ import { measure, linkRanges, sliceByteRange } from "@particle-academy/fancy-connector-core";
80
+
81
+ measure("👍", "graphemes"); // 1 — "👍".length is 2
82
+ linkRanges("👍 https://example.test"); // UTF-8 BYTE ranges, not char offsets
83
+ ```
84
+
85
+ `ByteRange` is a **type**, producible only by a function that encoded the string.
86
+ A character-offset implementation is correct for ASCII and silently corrupts the
87
+ *link* on any post containing an emoji — the post looks fine and goes somewhere
88
+ wrong.
89
+
90
+ ---
91
+
92
+ ## Rendering: pure, versioned, honest about loss
93
+
94
+ ```ts
95
+ const payload = render(copy, {
96
+ limit: 300, unit: "graphemes", thread: true, links: true, label: "Bluesky",
97
+ });
98
+
99
+ payload.segments; // one entry per message; more than one means a thread
100
+ payload.problems; // what could NOT be fitted. Never silently applied.
101
+ await payloadHash(payload); // RENDERER_VERSION is INSIDE the hash
102
+ ```
103
+
104
+ Show the payload before you approve it, recompute it at dispatch, refuse on
105
+ mismatch. That is the only way "what was approved is what was sent" survives the
106
+ existence of adaptation — and the renderer version being inside the hash is what
107
+ stops it being checked separately and forgotten.
108
+
109
+ ---
110
+
111
+ ## Threading
112
+
113
+ ```ts
114
+ await postChain(payload.segments.map((s) => s.text), answering, post);
115
+ ```
116
+
117
+ Root fixed at the top of the chain; parent advancing to whatever was just posted.
118
+ Reverse them and every message attaches to the first — a fan, not a thread — and
119
+ the provider's response looks identical either way. The post function is an
120
+ **argument** so that is reachable by a test.
121
+
122
+ ---
123
+
124
+ ## Probes: an auth error proves the transport
125
+
126
+ A connector written from documentation can be wrong in three ways that look
127
+ identical until a credential arrives: the URL is wrong, the request shape is
128
+ wrong, or the error handling never runs. **All three are invisible to a fake
129
+ server, because a fake server agrees with whatever your code does.**
130
+
131
+ So call the real API with a deliberately invalid credential and require an
132
+ auth-shaped refusal.
133
+
134
+ - `authStatuses` is declared per provider — a Discord webhook answers `404` for
135
+ an unknown id, so 404 IS its auth answer.
136
+ - **A 2xx is a failure.** The credential is reaching nothing.
137
+ - **Offline is `skip`, never `fail`.** A check that goes red on a train gets
138
+ ignored, and is then worth nothing when it goes red for real.
139
+
140
+ ---
141
+
142
+ ## Drift: it reports, it never adapts
143
+
144
+ A connector's worst failure is silent — the provider changes its API and nothing
145
+ says so until something breaks.
146
+
147
+ **And the fix is not a connector that reshapes itself.** That connector cannot be
148
+ reviewed, its behaviour depends on when it ran, and adaptation is only ever a
149
+ guess at intent. So:
150
+
151
+ > Drift reports. The provider decides. A person acts.
152
+
153
+ Drift also never changes runtime behaviour: it runs out of band, and a check that
154
+ failed a working call because a *document* changed would be a self-inflicted
155
+ outage.
156
+
157
+ What is checked is a narrow projection, not a diff — *for the operations this
158
+ connector actually calls, do the path, method, sent fields and read fields still
159
+ exist?* Where a provider publishes no spec (most do not), the fallback is a
160
+ recorded shape: field **names** only.
161
+
162
+ `outcome` is `clean` | `drifted` | **`unchecked`**. The third is never collapsed
163
+ into the first — a checker that could not see is not a checker that saw nothing
164
+ wrong.
165
+
166
+ ---
167
+
168
+ ## Documentation
169
+
170
+ - [`AGENTS.md`](./AGENTS.md) — the invariants, the traps, and what a change here
171
+ breaks.
172
+ - `.ai/plans/fancy-connectors.md` in the envelope — the architecture, the
173
+ Socialite seam, the drift research, and every decision with its reason.
174
+
175
+ MIT.