Skip to content

fix(cloud-connection)!: bind/start, bind/poll and unbind require setup.access; GET /status serves the connection details only to a holder - #24167

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-24148-cloud-connection-setup-gate
Oct 17, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-24148-cloud-connection-setup-gate

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #24148

Clause-②: no (narrowing)

The Console's Cloud Connection panel sits on the Setup app, which declares requiredPermissions: ['setup.access'] (packages/platform-objects/src/apps/setup.app.ts:47). The routes behind that panel now apply the same permission, so the API refuses exactly who the page refuses. Bound by triage (6229951531); the one choice it left, setup.access over an explicit environment-admin check, is the seat's ruling on the claim (6231718544). This is a permission boundary, so it waits for the security / contract review the seat dispatches.

Per the maintainer's disclosure ruling (2026-08-18, recorded on #9387), this PR states that the hole existed and names the pins; it carries no reproduction.

What changed (packages/cloud-connection/src/cloud-connection-plugin.ts)

  • POST /api/v1/cloud-connection/bind/start, /bind/poll and /unbind refuse a signed-in caller who does not hold setup.access: 403, code PERMISSION_DENIED, a message naming the capability, in the declared ADR-0112 envelope. The check sits right after the existing 401 and ADR-0069 held-session 403, and before everything else: the 503 CLOUD_UNCONFIGURED answer, the device_code validation, every control-plane call, and the stored-credential read and clear. The unparseable-body 400 still comes first, as before.
  • GET /api/v1/cloud-connection/status still answers every caller 200 (it stays session-less on purpose, so a probe can tell the plugin is mounted). It narrows what it SERVES: bound, runtimeId and provider only, unless the caller holds setup.access. A holder is also served environmentId and connection, so the panel's "Approved by" and "Organization" rows keep working for an administrator. It never refuses. No session, a session the ADR-0069 policy holds, and a session without the capability all get the narrowed answer.
  • Why PERMISSION_DENIED, measured. It is the standard catalog's 403 member (HttpStatusErrorCodeMap[403]). The ledger's admission rule sends a generic permission condition there rather than to a registered synonym, so nothing is minted and packages/spec is untouched. FORBIDDEN (what the install-local doors next door answer) is a [finding] @objectstack/rest registers four generic synonyms the standard catalog already covers (CONFLICT, NOT_FOUND, FORBIDDEN, INTERNAL) — contract call, not a cleanup #8211-waived pre-gate synonym, kept on the wire and never endorsed for new code.

How the permission is read (mechanism assumption 3)

holdsSetupAccess calls resolveAuthzContext (@objectstack/core, guarded by check:authz-resolver) and reads systemPermissions. This is the shape copied from requireInstallCapability / resolveInstallPrincipal in this package's marketplace-install-local-plugin.ts. packages/services/service-datasource/src/admin-routes.ts gates on the same resolver and field. It passes the same inputs those seams pass: the posture via classifyAdmissionTenancyPosture (async registry, so a broken tenancy service is loud), userAuthGateSourceOf and apiKeyLifecyclePolicySourceOf. No permission read is re-spelled.

  • Identity does not move. resolveSession is still the one session read for all seven session doors. The resolver is handed back that session read (no second read) plus the request headers, and its answer counts only when it resolved the SAME principal through the session credential. An API key sent beside a session resolves first inside the resolver, and its owner's grants never admit that session. So the change is a pure narrowing: no credential these routes refused becomes one they accept.
  • Grants come from where the session came from. Multi-tenant mode reads the environment's own kernel (objectql, tenancy); single-environment mode reads the host's. With no data engine there are no grants, so it fails closed.
  • An outage is not a denial. AuthzStoreUnavailableError is re-raised on the three doors, so the host's thrown-envelope render answers 503 SERVICE_UNAVAILABLE instead of a 403 that reads like a real refusal. On status the details are withheld, the answer is still 200, and the outage is logged at warn.

Also changed (packages/core, test only)

Mechanism assumptions, measured on origin/main 52c04b360

  1. Holds. resolveSession returned only { userId, authGate? }, and the three write doors checked only that it resolved. Its seven call sites map to these routes: bind/start, bind/poll, unbind, install, installation, installed, org-packages. status called none.
  2. Holds. status answered connection: data.connection ?? null, the control plane's record verbatim, to every caller.
  3. Holds. The check is resolveAuthzContext(...).systemPermissions, copied from marketplace-install-local-plugin.ts as described above.

Pins

New file: packages/cloud-connection/src/cloud-connection-setup-access.pin.test.ts. It has 26 cases across both resolution modes, driven through the routes CloudConnectionPlugin mounts (start() + kernel:ready). Grants are real permission-set rows served through the engine's find, honouring its where, with set bodies from the bound security catalog. Refusals assert status + code + the declared envelope (BaseResponseSchema, envelopeViolations, ApiErrorSchema), never a bare throw.

  • a signed-in caller without setup.access gets 403 PERMISSION_DENIED, with one case per door (bind/start, bind/poll, unbind); no control-plane call, credential untouched;
  • CONTROL: a holder is served by all three doors as before. That includes a grant scoped to the session's active organization (the organization_admin shape);
  • a session without it is refused even when the request also carries a key whose owner holds it;
  • an unreadable permission store raises the outage, not a 403;
  • status, anonymous: exactly bound / runtimeId / provider, no connection;
  • status gives the same narrowed answer to a caller signed in without the permission, to a held session, to a session with a key beside it, and during an outage;
  • CONTROL: status for a holder carries connection and environmentId.

Ablations (each refusal once; both legs recorded)

Each mutation went through scripts/ablation-replace.mjs. The anchor had to hit exactly once, the landing was verified on disk, and the restore was proven as blob == HEAD with an empty git diff HEAD. The suite imports the plugin from src/ by relative path, so the mutation reaches the run without a build. Tree a6745b5ae and again at the final head 7f45419be; plugin blob 44de963e5afa both times.

Ablation Mutation leg (pin suite) Restore leg
bind/start's check short-circuited red: 6 failed / 20 passed. Its door pin in both modes, plus the key-beside and outage cases (which drive bind/start) blob == HEAD
bind/poll's check short-circuited red: 2 failed. Its door pin, both modes blob == HEAD
unbind's check short-circuited red: 2 failed. Its door pin, both modes blob == HEAD
status serves the full answer unconditionally red: 10 failed. Every narrowed-status case, both modes blob == HEAD
same-principal / session-credential condition removed red: 4 failed. Both key-beside cases, both modes blob == HEAD

After all five: git status clean, and the pin suite on HEAD shows 26 passed.

Existing suites adjusted (subjects unchanged)

  • cloud-connection-auth-gate.pin.test.ts (ADR-0069): its users now hold setup.access, so a refusal there stays the gate's alone. The census case used to assert that /status reads no session. It now asserts that /status answers a held session 200 with the narrowed shape, which is the new contract.
  • cloud-connection-plugin.test.ts: anonymous status expectations are now the narrowed shape. The two cases whose subject is the resolved environmentId now sign in as a holder.
  • error-envelope.conformance.test.ts: the harness grants setup.access by default, and there are three new cases for the 403 PERMISSION_DENIED envelope on the three doors.
  • connection-credential-store.test.ts: its admin holds setup.access.

Docs

content/docs/deployment/cloud-connection.mdx:

  • #before-you-start: the "known defect" sentence is replaced by the rule. Connect and disconnect take Setup Access, and a caller without it is refused 403 (PERMISSION_DENIED) before the server calls the cloud.
  • #reference: the status row now says what every caller gets and what a Setup Access holder also gets.

Changeset

.changeset/24148-cloud-connection-setup-access.md contains:

Gate readings: check-adr-0087-registration printed "1 declared-breaking changeset(s), each carrying an ADR-0087 disposition … [BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription)", exit 0. check-changeset-no-major printed "This diff introduces no major bump.", exit 0.

Verification (final tree)

