Skip to content

fix(catalog): derive connector status, auth and actions from the shipped adapters - #133

Merged
drewstone merged 4 commits into
mainfrom
feat/accounting-connector-mechanism
Jul 29, 2026
Merged

fix(catalog): derive connector status, auth and actions from the shipped adapters#133
drewstone merged 4 commits into
mainfrom
feat/accounting-connector-mechanism

Conversation

@drewstone

@drewstone drewstone commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

What the owner sees

QuickBooks and Xero appear in the product and do not work. They are not missing — both adapters ship, and production knows them:

POST https://id.tangle.tools/v1/hub/connections/{id}/start
  gmail       -> 200  + real Google consent URL   (control: the flow works)
  quickbooks  -> 503  HUB_CONFIG_MISSING
  xero        -> 503  HUB_CONFIG_MISSING

503 HUB_CONFIG_MISSING, not 404 HUB_PROVIDER_MISSING: the platform has the connectors and has no OAuth credentials for them. That last step is a human one and no code in this PR can do it — see the handoff at the bottom.

What this PR fixes is everything the catalog was saying that was not true.

The catalog described connectors instead of reading them

Three separate fabrications, all from the same root: specs/registry.ts restated facts that live in the adapters.

before after
specs reporting status: 'executable' 15 110
oauth2 specs with an example.invalid authorize URL 104 / 131 0
QuickBooks advertised actions that exist 1 / 4 16 / 16
Xero advertised actions that exist 1 / 4 15 / 15
  1. Status came from a hand-kept list. EXECUTABLE_KINDS held 18 names; the package ships 530 adapters. Every adapter added after the set was written reported catalog — gmail included.

  2. Authorization URLs were invented. When the family table had no endpoint, the oauth2 branch substituted https://example.invalid/<kind>/authorize. .invalid is a reserved TLD (RFC 2606) — it can never resolve. An integration nothing could authenticate was byte-indistinguishable from a working one, which is what made a dead Connect button look like a live one. An unauthenticatable integration now exposes no URL rather than a fake one.

  3. Auth mode was guessed. Coverage entries with no auth column fell through to the standard-oauth2 family. That is how Coda — an api-key connector — came to advertise an OAuth flow it does not have.

  4. Actions were synthesized from 19 generic packs. Every finance connector advertised transactions.search, accounts.read, invoices.create, records.sync. QuickBooks implements one of those. The capabilities that do exist went unmentioned — including reports.get (the P&L, balance sheet and trial balance a return is built from) and Xero's tenants.list, without which no other Xero call can be addressed. A named-but-absent tool is worse than an unnamed one: the model spends the turn on it.

connectors/bundled-manifests.ts is now the single enumerator over all 530 bundled adapters, and status, auth mode, endpoints, scopes and actions all derive from it. The platform keeps a private collectBundledAdapters() copy that this supersedes.

A tenant boundary in the declarative REST engine

renderScope spread arguments after the reserved connection namespace, so an argument named connection shadowed it:

connection pinned to realm 9341454792738105
  baseline                          -> /v3/company/9341454792738105/companyinfo/9341454792738105
  args {connection:{realmId:'...'}} -> /v3/company/9341454792738105/companyinfo/ATTACKER-REALM-999

The request still carried that connection's OAuth token. Arguments on this path are model-authored, so a hallucinated field or an injected instruction was enough to point a trusted token at another company's books. Whether Intuit rejects it is not our boundary to delegate.

Connection metadata now wins. Safe for existing adapters: no capability template references a bare {connection}, and Auth0's identically-named parameter travels through the body: 'args' splat, which resolves against raw arguments.

Every new test was run against a broken mechanism first

Per the repo bar — break it, watch it go red, restore, watch it go green:

mutation result
restore example.invalid placeholders RED — placeholder endpoint detected
restore the hand-kept EXECUTABLE_KINDS RED — status disagrees with adapter presence
drop auth-mode derivation RED — coda reports oauth2
restore argument precedence RED — request reached companyinfo/1111111111111111
revert to synthesized actions RED — gmail advertises "messages.search", which it cannot execute

630 files / 4627 tests pass. tsc --noEmit clean.

Also in this PR (earlier commit)

Connection-scoped {connection.<field>} placeholders (QuickBooks could not run its own health check without them), honest 409/429 mutation outcomes instead of reporting a non-commit as committed, and the reports/discovery capabilities both connectors shipped without.

The human step that remains

