Skip to content

refactor(contracts): plugin-owned contracts, part 3 (channels, connector accounts) - #53

Merged
danielgerlag merged 5 commits into
mainfrom
refactor/contracts-split-3
Sep 26, 2026
Merged

danielgerlag merged 5 commits into
mainfrom
refactor/contracts-split-3

Conversation

@danielgerlag

@danielgerlag danielgerlag commented Sep 26, 2026 •

Copy link
Copy Markdown
Owner

Refs #31

Part 3 of the contracts split. The channel contracts move into their plugins, and connector account management moves to a subpath, following the connector-accounts refinement from review. The calendar, contacts and drive schemas become capability subpaths.

Commits

  1. test(contracts): pin connector key formats before the connector split, committed before anything moves.
    • packages/contracts/test/connector-key-formats.test.ts pins the persisted formats of oauthGrantKey, connectorAdapterId, connectorSecretKey and connectorStoreKey: the default account keeps the legacy unprefixed keys, other accounts use <plugin>.<id>, <id>.<leaf> and <id>/<leaf>.
    • It also pins which account ids are accepted and which throw. The wire snapshot covers schemas only, so this test is what guards the stored keys.
  2. build: check the contract include against the plugin's build tsconfig
    • The ./contract check read tsconfig.main.json unconditionally. It crashed with ENOENT on channel-mock, which is main-only and builds with tsconfig.json.
    • It now reads the tsconfig named in the build script, accepts src/*.ts and src/**/*.ts globs, and reports a missing tsconfig as a failure.
  3. refactor(channels): move channel contracts into each channel plugin: mock, discord, slack, imap, m365 and google each get src/contract.ts, exported as ./contract.
    • channelInboundMessage, channelAttachmentHandleSchema and KERNEL_ONLY_EVENT_IDS stay in the root, because the kernel publishes and guards that event. Their behavior is unchanged.
    • There is no shared channel plugin.
    • The Slack command test moves to plugins/channel-slack/test/contract.test.ts.
    • The discord and slack dependency-pin tests now allow zod.
  4. refactor(contracts): move connector account management to a subpath: the new @borg/contracts/connector-accounts subpath.
    • Moved to the subpath: slugifyConnectorAccountName, allocateConnectorAccountId, MAX_CONNECTOR_ACCOUNTS, CONNECTOR_ACCOUNT_NAME_MAX, connectorAccountNameSchema, connectorCommandInputSchema (with ConnectorCommandInput).
    • Kept in the root, so one parser owns every key format: oauthGrantKey, isUnprefixedConnectorAccountId, parsedConnectorAccountId, connectorAccountIdSchema, DEFAULT_CONNECTOR_ACCOUNT_ID, connectorSecretKey, connectorStoreKey, connectorAdapterId. CONNECTOR_ACCOUNT_ID_MAX and CONNECTOR_ACCOUNT_ID_PATTERN also stay, because connectorAccountIdSchema is built from them.
    • The subpath imports from the root. The root never imports the subpath.
    • Channel plugins and coinbase now import the moved names from the subpath. docs/architecture.md describes the split.
  5. refactor(contracts): move calendar, contacts and drive schemas to capability subpaths
    • These become @borg/contracts/calendar, /contacts and /drive.
    • Both channel-google and channel-m365 implement them, and the kernel never uses them, so they follow the same pattern as web-search.

All moves are verbatim. I checked this by diffing the lines removed from packages/contracts/src/index.ts against the new module bodies; only import lines differ. Command ids, timeouts and schema digests are unchanged, and apps/desktop/test/wire-contract.snapshot.txt is untouched.

Contracts root size (packages/contracts/src/index.ts)

