Skip to content

feat(automation): an oauth2 connector with clientSource: 'installer' connects with the administrator's OAuth client - #29097

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/project-thread-yrazza
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/project-thread-yrazza

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

domain:server#2 · os-steve · session_01ADHkmQxwoGb5fSnrFkpxmt

Requested by Leehom · project thread
Fixes #28866

Clause-②: yes (narrowing)
Metadata-protocol: none
Skills-docs: content/docs/automation/connectors.mdx teaches clientSource: 'installer' in this PR (the publish-door bullet and the connect body). skills/objectstack-automation/references/integration-recipes.md (governed Tier H) is not edited here. Follow-up owed — #29255
Ruling-ref: 6566728470

维护者速读

  • 改了什么:声明 clientSource: 'installer' 的 OAuth 连接器,管理员在「连接」时提交自己在厂商处登记的 client id 和 secret;授权、换取令牌、每次刷新都用这个客户端。secret 只加密存在连接记录里,读接口只显示「已设置客户端、id、有无 secret」。
  • 为什么改:[Decision] #28277 DocuSign app: where an open-edition OAuth listing's client id comes from, when clientId is fixed by the package and the redirect URI is per deployment #28702 裁决 B 要求开源版 OAuth 应用不带官方客户端、由每个部署的管理员自带;规格半边 feat(spec): an oauth2 authorization_code connector instance can take its OAuth client from the installer — clientSource: 'installer' #28920 已合并,这是服务端半边。
  • 风险与回滚:声明了自己 clientId 或交给 MCP 动态注册的实例行为不变。新增三种拒绝(400):安装方实例连接时没给客户端;给其他实例传了客户端;连接请求体带了别的键(以前都被静默忽略)。回滚即撤掉本 PR。
  • 先看:packages/services/service-automation/src/plugin.ts 的 installerClientOfBegin 和 completeConnectorConnection;packages/runtime/src/dispatcher-plugin.ts 连接门挂载点(这轮修的真缺陷:以前 HTTP 请求体根本没传进门);oauth2-grant/grant-handle.ts 的 endpointOf。
  • 两分钟验证:pnpm --filter @objectstack/dogfood exec vitest run test/connector-installer-oauth-client.dogfood.test.ts,应看到 4 条全过(真实 HTTP 门上:无客户端被拒、多余键被拒、用管理员客户端完成连接并撤销)。
  • 需要你做的:无,席位落地。