Nothing here can authenticate. Two OAuth apps must be registered by a person:

  • Intuit — create the app at developer.intuit.com, complete the app-assessment questionnaire Intuit must approve before issuing production keys, set QUICKBOOKS_OAUTH_CLIENT_ID / QUICKBOOKS_OAUTH_CLIENT_SECRET. Note the refresh token rotates on every use.
  • Xero — create the app at developer.xero.com, set XERO_OAUTH_CLIENT_ID / XERO_OAUTH_CLIENT_SECRET. Uncertified apps are capped at 25 connected organisations.

Both redirect to https://id.tangle.tools/v1/hub/connections/oauth/callback. Both names must also be added to the platform's HUB_OAUTH_ALLOWED_ENV_NAMES allowlist, which is fail-closed — a missing entry is indistinguishable from a missing credential.

One platform gap remains after that: Intuit returns realmId as a query parameter on the redirect, not in the token response. The hub captures token-response metadata but not callback-query metadata. {connection.realmId} resolves correctly the moment something writes it.

…n-scoped placeholders, honest mutation outcomes, the reads a return is built from

Three defects kept QuickBooks and Xero from being usable, all proven with
tests that were watched failing before they were made to pass.

1. A value pinned at connect time could not reach a request. Placeholders
   resolved against the capability's arguments only, so the one identifier
   these APIs require on every call — QuickBooks `realmId`, and anything
   like it — could only arrive through `baseUrl.metadataKey`. That cannot
   serve a header, a query param, or `test`, whose invocation has no
   arguments at all. QuickBooks' health check therefore threw
   "missing required argument: realmId" before opening a socket: red for
   every connection, including healthy ones. Placeholders now resolve
   against a reserved `{connection.<field>}` namespace in addition to the
   arguments. Arguments win on a name clash, and `body: 'args'` still
   splats arguments only — splatting the scope would post connection
   metadata upstream on all 580-odd declarative connectors.

2. A write that did not happen reported as a write that did. The transport
   maps 409/412 and 429 to a tagged payload instead of throwing, and the
   mutation wrapper returned all of it under `status: 'committed'`. A CAS
   conflict or a throttle read as success, so the caller skipped its retry
   and the record silently never existed. Both now map to the `conflict`
   and `rate-limited` branches the contract already defines, with
   `Retry-After` honored (seconds or HTTP-date, 60s floor rather than 0).

3. Neither connector could read a financial statement. Both exposed an
   AR/AP surface — invoices, customers, bills, payments — and nothing from
   the reports namespace, which is not reachable through the record
   endpoints on either API. Adds `reports.get` to both (P&L, balance
   sheet, trial balance, general ledger, cash flow, aged AR/AP),
   `companyinfo.get` to QuickBooks, and `tenants.list` to Xero — without
   which the connector is unusable by an agent, since every other Xero
   capability requires a `tenantId` GUID that had no discovery path. Xero
   also now requests `accounting.reports.read`, a distinct scope the
   reports namespace 403s without.

630 test files / 4617 tests green.

@tangletools tangletools left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Auto-approved drewstone PR — 6108f936

This PR was opened by the trusted drewstone account.
The full PR reviewer audit still runs separately and will publish findings if it detects issues.

tangletools · auto-approval · reason: drewstone_author · 2026-07-28T23:28:01Z

@tangletools tangletools left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Value Audit — sound-with-nits

Verdict sound-with-nits
Concerns 3 (1 low, 2 weak-concern)
Heuristic 0.0s
Duplication 0.0s
Interrogation 81.5s (2 bridge agents)
Total 81.5s

💰 Value — sound