lines commands events exports
main (af40cc5, after #46/#47) 2026 24 6 232
this PR 1573 7 6 170
original (before #44) 3210 82 24 410

Planted violations

I planted each of these in the new code, ran node scripts/check-plugin-boundaries.mjs, saw the failure, and reverted it:

Plant Failure
export * from "./connector-accounts" in the root index packages/contracts/src/index.ts reaches packages/contracts/src/connector-accounts.ts, which backs the ./connector-accounts subpath; the root export must not include capability schemas
root index imports ./drive ... reaches packages/contracts/src/drive.ts, which backs the ./drive subpath ...
kernel imports @borg/contracts/connector-accounts packages/kernel/src/a2a-service.ts imports @borg/contracts/connector-accounts; packages/kernel may import @borg/contracts only as @borg/contracts
channel-mock ./contract export removed @borg/plugin-channel-mock has src/contract.ts but does not export ./contract
channel-mock tsconfig.json include narrowed to src/main.ts @borg/plugin-channel-mock exports ./contract but tsconfig.json include does not contain "src/contract.ts"
defineCommand(...) in plugins/channel-slack/src/runtime.ts plugins/channel-slack/src/runtime.ts calls defineCommand/defineEvent; only packages/contracts/src and plugins/*/src/contract.ts declare bus contracts
channel-google ⇄ channel-m365 dependencies plugin dependency cycle: @borg/plugin-channel-google -> @borg/plugin-channel-m365 -> @borg/plugin-channel-google (plus the unused-dependency failures)
channel-google imports @borg/plugin-channel-m365/main ... may import @borg/plugin-channel-m365 only as @borg/plugin-channel-m365/contract
import "./config" in channel-slack/src/contract.ts src/contract.ts may import only zod, @borg/contracts, @borg/contracts/<subpath>, or @borg/plugin-<name>/contract

One gap turned up: importing defineCommand as d and calling d(...) is not caught. I added it to #45 and did not fix it here.

Gates (Node 24, Linux), rebased on main at af40cc5 (#46)

After the rebase, the lockfile diff against main is only the six zod importer entries, and every planted violation above fails the same way.

  • pnpm install --frozen-lockfile: OK. The lockfile only adds zod for the six channel packages.
  • pnpm typecheck: pass.
  • pnpm test: 138 files, 933 tests passed (main plus the new pin test file, 4 tests).
  • xvfb-run pnpm test:e2e: 44 passed, 2 skipped, 0 failed.

Left for slice 4: mcp/mcp-apps (with mcp-app-csp.ts), embeddedContentRegistered, feedback, and borg.a2a.getStatus. The #45 items and #48 are out of scope.

oauthGrantKey, connectorAdapterId, connectorSecretKey and connectorStoreKey
produce persisted keys (OAuth grants, adapter ids, secret-store and plugin
store keys). The wire snapshot covers schemas only, so pin every format,
the default-account legacy keys and the accepted account-id shape before
any helper moves.
The ./contract export check read tsconfig.main.json unconditionally and
crashed with ENOENT for main-only plugins such as channel-mock, which
build with tsconfig.json. Read the tsconfig named by the build script
(defaulting to tsconfig.main.json), accept src/*.ts and src/**/*.ts
globs, and report a missing tsconfig as a failure instead of throwing.
Moves the channel-specific commands and status schemas verbatim from the
@borg/contracts root into plugins/channel-<name>/src/contract.ts, each
exported as ./contract:

- channel-mock: mockChannelInject, mockChannelSend
- channel-discord: discordChannelStatusSchema .. discordChannelDisconnect
- channel-slack: slackSocketStateSchema .. slackChannelDisconnect
- channel-imap: imapChannelInject
- channel-m365: m365ChannelStatusSchema .. m365ChannelInject
- channel-google: googleChannelStatusSchema .. googleChannelInject

channelInboundMessage, channelAttachmentHandleSchema and
KERNEL_ONLY_EVENT_IDS stay in the root: the kernel publishes and guards
that event. There is no shared channel plugin; the contracts import the
connector account schemas from @borg/contracts.

packages/contracts/test/slack-channel-commands.test.ts moves to
plugins/channel-slack/test/contract.test.ts unchanged apart from its
import. The discord and slack dependency pins now include zod. Command
ids, timeouts and schema digests are unchanged; the wire snapshot is
untouched.
Moves the account-management helpers verbatim from the @borg/contracts
root to packages/contracts/src/connector-accounts.ts, published as
@borg/contracts/connector-accounts: slugifyConnectorAccountName,
allocateConnectorAccountId, MAX_CONNECTOR_ACCOUNTS,
CONNECTOR_ACCOUNT_NAME_MAX, connectorAccountNameSchema and
connectorCommandInputSchema (with ConnectorCommandInput).

The account id and every persisted key format stay in the root, so one
parser owns them: connectorAccountIdSchema, CONNECTOR_ACCOUNT_ID_MAX,
CONNECTOR_ACCOUNT_ID_PATTERN, DEFAULT_CONNECTOR_ACCOUNT_ID,
isUnprefixedConnectorAccountId, parsedConnectorAccountId, oauthGrantKey,
connectorAdapterId, connectorSecretKey and connectorStoreKey. The kernel
still needs only the root (oauthGrantKey). The subpath imports the id
schema and constants from the root; the root never imports the subpath,
which check-plugin-boundaries enforces.

Channel plugins and coinbase import the moved names from the subpath.
The key-format pin test is unchanged and passes; the wire snapshot is
untouched.
…ability subpaths

The calendar, contacts and drive tool schemas are implemented by both
channel-google and channel-m365, and the kernel never uses them. Like
web-search, each moves verbatim out of the root export into its own
module: @borg/contracts/calendar, @borg/contracts/contacts and
@borg/contracts/drive (DRIVE_ITEM_ID_PATTERN and MAX_DRIVE_TEXT_CHARS go
with drive; the private contactEmailSchema goes with contacts). Each
imports connectorAccountIdSchema from the root.

Schema digests are unchanged; the wire snapshot is untouched.
@danielgerlag
danielgerlag force-pushed the refactor/contracts-split-3 branch from d21260d to 91a847e Compare September 26, 2026 10:26
@danielgerlag
danielgerlag merged commit f2d0528 into main Sep 26, 2026
2 checks passed
@danielgerlag
danielgerlag deleted the refactor/contracts-split-3 branch September 26, 2026 10:32
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.

1 participant