All readings below are from the final head, 7f45419be (git rev-parse --short HEAD), unless a row says otherwise.

  • Build of the dependency closure: pnpm --filter '@objectstack/cloud-connection^...' build, then pnpm --filter @objectstack/cloud-connection build. Run after merging origin/main (eda6f14b7), lock verdict command-exit 0.
  • Typecheck: pnpm --filter @objectstack/cloud-connection typecheck (both programs) exits 0. tsconfig.test.json --listFiles reaches all 48 *.test.ts files, the new pin among them.
  • Tests: pnpm --filter @objectstack/cloud-connection test gives "Test Files 48 passed (48) · Tests 595 passed (595)".
  • Lint: pnpm exec eslint --no-inline-config over the 6 changed .ts files exits 0 with no output. The .md / .mdx files are "ignored because no matching configuration", exit 0.
  • Derived gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, no paths. It derived 93 commands over the 8 changed paths, and all 93 ran on 7f45419be, each exit captured before any pipe; all exited 0. Reconciliation --ran printed "93 derived famil(ies) accounted for — 93 run, 0 NOT-MEASURED (a DERIVED zero …)", exit 0.
  • Roster gates outside the derivation: I also ran the four roster gates whose roster sits under a path of mine, all exit 0. check:authz-resolver printed "single shared authorization resolver intact; both entry points delegate". check:error-code-casing printed OK. check:filter-alias-parity printed OK. check-changeset-fixed printed "in sync with 73 public workspace packages".
  • Selected verdict lines:
    • check:slot-lookup: "ratchet holds … none new".
    • check:route-envelope: exit 0.
    • check:objectql-double-limit: "conformance holds … none new". An earlier pass of the new pin's find double was refused here for ignoring limit; fixed in 7f45419be.
    • check:where-matcher: "conformance holds".
    • check:nul-bytes: OK.
    • check-adr-0087-registration and check-changeset-no-major: as quoted above.
  • Ablations, re-run on 7f45419be: the five ablations above were run again on the final head, with the plugin blob unchanged (44de963e5afa), and gave the same red legs and the same blob == HEAD restores. One of those reruns was malformed: a shell split mangled the principal-coherence anchor, so the suite failed to load ("no tests") instead of measuring. That reading is void, and the redo with the intact anchor reddened the same 4 cases.
  • Round 3, e9fe7fe9b (the ledger row): Test Core (3/6) on 47e78745d was red in core's test:repo, at the [[finding] a permission-store read failure resolves as an AUTHENTICATED caller holding ZERO capabilities — the package door answers 403 FORBIDDEN, byte-identical to a genuine capability denial #13279] ⭐ SET EQUALITY test only: 9 transports found in source against 8 in the ledger. Reproduced locally before the fix (1 failed, 82 passed), and green after (pnpm --filter @objectstack/core run test:repo: 6 files, 84 passed). None of core's five other repo tests names this plugin. The other packages' test:repo projects (types, objectql, rest, runtime, spec) and cloud-connection's suite (48 files, 595 passed) are green on e9fe7fe9b. The changeset gate demands no core entry, because the test file is not core's published output.
  • Not run locally, left to CI: in rounds 1 and 2, the test:repo vitest project (run in round 3, above); the CI type-check lanes (workspace, ./examples/*, downstream-contract), and the path-scheduled CI jobs (Build Core, Dogfood, Build Docs). The workspace consumers of the plugin's type face (packages/cli, examples/app-showcase) were not type-checked locally.

Acceptance notes

  • /install hold-off, reported, not changed: its handler still says "TODO: tighten to an explicit env-admin role check" and gates on a session alone. Its posture was ruled on cloud-connection: the marketplace-install route supplies no tenancyPosture to resolveAuthzContext — an ex-member's org-stamped API key is admitted #15353; that route, installation, installed and org-packages are untouched here.
  • Mount log line: the kernel:ready info line lists {status,bind/start,bind/poll,install,installation,installed,org-packages} and omits unbind. This is cosmetic text in a log; noted, not filed.
  • Docs, beyond the two named spots: the #reference table's "Sign-in" column still says "required" for the three doors. That is true but does not name Setup Access. The #connect failure table has no row for the new 403 message, which a panel user can only meet if their grant is revoked mid-session. Left as they are, to keep inside the claimed surface.
  • Public type face: the package-local structural PluginContext gained an optional getKernel?(), read only for the async tenancy lookup. This widens what start(ctx) accepts. The workspace consumers (packages/cli serve.ts, examples/app-showcase) were not type-checked locally; CI's TypeScript Type Check lanes cover them.

Generated by Claude Code

…p.access; status serves connection details only to a holder

WIP: implementation; pins follow.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVm1pAXVXQ3PjC42j1kPFf
…e outage; changeset and docs

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVm1pAXVXQ3PjC42j1kPFf
…mit; the outage double reads nothing

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

github-actions Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cloud-connection, touching 13 documentable anchor(s).

23 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 7cda377c17626047ea4a76dbfd14a6671f089fd7.

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

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: PERMISSION_DENIED (literal, 39 pages)
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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 — 4 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 7cda377c17626047ea4a76dbfd14a6671f089fd7 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from fbaaddccb41a4f293b9108365ab5648f02976ba0 — the merge of head e9fe7fe9bc75ea20cc85345c161d85ad79d9f5d0 into base 7cda377c17626047ea4a76dbfd14a6671f089fd7, 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 fbaaddccb41a4f293b9108365ab5648f02976ba0 && git checkout fbaaddccb41a4f293b9108365ab5648f02976ba0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7cda377c17626047ea4a76dbfd14a6671f089fd7 e9fe7fe9bc75ea20cc85345c161d85ad79d9f5d0 && git checkout -B drift-repro 7cda377c17626047ea4a76dbfd14a6671f089fd7 && git merge --no-ff e9fe7fe9bc75ea20cc85345c161d85ad79d9f5d0

node scripts/docs-audit/affected-docs.mjs --json 7cda377c17626047ea4a76dbfd14a6671f089fd7

⚠️ 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 7cda377c17626047ea4a76dbfd14a6671f089fd7 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 7f45419be78a588d323be17741e2823fc94224a9
Local-runs: none
Scope: packages/cloud-connection/src/cloud-connection-plugin.ts, packages/cloud-connection/src/cloud-connection-setup-access.pin.test.ts, packages/cloud-connection/src/cloud-connection-auth-gate.pin.test.ts, packages/cloud-connection/src/cloud-connection-plugin.test.ts, packages/cloud-connection/src/connection-credential-store.test.ts, packages/cloud-connection/src/error-envelope.conformance.test.ts, .changeset/24148-cloud-connection-setup-access.md, content/docs/deployment/cloud-connection.mdx + packages/core/src/security/resolve-authz-context.ts, packages/core/src/security/admission-tenancy-posture.ts, packages/core/src/security/authz-store-unavailable.ts, packages/core/src/security/auth-gate.ts, packages/core/src/security/api-key.ts, packages/core/src/service-not-registered.ts, packages/core/src/plugin-loader.ts, packages/core/src/kernel.ts, packages/core/src/types.ts, packages/types/src/in-process-session-read.ts, packages/types/src/thrown-http-error.ts, packages/plugins/plugin-hono-server/src/adapter.ts, packages/cloud-connection/src/marketplace-install-local-plugin.ts, packages/cloud-connection/src/tests/security-catalog.testkit.ts, packages/services/service-datasource/src/admin-routes.ts, packages/platform-objects/src/apps/setup.app.ts, packages/spec/src/api/errors.zod.ts, packages/spec/src/api/error-code-ledger.zod.ts, .changeset/pre/23190-auth-gate-cli-sites.md, scripts/check-changeset-no-major.mjs, .github/workflows/pr-automation.yml

Inputs: card #24148 (body and its three comments: triage 6229951531, claim 6231718544, dev report 6232582208); PR #24167 (body, its 8-path file list, and the net diff against main at the head — the merge base eda6f14b7 is origin/main's tip, so the three-dot diff is the PR's own); the 39 check-runs on the head. Read-only: nothing built, run or re-run. This record is also the security review triage requires before landing (6229951531); the permission boundary is judged inside ① and ③. Disclosure (the maintainer's ruling recorded on #9387): findings below are stated as behaviour and code locations, with no reproduction.

① Derived judgments

Line numbers are the plugin at the head. The boundary as built: holdsSetupAccess (:410-443), refuseWithoutSetupAccess (:445), servedStatus (:466-480), the status handler (:485-531), and the three door checks at :558 (bind/start), :619 (bind/poll) and :720 (unbind).

  1. Accept-set of bind/start, bind/poll, unbind narrows — right. A signed-in, un-held session that does not hold setup.access is answered 403 PERMISSION_DENIED naming the capability. The order at each door is unchanged ahead of the new check and the check sits before every effect: unparseable JSON body 400 (bind/start, bind/poll) → environment 404 → ADR-0069 held-session 403 → anonymous 401 → setup.access 403 → CLOUD_UNCONFIGURED 503 → body validation → every control-plane fetch → every ConnectionCredentialStore read, write or clear. Nothing these doors refused on main becomes accepted; a holder's path is byte-identical to main's. Because the refusal precedes the cloudUrl test, a caller without the capability is not told whether a control plane is configured.

  2. Every credential shape the doors accept, judged — right. (a) Anonymous: resolveSession is null → 401, as before. (b) An API key alone (x-api-key, or Authorization: Bearer with the osk_ prefix — api-key.ts:82-97): no session resolves → 401, as before; these doors admit no key and never did. (c) A key beside a session: the shared resolver's step 1 admits, holds or refuses the key before its session arm runs (resolve-authz-context.ts, "API key takes precedence over session"), so authz.credential is not session — or no principal resolves — and holdsSetupAccess answers false → 403. A refused or lifecycle-refused key beside a holder's session is therefore refused where main served the session: a narrowing beyond the card's words, and the resolver's own rule (a refused key never falls through to the session). Observation, not a defect: a lifecycle-refused key here answers PERMISSION_DENIED naming setup.access rather than the key's located 403 that the install-local doors re-raise, because the catch at :439-442 returns false for every non-outage throw; fail-closed, and the Console panel sends no key. (d) A held session: the ADR-0069 403 at :554/:616/:717 comes first, unchanged. (e) The same-principal guard authz.credential !== 'session' || authz.userId !== caller.userId → false fails closed on any mismatch.

  3. The shared permission read is reused, not re-spelled — right. holdsSetupAccess reads resolveAuthzContext(...).systemPermissions and nothing else, the shape of resolveInstallPrincipal / requireInstallCapability (marketplace-install-local-plugin.ts:3365-3505) and of admin-routes.ts:504-535, with the same inputs (classifyAdmissionTenancyPosture, userAuthGateSourceOf, apiKeyLifecyclePolicySourceOf). The aggregation is the resolver's (resolveUserAuthzGrants: sys_member → sys_user_position → permission sets by catalog name; grantAppliesInTenant at resolve-authz-context.ts:855 admits a global row or one scoped to the session's active organization — the organization-administrator shape the pins control for). No sys_* read is spelled in the plugin (its only sys_ strings are comments and control-plane object names); check:authz-resolver sits in the green Lint & Repo Gates run. The resolver is handed the session this surface already read (its getSession input returns that same sessionData; no second read), so identity does not move: resolveSession stays the one session read for all seven session doors.

  4. Grants come from where the session came from, and no engine fails closed — right. Multi-tenant: the environment kernel the session was read from (readFrom.kernel) answers objectql and tenancy; single-environment: the host's objectql through ctx.getService, the host kernel's tenancy through ctx.getKernel?.(). With no engine the resolver returns a principal with no grants → false. A tenancy or objectql that was never registered is the quiet path (the loader throws the branded serviceNotRegisteredError, plugin-loader.ts:241; classifyAdmissionTenancyPosture maps that brand to undefined), so a lean composition is not turned into an outage.

  5. The outage path fails closed on the doors — right. AuthzStoreUnavailableError — from the resolver's tryFind on a read failure that is not a missing table, or from classifyAdmissionTenancyPosture on a tenancy that broke — is re-raised by holdsSetupAccess (:440) and escapes the three handlers. The hono adapter's declaredEnvelopeForThrow (adapter.ts:334, with resolveThrownHttpError, thrown-http-error.ts:206) renders a throw that declares its status and code; this error declares 503 / SERVICE_UNAVAILABLE (authz-store-unavailable.ts:90-96,151). So an unreadable store answers 503, never a 403 that reads as a denial. Pinned ("an outage, not a 403", both modes, no control-plane call).

  6. status: accept-set unchanged, served-set narrowed — right. It still answers every caller 200 and issues no 401/403. bound, runtimeId, provider go to everyone; environmentId and the control plane's connection record go only when servedStatus finds a session that is not held, resolved through the session credential as the same user, and holding setup.access. Anonymous, signed-in-without, held, key-beside and outage callers all get the narrowed shape, pinned as an exact key set. connection no longer passes through verbatim: the control-plane answer is built inside the fetch's try and served outside it (:503-525), so a fault of servedStatus is not swallowed by the fetch's fall-through. Derived consequence, judged right: status now reads the session for every caller (and, multi-tenant, calls kernel-manager.getOrCreate for the hostname-resolved environment) where it read none before — the card's done-condition (details to a holder) cannot be met otherwise, and the claim's hold-off is honoured in what it meant: status refuses nobody and answers a held session narrowed; the auth-gate census pin was rewritten to this contract. Note, pre-existing and unchanged: the runtime's own status fetch to the control plane (with the runtime's credential, never the caller's) still precedes the permission read; the check decides what is served, not whether the runtime asks.

  7. Error code — right. PERMISSION_DENIED is HttpStatusErrorCodeMap[403] (errors.zod.ts:186); the ledger sends a generic permission condition to the standard catalog rather than a registered synonym (error-code-ledger.zod.ts, "Registering a new code"), and FORBIDDEN is the [finding] @objectstack/rest registers four generic synonyms the standard catalog already covers (CONFLICT, NOT_FOUND, FORBIDDEN, INTERNAL) — contract call, not a cleanup #8211-waived synonym that shadows it (:1761). Nothing is registered, packages/spec is untouched, and the message names the capability and no secret. The conformance suite pins the envelope on all three doors.

  8. Public type face — right, named. The package-local, non-exported PluginContext gained an optional getKernel?(), so the exported CloudConnectionPlugin.start(ctx) accepts a superset of host contexts; every host that satisfied the old shape satisfies the new one, and core's real PluginContext.getKernel(): ObjectKernel (types.ts:92, kernel.ts:861) is structurally assignable to the optional slot. No wire key, export, option or config field moves. This is not a Clause-② widening (that clause is a new key on a published payload, in the gate's own words) and does not change the no (narrowing) declaration. The consumer type-check lane is pending (③).

  9. Pins — right. 26 new cases, both resolution modes, through the routes start() + kernel:ready really mount. Grants are real permission-set rows read through find, with a find double that honours where and limit, so a grant row cannot be read as everyone's. Each refusal asserts status, code, the declared envelope, zero control-plane calls and an untouched credential; controls cover the platform-administrator and organization-scoped shapes and the holder's full status. The four existing suites change only in granting their default users setup.access; their subjects are unchanged. The five ablations are reported, not re-run here; the CI test lanes are pending (②).

  10. Docs and hold-offs — right. The #before-you-start sentence and the #reference status row state what the code does; "Sign-in: not required" on the status row stays true. No hunk touches install, installation, installed, org-packages, the install-local doors, objectui or packages/spec. The mount log line's omission of unbind is on main already (:726 there) — not this PR's.

② Semver level

The changeset .changeset/24148-cloud-connection-setup-access.md grades '@objectstack/cloud-connection': patch, under fix(cloud-connection)!:, a BREAKING banner, the no (narrowing) declaration, and one ADR-0087 marker, not-required (no-migration-prescription).

  • The PR's Clause-②: no (narrowing) line — right. The act is an accept-set narrowing at three doors and a served-set narrowing at one; nothing refused becomes accepted (①.8 on the type face). The PR body carries the same line, line-initial.
  • The ADR-0087 disposition — right. A runtime authorization narrowing; no spec key, export, config field or stored shape moves. The Check Changeset job's check-adr-0087-registration step passed on this head ("1 declared-breaking changeset(s), each carrying an ADR-0087 disposition … [BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription)").
  • The level — wrong. A (narrowing) declaration is BREAKING (AGENTS.md, Post-Task Checklist 3), and during the launch window a breaking change ships at least minor (scripts/check-changeset-no-major.mjs, "The DIRECTION ARM" and judgeLevel). The Check Changeset check-run on this head concluded failure on exactly this: its level axis returned enforce — "Moved and graded patch: @objectstack/cloud-connection: patch (.changeset/24148-cloud-connection-setup-access.md). None of them is graded minor or above. · declaration line: Clause-②: no (narrowing) · direction arm: narrowing — a BREAKING change; during the launch window it ships minor" — exit 1. The changeset's own sentence "shipped as patch: no public surface widens" states the wrong rule: the grade a breaking change owes is not conditioned on widening. The dev's local reading ("no major bump, exit 0") was the major guard alone — with no pull_request payload the level axis stands down (no-pull-request), so this could not be measured locally. The cited precedent (PR fix(rest, core)!: the anonymous public-form submit enforces the form's own required; the emptiness rule moves to @objectstack/core #24161's rest changeset) is not on main at the merge base and is not an input to this record; every Clause-②: no (narrowing) changeset on main grades a moved package minor or higher, this package's own precedent included (.changeset/pre/23190-auth-gate-cli-sites.md, '@objectstack/cloud-connection': minor).
  • Remedy, one word plus one sentence: '@objectstack/cloud-connection': minor, and the banner reworded to the convention's sentence ("shipped as minor under the launch-window convention for breaking changes"). Nothing else in the changeset moves. skip-changeset does not apply: a released package publishes.

Gate verdicts on the head, as read in this act (39 check-runs): 25 success, 1 failure (Check Changeset), 8 in_progress, 5 skipped. Pending and not waited on: Build Docs; Test Core 1/6 through 6/6; Type Check · consumer gates. Skipped: Console Pin Gate, the Dogfood Regression Gate matrix shard, Dogfood Verify CLI, Packed-tarball smoke (opt-in), Temporal Conformance. Green and material here: Lint & Repo Gates and every Lint · lane, Type Check · workspace / · source gates / · debt ledger, Build Core, Spec property liveness, Governed Surface Queue Guard (no governed path in the file list), the single-claim and card-claims-branch checks.

③ Boundary flags

  1. Dev's open question — GET /status during a permission-store outage, signed-in caller: A (withhold, 200, warn) or B (503). Answered: A, as built; no change asked. The outage ruling's harm is an outage reported as a capability denial; status reports no denial — it issues no 401/403 — so withholding misreports nothing. It serves strictly less than it would with the store readable, never more, so no accept-set or served-set widens. The probe semantics (does the plugin answer, is the runtime bound) are the hold-off the claim named, and B would break them in exactly the window an operator is diagnosing. The three doors, where a verdict is required, re-raise and answer 503 — that is where the ruling bites, and it is honoured (①.5). The outage is logged at warn (servedStatus), so it is not silent. Both directions are pinned. Closed here, not escalated.

  2. Dev's out-of-scope note — /install still gates on a session alone with its TODO: tighten. The hold-off held: no hunk touches install, installation, installed or org-packages. Its posture was ruled on cloud-connection: the marketplace-install route supplies no tenancyPosture to resolveAuthzContext — an ex-member's org-stamped API key is admitted #15353 and the carrier is the maintainer; this record escalates nothing and files nothing — it stays as the dev reported it on the card.

  3. Dev's deviations. test:repo not run locally → the Test Core lanes are pending; a gate verdict still to come, not a finding against the diff. Consumer type-check of the getKernel? optional member not run locally → Type Check · consumer gates is pending; · workspace and · source gates are green, and ①.8 judges the member as not moving the declaration. The twice-run battery, the shallow checkout and the attribution choice are procedural; nothing to answer. One prose slip in the PR body: the datasource precedent is spelled services/service-datasource/src/admin-routes.ts; the file is packages/services/service-datasource/src/admin-routes.ts, and the code claim about it (same resolver, same field, 403 PERMISSION_DENIED) holds there.

  4. Security review (triage 6229951531), the four questions. (i) Can a caller lacking setup.access still reach a write door's effect, or status's connection? Not by any credential shape read here (①.1, ①.2, ①.6): anonymous 401; key-only 401; held 403; signed-in-without 403; key-beside refused; status narrowed for every one of these. Grants resolve through the one resolver from the kernel the session came from, and no engine fails closed (①.3, ①.4). (ii) Does the outage path fail closed on the doors? Yes — re-raised, rendered 503 SERVICE_UNAVAILABLE by the host (①.5). (iii) Does the check run before any control-plane call or credential touch? On the three doors, yes, before all of them (①.1); on status the runtime's own status fetch precedes the read, as on main, with the runtime's credential and never the caller's, and the check decides only what is served (①.6). (iv) Is the shared permission read reused rather than re-spelled? Yes (①.3). The boundary holds as built. The FAIL below is on ② alone.

  5. Disclosure. This record carries no reproduction; existence, code locations and the pins only.

Implemented-by: claude/issue-24148-cloud-connection-setup-gate
Reviewed-by: session_01BVm1pAXVXQ3PjC42j1kPFf

VERDICT: FAIL — on ② alone: the changeset grades a BREAKING (narrowing) patch, and the head's Check Changeset gate refused it. ① and ③ hold as built. The next head needs the bump raised to minor (with the banner sentence), a green Check Changeset, the pending lanes green, and a fresh record naming that head.


Generated by Claude Code

…the launch-window convention

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVm1pAXVXQ3PjC42j1kPFf
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 47e78745d91fcf50c5e8f74ce8a04c1ef5aa68b2
Local-runs: none
Scope: packages/cloud-connection/src/cloud-connection-plugin.ts, packages/cloud-connection/src/cloud-connection-setup-access.pin.test.ts, packages/cloud-connection/src/cloud-connection-auth-gate.pin.test.ts, packages/cloud-connection/src/cloud-connection-plugin.test.ts, packages/cloud-connection/src/connection-credential-store.test.ts, packages/cloud-connection/src/error-envelope.conformance.test.ts, .changeset/24148-cloud-connection-setup-access.md, content/docs/deployment/cloud-connection.mdx + packages/core/src/security/resolve-authz-context.ts, packages/core/src/security/admission-tenancy-posture.ts, packages/core/src/security/authz-store-unavailable.ts, packages/core/src/security/auth-gate.ts, packages/core/src/security/api-key.ts, packages/core/src/service-not-registered.ts, packages/core/src/plugin-loader.ts, packages/core/src/kernel.ts, packages/core/src/types.ts, packages/types/src/in-process-session-read.ts, packages/types/src/thrown-http-error.ts, packages/plugins/plugin-hono-server/src/adapter.ts, packages/cloud-connection/src/marketplace-install-local-plugin.ts, packages/cloud-connection/src/tests/security-catalog.testkit.ts, packages/services/service-datasource/src/admin-routes.ts, packages/platform-objects/src/apps/setup.app.ts, packages/spec/src/api/errors.zod.ts, packages/spec/src/api/error-code-ledger.zod.ts, .changeset/pre/23190-auth-gate-cli-sites.md, scripts/check-changeset-no-major.mjs, .github/workflows/pr-automation.yml

Round 2, on the head that answers record 6232716299 (FAIL on ② alone). Inputs: card #24148 (body and its four comments: triage 6229951531, claim 6231718544, dev reports 6232582208 and 6232795868); PR #24167 (body, its 8-path file list, and the net diff against main at this head — the merge base eda6f14b7 is still origin/main's tip); the 40 check-runs on 47e78745d. Read-only: nothing built, run or re-run. This record is also the security review triage requires before landing (6229951531). Disclosure (the maintainer's ruling recorded on #9387): behaviour and code locations only, no reproduction.

What moved between the heads. 47e78745d is one commit on top of 7f45419be, touching .changeset/24148-cloud-connection-setup-access.md alone (+2/−2): the bump patch → minor, and the banner sentence reworded to the launch-window convention. git diff 7f45419be 47e78745d -- packages/ content/ is empty, so every code path, pin and docs page judged in the round-1 record is byte-identical here; ① and ③ are re-stated against this head on that basis, and ② is judged afresh.

① Derived judgments

Line numbers are the plugin at this head (unchanged from 7f45419be): holdsSetupAccess (:410-443), refuseWithoutSetupAccess (:445), servedStatus (:466-480), the status handler (:485-531), and the three door checks at :558 (bind/start), :619 (bind/poll) and :720 (unbind).

  1. Accept-set of bind/start, bind/poll, unbind narrows — right. A signed-in, un-held session without setup.access is answered 403 PERMISSION_DENIED naming the capability. The order ahead of the new check is unchanged and the check sits before every effect: unparseable JSON body 400 (bind/start, bind/poll) → environment 404 → ADR-0069 held-session 403 → anonymous 401 → setup.access 403 → CLOUD_UNCONFIGURED 503 → body validation → every control-plane fetch → every ConnectionCredentialStore read, write or clear. Nothing these doors refused on main becomes accepted; a holder's path is byte-identical to main's. The refusal precedes the cloudUrl test, so a caller without the capability is not told whether a control plane is configured.

  2. Every credential shape the doors accept — right. (a) Anonymous: resolveSession null → 401, as before. (b) An API key alone (x-api-key, or Authorization: Bearer with the osk_ prefix, api-key.ts:82-97): no session resolves → 401, as before; these doors admit no key and never did. (c) A key beside a session: the shared resolver's step 1 admits, holds or refuses the key before its session arm (resolve-authz-context.ts, "API key takes precedence over session"), so authz.credential is not session — or no principal resolves — and holdsSetupAccess answers false → 403. A refused or lifecycle-refused key beside a holder's session is therefore refused where main served the session: a narrowing beyond the card's words, and the resolver's own rule (a refused key never falls through to the session). Observation, not a defect: a lifecycle-refused key here answers PERMISSION_DENIED naming setup.access rather than the key's located 403 the install-local doors re-raise, because the catch at :439-442 returns false for every non-outage throw; fail-closed, and the Console panel sends no key. (d) A held session: the ADR-0069 403 at :554/:616/:717 comes first, unchanged. (e) The same-principal guard (credential must be session and userId must equal the admitted session's) fails closed on any mismatch.

  3. The shared permission read is reused, not re-spelled — right. holdsSetupAccess reads resolveAuthzContext(...).systemPermissions and nothing else — the shape of resolveInstallPrincipal / requireInstallCapability (marketplace-install-local-plugin.ts:3365-3505) and of admin-routes.ts:504-535, with the same inputs (classifyAdmissionTenancyPosture, userAuthGateSourceOf, apiKeyLifecyclePolicySourceOf). The aggregation is the resolver's (resolveUserAuthzGrants; grantAppliesInTenant at resolve-authz-context.ts:855 admits a global row or one scoped to the session's active organization — the organization-administrator shape the pins control for). No sys_* read is spelled in the plugin (its only sys_ strings are comments and control-plane object names); check:authz-resolver sits in the green Lint & Repo Gates run on this head. The resolver is handed the session this surface already read (its getSession input returns that same sessionData; no second read), so identity does not move: resolveSession stays the one session read for all seven session doors.

  4. Grants come from where the session came from, and no engine fails closed — right. Multi-tenant: the environment kernel the session was read from (readFrom.kernel) answers objectql and tenancy; single-environment: the host's objectql through ctx.getService, the host kernel's tenancy through ctx.getKernel?.(). No engine → a principal with no grants → false. A never-registered tenancy or objectql is the quiet path (branded serviceNotRegisteredError, plugin-loader.ts:241; mapped to undefined by classifyAdmissionTenancyPosture), so a lean composition is not turned into an outage.

  5. The outage path fails closed on the doors — right. AuthzStoreUnavailableError — from the resolver's tryFind on a read failure that is not a missing table, or from classifyAdmissionTenancyPosture on a broken tenancy — is re-raised by holdsSetupAccess (:440) and escapes the three handlers; the hono adapter's declaredEnvelopeForThrow (adapter.ts:334, via resolveThrownHttpError, thrown-http-error.ts:206) renders the declared 503 / SERVICE_UNAVAILABLE (authz-store-unavailable.ts:90-96,151). Never a 403 that reads as a denial. Pinned, both modes, no control-plane call.

  6. status: accept-set unchanged, served-set narrowed — right. Every caller is still answered 200; no 401/403 is issued. bound, runtimeId, provider go to everyone; environmentId and the control plane's connection record only when servedStatus finds a session that is not held, resolved through the session credential as the same user, holding setup.access. Anonymous, signed-in-without, held, key-beside and outage callers get the narrowed shape, pinned as an exact key set. connection no longer passes through verbatim; the control-plane answer is built inside the fetch's try and served outside it (:503-525), so a fault of servedStatus is not swallowed. Derived consequence, judged right: status now reads the session for every caller (multi-tenant, through kernel-manager.getOrCreate for the hostname-resolved environment) where it read none before — the card's done-condition cannot be met otherwise, and the claim's hold-off is honoured in what it meant: status refuses nobody and answers a held session narrowed; the auth-gate census pin now states this contract. Pre-existing and unchanged: the runtime's own status fetch to the control plane (the runtime's credential, never the caller's) precedes the permission read; the check decides what is served, not whether the runtime asks.

  7. Error code — right. PERMISSION_DENIED is HttpStatusErrorCodeMap[403] (errors.zod.ts:186); the ledger sends a generic permission condition to the standard catalog rather than a registered synonym (error-code-ledger.zod.ts, "Registering a new code"), and FORBIDDEN is the [finding] @objectstack/rest registers four generic synonyms the standard catalog already covers (CONFLICT, NOT_FOUND, FORBIDDEN, INTERNAL) — contract call, not a cleanup #8211-waived synonym that shadows it (:1761). Nothing registered, packages/spec untouched, the message names the capability and no secret; the conformance suite pins the envelope on all three doors.

  8. Public type face — right, named. The package-local, non-exported PluginContext gained an optional getKernel?(), so the exported CloudConnectionPlugin.start(ctx) accepts a superset of host contexts; every host that satisfied the old shape satisfies the new one, and core's PluginContext.getKernel(): ObjectKernel (types.ts:92, kernel.ts:861) is assignable to the optional slot. Not a Clause-② widening (a new key on a published payload, in the gate's words); the no (narrowing) declaration stands. Type Check · consumer gates is green on this head, which closes the dev's round-1 flag on it.

  9. Pins — right. 26 new cases, both resolution modes, through the routes start() + kernel:ready really mount; grants as real permission-set rows read through a find that honours where and limit; each refusal asserts status, code, the declared envelope, zero control-plane calls and an untouched credential; controls for the platform-administrator and organization-scoped shapes and the holder's full status. The four existing suites change only in granting their default users setup.access. Ablations are reported (round 1), not re-run; byte-identical code, so none is owed on this head.

  10. Docs and hold-offs — right. The #before-you-start sentence and the #reference status row state what the code does; "Sign-in: not required" on the status row stays true. No hunk touches install, installation, installed, org-packages, the install-local doors, objectui or packages/spec. The mount log line's omission of unbind is on main already — not this PR's.

② Semver level

The changeset .changeset/24148-cloud-connection-setup-access.md at this head grades '@objectstack/cloud-connection': minor, under fix(cloud-connection)!:, a BREAKING banner reading "shipped as minor under the launch-window convention for breaking changes", the line-initial no (narrowing) declaration, and one ADR-0087 marker, not-required (no-migration-prescription). The two changed lines are exactly the remedy record 6232716299 named; nothing else in the changeset moved.

  • The level — right. A (narrowing) declaration is BREAKING (AGENTS.md, Post-Task Checklist 3), and during the launch window a breaking change ships at least minor (scripts/check-changeset-no-major.mjs, "The DIRECTION ARM", judgeLevel). minor is also what every Clause-②: no (narrowing) changeset on main grades a moved package, this package's own precedent included (.changeset/pre/23190-auth-gate-cli-sites.md). The Check Changeset check-run on this head concluded success; its level axis printed "✓ LEVEL AXIS: this PR declares clause-② no (narrowing), and no package whose published source (src/**, a packed bin target, or a repo file its build copies in) it moves is graded patch." with "· direction arm: narrowing — a BREAKING change; during the launch window it ships minor", and "✓ This diff introduces no major bump." The ADR-0087 step passed on it too ("1 declared-breaking changeset(s), each carrying an ADR-0087 disposition … [BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription)"). Pre mode is on (.changeset/pre.json, tag next), so this ships in an 18.0.0-next.N.
  • The PR's Clause-②: no (narrowing) line — right, unchanged between the heads. The act is an accept-set narrowing at three doors and a served-set narrowing at one; nothing refused becomes accepted (①.8 on the type face). Body and changeset carry the same line, line-initial.
  • The ADR-0087 disposition — right, unchanged. A runtime authorization narrowing; no spec key, export, config field or stored shape moves, so objectstack migrate meta has nothing to rewrite.
  • skip-changeset does not apply: a released package publishes, and the label is absent.

Gate verdicts on this head, as read in this act (40 check-runs): 30 success, 0 failure, 5 in_progress, 5 skipped. Pending and not waited on: Test Core 1/6, 2/6, 3/6, 4/6 and 6/6 (5/6 is green). Skipped: Console Pin Gate, the Dogfood Regression Gate matrix shard, Dogfood Verify CLI, Packed-tarball smoke (opt-in), Temporal Conformance. Green and material here: Check Changeset, Lint & Repo Gates and every Lint · lane, Type Check · workspace / · consumer gates / · source gates / · debt ledger, TypeScript Type Check, Build Core, Build Docs, Spec property liveness, Governed Surface Queue Guard (no governed path in the file list), the single-claim and card-claims-branch checks.

③ Boundary flags

  1. Dev's round-1 open question — GET /status during a permission-store outage, signed-in caller: A (withhold, 200, warn) or B (503). Carried forward on byte-identical code: A, as built; no change asked. The outage ruling's harm is an outage reported as a capability denial; status reports no denial — it issues no 401/403 — so withholding misreports nothing. It serves strictly less than it would with the store readable, never more, so nothing widens. The probe semantics are the hold-off the claim named, and B would break them in exactly the window an operator is diagnosing. The three doors, where a verdict is required, re-raise and answer 503 (①.5) — that is where the ruling bites, and it is honoured. The outage is logged at warn. Pinned both ways. Closed here, not escalated. The round-2 report lists no open question.

  2. Dev's round-1 out-of-scope note — /install still gates on a session alone with its TODO: tighten. The hold-off held on both heads: no hunk touches install, installation, installed or org-packages. Its posture was ruled on cloud-connection: the marketplace-install route supplies no tenancyPosture to resolveAuthzContext — an ex-member's org-stamped API key is admitted #15353 and the carrier is the maintainer; this record escalates nothing and files nothing. The round-2 report lists no out-of-scope finding.

  3. Dev's deviations. Round 1: test:repo not run locally → the Test Core lanes are the gate verdict and five are pending — not a finding against the diff; consumer type-check of getKernel? → Type Check · consumer gates is green on this head, closed (①.8); the twice-run battery, shallow checkout and attribution choice are procedural. Round 2: dispatch-gates run with the changeset path as argument rather than path-less — the other seven paths are unchanged since 7f45419be, and the CI lanes on this head, not the local battery, are the verdicts read here; the level axis armed locally from a REST-built event file — the dev's own probe, not this record's; the CI Check Changeset run is the verdict and it is green. The PR body changed between the heads in one prose line only — the datasource precedent now spelled packages/services/service-datasource/src/admin-routes.ts, the slip the round-1 record named — and its Clause-② line is unchanged.

  4. Security review (triage 6229951531), the four questions, on code byte-identical to the round-1 head. (i) Can a caller lacking setup.access still reach a write door's effect, or status's connection? Not by any credential shape read here (①.1, ①.2, ①.6): anonymous 401; key-only 401; held 403; signed-in-without 403; key-beside refused; status narrowed for every one of these; grants resolve through the one resolver from the kernel the session came from, and no engine fails closed (①.3, ①.4). (ii) Does the outage path fail closed on the doors? Yes — re-raised, rendered 503 SERVICE_UNAVAILABLE by the host (①.5). (iii) Does the check run before any control-plane call or credential touch? On the three doors, before all of them (①.1); on status the runtime's own status fetch precedes the read, as on main, with the runtime's credential and never the caller's, and the check decides only what is served (①.6). (iv) Is the shared permission read reused rather than re-spelled? Yes (①.3). The boundary holds as built.

  5. Disclosure. This record carries no reproduction; existence, code locations and the pins only.

Implemented-by: claude/issue-24148-cloud-connection-setup-gate
Reviewed-by: session_01BVm1pAXVXQ3PjC42j1kPFf

VERDICT: PASS — the one defect record 6232716299 named is corrected (minor, banner reworded) and the head's Check Changeset gate is green; ① and ③ hold on code byte-identical to the reviewed head. The queue still waits on the five pending Test Core lanes; this record does not lift that.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 — ACCEPT · domain:cli seat 1 · os-sales · session session_01BVm1pAXVXQ3PjC42j1kPFf · 2026-10-17T10:32Z

Head 47e78745d. This is the seat's review of record for #24148 (claim 6231718544). The contract faces and the security review triage requires before landing (6229951531) are judged by the at-tier record 6232831390 on this head (Served-tier: CONTRACT_REVIEW_TIER, PASS, round 2, from an isolated read-only agent given only the card, the PR and the check-runs). Round 1, 6232716299, failed on ② alone (the changeset graded the narrowing patch). The head that answers it moves only the changeset, and git diff 7f45419be 47e78745d -- packages/ content/ is empty. This record covers what that review leaves to the seat: the form, the prose faces, the seat's own read of the diff, and the landing.

  • Form:
    • Draft → main, titled fix(cloud-connection)!: ….
    • The body's first line is Fixes #24148, its only closing keyword (scanned).
    • Clause-②: no (narrowing) is line-initial in both the body and the changeset, and matches the claim's no. Three doors narrow what they accept and status narrows what it serves; nothing refused becomes accepted.
  • The ruling executed as ruled: the capability is setup.access, the one the Setup app that hosts the panel declares (setup.app.ts:47). That is the seat's choice on the claim, over an explicit environment-admin check. The grant read goes through resolveAuthzContext, the shared resolver, and no sys_* read is spelled in the plugin. No packages/spec path moves (AGENTS.md Prime Directive 2), and none of the claim's held-off doors (install, installation, installed, org-packages) has a hunk.
  • The seat's own read of the code (cloud-connection-plugin.ts at this head):
    • The ordering is the same on all three doors (:553-558, :615-619, :716-720):

      1. the ADR-0069 held-session 403,
      2. then the anonymous 401,
      3. then holdsSetupAccess,
      4. and only after that the CLOUD_UNCONFIGURED 503, the control-plane fetch and the credential-store touch.

      So a caller without the capability learns nothing about whether a control plane is configured.

    • holdsSetupAccess answers true only when the resolver's credential is session and the user is the session this surface already read. It re-raises AuthzStoreUnavailableError, which the host renders as 503, and fails closed on any other fault.

    • servedStatus returns the full shape only to a caller who is not held and holds the capability. It catches only the outage, logs it at warn and serves the narrowed shape; any other throw propagates.

    • On the control-plane path, the answer is built inside the fetch's try and served outside it, so a fault in servedStatus cannot fall through to the implicit answer.

  • Prose faces, sentence by sentence against the diff:
    • The changeset's Refused now bullet ("before any call to the control plane and before the stored credential is touched") matches the ordering above. ✓ "An API key sent beside the session lends its owner's grants to nobody" matches the credential !== 'session' arm. ✓
    • Narrowed now ("bound, runtimeId and provider only") matches the narrowed return in servedStatus, and a pin asserts it as an exact key set. ✓
    • Unchanged: the holder's path is byte-identical to main's; the 401 and ADR-0069 403 come first, as before; the four other session routes have no hunk. ✓
    • An unreadable permission store is not a denial matches the re-raise on the doors and the warn and withhold on status. ✓
    • "Which the default platform-administrator and organization-administrator permission sets already carry": setup.access is in ADMIN_FULL_ACCESS_CAPABILITIES (spec/src/identity/eval-user.zod.ts:146, spread into admin_full_access) and in the organization administrator's systemPermissions (plugin-security/src/objects/default-permission-sets.ts:487), checked against main by the seat. ✓
    • content/docs/deployment/cloud-connection.mdx: the #before-you-start sentence and the status reference row state what the code does. "Sign-in: not required" on status stays true, since it refuses nobody. ✓
  • The review's boundary flags, routed:
    • ③.1 (status during a permission-store outage): A, as built, with the doors keeping the 503. Closed by the record, so nothing is escalated.
    • ③.2 (/install's session-only gate and its TODO: tighten): its posture was ruled on cloud-connection: the marketplace-install route supplies no tenancyPosture to resolveAuthzContext — an ex-member's org-stamped API key is admitted #15353 and is held off by the claim. Nothing is filed, being below the filing gate and already carried.
    • The record's ①.2 observation (a lifecycle-refused key beside a holder's session answers PERMISSION_DENIED naming setup.access rather than the key's own located 403) is fail-closed and no Console caller sends a key. It goes in the Acceptance notes with no carrier.
    • This change tightens a permission boundary and relaxes none, so it does not touch the human floor.
  • Disclosure: per skills: five principle gaps the #9296 wave exposed in checklist-test / checklist-author / dogfood-verification #9387, this record and the PR carry no reproduction, only the existence of the hole, code locations and the pins.
  • Landing: not governed (check-governed-merges --pr 24167: 0 of 8 paths on the register; 730 changed lines ≤ 3000). With the review of record PASS on this head, the PR goes ready with auto-merge armed once every check on the head is green.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e9fe7fe9bc75ea20cc85345c161d85ad79d9f5d0
Local-runs: none
Scope: packages/cloud-connection/src/cloud-connection-plugin.ts, packages/cloud-connection/src/cloud-connection-setup-access.pin.test.ts, packages/cloud-connection/src/cloud-connection-auth-gate.pin.test.ts, packages/cloud-connection/src/cloud-connection-plugin.test.ts, packages/cloud-connection/src/connection-credential-store.test.ts, packages/cloud-connection/src/error-envelope.conformance.test.ts, packages/core/src/security/authz-store-unavailable.test.ts, .changeset/24148-cloud-connection-setup-access.md, content/docs/deployment/cloud-connection.mdx + packages/core/src/security/resolve-authz-context.ts, packages/core/src/security/admission-tenancy-posture.ts, packages/core/src/security/authz-store-unavailable.ts, packages/core/src/security/auth-gate.ts, packages/core/src/security/api-key.ts, packages/core/src/service-not-registered.ts, packages/core/src/plugin-loader.ts, packages/core/src/kernel.ts, packages/core/src/types.ts, packages/core/vitest.repo-tests.json, packages/core/package.json, packages/core/tsup.config.ts, packages/core/tsconfig.json, packages/types/src/in-process-session-read.ts, packages/types/src/thrown-http-error.ts, packages/plugins/plugin-hono-server/src/adapter.ts, packages/cloud-connection/src/marketplace-install-local-plugin.ts, packages/cloud-connection/src/tests/security-catalog.testkit.ts, packages/services/service-datasource/src/admin-routes.ts, packages/platform-objects/src/apps/setup.app.ts, packages/spec/src/api/errors.zod.ts, packages/spec/src/api/error-code-ledger.zod.ts, .changeset/pre/23190-auth-gate-cli-sites.md, scripts/check-changeset-no-major.mjs, .github/workflows/pr-automation.yml, .github/workflows/ci.yml

Round 3, on the head that moved off 47e78745d (record 6232831390, PASS). Inputs: card #24148 (its comments newer than round 2: the seat pointer 6232864327 and the round-3 dev report 6233378136); PR #24167 (body as rewritten at this head, its 9-path file list, and the net diff against main — the merge base is still eda6f14b7); the 47 check-runs on e9fe7fe9b, plus the final runs on 47e78745d with the annotations and step list of its red shard. Read-only: nothing built, run or re-run. This record is also the security review triage requires before landing (6229951531). Disclosure (the maintainer's ruling recorded on #9387): behaviour and code locations only, no reproduction.

What moved. git log 47e78745d..e9fe7fe9b is one commit, e9fe7fe9b (parent 47e78745d; no amend, rebase or force). git diff 47e78745d e9fe7fe9b is one file, +1/−0: the row 'packages/cloud-connection/src/cloud-connection-plugin.ts': 'guarded' added to TRANSPORT_LEDGER in packages/core/src/security/authz-store-unavailable.test.ts, after the marketplace-install-local-plugin.ts row. git diff 47e78745d e9fe7fe9b -- packages/cloud-connection content/ .changeset/ is empty (0 bytes), and the plugin's blob is 44de963e5afa at 7f45419be, 47e78745d and this head alike. Nothing else under packages/, content/ or .changeset/ moved. The round-1 and round-2 judgments on those paths therefore carry forward on byte-identity, and the row is judged afresh.

① Derived judgments

  1. The row is the truthful disposition — right. The suite defines guarded as "the call sits inside a fail-closed catch that would have re-silenced the outage, so that catch now re-raises it explicitly", and propagates as "nothing between the call and the door swallows". The plugin's one resolveAuthzContext({ call (cloud-connection-plugin.ts:428-435, inside holdsSetupAccess) sits in a try whose catch (:439-442) returns false for every throw except the branded outage, which it re-raises through isAuthzStoreUnavailableError. That is the guarded shape exactly. propagates would be a false claim about this file — a swallowing catch does sit between the call and the door — and it would pass the arm's mechanical check anyway (its regex looks for a bare catch { returning undefined/null/{}, which this catch (err) is not), so guarded is both the only truthful row and the only one the suite actually verifies: the it.each guarded arm requires an import from '@objectstack/core' (:106-117) and a use of isAuthzStoreUnavailableError or rethrowAuthzStoreUnavailable (:440, and :476 in servedStatus). Both hold on this head.

  2. Set equality on this head — right. The suite rebuilds the enumeration from source: every .ts under packages/ outside node_modules/dist/.turbo that is not scaffolding (*.test.ts, *.testkit.ts, *.fixture(s).ts, a dogfood directory) and contains the bytes resolveAuthzContext({. Read independently at e9fe7fe9b with the same needle and exclusions, the production set is exactly the ledger's nine keys: rest/src/rest-server.ts, runtime/src/security/resolve-execution-context.ts, mcp/src/plugin.ts, services/service-datasource/src/admin-routes.ts, services/service-settings/src/settings-service-plugin.ts, services/service-storage/src/storage-service-plugin.ts, plugins/plugin-sharing/src/sharing-plugin.ts, cloud-connection/src/marketplace-install-local-plugin.ts, cloud-connection/src/cloud-connection-plugin.ts (two CHANGELOG.md hits are not .ts and are outside the walker's reach). At the merge base the same read returns the eight old rows. So no other production file gained or lost the call; the plugin is the one delta, and the ledger names it.

  3. The round-2 red is fully explained by this suite — right. On 47e78745d, Test Core (3/6) failed in one step, "Run this shard's tests"; its annotations name one failing command, pnpm run test:repo in packages/core exited 1, and the process exit — nothing else. The reachable portions of that job's log (its last 400 and last 5,000 lines) end at @objectstack/cli:test (155 files, 1836 passed) and the exit; they do not reach core's test:repo block, and the raw log download is refused to this seat, so which core test failed is not read off the log here. It is settled another way: core's repo project is six files (vitest.repo-tests.json); only authz-store-unavailable.test.ts names cloud-connection or censuses resolveAuthzContext( — the other five are untouched between the heads and never reference the plugin — and the census at 47e78745d returns nine transports against an eight-row ledger, which is exactly what the ⭐ SET EQUALITY arm refuses. The dev's local reproduction (1 failed / 82 passed, then 84 passed on this head) agrees; it is reported, not re-run. CI's own verdict on the fix, Test Core (3/6) on this head, is still pending (②).

  4. No behaviour moves. The row is a classification in a test ledger; it changes no runtime path, pins no new behaviour of the plugin beyond what ①.5 of record 6232716299 already judged (the outage re-raised, rendered 503 SERVICE_UNAVAILABLE by the host), and adds the plugin to the repo-wide instrument that holds that rule per transport — the guarded arm now fails if the shared predicate's import or use is ever removed from this file. Not a governed surface; no ablation owed.

  5. Carried forward on byte-identity — every judgment of record 6232831390 ①.1-①.10 (the three doors' order and refusal; every credential shape; the shared resolver read, not re-spelled; grants from the session's kernel and fail-closed with no engine; the outage path fails closed on the doors; status narrowed in what it serves and never refusing; PERMISSION_DENIED as the standard-catalog member; the optional getKernel? type face; the 26 pins and the four adjusted suites; docs and hold-offs honoured). The code they describe is unchanged to the byte.

② Semver level

The changeset .changeset/24148-cloud-connection-setup-access.md is byte-identical to 47e78745d: '@objectstack/cloud-connection': minor, fix(cloud-connection)!:, the BREAKING banner under the launch-window convention, the line-initial no (narrowing) declaration, one ADR-0087 marker (not-required (no-migration-prescription)). The PR body's Clause-②: no (narrowing) line is unchanged. These hold as record 6232831390 judged them: a (narrowing) is BREAKING and ships minor in the launch window; nothing refused becomes accepted.

  • The packages/core file with no core changeset — right, none is owed. The gate's published-source predicate (publishedSourceOwners, scripts/check-changeset-no-major.mjs) has a shape-only first leg, D/src/**, so a *.test.ts under packages/core/src/ does count as "moving core's published source" for the level axis; the gate measures and keeps that over-read on purpose ("35 of the 50 moved only src/**/*.test.ts, which a bundled package never emits", "its cost is zero": a package the diff moves but does not grade is never an offender). On this head the axis therefore sees two moved packages — cloud-connection, graded minor (raised), and core, not graded (neither offender nor raised) — and returns clean. The Check Changeset check-run on this head concluded success twice (the push run and the body-edit run); the later one printed "✓ LEVEL AXIS: this PR declares clause-② no (narrowing), and no package whose published source (src/**, a packed bin target, or a repo file its build copies in) it moves is graded patch.", the arm line "· direction arm: narrowing — a BREAKING change; during the launch window it ships minor", and "✓ This diff introduces no major bump." What the gate does not read, this record does: core publishes dist, README.md and CHANGELOG.md only (package.json files); its tsup entries are src/index.ts and src/logger.ts; its tsconfig.json excludes **/*.test.ts; and Build Core's "No compiled test files in any dist" step is green on this head. A test file publishes nothing from @objectstack/core, so AGENTS.md's "a changeset for anything that publishes" asks nothing of core. skip-changeset stays inapplicable: cloud-connection publishes.
  • The ADR-0087 disposition — unchanged, right; the check-adr-0087-registration step passed inside the green Check Changeset job on this head.

Gate verdicts on this head, as read in this act (47 check-runs, two event runs overlapping): 38 success, 0 failure, 2 in_progress, 7 skipped. Pending and not waited on: Test Core (2/6) and Test Core (3/6) — the latter is the shard that runs core's test:repo, so CI's confirmation of this head's one change is among the pending lanes; the aggregate Test Core row has not been created yet. Skipped: Console Pin Gate, the Dogfood Regression Gate matrix shard, Dogfood Verify CLI, Packed-tarball smoke (opt-in), Temporal Conformance, and the body-edit run's Auto Label and Check PR Size (skipped by that workflow's own if: on edited; the push run's are green). Green and material here: Check Changeset (both runs), Lint & Repo Gates and every Lint · lane, Type Check · workspace / · consumer gates / · source gates / · debt ledger, TypeScript Type Check, Build Core, Build Docs, Test Core 1/6, 4/6, 5/6 and 6/6, Spec property liveness, Governed Surface Queue Guard (no governed path in the file list), the single-claim and card-claims-branch checks (both runs). On 47e78745d, final: Test Core (3/6) and the aggregate Test Core concluded failure (①.3); every other lane green.

③ Boundary flags

  1. Dev's round-3 deviation — the PR body not patched by the dev; the line handed to the seat. Answered: the body at this head carries the handed line verbatim, under a new "Also changed (packages/core, test only)" section, plus one round-3 verification bullet; its Clause-② line is unchanged. The body's claims match the diff read here: one row, guarded, the fail-closed catch re-raising through isAuthzStoreUnavailableError (①.1). The conflict was reported rather than resolved silently, which is the right direction for a body the dev does not own after creation.

  2. Dev's round-3 deviation — check:dual-build-cjs-loads NOT MEASURED locally (exit 3, no whole-workspace dist/). Answered: the gate is hosted by ci.yml's build-core job ("Build Core"), step "Every published require entry point actually loads", which runs after a real build and exits 3 rather than green when dist/ is absent. On this head Build Core concluded success with that step success, so the gate is measured in CI; nothing is left unmeasured.

  3. Dev's round-3 deviation — dispatch-gates run with the one changed path. The other eight paths are unchanged since 7f45419be, whose path-less 93-command battery stands as reported; the CI lanes on this head, not the local battery, are the verdicts read here.

  4. Round-1 and round-2 flags, carried forward on byte-identity. GET /status during a permission-store outage: A (withhold, 200, warn), as built — closed, not escalated. /install's TODO: tighten: the hold-off held on every head; the carrier is the maintainer (cloud-connection: the marketplace-install route supplies no tenancyPosture to resolveAuthzContext — an ex-member's org-stamped API key is admitted #15353); nothing filed. The optional getKernel? type face: Type Check · consumer gates green on this head too. The round-3 report lists no open question and no out-of-scope finding.

  5. Security review (triage 6229951531), the four questions. Unchanged in substance: the code is byte-identical to the heads records 6232716299 and 6232831390 judged. (i) No credential shape read reaches a write door's effect or status's connection without setup.access. (ii) The outage path fails closed on the doors — and this head adds the plugin to the ledger that holds that per transport (①.1, ①.4). (iii) The check precedes every control-plane call and credential touch on the doors; on status the runtime's own fetch precedes it, as on main, and the check decides only what is served. (iv) The shared permission read is reused, not re-spelled. The boundary holds as built.

  6. Disclosure. This record carries no reproduction; existence, code locations and the pins only.

Implemented-by: claude/issue-24148-cloud-connection-setup-gate
Reviewed-by: session_01BVm1pAXVXQ3PjC42j1kPFf

VERDICT: PASS — the head's one change is a truthful guarded row for a transport the census now finds, it publishes nothing from @objectstack/core, and cloud-connection's minor / no (narrowing) holds; ① and ③ carry forward on code byte-identical to the reviewed heads. The queue still waits on Test Core (2/6) and Test Core (3/6), the latter being CI's own measurement of this row; this record does not lift that.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 — ACCEPT (head moved) · domain:cli seat 1 · os-sales · session session_01BVm1pAXVXQ3PjC42j1kPFf · 2026-10-17T11:41Z

Head e9fe7fe9b. This record supersedes the seat's ACCEPT 6232859180 (head 47e78745d) for landing. The at-tier review of record on this head is 6233457091 (Served-tier: CONTRACT_REVIEW_TIER, PASS, round 3).

  • Why the head moved. On 47e78745d, Test Core (3/6) was red in core's test:repo: the [[finding] a permission-store read failure resolves as an AUTHENTICATED caller holding ZERO capabilities — the package door answers 403 FORBIDDEN, byte-identical to a genuine capability denial #13279] ⭐ SET EQUALITY test in packages/core/src/security/authz-store-unavailable.test.ts. The plugin's new resolveAuthzContext({ call made it a ninth transport, and the ledger listed eight. The seat diagnosed this from source, because the job log is not reachable from this container. The dev reproduced it on that head (1 failed, 82 passed) before the fix.
  • The seat's own read of the move: one commit, +1/−0, the row 'packages/cloud-connection/src/cloud-connection-plugin.ts': 'guarded'. git diff 47e78745d e9fe7fe9b -- packages/cloud-connection content/ .changeset/ is empty, so the code, pins, docs and changeset the seat accepted at 6232859180 are byte-identical here. guarded is the truthful disposition: the call sits in a try whose catch (:439-442) fails closed and re-raises the outage through isAuthzStoreUnavailableError, imported from @objectstack/core.
  • The core path with no core changeset: none is owed. Core publishes dist only, and its tsconfig excludes **/*.test.ts. Check Changeset is green on this head.
  • Form: the body was rewritten by the seat at this head. The dev declined to edit it after creation, per os-dev.md, and handed over the line. The new body adds an "Also changed (packages/core, test only)" section and one round-3 verification bullet. Fixes #24148 and Clause-②: no (narrowing) are unchanged and line-initial.
  • Landing: still not governed (check-governed-merges --pr 24167 re-run on this file list: 0 of 9 paths on the register; 731 changed lines ≤ 3000). The PR goes ready with auto-merge armed once every check on this head is green. Test Core (3/6), the lane that runs this row's own measurement, is the last one pending.

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/l tests tooling

Projects

None yet

2 participants