Fixes three real defects that left the accounting connectors unusable — a connection-scoped placeholder namespace so connect-time ids can reach the wire, honest conflict/rate-limited mutation outcomes using branches the type already defines, and the report/tenant reads a tax return needs — all in th

  • What it does: Three behavior deltas in the declarative REST engine + the two accounting adapters. (1) A reserved {connection.<field>} placeholder namespace is added (declarative-rest.ts:495-497 renderScope) so a value pinned in connection metadata can be interpolated into a request path, header, or query — including the test health check whose invocation carries no args. Args are spread AFTER connection s
  • Goals it achieves: Make the accounting connectors usable end-to-end. Before: every QuickBooks connection health check was permanently red because {realmId} resolved against the empty test-arg map and threw before opening a socket (prior quickbooks.ts:26 was path: 'companyinfo/{realmId}'); a 409 stale-SyncToken or 429 throttle reported as status:'committed' so a caller would skip its retry and the record silent
  • Assessment: Sound on its merits and built in the grain of the codebase. The {connection.*} namespace is the minimal extension of the existing {placeholder} interpolation (interpolate/renderObject/renderQueryValue at declarative-rest.ts:503-574) — it threads one new scope object through the same render functions, with args-first precedence guaranteeing zero behavior change for every existing adapter (the
  • Better / existing approach: Searched for an existing connection-metadata-into-request mechanism (rg 'connection.' and 'source.metadata' across src/connectors/adapters): none for declarative connectors — baseUrl.metadataKey is host-only, and the direct inv.source.metadata reads are all in hand-written adapters, not the declarative engine. Searched for an existing retry-after parser to reuse: http.ts:321 parseRetryAfterMs exi
  • Model: opencode/zai-coding-plan/glm-5.2
  • Bridge attempts: 2
  • Bridge warning: opencode/kimi-for-coding/k2p7: bridge stream ended without value-audit content

🎯 Usefulness — sound-with-nits

Fixes three real defects that made the accounting connectors unusable (unreachable health-check value, commits misreported on conflict/throttle, missing report/tenant reads); wires into existing registration and tool-derivation paths with no competing pattern.

  • Integration: Fully reachable. quickbooksConnector/xeroConnector are exported from the catalog (src/connectors/adapters/index.ts:81-82) and every capability — including the new reports.get, companyinfo.get, tenants.list — is auto-flattened into the agent tool registry by buildIntegrationToolCatalog (src/catalog.ts:86-90) and dispatched by name in invokeAction (src/adapter-provider.ts:254-292). The manifest cont
  • Fit with existing patterns: Fits the codebase grain on every axis. reports.get matches the established sibling convention (invoiceninja.ts:313, mailchimp.ts:297, lucidya.ts:361 all use the same capability name). The conflict/rate-limited branches being returned were ALREADY declared in CapabilityMutationResult (types.ts:157-185) but the runtime never produced them — this closes that contract gap rather than inventing new sur
  • Real-world viability: Holds up on the paths that matter. Retry-After parsing handles both seconds and HTTP-date forms with a 60s floor instead of 0 (declarative-rest.ts:484-493), preventing a busy-loop on throttle. The connection-scoped placeholder resolves the documented QBO/Xero tenant-pin pattern. One edge: mutationOutcome (declarative-rest.ts:468-474) keys off a bare lowercase status field in the response body wi
  • Model: opencode/zai-coding-plan/glm-5.2
  • Bridge attempts: 1

🔎 Heuristic Signals

🟡 Cruft: magic number added src/connectors/adapters/declarative-rest.ts

  • if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1000)

🎯 Usefulness Audit

🟡 mutationOutcome trusts a bare status field across the shared transport [robustness] ``

executeRestRequest sets {status:'conflict'|'rate-limited'} only on 409/412/429 (declarative-rest.ts:307-324), but mutationOutcome (declarative-rest.ts:468-474) re-reads that tag off the parsed body WITHOUT verifying the HTTP status was non-2xx. Because this lives in the shared declarativeRestConnector used by ~200 connectors, any 2xx success body shaped {status:'conflict'|'rate-limited', ...} would now be misrouted to the non-commit branch and reported as a failed write. Low likelihood — most RE

💰 Value Audit

🟡 Retry-after parsing is now copied a 6th time; a shared util is the cleaner long-term home [duplication] ``

retryAfterMsOf (declarative-rest.ts:484) reimplements the same seconds-or-HTTP-date parse already present as http.ts:321 parseRetryAfterMs and, in a coarser form, across pandadoc.ts:498/587/634, hellosign.ts:493/544/609, and docuseal.ts:334/398. This PR follows the existing per-adapter pattern rather than fighting it, and the input shape (a tagged data.retryAfter field) genuinely differs from a raw header, so it is not a clean drop-in today. The available improvement is a shared exported `parseR


What this audit checks

It judges the change on its merits — not whether it was tasked out in an issue. Unticketed, fast-moving work is fine; the question is whether the change is good and whether a better or existing approach should be used instead.

Pass What it asks
Heuristic Vague title? Whitespace-only or cruft-bearing diff? (content signals only)
Duplication Do added function/class names already exist elsewhere in the repo?
Value Audit What does it do? What goal does it achieve? Is it good? Better architecture or already-exists?
Usefulness Audit Does it integrate and fit? Will it hold up in real use and actually get used?

Findings are concerns, not blocks — the human reviewer decides what to do with them.

value-audit · 20260728T233022Z

The spec catalog described connectors instead of reading them, and the two
descriptions drifted apart:

  - `EXECUTABLE_KINDS` was a hand-kept set of 18 names. The package ships 530
    adapters, so every adapter added since the set was written reported
    `status: 'catalog'` — gmail among them.
  - The oauth2 branch of `authFor` substituted
    `https://example.invalid/<kind>/authorize` whenever the family table had
    no endpoint. `.invalid` is a reserved TLD (RFC 2606): it can never
    resolve. 104 of 131 oauth2 entries carried one, so an integration nothing
    could authenticate was indistinguishable from a working one.
  - Auth MODE was guessed too. Coverage entries with no auth column fell
    through to the `standard-oauth2` family, which is how Coda — an api-key
    connector — came to advertise an OAuth flow it does not have.