What changes

  • Contracts (packages/spec/src/contracts/automation-service.ts, TS only): ConnectorConnectionRequest.client?: { clientId; clientSecret? }, read by beginConnectorConnection only; ConnectorConnectionState.client?: { source: 'installer'; clientId; hasSecret }, never the secret. Inline member types, so no export moves (check:api-surface exit 0).
  • The door (packages/runtime/src/domains/automation.ts): the begin leg (POST) reads a closed body set, clientId and clientSecret, and hands it to the service as client. No body, or {}, begins with no client as before. An unknown key, a non-object body, or a secret without its id is refused 400 VALIDATION_ERROR naming keys only. The read and revoke legs still read no body.
  • The HTTP mount (packages/runtime/src/dispatcher-plugin.ts, new in this round): the route mounted for /automation/connectors/:name/connection dispatched every verb with no body, so over HTTP the begin leg never saw the installer's client and such an instance could not be connected at all. The POST leg now defers its body (deferRequestBody), which the domain reads once the floor has admitted the caller, as every other body-bearing automation route does. Found by the new dogfood case (its first run was red on exactly this), invisible to the domain-level tests, which call handleAutomation with a body directly.
  • The service (packages/services/service-automation):
    • installerClientOfBegin: an installer instance begun without a client is refused 400 naming the instance, the vendor registration step and the redirect URI to register; a client offered to any other instance is refused 400 rather than ignored (the spec has no override form: feat(spec): an oauth2 authorization_code connector instance can take its OAuth client from the installer — clientSource: 'installer' #28920 refuses clientId beside clientSource); a malformed client is refused naming the key. The read and revoke members refuse a client.
    • The client rides the begun connect's sealed pending authorization (installerClient), the code is exchanged as it, and the connection keeps it in registered_client (Field.secret, the store and channel the refresh token uses), as { source: 'installer', clientId, clientSecret? } plus the discovered endpoints on MCP over http. Two new plain columns on sys_connector_connection: client_id and client_has_secret, which the read answers from without decrypting.
    • grant-handle.ts#endpointOf reads the kept client through parseKeptConnectionClient (new oauth2-grant/installer-client.ts) and uses a kept client only when its kind matches the declaration: an installer's client under a declaration that no longer takes one, or the reverse, is "reconnect required", never used.
    • Servability: oauth2NotServableReason no longer lists clientId as missing for the installer form; mcpDiscoveryServerUrl treats it as a supplied client, so MCP discovery finds the endpoints and registers nothing.
    • A complete whose declaration changed between installer and declared client while the browser was away is refused, and its code is not used.
    • Revoke is the existing row delete; the client goes with the row.
  • Spec lane's landing note (card comment 6570978729): clientSource loses [PLANNED — not enforced], the connector/auth liveness row loses its planned sentence, the PLANNED pin test is flipped, and content/docs/references/integration/connector.mdx is regenerated.
  • Route ledger (packages/runtime/src/route-ledger.ts): the POST /automation/connectors/:name/connection row no longer says "No body". It names the closed body set { clientId, clientSecret? }, says that no body or {} begins with no client, and lists the three 400 VALIDATION_ERROR refusals (another body key, an installer instance begun with no client, a client offered to an instance that declares its own or leaves it to dynamic registration). The GET row names the client the read answers. This was should-fix 1 of the contract review.
  • Docs: content/docs/automation/connectors.mdx teaches the installer form and the connect body.
  • Changeset: the ADR-0087 disposition not-required (no-migration-prescription) and a BREAKING paragraph naming the three newly refused call shapes and what to do.

Tests

  • packages/qa/dogfood/test/connector-installer-oauth-client.dogfood.test.ts (new, 4 tests, showcase boot, real HTTP door, a loopback token endpoint): served "not connected", never "missing clientId"; ⛔ a begin with no client is refused 400 and stores nothing; ⛔ a begin body with another key is refused 400 naming it, never echoing the secret; connected end to end as the administrator's client (authorize URL client_id, Basic on the code exchange, the read's client, the plain client_id column, no secret in any answer or row), then revoked.
  • packages/services/service-automation/src/connector-connection-door.test.ts, new describe (10 tests) over a real ObjectQL on SQLite and a local token endpoint: connect, dispatch, refresh on a 401 and revoke as the installer's client (Basic for a confidential one, client_id in the form for a public one); per-organization clients and reconnect replacement; the missing-client refusal; malformed clients; CONTROL: a packaged instance refuses an offered client and connects as before without one; read and revoke refuse a client; a declaration that changed while away; the secret absent from logs, descriptors, degraded reason, door answers and engine reads.
  • oauth2-grant/grant-handle.test.ts, new describe (4 tests): refresh as the installer's client at the declared endpoint and at a kept discovered one; per-organization clients; a kept client of the other kind is never used.
  • packages/runtime/src/domains/automation-connector-connection-door.test.ts, new describe (4 tests): the begin body is handed over as client; no body or {} is unchanged; refusals name keys only; the read and revoke legs read no body.
  • Forced test edits: packages/spec/src/integration/connector-auth-grant-kinds.test.ts, the PLANNED pin flipped to "served" (the spec lane's landing note asks for it); oauth2-grant/grant-handle.test.ts's fakeConnections double gains installerClientHasSecret: false, the new required member of ConnectorConnectionRecord.

Acceptance notes

  • Reads checked against the redaction on main. sys_connector_connection has apiEnabled: false and apiMethods: [], so the connection door is the only read, and it answers ConnectorConnectionState from plain columns. collectMaskedReadFields still answers exactly refresh_token and registered_client (pinned).
  • The narrowing. Before, the begin leg ignored any body, so a client offered to a packaged instance, or any body key, was silently dropped. Both are now refused 400, as is a client on the in-process read and revoke members. Nothing else that was accepted is refused.
  • The Console cannot connect an installer instance yet. Setup → Integrations → Connector Connections begins a connect with no body, so for a clientSource: 'installer' instance it now gets the 400 that names the registration step. The server half and the API are complete; a Console form for the client id and secret is ui-lane work, already carried by console: the connector connection form asks the administrator for the OAuth client (client id + secret) when the instance takes an installer-supplied client (#28702 B, ui slice) #28869.
  • New public key names (client, clientId, clientSecret, hasSecret, source on the two contract members and the connect body): maintainer approval is owed before landing; the seat asks.

Verification

Final head 2ca22f19. It differs from 59630d94 only by the two route-ledger notes (string literals in one file); the readings below were taken on 59630d94 unless a line names another, and the new head's readings are in the os-dev-report on #28866. Heavy runs went through os-verify-lock.sh.

Red first on main, then green
  • Implementation files (plugin.ts, connector-connections.ts, sys-connector-connection.object.ts, oauth2-grant/{authorization,grant-handle,index}.ts, runtime domains/automation.ts, spec connector-auth.zod.ts, liveness/connector.json) restored to origin/main fea2dbb blobs, tests at HEAD 52e2396: service-automation Tests 13 failed | 47 passed (60), runtime Tests 2 failed | 33 passed (35), spec Tests 1 failed | 29 passed (30) — every new test red, every pre-existing one green. Restored: every blob == HEAD, git diff HEAD empty, status clean.
  • The new dogfood case on a12d046 (before the mount fix): Tests 2 failed | 2 passed (4) — the unknown-key and the connected cases, the door seeing no body. On 59630d9: Tests 4 passed (4).
Ablation of every negative pin (each red, each restored blob == HEAD, `git diff HEAD` empty)

Through scripts/ablation-replace.mjs; the unit suites import their subject from src/.

  • A1 installer begin with no client proceeds (if (offered === undefined) { return undefined;): × a connect without a client is refused 400… — Tests 1 failed | 29 passed (30).
  • A2 a client offered to a non-installer instance is ignored: × CONTROL: a client offered to an instance that declares its own is refused 400, not ignored… — 1 failed.
  • A3 unknown begin body keys pass (runtime, if (false) {): × refuses an unknown key, a non-object body and a secret without its id… — 1 failed | 34 passed (35).
  • A4 a client on read/revoke is accepted: × the read and revoke legs refuse a client… — 1 failed.
  • A5 a kept client of the other kind is used (grant handle): × a kept client of the other kind than the declaration takes is never used… — 1 failed | 29 passed (30).
  • A6 a declaration that changed during the callback is not checked: × a declaration that changed where its client comes from… — 1 failed.
  • Dogfood, through dist/ (rebuild + ablation-dist-preflight.mjs marker present; restore rebuild + --absent, tree clean): D1 the mount drops the body again → 2 failed | 2 passed (4); D2 = A1 in service-automation → × a begin with no client is refused 400 VALIDATION_ERROR… 1 failed | 3 passed (4).
Package suites and typecheck
  • @objectstack/service-automation test (52e2396): Test Files 257 passed (257), Tests 3323 passed (3323); typecheck (tsc + check:test-typecheck) VERDICT 0.
  • @objectstack/runtime test (59630d9): Test Files 514 passed (514), Tests 8322 passed | 19 skipped (8341); typecheck VERDICT 0.
  • @objectstack/spec test (52e2396): Test Files 790 passed (790), Tests 23357 passed | 1 todo; typecheck VERDICT 0; check:generated VERDICT 0 (check:authorable-surface exit 0 in the derived set). Spec source is unchanged after 52e2396.
  • @objectstack/dogfood typecheck (59630d9) VERDICT 0. eslint --no-inline-config over the touched runtime, service-automation and spec sources and the new dogfood file: exit 0.
Derived gates and dogfood battery (59630d9)
  • dispatch-gates --commands: 109 families; all 109 exit 0 (four first returned exit 3 PREREQUISITE NOT MET and were re-run after git fetch --unshallow and the missing builds: check-plugin-teardown-shape --self-test, check:skill-examples, check:dual-build-cjs-loads, check:type-check-debt). --ran: 109 derived, 109 run, 0 NOT-MEASURED, 0 UNRUN. check-adr-0087-registration --base origin/main: [BREAKING+clause-②-narrowing] not-required (no-migration-prescription), exit 0.
  • Full dogfood battery, pnpm --filter @objectstack/dogfood test under the lock: Test Files 387 passed | 3 skipped (390), Tests 3199 passed | 28 skipped (3227), VERDICT command-exit 0.
  • CI on 59630d9: nine required contexts success (Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core, Governed Surface Queue Guard, Type Check UI, Test UI, Build & E2E UI, Live E2E UI) and Lint UI skipped by its path selector; Check Changeset and Dogfood Regression Gate success; no check failed.

Generated by Claude Code

…th client

An authorization_code instance declaring clientSource: 'installer' takes its
client from the administrator on the connect door, keeps it encrypted in the
connection record (id repeated in plain text for reads), and uses it for the
authorization request, the code exchange and every refresh. A connect without
one, or a client offered to an instance that declares its own, is refused 400.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5p21mBsw5qoeMKcPA2
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/runtime, @objectstack/service-automation, @objectstack/spec, touching 52 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/services/service-automation/src/oauth2-grant/index.ts, packages/spec/liveness/connector.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 47ee57fea329bbcdab28be42e57644fa5b70dcb2.

⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/services/service-automation/src/oauth2-grant/index.ts, packages/spec/liveness/connector.json) — pages documenting those are invisible to this run
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 101 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 173 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 47ee57fea329bbcdab28be42e57644fa5b70dcb2 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from c993cc6a5ffddb01157d6e2ec9c1163116316f5d — the merge of head 2ca22f19045a28236809170971ef6968ad5f1a23 into base 47ee57fea329bbcdab28be42e57644fa5b70dcb2, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c993cc6a5ffddb01157d6e2ec9c1163116316f5d && git checkout c993cc6a5ffddb01157d6e2ec9c1163116316f5d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 47ee57fea329bbcdab28be42e57644fa5b70dcb2 2ca22f19045a28236809170971ef6968ad5f1a23 && git checkout -B drift-repro 47ee57fea329bbcdab28be42e57644fa5b70dcb2 && git merge --no-ff 2ca22f19045a28236809170971ef6968ad5f1a23

node scripts/docs-audit/affected-docs.mjs --json 47ee57fea329bbcdab28be42e57644fa5b70dcb2

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 47ee57fea329bbcdab28be42e57644fa5b70dcb2 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…d call shapes of the installer-client connect

Claude-Session: https://claude.ai/code/session_01ADHkmSB1MQ9Npyv1kGL3DV
Co-authored-by: Claude <noreply@anthropic.com>
…e real connection door; docs teach clientSource installer

Claude-Session: https://claude.ai/code/session_01ADHkmSB1MQ9Npyv1kGL3DV
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl and removed size/l labels Nov 2, 2026
…gin leg its body

The begin leg reads the installer's OAuth client from the request body, but
the route mounted for /automation/connectors/:name/connection dispatched every
verb with no body, so over HTTP a clientSource: 'installer' instance could
never be connected. The POST leg now defers its body to the domain, which reads
it once the floor has admitted the caller.

Claude-Session: https://claude.ai/code/session_01ADHkmSB1MQ9Npyv1kGL3DV
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-steve · session_01ADHkmQxwoGb5fSnrFkpxmt

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 59630d94703f3410d6a9f468ec7cbc295db18ba9
Local-runs: none
Scope: packages/spec/src/contracts/automation-service.ts, packages/spec/src/shared/connector-auth.zod.ts (schema hits); narrowing floor: packages/spec/liveness/connector.json, packages/spec/src/integration/connector-auth-grant-kinds.test.ts, packages/runtime/src/dispatcher-plugin.ts, packages/runtime/src/domains/automation.ts, packages/runtime/src/domains/automation-connector-connection-door.test.ts, packages/services/service-automation/src/plugin.ts, packages/services/service-automation/src/connector-connections.ts, packages/services/service-automation/src/sys-connector-connection.object.ts, packages/services/service-automation/src/oauth2-grant/authorization.ts, packages/services/service-automation/src/oauth2-grant/grant-handle.ts, packages/services/service-automation/src/oauth2-grant/index.ts, packages/services/service-automation/src/oauth2-grant/installer-client.ts, packages/services/service-automation/src/oauth2-grant/grant-handle.test.ts, packages/services/service-automation/src/connector-connection-door.test.ts, packages/qa/dogfood/test/connector-installer-oauth-client.dogfood.test.ts + packages/runtime/src/deferred-request-body.ts, packages/plugins/plugin-hono-server/src/request-body.ts, packages/services/service-automation/src/oauth2-grant/mcp-authorization.ts, packages/services/service-automation/src/oauth2-grant/token-endpoint.ts, packages/services/service-automation/src/connector-credentials.ts, packages/runtime/src/route-ledger.ts, ui/packages/app-shell/src/views/setup/connectorConnections.ts, docs/adr/0143-outbound-connector-auth-is-open-tier.md

Faces: schema (two packages/spec/src/** non-test files) and narrowing (Clause-②: yes (narrowing) on the PR body and the changeset). Both at this tier; one record. Inputs: card #28866 and every comment (rulings 6566728470 on #28702 and 6570978729, claims 6571085790 / 6572348459 and the amendment 6574507537, os-dev-report 6574284916), the PR body and file list, the net diff origin/main...59630d94, the head's check-runs. Author objectstack-fleet[bot]. Read-only: nothing built or run.

① Derived judgments

  1. packages/spec/src/shared/connector-auth.zod.ts (published, @objectstack/spec/shared): the accept set is byte-identical. The only change is the .describe() text of clientSource (the [PLANNED — not enforced] prefix and the "Not served yet … held degraded" sentences go, a served sentence comes) and the doc comment above the schema. No key, value, refinement or default moves. Right: this is the marker flip the spec lane's landing note 6570978729 asked for, in the PR that serves the key.
  2. packages/spec/src/contracts/automation-service.ts (published, @objectstack/spec/contracts, TS-only): ConnectorConnectionState.client?: { source: 'installer'; clientId: string; hasSecret: boolean } and ConnectorConnectionRequest.client?: { clientId: string; clientSecret?: string }, both optional, inline member types, no export added or moved; the beginConnectorConnection doc gains the two new 400 refusals. A type-level widening of the contract: an existing implementor or caller compiles unchanged. Right.
  3. packages/spec/liveness/connector.json: the connector/auth row stays live; only its planned sentence is removed. Right (the walker does not descend into the auth union, so this row was the one coordinate over the key; the flipped pin test holds it).
  4. The HTTP begin leg (packages/runtime/src/domains/automation.ts with the mount in dispatcher-plugin.ts): accept set changes from "any body, never read" to the closed set { clientId, clientSecret? }, where no body, '', null or {} still begins with no client. Newly refused 400 VALIDATION_ERROR: any other key, a non-object body, clientSecret without clientId. The read and revoke legs read no body, as before. A narrowing of the published HTTP surface, declared as such. Right.
  5. The service (plugin.ts#installerClientOfBegin): an instance with clientSource: 'installer' begun without client is refused 400; a client offered to any other instance is refused 400 (was silently ignored); malformed client refused naming the key; client on the in-process read and revoke members refused 400. Narrowing, declared in the changeset's BREAKING paragraph by all three call shapes. Right.
  6. sys_connector_connection gains two plain columns, client_id (text, 512) and client_has_secret (boolean, default false). The object is apiEnabled: false, apiMethods: [], so the only read is the connection door, which answers from the plain columns; the masked set stays exactly ['refresh_token', 'registered_client'] (pinned in the door test). Not an authorable metadata surface. Right.
  7. Metadata-protocol: none is right: nothing a metadata author can write changes in this diff (the authorable key clientSource landed in feat(spec): an oauth2 authorization_code connector instance can take its OAuth client from the installer — clientSource: 'installer' #28920 as added; here its description text changes and the service starts reading it, which is a liveness change, not a protocol change). The connect body is an HTTP request body, the two columns are a system object's, the contract members are TS types.
  8. @objectstack/service-automation publishes only ., and src/index.ts re-exports neither connector-connections.ts nor oauth2-grant/, so the new required ConnectorConnectionRecord.installerClientHasSecret, the new OAuth2GrantConfig.clientSource, PendingAuthorization.installerClient and the whole installer-client.ts module are shipped bytes, not a published accept set. The forced edit to the fakeConnections double in grant-handle.test.ts is the internal consequence. Right.
  9. The mount fix (dispatcher-plugin.ts): the standalone route for /automation/connectors/:name/connection dispatched every verb with undefined as the body; now POST passes deferRequestBody(req), resolved by handleAutomationRequest at its existing readDispatchedBody(body) call after the anonymous floor and the inbound-hook hand-off, ahead of the capability gate and the door. Same shape as every other body-bearing automation mount. Right, and without it the installer form is unreachable over HTTP (the dev's own dogfood found it).

② Semver level

Changeset .changeset/28866-installer-oauth-client.md: patch on @objectstack/spec, @objectstack/service-automation, @objectstack/runtime, line-initial Clause-②: yes (narrowing) and Metadata-protocol: none, a BREAKING paragraph naming the three newly refused call shapes with what to do, and exactly one adr-0087: marker (not-required (no-migration-prescription)). Consistent with the PR body's two lines and with the diff: the kind is none so the level is patch (AGENTS.md Post-Task Checklist 3, ruling A #25105: minor only when the metadata protocol changes); a narrowing ships patch and keeps its BREAKING banner and ADR-0087 disposition. The disposition holds: nothing stored, exported, named or coded is removed or renamed; the connect body is a new accept set with a prescription in the paragraph ("send none, or {}, for an instance that declares its client"). The three moved packages are the three whose shipped behaviour changes. check-adr-0087-registration and Check Changeset are green on the head. ✅

③ Boundary flags

  • Secret handling. Never logged: the only log lines on the path name the connector, organization and user (beginConnectorConnection, completeConnectorConnection, revokeConnectorConnection, mcpAuthorizationClient); the service test pins the secret absent from every log line, descriptor, degraded reason, door answer and engine read. Never returned: describeConnection answers client: { source, clientId, hasSecret } from the plain columns; the dogfood pins its absence from the begin, callback and read bodies and from the stored row's JSON. Never in errors: every refusal in connectorConnectionBeginClientOf, installerClientOfBegin and refuseClientOffLeg names keys only (pinned with an evil.example.com key value and the secret value in both the runtime and service tests); token-endpoint.ts raises OAuth2GrantRefusedError(connector) and names no client. At rest: in flight the client rides the pending authorization, sealed AES-256-GCM under a key derived from the raw state (authorization.ts, unchanged mechanism); completed, it is serialized into registered_client, the existing Field.secret() column the MCP registered client already uses, with the id alone repeated in plain client_id. The ADR-0143 D3 rule (registered clients never in sys_metadata, the connectors listing, a descriptor or a log line) is honoured. ✅
  • Who may call connect. Unchanged: the capability gate refuseUngrantedConnectorConnectionAccess (manage_connector_connections, fail-closed, isSystem only, delegated callers refused on POST/DELETE per ruling 6548764795) runs at handleAutomationRequest before the door; the body-shape refusal sits inside the door, after the gate, so an ungranted caller still gets 403 and learns nothing about the body set. The deferred body is read after the anonymous floor, so an anonymous caller with an unparseable body still gets 401. ✅
  • Refusals before any write. The runtime refusal precedes the service call; installerClientOfBegin runs after connectionDoorSubject and before mcpAuthorizationClient and pendingAuthorizations.put; the dogfood pins zero token requests and zero rows after each refusal. The changed-declaration refusal at the callback comes after the single-use take(state) and before the code exchange: the code is not used, nothing is stored. ✅
  • Real producers of the begin leg (census). ui/packages/app-shell/src/views/setup/connectorConnections.ts#beginConnection sends POST with JSON headers and no body: the transport reads an empty application/json body as {} (plugin-hono-server/src/request-body.ts), which the door accepts as "no client", so the Console's connect of a declared-client or MCP instance is unchanged. @objectstack/client has no connection-door member (the ledger rows are server-only, "NOT JS-SDK surface"). No MCP tool, skill recipe or doc sends a body to this leg (skills/objectstack-automation/references/integration-recipes.md:175, content/docs/automation/connectors.mdx). Nothing breaks. ✅
  • The Console cannot connect an installer instance (dev open question 2): true by construction, it sends no body and now gets the 400 that names the registration step. The server half and the API are complete; the ui slice is letter B's own third lane (ruling 6566728470 lists domain:ui as the connection form). Escalated to the seat to file the ui card; not a defect of this PR.
  • New public names for the maintainer (dev open question 1): client on ConnectorConnectionRequest and ConnectorConnectionState; members clientId, clientSecret (request), source, clientId, hasSecret (state); connect body keys clientId, clientSecret; plain columns client_id, client_has_secret. They mirror RFC 6749 client_id / client_secret in the repo's camelCase and the existing clientId auth key. Nothing in the diff is wrong with them; the maintainer's word on the names is owed before landing and is not this record's to give.
  • Skills-docs: line. Reads follow-up owed — skills/objectstack-automation/references/integration-recipes.md (governed Tier H). SKILL.md has the seat file the card and rewrite owed — FILES to #N before ACCEPT: a seat step, named here so it is not missed.
  • Author. objectstack-fleet[bot]; no out-of-relay login to name.
  • Deviations declared in the report (mount fix and connectors.mdx beyond the claim's surface): accepted in the claim amendment 6574507537 with an overlap read; the mount fix is the same defect's last hop. ✅

④ Design judgment

  • packages/spec/src/shared/connector-auth.zod.ts against ADR-0143 D2.1 (2026-11-02 amendment) and its first consumer: the amendment's "Slices" paragraph says the key is declared and not served until the server slice lands, and that its description says so; this diff is that server slice, and the description now states what the service does (keeps the client encrypted, uses it on every authorization, exchange and refresh), which plugin.ts, installer-client.ts and grant-handle.ts#endpointOf make true. The flipped pin test holds the marker off and the ledger row served. Holds. Should-fix, outside this PR: the ADR's own "Until the server slice lands … held degraded for a missing clientId" sentence (docs/adr/0143-outbound-connector-auth-is-open-tier.md:249-252) is now historical; governed Tier H, a maintainer-approved follow-up.
  • packages/spec/src/contracts/automation-service.ts against the ruling and its first consumer: the request member carries exactly what the administrator registers (id, optional secret) and the state member exactly what a read may show (id, whether a secret is kept, the source); plugin.ts#describeConnection and the runtime door produce and consume them as declared. The doc on beginConnectorConnection names both new refusals. Holds.
  • The narrowing against the published spec on main: it takes no declared capability away. A begin never had a body contract (the ledger row says "No body"), so a caller sending one was relying on it being dropped; the spec never offered a connect-time override of a declared clientId (feat(spec): an oauth2 authorization_code connector instance can take its OAuth client from the installer — clientSource: 'installer' #28920 refuses clientId beside clientSource), so refusing a client on a declared-client instance makes the spec's own refusal real at the door instead of silently connecting as the packaged client. Holds.
  • Installer instance end to end, read in the tree: begin takes the client after connectionDoorSubject (servability no longer lists clientId missing for the installer form), seals it with the verifier, and builds the authorization URL with installerClient.clientId; on MCP over http mcpDiscoveryServerUrl treats the installer client as supplied, so discoverMcpAuthorization runs with declared.clientId and returns registered: false (it registers nothing; mcp-authorization.ts:336-341), and the reuse-the-kept-client branch is skipped so a reconnect may bring a new client; the callback exchanges with installerClient.clientSecret in Basic (or the id alone in the form for a public client), refuses a declaration that flipped kind while away, and stores serializeInstallerClient(client, discovered) into registered_client plus the plain id and flag; refresh reads it back through parseKeptConnectionClient, which keeps the secret for registered: false (parseMcpAuthorizationClient:373-377) and uses a kept client only when its kind matches config.clientSource; revoke is the existing row delete. The dogfood and the three unit describes pin each leg. Holds.
  • Non-installer instances byte-unchanged: endpointOf still returns the declared endpoint and client first; for a dynamic MCP client kept.installer is false and config.clientSource is undefined, so the match test passes and tokenUrl is always present on a parsed MCP client, so the two new undefined returns cannot fire there; mcpAuthorizationClient reads existing exactly as before when no installer client is offered; oauth2NotServableReason lists clientId for every instance without clientSource. The service CONTROL pins the packaged instance connecting as before. Holds.
  • Should-fix, this PR or a named follow-up (no gate pins it): packages/runtime/src/route-ledger.ts:587 still says "No body." for POST /automation/connectors/:name/connection; the row should name the closed body set clientId / clientSecret, the three 400 refusals and that no body or {} begins with no client, since the ledger is the route's record of record.
  • Not a must-fix anywhere. The declared verification (red-first, six ablations, three package suites, 109 gate families, full dogfood) is the dev's account; what this record rests on is the tree and the head's check-runs: every check success or path-skipped, none failed.

Implemented-by: claude/project-thread-yrazza
Reviewed-by: session_01ADHkmQxwoGb5fSnrFkpxmt

VERDICT: PASS

… leg's closed body set and its refusals

Claude-Session: https://claude.ai/code/session_01ADHkmSB1MQ9Npyv1kGL3DV
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

Contract-review-claim: session_01AavokzJ5DndAwitDXvKy4U · head 2ca22f1 · 2026-11-02T11:59Z

Taken under (b): the head has waited 88 min since the lane's PASS at 59630d94 (6574689504), with no lane claim or hold. A delta re-review: one docs commit to packages/runtime/src/route-ledger.ts.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 2ca22f19045a28236809170971ef6968ad5f1a23
Local-runs: none
Scope: packages/runtime/src/route-ledger.ts (the delta) + packages/runtime/src/domains/automation.ts (connectorConnectionBeginClientOf, CONNECTOR_CONNECTION_BEGIN_BODY_KEYS), packages/services/service-automation/src/plugin.ts (installerClientOfBegin, the connection read's client), packages/services/service-automation/src/connector-connections.ts (installerClient.hasSecret)

Inputs read: the lane's PASS record 6574689504 at 59630d94703f3410d6a9f468ec7cbc295db18ba9, the delta git diff 59630d94 2ca22f19 (one commit, route-ledger.ts, 2 lines changed), the head's check-runs, and the cited code by git show prr-29097:. A delta review on the same PR. Taken by triage under (b): the head waited 88 min with no lane claim or hold. Every judgment of 6574689504 not restated below stands as recorded, and this record adopts it for 2ca22f19.

① Derived judgments

  • The delta is prose on two ledger rows, and it is the lane record's own should-fix (line 47 of 6574689504). git diff 59630d94 2ca22f19 --stat: packages/runtime/src/route-ledger.ts, 2 insertions, 2 deletions. Only the note strings of GET and POST /automation/connectors/:name/connection move. route, domain and disposition are unchanged, so no ledger gate's key set moves.
  • GET row, read against the code: right. "For an instance declaring clientSource: 'installer', client: { source, clientId, hasSecret }" is what plugin.ts ≈L4005 serves ({ client: { source: 'installer', clientId: record.installerClientId, hasSecret: record.installerClientHasSecret } }), and hasSecret is stored from installerClient.clientSecret !== undefined (≈L4264, connector-connections.ts ≈L289). "⛔ Never a token or a client secret": the read projects no secret field. Right.
  • POST row, read against the code: right.
    • "Closed body set { clientId, clientSecret? }; no body, or {}, begins with no client" is CONNECTOR_CONNECTION_BEGIN_BODY_KEYS plus the early returns of connectorConnectionBeginClientOf (undefined, null, '' and zero keys all yield {}).
    • The three 400 groups match:
      • an unknown key, a non-object body, or clientSecret without clientId (door, automation.ts ≈L961–976);
      • an installer instance with no client, where the message names the vendor registration and the redirect URI (installerClientOfBegin);
      • a client offered to an instance that names its own or leaves it to MCP dynamic registration (!takesInstallerClient(auth) arm).
    • "Naming keys only and never repeating a value": every refusal string interpolates key names, the instance name or the redirect URI, never a sent value.
    • "Kept only encrypted, never answered back": consistent with the lane record's ① on sys_connector_connection.
  • Gate verdicts on this head: 59 success, 10 skipped, 0 pending, 0 failures (commits/2ca22f19…/check-runs, total_count: 69). The PR reads mergeable: true, mergeable_state: clean, merge base fea2dbb7.

② Semver level

Unchanged by the delta. A ledger note is no published surface, and the changeset and PR-body declarations are as the lane record judged them. Right.

③ Boundary flags

④ Design judgment

The ledger is the route's record of record. Before this commit the POST row said "No body", which became false once the door took an installer client. The rewrite names the closed set and the refusals by class, and it does not restate values.

Tried to break it:

  • A clientSecret with no clientId: the door refuses before the service. The row lists it. Right.
  • {} on a declared-client instance: the begin goes ahead with no client. The row says so, and the Console's no-body connect (lane record line 33) is unchanged. Right.
  • A value-shape refusal on clientId: empty, or longer than INSTALLER_CLIENT_ID_MAX_LENGTH, in installerClientOfBegin. The row does not list it. That is a nit, not a contradiction, because the row's refusal list is by class and says "naming keys only".
  • Security: nothing is security-relevant. The delta changes prose only and no door.

Must-fix: none.

Should-fix: none new. Nit, optional: add "a clientId that is empty or over the length cap" to the POST row's refusal list the next time the row is touched.

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-steve · session_01ADHkmQxwoGb5fSnrFkpxmt

席位核对

ACCEPT · domain:server seat 2 · os-steve · session_01ADHkmQxwoGb5fSnrFkpxmt · 2026-11-02T15:06Z · head 2ca22f19 · claimed by seat 2 on #28866 (claim 6572348459, amendment 6574507537)

Two maintainer rulings apply:

  • 6566728470 (letter B), the installer-supplied OAuth client.
  • 6580268872, batch Fix Zod v4 async function type inference in schema definitions #458 item 4, letter A, 「同意」 2026-11-02T14:54Z. The public key names land as filed: client { clientId, clientSecret } on connect and client { source, clientId, hasSecret } on the state. The secret is never read back, and there is zero rework.
  1. Shape:
  2. Contract review: PASS (6574689504) plus triage's delta PASS (6576596413) at this head.
  3. Acceptance:
    • An installer instance connects as the administrator's client, end to end over the real HTTP door: authorize, code exchange, refresh, revoke. This includes the dispatcher mount fix that defers the begin leg's body.
    • The three refusals answer 400 and name keys only.
    • The secret is absent from every answer, log and engine read.
    • Packaged and dynamic-registration instances are unchanged (CONTROL).
  4. Evidence: CI on this head shows 60 success, 14 skipped, 0 failed, all completed. mergeable_state: clean.
  5. After landing:
  6. Landing: pr_ready + automerge_enable through the relay.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 2, 2026 15:07
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 2, 2026
Merged via the queue into main with commit ec0316e Nov 2, 2026
78 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-yrazza branch November 2, 2026 15:29
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-steve · session_01ADHkmQxwoGb5fSnrFkpxmt

Landing record · PR #29097

domain:server seat 2 · session_01ADHkmQxwoGb5fSnrFkpxmt · 2026-11-02T15:30Z · card #28866

Reading Probe Result
Merged PR #29097 state merged, merge commit ec0316ea44
On main git merge-base --is-ancestor ec0316ea44 origin/main yes
Queue refs git ls-remote origin refs/heads/gh-readonly-queue/* matching 29097 0
Content git grep -l installerClientOfBegin origin/main -- packages/services/service-automation/src present

Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

automation: a sys_connector_connection stores and uses the installer-supplied OAuth client for an authorization_code instance (#28702 B, server slice)

1 participant