fix(catalog): derive connector status, auth and actions from the shipped adapters - #133
Conversation
…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
left a comment
There was a problem hiding this comment.
✅ 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
left a comment
There was a problem hiding this comment.
🟡 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 thetesthealth check whose invocation carries no args. Args are spread AFTERconnections - 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 waspath: 'companyinfo/{realmId}'); a 409 stale-SyncToken or 429 throttle reported asstatus:'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 newscopeobject 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
statusfield 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.
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.
❌ Needs Work —
|
tangletools
left a comment
There was a problem hiding this comment.
❌ 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
tangletools
left a comment
There was a problem hiding this comment.
✅ 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.
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.
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:
503 HUB_CONFIG_MISSING, not404 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.tsrestated facts that live in the adapters.status: 'executable'example.invalidauthorize URLStatus came from a hand-kept list.
EXECUTABLE_KINDSheld 18 names; the package ships 530 adapters. Every adapter added after the set was written reportedcatalog— gmail included.Authorization URLs were invented. When the family table had no endpoint, the oauth2 branch substituted
https://example.invalid/<kind>/authorize..invalidis 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.Auth mode was guessed. Coverage entries with no auth column fell through to the
standard-oauth2family. That is how Coda — an api-key connector — came to advertise an OAuth flow it does not have.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 — includingreports.get(the P&L, balance sheet and trial balance a return is built from) and Xero'stenants.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.tsis 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 privatecollectBundledAdapters()copy that this supersedes.A tenant boundary in the declarative REST engine
renderScopespread arguments after the reservedconnectionnamespace, so an argument namedconnectionshadowed it: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 thebody: '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:
example.invalidplaceholdersEXECUTABLE_KINDScodareports oauth2companyinfo/1111111111111111gmail advertises "messages.search", which it cannot execute630 files / 4627 tests pass.
tsc --noEmitclean.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:
QUICKBOOKS_OAUTH_CLIENT_ID/QUICKBOOKS_OAUTH_CLIENT_SECRET. Note the refresh token rotates on every use.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'sHUB_OAUTH_ALLOWED_ENV_NAMESallowlist, which is fail-closed — a missing entry is indistinguishable from a missing credential.One platform gap remains after that: Intuit returns
realmIdas 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.