Adds `connectors/bundled-manifests.ts`, the single enumerator over every
bundled adapter, and derives status, auth mode, endpoints and scopes from it.
An integration nothing implements now exposes NO authorization URL rather than
a fake one, so callers can tell the difference.

  executable specs         15 -> 110
  example.invalid oauth2  104 -> 0

Also closes a tenant boundary in the declarative REST engine. `renderScope`
spread arguments AFTER the reserved `connection` namespace, so an argument
named `connection` shadowed it: a QuickBooks connection pinned to realm
9341454792738105 could be driven to `companyinfo/<other realm>` while still
presenting that connection's OAuth token. Arguments on this path are
model-authored, so a hallucinated field or an injected instruction was enough.
Connection metadata now wins. No capability template references a bare
`{connection}`, and Auth0's identically-named parameter travels through the
`body: 'args'` splat, which resolves against raw arguments.

Each new test was run against a reverted mechanism and observed to fail:
placeholder URLs restored -> red, hand-kept status list restored -> red,
auth-mode derivation dropped -> red, argument precedence restored -> red
(request reached `companyinfo/1111111111111111`).

630 files / 4625 tests pass; tsc --noEmit clean.
…ed ones

The coverage table builds each connector's action list from 19 generic
"action packs", so every finance-pack connector advertised the same four
names: transactions.search, accounts.read, invoices.create, records.sync.

QuickBooks implements one of those four. Xero implements one. Meanwhile the
capabilities that do exist went unmentioned — including reports.get, which is
how a P&L, balance sheet or trial balance is fetched, and Xero's tenants.list,
without which no other Xero call can be addressed at all. Slack advertised
messages.post; its adapter exposes post_message.

A named-but-absent tool is worse than an unnamed one, because the model spends
the turn calling it and gets an unknown-capability error.

Actions now come from the adapter's capability catalog wherever one exists,
carrying the real description, JSON-Schema parameters, required scopes, and
`approvalRequired` for mutations with external effect.

Connector identity now outranks a generic verb in tool search. With real
capability names, "send a slack message" tied Slack's `post_message` against
Outlook's `send_message` at equal score and resolved alphabetically to
Outlook. Naming the connector is the stronger signal.

Proven failable: reverting to synthesized actions reports
`gmail advertises "messages.search", which it cannot execute`.

630 files / 4629 tests pass; tsc --noEmit clean.
@tangletools

Copy link
Copy Markdown
Contributor

❌ Needs Work — 6108f936

Review health 100/100 · Reviewer score 26/100 · Confidence 75/100 · 11 findings (1 high, 2 medium, 8 low)

glm: Correctness 26 · Security 26 · Testing 26 · Architecture 26

Reviewer score is advisory once the run is complete and the verdict has no blockers.

Full multi-shot audit completed 3/3 planned shots over 7 changed files. Global verifier still owns final merge decision.

Blocking

🔴 HIGH conflict branch sets currentState to the transport wrapper, not the upstream body — src/connectors/adapters/declarative-rest.ts

On a 409/412, executeRestRequest returns data: {status:'conflict', message: await safeErrorText(res)} — the upstream body is flattened to a raw string in message and the parsed JSON shape is discarded. executeMutation then does currentState: response.data, so the caller receives {status:'conflict', message:'<raw text>'} as the 'current authoritative state'. The CapabilityMutationResult contract (types.ts:173-175) says currentState is 'The current authoritative state, if the connector could re-read cheaply.' The canonical implementation in http.ts:167 does it right: currentState: data where data is the parsed upstream body. Impact: any caller that branches on currentState (e.g. to diff against the attempted write, or to surface alternatives) gets a useless wrapper object and c

Other

🟠 MEDIUM headline behaviors have no execution tests — only manifest capability-list assertions — src/connectors/adapters/__tests__/quickbooks.test.ts

The test diffs only append 'reports.get' and 'companyinfo.get' to the expected capability list and 'accounting.reports.read' to Xero scopes. None of the following is exercised end-to-end: (1) the central bug fix — quickbooksConnector.test(source) with the new {connection.realmId} path (a regression here is invisible); (2) the new conflict and rate-limited branches in executeMutation (no mock returns 409/412/429 to assert the result.status); (3) execution of reports.get / companyinfo.get (QBO) or tenants.list / reports.get (Xero) — including the Xero xero-tenant-id header coming from an arg, and the QBO realmId coming from connection metadata. Also note: the xero test source's scopes array (xero.test.ts:12) was NOT updated to include 'accounting.reports.read', so a future execution te

🟠 MEDIUM mutationOutcome re-reads the outcome tag from response.data.status — collides with legitimate upstream fields — src/connectors/adapters/declarative-rest.ts

mutationOutcome decides commit-vs-conflict-vs-rate-limited by reading data.status off the parsed 2xx body. The transport only sets that field on 409/412/429, but mutationOutcome cannot distinguish a transport-injected tag from an upstream field of the same name. Any future (or existing) connector whose 2xx success body carries a top-level status: 'conflict' or status: 'rate-limited' (lowercase, exact match) would have a successful commit silently re-classified as a non-commit — the caller would skip its retry / abort the workflow even though the write landed. Current QBO/Xero responses are safe (Xero uses Status capital-S; QBO has no top-level status), so this is latent, not active — but it is a foot-gun for the next adapter. Fix: change executeRestRequest to return a tagged unio

🟡 LOW orphaned JSDoc — renderScope comment sits detached above mutationOutcome — src/connectors/adapters/declarative-rest.ts

Lines 460-463 carry a JSDoc that begins 'Placeholder-resolution scope for one invocation...' — that describes renderScope (defined at line 495), not mutationOutcome. Lines 464-467 then carry mutationOutcome's actual JSDoc. Two consecutive /** */ blocks precede mutationOutcome, the first describing a different function. Cosmetic,

🟡 LOW retryAfterMsOf comment claims 'never 0' but Retry-After: 0 returns 0ms — src/connectors/adapters/declarative-rest.ts

The JSDoc says 'falls back to 60s — a conservative wait, never 0, which would busy-loop the upstream.' The 60s fallback indeed cannot be 0, but the explicit-numeric branch (Number.isFinite(seconds) && seconds >= 0) accepts 0 and returns 0ms, so a real Retry-After: 0 header (legal HTTP, sometimes sent when the bucket is already refilled) triggers an immediate retry. Minor: the invariant in the comment is narrower than the comment claims. Either honor the comment with Math.max(1000, ...) on the seconds branch, or narrow the comment to 'the fallback is never 0'.

🟡 LOW 412 (Precondition Failed) conflict branch is untested — tests/accounting-connectors.test.ts

declarative-rest.ts:307 maps BOTH 409 and 412 to the conflict payload, but only 409 is exercised (line 99). If the 412 branch were dropped, no test in the suite would catch it. Adding a parallel 412 case would close the gap cheaply.

🟡 LOW Authorization header placement not asserted — tests/accounting-connectors.test.ts

source() sets credentials.accessToken='access-token' but no test asserts the outgoing request carries 'authorization: Bearer access-token'. applyCredentials (declarative-rest.ts:361) is the credential-placement path for all bearer connectors; a regression there would silently send unauthenticated requests and the tests would still pass because the stub ignores headers. calls[].headers is captured but never inspected except for xero-tenant-id.

🟡 LOW Conflict result shape under-asserted — tests/accounting-connectors.test.ts

Only result.status==='conflict' is checked. The CapabilityMutationResult.conflict variant also returns alternatives, currentState (the upstream error body), and message. A regression that dropped message/currentState would not be caught. Compare the rate-limited test (line 122) which does narrow and assert retryAfterMs.

🟡 LOW Test 9 name/comment mismatch the assertion — tests/accounting-connectors.test.ts

The describe block claims 'arguments still win over connection metadata' and the inline comment says 'Both namespaces must stay addressable and distinct.' But the args used ({customerId:'42'}) do NOT clash by name with any connection field (metadata carries realmId/apiBaseUrl). The test proves an arg reaches the path; it does NOT exercise the documented args-win-on-clash semantics of renderScope ({ connection: metadata, ...args }). To actually test the claim, the arg and a metadata key would need the SAME name. Coverage gap, not a bug — the assertion at line 177 still validates correct behavior for the case it does exercise.

🟡 LOW Xero reports.get only asserts the 'date' query param — tests/accounting-connectors.test.ts

xero.ts reports.get declares fromDate, toDate, date, and contactID query params (xero.ts:89). The test only passes and asserts 'date'. The renderQueryValue path for fromDate/toDate/contactID placeholders (which should be omitted when absent) is not exercised for this connector.

🟡 LOW retryAfterMsOf HTTP-date and 60s-floor branches untested — tests/accounting-connectors.test.ts

retryAfterMsOf (declarative-rest.ts:484) has three paths: numeric seconds, HTTP-date parsing, and the 60s conservative floor. Only the numeric-seconds path (retry-after: 30 -> 30000ms) is covered. The HTTP-date branch and the absent/unparseable -> 60_000 default (the 'never 0, would busy-loop' guard) are not asserted.


tangletools · 2026-07-28T23:35:29Z · trace

@tangletools tangletools left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ 1 Blocking Finding — 6108f936

Full multi-shot audit completed 3/3 planned shots over 7 changed files. Global verifier still owns final merge decision.

Full immutable report for this review: trace

Summary comment for this run: full summary


tangletools · 2026-07-28T23:35:29Z · immutable trace

@drewstone drewstone changed the title fix(connectors): make the accounting connectors reachable — connection-scoped placeholders, honest mutation outcomes, the reads a return is built from fix(catalog): derive connector status, auth and actions from the shipped adapters Jul 28, 2026

@tangletools tangletools left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Auto-approved drewstone PR — f98849c1

This PR was opened by the trusted drewstone account.
The full PR reviewer audit still runs separately and will publish findings if it detects issues.

tangletools · auto-approval · reason: drewstone_author · 2026-07-28T23:47:56Z

Addresses the review's blocking finding and the collision it shares a root
cause with.

`executeRestRequest` tagged a 409/412/429 by REPLACING the response body with
`{status:'conflict'|'rate-limited', message}`. Two things went wrong at once:

  - The upstream's parsed JSON was discarded, so `currentState` — which the
    CapabilityMutationResult contract calls "the current authoritative state"
    — received the transport's own wrapper. A caller diffing against its
    attempted write, or reading a fresh SyncToken to retry with, got an
    object it could do nothing with.
  - The outcome was then re-read off `data.status`, indistinguishable from an
    upstream field of the same name. ~200 connectors share this transport, so
    any connector whose 2xx success body carried a top-level
    `status: 'conflict'` would have had a landed write reported as a failure,
    and the caller would abort or retry a write that already happened.

The outcome now rides on the transport envelope (`RestTransportResponse`)
beside `data`, and `data` is always the upstream's own parsed body. An
upstream field can no longer be mistaken for a transport tag.

Also from the review:
  - `Retry-After: 0` (legal HTTP, sent when a bucket has refilled) returned a
    0 ms wait, turning a throttle into a busy-loop. Parsed values are floored
    at 1 s; the absent/unparseable fallback stays 60 s.
  - Removed an orphaned JSDoc block that described `renderScope` while sitting
    above `mutationOutcome`.

New tests, each run against the reverted mechanism first:
  - upstream body reaches currentState  -> RED: got the wrapper, no SyncToken
  - 200 body with status:'conflict'     -> RED: 'conflict' instead of 'committed'
  - 412 maps to conflict (was untested — only 409 was)
  - Retry-After: 0 yields a non-zero wait
  - the bearer token actually reaches the wire (no test inspected it)

630 files / 4632 tests pass; tsc --noEmit clean.
@drewstone
drewstone merged commit 605b3b8 into main Jul 29, 2026
@drewstone
drewstone deleted the feat/accounting-connector-mechanism branch July 29, 2026 00:01
drewstone added a commit that referenced this pull request Jul 29, 2026
Ships the connector-truth fixes from #133 so consumers can actually get them:
status/auth/actions derived from the 530 bundled adapters instead of a
hand-kept list, no fabricated example.invalid endpoints, the reserved
connection namespace no longer shadowable by a caller argument, and a
non-commit reported on the transport envelope rather than as a body field.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants