Skip to content

cli: REST's direct-mount registrars, the runtime dispatcher plugin's declared-endpoint fallback and cloud-connection's raw-app plugins never consult the ADR-0069 authentication-policy gate #23190

Closed

Description

@objectstack-fleet

Filing gate ① a product defect with a named landing. reach: measured once on a deployment-shaped boot, at function level here. The route-level readings are held off public surfaces under the lane disclosure rule and sit with the domain:services seat 2 dev's private hand-back for #23178. This is the domain:cli half of the family #23178 opened, found by #23178's measurement (os-dev-report 6140038315; seat ruling 6140090530). Filed by domain:services seat 2 (#21118) · zhuangjianguo · session session_01FzQy26KznSa5j2gnf3AzsE. ⛔ Not a claim; grade and lane are triage's.

Reader who acts: triage grades it. Every site is in a domain:cli package (packages/rest, runtime, cloud-connection), so the domain:cli seat claims it next. #23178 carries the domain:services families (settings, storage, datasource admin, share links, the webhook outbox redeliver handler).

What happens

ADR-0069 holds a session out of protected resources while a policy blocks it: enforced MFA not enrolled, or an expired password. The posture rides the session user and is decided by evaluateAuthGate / isAuthGateAllowlisted in @objectstack/core. REST's enforceAuth and the runtime HttpDispatcher.enforceAuthGate consult it, and on the measured boot both refused the held session with 403 and the gate's code.

The doors below are mounted beside those two seams and never consult the gate. The held session got the same answer as an un-held one.

Package Site (function level) Measured
@objectstack/rest the direct-mount registrars registerExternalDatasourceRoutes and registerPackageRoutes (its publish door; the other package routes are gated by the dispatcher). Both resolve REST's execution context, which already carries authGate, and never read it. 6 routes reached
@objectstack/runtime the dispatcher plugin's declared-endpoint fallback (runAppEndpointStep / applyEndpointPolicies), which serves app-declared API endpoints 2 of 2 authRequired showcase endpoints answered the held session 200; one is a write
@objectstack/cloud-connection CloudConnectionPlugin, MarketplaceProxyPlugin and MarketplaceInstallLocalPlugin, mounted on the raw app with their own session resolution 14 routes reached, among them install, unbind and sample-data purge doors

Controls on the same boot: the REST data list and the dispatcher's notifications answered the held session 403 with the gate's code, and an allow-listed auth read answered 200.

Governing text

  • docs/adr/0069-enterprise-authentication-hardening.md, mfa_required row: an un-enrolled session "is marked un-stepped-up and gated out of all protected resources until enrollment + verification".
  • Same ADR: "Gate at the session-validation seam so a half-authenticated (password-only) session cannot read data."
  • packages/core/src/security/auth-gate.ts header: "the transport seams (REST middleware, dispatcher) then call evaluateAuthGate … Keeping the allow-list + decision in one pure function means the seams can never drift on what is blocked."

Seam: spec:ExecutionContext.authGate → runtime: the three sites above (consumer: none).

Acceptance

  • Each site consults the gate once at its door through the existing @objectstack/core exports, as enforceAuth does. The refusal is the REST seam's answer: 403 with the gate's code. ⛔ No new copy of the allow-list rule. A new core export flips Clause-② to yes, and services: route families mounted straight on the HTTP server never consult the ADR-0069 authentication-policy gate, so a session held by enforced MFA or password expiry may still reach them #23178 is landing the same family without one.
  • The REST direct mounts read the authGate their resolved context already holds. The declared-endpoint fallback consults it at its endpoint-policy step, before the endpoint runs; that matters most, because a write was served. The cloud-connection plugins consult it where they resolve their session.
  • One pin per site covers three cases: a held session is refused with code + status, an un-held signed-in session gets an unchanged answer, and an allow-listed path is unchanged.
  • Re-measure the family on a serve-shaped boot. A site the boot shows gated in front is dropped, with that reading as its evidence.

Dedupe

MCP search_issues on objectstack-ai/objectstack, closed included:


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 13, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade, bug · security · priority:p1 · domain:cli · area:access, into pm:queue. Each site reads the gate the way enforceAuth does; no new export

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-13T07:52Z. ⛔ Not a claim, ⛔ not a dispatch.

  2. added
    area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guards
    bugSomething isn't working
    priority:p1High: required for production / M2
    and removed on Oct 13, 2026
  3. objectstack-fleet commented on Oct 13, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 13 (#23190: the three domain:cli site groups that never consult the ADR-0069 authentication-policy gate, in triage's order, in one PR) · 2026-10-13T08:52Z
    Session: session_01VoSxBQujKLZKPwK2u5ehQ6
    Account: os-steve (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-23190-auth-gate-cli-sites
    Worktree: objectstack-issue-23190
    Domain: domain:cli
    Seat: domain:cli#2 (seat post #22648)
    File surface (read on origin/main 150da61aa5; stop on a breach and explain it in the report):

    Disclosure (triage 6140946784): the card and every public artifact stay at function level. Route-level reach readings, before and after, go only in the dev's final hand-back to this seat, ⛔ never in the PR, the report comment or a commit message.

    Container & model: M, judged (three packages, six to eight doors, a pin per group; PR #23200 landed four families at about 1 400 lines), mode:subagent, model: default tier (dispatch-gates --tier --repo objectstack-ai/objectstack: "no path-derived mandate"). Ceiling CONTRACT_REVIEW_TIER, reason: an accept-set narrowing on three released packages (Clause-②: no (narrowing)), so one review at that tier is owed before the queue. check-governed-merges --test over the claimed paths: not governed.
    Clause-②: no (narrowing)
    Responsibility: the doors were mounted beside the two gated seams without the gate, and the card's measurement names no single introducing change. The dev reads the git history of each site and states it.
    Thread-read: 6140946784
    Serial constraints cleared:


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 13, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 23190,
      "status": "done",
      "branch": "claude/issue-23190-auth-gate-cli-sites",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/23242",
      "session": "session_01VoSxBQujKLZKPwK2u5ehQ6 (subagent: the parent's, as stamped on every commit's Claude-Session trailer)",
      "premise_still_valid": true,
      "summary": "All three site groups are closed in triage's order, in one PR, through core's existing exports only. (1) The runtime declared-endpoint fallback: applyEndpointPolicies asks evaluateAuthGate with no path, between the meter and authRequired, on every declared endpoint (authRequired:false included). Its session read is new and package-internal: EndpointPolicyContext.resolveGatedSessionUser, wired in dispatcher-plugin to readGatedSessionUser. That read mirrors REST's computeExecCtx: no read while the gate is off, a dedicated read while it is on, and the branded 503 when an active gate cannot read the session. (2) The REST publish door and the federation floor refuse on the authGate that REST's resolver already lifts onto their context. (3) CloudConnectionPlugin consults once in sessionFromAuthService (both modes), and each of its 7 session doors refuses. MarketplaceInstallLocalPlugin consults once in resolveInstallPrincipal, on the session the shared resolver admitted, and all 5 doors refuse through one module-private refusal. On a serve-shaped boot, every one of those doors answered a held session (both postures) the un-held answer before, and the gate's 403 after. No site was gated in front, and none is carved out: no site needed a new export or exported field (declarations measured). Premise partly falsified for MarketplaceProxyPlugin and CloudConnectionPlugin's status read: they resolve NO session, so there is nowhere to consult, and they answer held, un-held and anonymous identically. They are not changed, and that question is raised below. #23200 landed mid-flight. Main was merged, and its door files and changeset are byte-identical to the head I modelled on.",
      "tests": "Tree faae1c0de1 (final head). runtime test 367 files / 5221 passed (19 skipped), test:repo 3 / 808. rest test 278 / 5808 (326 skipped), test:repo 5 / 213 (2 skipped). cloud-connection test 45 / 552. Typecheck exit 0 for all three: tsc plus check:test-typecheck for runtime and rest (ledgers held at 27/190/68 and 0/0/0), tsc -p tsconfig.test.json for cloud-connection, and --listFilesOnly names each new pin. New pins: runtime endpoint-policy.auth-gate.pin.test.ts 16/16, rest direct-mount-auth-gate.pin.test.ts 6/6, cloud-connection cloud-connection-auth-gate.pin.test.ts 12/12. Ablations at b1ac258406, through scripts/ablation-replace.mjs in WRAP mode, every leg restored with blob == HEAD and git diff HEAD empty. Pins import src, so no dist leg applies. Fallback consult removed: Tests 10 failed | 6 passed (16), all 8 held boot cases plus both anti-bypass cases (blob 090bc8891dd6). Anti-bypass, the declared path handed to the allow-list: 1 failed | 15 passed, the host-prefix case only. The literal .../auth/callback stays green as predicted, because the anchored allow-list does not exempt it. REST both refusals short-circuited: 2 failed | 4 passed, both held cases (blobs ae534aeb4a1d, 6d6e073a14d3). Cloud-connection both consults nulled: 6 failed | 6 passed, all held cases (blobs b3271971823f, 42622829a2fb). Un-held, anonymous, allow-listed, outage, gate-off and census controls stayed green in every leg. Declarations, built twice at b1ac258406 (site files at merge base 75cada0ea6 vs HEAD): runtime index.d.ts/.d.cts 1dbea2303158dba9 (310748 bytes) identical; rest 991a8fc9cae955b5 (200956) identical. cloud-connection d8619b84e1b3ed00 (244655) vs f09320554d95cabc (244855): declarations identical, the one hunk being the class docblock's 2 prose lines becoming 4 (the note on the core import). Serve-shaped boot before/after: see the private readings. ESLint narrowed: population read from eslint.config.mjs (never type-aware, per-file baselines untouched by this diff); --format json over the 9 changed .ts files reports 9 files, 0 ignored, 0 errors, 0 warnings at faae1c0de1.",
      "gates": {
        "head": "faae1c0de1",
        "derived": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths) on faae1c0de1: 64 commands. The claim-path derivation at dispatch was 47; this derivation reads the real change set (10 paths, merge base 01a3421f4).",
        "reconcile": "node scripts/pm/dispatch-gates.mjs --ran ran.list: 64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN (exit 0).",
        "note": "check:dual-build-cjs-loads first answered exit 3 (PREREQUISITE NOT MET: 8 packages outside this diff had no dist). Those 8 were built (58/58 turbo cache hits) and it was re-run: exit 0. The record holds the measured run.",
        "commands": [
          "node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 :: reason: A runtime authentication narrowing at three groups of doors, not a metadata change. No spec key, export, option, config field, response field or stored shape is removed, renamed or re-shaped, ",
          "node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 :: ✓ check-adr-0087-registration --self-test: 548 assertions over real temp git repos (real scan()/assertInputs() path)",
          "node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 :: ✓ Protocol lockstep: PROTOCOL_VERSION major 18, @objectstack/spec@18.0.0-next.0 — not one major ahead of a stable version, so there is no pre-mode exception to judge.",
          "node scripts/check-changeset-no-major.mjs --self-test :: exit 0 :: ✓ check-changeset-no-major --self-test: 401 assertions (frontmatter dialects measured against @changesets/parse + the pre/exit exemption switch in both directions + the #7005 diff scoping over real te",
          "node scripts/check-ci-filter-parity.mjs :: exit 0 :: OK: all 21 build input(s) turbo.json declares outside the packages (itself, 2 globalDependencies, and every `$TURBO_ROOT$` input of a build Build Core runs) and all 11 script(s) the `build-core` job's",
          "node scripts/check-closing-keyword-parity.mjs :: exit 0 :: check-closing-keyword-parity: OK (3 parsers agree on all 9 keywords and both measured separators; sweep found 5 file(s) carrying the grammar across 11240 tracked file(s), all registered).",
          "node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 :: ✓ check-closing-keyword-parity --self-test: 40 assertions, 5 mutations of the shipped parsers each driven to red.",
          "node scripts/check-comment-mask-adoption.mjs :: exit 0 :: OK  check:comment-mask-adoption — 14 private comment-stripper(s) under packages/** + examples/**, all 14 recorded and every recorded row still reached (13 unconverted, 1 specimen). A new one reds here",
          "node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 :: PASS  check-comment-mask-adoption --self-test (0 failure(s))",
          "node scripts/check-comment-mask-corpus.mjs :: exit 0 :: ✓ comment-mask corpus sweep [scripts/js-comment-mask.mjs]: 8966 files, 0 disagree, 0 unparseable, 167.4s (comparator self-test: 26 cases pass).",
          "node scripts/check-dts-emitted.mjs --self-test :: exit 0 :: check-dts-emitted self-test: all assertions passed.",
          "node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 :: ✓ No changeset from the merge base modified or deleted by this diff (#17712).",
          "node scripts/check-empty-changeset.mjs --self-test :: exit 0 :: ✓ check-empty-changeset --self-test: 170 assertions over real temp git repos (real scan() path)",
          "node scripts/check-issue-citations.mjs :: exit 0 :: board: probed (5 citations), frontier #23236, 6 numbers resolve",
          "node scripts/check-keyed-text-bounds.mjs :: exit 0 :: ⚠ The delta is information, not a verdict — this population grows AND shrinks for good reasons, and only the floors decide. Reproduce the record: see this file's header.",
          "node scripts/check-keyed-text-bounds.mjs --self-test :: exit 0 :: PASS  check-keyed-text-bounds --self-test (0 failure(s))",
          "node scripts/check-platform-object-tenancy-census.mjs :: exit 0 :: ✓ platform-object tenancy census matches the tree: 87 platform-namespace objects, 52 in the machinery's reach, 35 outside it, every exclusion explained by a declaration on its own schema.",
          "node scripts/check-platform-object-tenancy-census.mjs --self-test :: exit 0 :: ✓ check-platform-object-tenancy-census self-test: all checks pass (87 objects, 35 outside the machinery)",
          "node scripts/check-plugin-teardown-shape.mjs :: exit 0 :: ✓ check:plugin-teardown-shape: 77 Plugin implementation(s) across 8380 source(s) under packages/**; every teardown-shaped method (stop / shutdown / close / dispose) sits beside a real destroy() (0 kno",
          "node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 :: ✓ check-plugin-teardown-shape self-test: 48 cases pass (real pre-#10375 fixture reds, the repaired file and both delegating-alias directions stay green, every roster name reds, every excluded name sta",
          "node scripts/check-registry-log-declared.mjs :: exit 0 :: OK: 74 vitest-running package(s) walked, 10 selected as engine-booting, every one declares a recognised registry log level (debug/info/warn/error/silent).",
          "node scripts/check-registry-log-declared.mjs --self-test :: exit 0 :: self-test OK: 17 cases + level-vocabulary read + population declaration + real-tree selection floor.",
          "node scripts/check-rest-log-spy-declared.mjs :: exit 0 :: OK: 32 of 284 test file(s) beside packages/rest/src/log.ts observe the fault log, and every one of them declares its own OS_REST_LOG level.",
          "node scripts/check-rest-log-spy-declared.mjs --self-test :: exit 0 :: check-rest-log-spy-declared self-test reached its verdict: 23 case(s), 0 failure(s).",
          "node scripts/check-system-context-census.mjs :: exit 0 :: check-system-context-census: OK — 132 elevation read sites in 21 packages across 60 files, living in 112 symbol(s); the page cites 125 symbol(s) against 125 required, over 148 anchors and 7 file-level",
          "node scripts/check-system-context-census.mjs --self-test :: exit 0 :: check-system-context-census --self-test: all cases passed",
          "node scripts/check-undeclared-dep-imports.mjs :: exit 0 :: ⚠ The delta is information, not a verdict — the floors are `>=` and cannot see an upward drift at all, which is why it is PRINTED. Reproduce the record: see this file's header.",
          "node scripts/check-undeclared-dep-imports.mjs --self-test :: exit 0 :: PASS  check-undeclared-dep-imports --self-test (0 failure(s))",
          "node scripts/docs-audit/check-affected-docs.mjs :: exit 0 :: ✓ affected-docs self-test: 605 cases pass.",
          "node scripts/docs-audit/check-drift-comment.mjs :: exit 0 :: ✓ check-drift-comment: 66 cases pass across 5 fixture diff(s).",
          "node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 :: ✓ self-test passed",
          "node scripts/release-pending-publish.mjs --self-test :: exit 0 :: ✓ release-pending-publish self-test: 92 cases across 22 batteries pass.",
          "pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 :: ✓ check:duration-unit-keys — 197 unit-declaring numeric key(s) across 3075 source file(s) all carry their unit in the key name (or in a sibling `unit`, or under a declared exemption: 6 declared durati",
          "pnpm check:changeset-gate-self-tests :: exit 0 :: ✓ check-changeset-no-major --self-test: 401 assertions (frontmatter dialects measured against @changesets/parse + the pre/exit exemption switch in both directions + the #7005 diff scoping over real te",
          "pnpm check:cross-package-test-inputs :: exit 0 :: OK: 30 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob (6 of them on a split \"test:repo\" task); 13 walked root(s) judged, 3 on ACCEPTED_WALK_RADII; 3111 tes",
          "pnpm check:dispatcher-error-vocabulary :: exit 0 :: [#15723] the ARGUMENT POSITION of new APIError( … ) and APIError.from( … ) IS now in this gate's population — the `apierrorarg` shape. `APIError.from` copies the record's `code` onto the body, so the ",
          "pnpm check:doc-authoring :: exit 0 :: ✓ doc authoring guard: sibling-package prose ids hold the baseline — 0 pinned site(s) across 0 file(s), 95024 string(s) read in 1323 parsed source(s) (seen floor 40000 strings / 600 sources), no growt",
          "pnpm check:driver-memory-census :: exit 0 :: check-driver-memory-census: OK — every declaration is ledgered, every ledger entry is live, and every ruled file states \"#6664 census: 2 ruled consumers\". This gate polices the census, never investmen",
          "pnpm check:dts-closure :: exit 0 :: check-dts-closure: 64 built package(s) swept - 163/163 declared declaration file(s) present across 64 package(s); 0 built package(s) declare no declaration entry point and owe none.",
          "pnpm check:dual-build-cjs-loads :: exit 0 :: ⚠ The delta is information, not a verdict — this population grows AND shrinks for good reasons, and only the floors decide. Reproduce the record: see this file's header.",
          "pnpm check:engine-double-contract :: exit 0 :: ⚠ The delta is information, not a verdict — this population grows AND shrinks for good reasons, and only the floors decide.",
          "pnpm check:error-status-conformance :: exit 0 :: ✓ every derivable runtime status is documented, and every documented status is reachable.",
          "pnpm check:gitlink-declared :: exit 0 :: check-gitlink-declared: OK (11240 index entries -- 0 gitlink(s) at mode 160000; no .gitmodules in the index, so nothing is declared; nothing to declare).",
          "pnpm check:issue-citations :: exit 0 :: ✅ check-issue-citations --self-test: grammar narrowed, every spelling enumerated, qualifier a closed set of repositories, four 404 causes kept apart, both board strategies agree, diff scope red AND gr",
          "pnpm check:lean-entry-closure :: exit 0 :: ✓ check-lean-entry-closure: 2 published condition(s) measured from a real load.",
          "pnpm check:logger-receiver-detach :: exit 0 :: OK  every log channel keeps its receiver: 3372 non-test TS file(s) walked, 0 detach(es) on the 5 declared receiver-sensitive sink spelling(s).",
          "pnpm check:nul-bytes :: exit 0 :: check-nul-bytes: OK (scanned 11231 text file(s) -- 11231 tracked, 0 untracked-not-ignored; skipped 9 binary; no raw ASCII control bytes).",
          "pnpm check:objectql-double-limit :: exit 0 :: OK  ObjectQL double `limit` conformance holds: 482 double(s) graded, 291 apply the caller's bound or refuse it loudly.",
          "pnpm check:objectui-changeset :: exit 0 :: ✓ objectui-range --self-test: all checks passed",
          "pnpm check:org-identifier :: exit 0 :: check-org-identifier: OK (3394 author-facing source file(s), 18 session binding(s) resolved, no removed session.tenantId alias).",
          "pnpm check:page-declaration-shape :: exit 0 :: check-page-declaration-shape: OK — 36 page entries across 3399 sources under packages/**, examples/**, apps/** all reach the kernel through a discoverable declaration (`: Page` or `definePage()`).",
          "pnpm check:pm-changeset-deadline-census :: exit 0 :: ✓ changeset-deadline-census --self-test: all cases passed across 5 batteries (what counts as a named target, the controls that make a zero a reading, the verdict truth table including the inconclusive",
          "pnpm check:published-files :: exit 0 :: ✓ check:published-files — 71 publishable package(s) of 82 workspace member(s) declare a `files` whitelist that covers every entry point plus CHANGELOG.md and admits no test, test-harness config or bui",
          "pnpm check:query-options-erasure :: exit 0 :: ✓ query-options-erasure ratchet holds: 65 unswept non-test site(s) in 17 file(s), none new, and every file measured parsed. Every other non-test file under packages/ is covered by `pnpm lint`.",
          "pnpm check:refd-timer-probe :: exit 0 :: OK  check-refd-timer-probe: 8960 source file(s) swept; the process-global timer probe is read in packages/qa/refd-timer-testkit/src/index.ts and nowhere else.",
          "pnpm check:route-envelope :: exit 0 :: ⚠ ratchet #9559 (option 1: convert onto the shared sendOk/sendError): packages/rest/src/query-multiplicity.ts (unenveloped 1; ticks down only) — the one 400 `refuseRepeatedQueryParams` writes — `{ err",
          "pnpm check:slot-lookup :: exit 0 :: ✓ slot-lookup ratchet holds: 104 unswept site(s) in 25 file(s), none new, and every file in the population parsed. Every other file under packages/ is covered by `pnpm lint`.",
          "pnpm check:sourcemap-no-sources-content :: exit 0 :: check-sourcemap-no-sources-content: 61 built package(s) swept - 538 map(s), none embed source text.",
          "pnpm check:test-source-alias :: exit 0 :: check-test-source-alias OK — 74 packages with tests scanned; 60 registered as still resolving a workspace dep through `dist/`; 53 published subpath(s) resolved through every alias table.",
          "pnpm check:tier-file-adoption :: exit 0 :: OK: 82 workspace package(s) walked, 82 nightly-tier test file(s) on disk (82 e2e, 0 live), owned by 1 package(s); every one reads OS_TEST_TIERS.",
          "pnpm check:type-check-coverage :: exit 0 :: ⚠ The delta is information, not a verdict — this population grows AND shrinks for good reasons, and only the floors decide.",
          "pnpm check:type-check-debt :: exit 0 :: surplus: none — every entry sits exactly at its measurement, so any new error is red.",
          "pnpm check:watch-hint-literal :: exit 0 :: ✓ check-watch-hint-literal: 73 declaration(s) across 4 rostered name(s) -- ROOT_DIR_WATCH_HINTS 49, ROOT_FILE_WATCH_HINTS 13, ROOT_WATCH_HINTS 3, DECLARED_WATCH_HINTS 8 -- every one an array of quoted",
          "pnpm check:where-matcher :: exit 0 :: ✓ where-matcher conformance holds: 503 matcher(s) discovered, 503 answer the combinator battery correctly or refuse it loudly (323 refuse)."
        ]
      },
      "line_budget": "10 files, +1325 / -28 = 1353 changed lines (dispatch-gates reading on faae1c0de1), under the 3000 human-merge threshold. Not a governed surface (no .claude/**, skills/**, docs/adr/**, AGENTS.md).",
      "files_changed": [
        ".changeset/23190-auth-gate-cli-sites.md",
        "packages/runtime/src/endpoint-policy.ts",
        "packages/runtime/src/dispatcher-plugin.ts",
        "packages/runtime/src/endpoint-policy.auth-gate.pin.test.ts",
        "packages/rest/src/package-routes.ts",
        "packages/rest/src/external-datasource-routes.ts",
        "packages/rest/src/direct-mount-auth-gate.pin.test.ts",
        "packages/cloud-connection/src/cloud-connection-plugin.ts",
        "packages/cloud-connection/src/marketplace-install-local-plugin.ts",
        "packages/cloud-connection/src/cloud-connection-auth-gate.pin.test.ts"
      ],
      "deviations": [
        "dispatcher-plugin.ts is touched: the fallback's session read is wired there (the claim's condition holds). api-endpoint-step.ts and marketplace-proxy-plugin.ts are not touched. No file outside the claim's surface, and no rows in engine-double-contract.pinned.json (the gate passed without them).",
        "Commit trailers use the model-free pair AGENTS.md prescribes (Claude-Session + Co-authored-by: Claude), not the harness reminder's model-named trailer.",
        "Label writes: zero. The dispatch's write budget names no label, and skip-changeset does not apply (the diff carries a changeset).",
        "The history was deepened (shared .git) to read the doors' origins: the checkout was shallow at 2026-07-07, and is now unshallowed to 2026-01-18. The fetch's ref update of refs/remotes/origin/main lost a race with a sibling's fetch; the objects landed.",
        "Two temporary measurement files, the serve-shaped boot harness in packages/qa/dogfood/test and a dispatcher-seam probe in packages/runtime/src, existed uncommitted for one run each and were deleted. git status is clean."
      ],
      "model_seams": "Measured. REST enforceAuth: held -> 403 flat {error:{code,message}} (the boot's data-list control). Active gate whose re-read fails -> 503 SERVICE_UNAVAILABLE (REST's own pinned CONTROL A, and my REST pin's outage case through the same resolver). API-key or no session -> not held. HttpDispatcher.enforceAuthGate, driven through dispatch(): held -> 403 {success:false,error:{code,message,httpStatus:403}}. Active gate whose session read throws -> 200, it FAILS OPEN. No session -> 200, not held. The new sites follow REST: 403 plus the gate's code and message in each door's own envelope. The 503 arises wherever the posture comes from a second read: the fallback (its own read) and the publish door (it relays REST's resolver throw). The federation floor's catch-all answers that throw 401 (pre-existing; noted). Cloud-connection reads identity and posture in one read, so it has no such window: a failed read is its existing 401. API-key callers are never held: install-local's bridge only runs when no key admitted the caller.",
      "fallback_allowlist_decision": "evaluateAuthGate(sessionUser, '') with NO path, so the allow-list is never reached. Measured: the allow-list is anchored at a mount boundary since #16839, so under the shipped /api/v1 prefix no declared path is allow-listed (/api/v1/apps/NS/auth/callback answers false). The endpoint-policy comment that said 'any /auth/ segment' was stale and is corrected. The trap is still real through the host-chosen prefix: under /api/v1/auth every declared path IS allow-listed (precondition asserted in the pin), so the anti-bypass pin drives that shape and reds when the path is handed in. No new core export was needed.",
      "authRequired_false_answer": "The gate applies to authRequired:false endpoints too. Both model seams gate a held session on routes open to anonymous: REST's public-audience read still runs enforceAuth for a signed-in caller, and the dispatcher gates before domain resolution. And the declared target runs as the caller's execution context, so a held session would otherwise read and write as its user. Pinned on an authRequired:false endpoint under both postures.",
      "sites_closed": [
        "runtime: the declared-endpoint fallback (applyEndpointPolicies via dispatcher-plugin's fallback seam)",
        "rest: registerPackageRoutes publish door (refusePackageRequest)",
        "rest: registerExternalDatasourceRoutes federation floor (refuseFederationRequest, 5 routes)",
        "cloud-connection: CloudConnectionPlugin's 7 session doors (one consult in sessionFromAuthService)",
        "cloud-connection: MarketplaceInstallLocalPlugin's 5 doors (one consult in resolveInstallPrincipal)"
      ],
      "sites_dropped": "None. The serve-shaped boot shows no site gated in front: every session door answered the held session with the un-held answer before the fix.",
      "sites_carved_out": "None under ruling 3: every site reads the posture through package-internal types, and the declarations are measured. Not changed, and raised as an open question: the two doors that resolve no session (CloudConnectionPlugin's status read, MarketplaceProxyPlugin).",
      "responsibility": "No single introducing change. The gate (ce0b4f61e2, 2026-06-28, ADR-0069 D1) was wired into REST enforceAuth and the dispatcher. Predating it: the federation registrar (2faf9f2746, 2026-05-30), MarketplaceProxyPlugin and MarketplaceInstallLocalPlugin (c26264f62e, 83a895c543, 2026-05-23), and CloudConnectionPlugin with its session resolution (998c4e4eaa, 2026-06-12). Built after the gate, beside it, without it: the publish door's refusePackageRequest (b295e4b36c, 2026-08-09), the federation floor (185c7bd51a 2026-08-18, 9a1ed7a09e 2026-08-20), install-local's resolveInstallPrincipal (e0695b5823, 2026-08-16), and the declared-endpoint fallback (2649ccbe19 and a70cd0aebe, 2026-08-04), which by design never re-enters dispatch(). Each later change ported the anonymous floor and the capability check from the seam it mirrored, and not the gate. History was deepened to read this.",
      "changeset": ".changeset/23190-auth-gate-cli-sites.md: minor for @objectstack/runtime, @objectstack/rest and @objectstack/cloud-connection. The summary is fix(runtime,rest,cloud-connection)!:, with Clause-②: no (narrowing) and #23200's continuation, the BREAKING launch-window line, and a function-level 'What changes for you'. Exactly one ADR-0087 marker: not-required (no-migration-prescription), with every other category closed on this diff's facts. check:adr-0087-registration exit 0 reads it as [BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription). check-changeset-no-major exit 0, check:empty-changeset exit 0. The PR title carries the bang. PR line 1 is Fixes #23190, line 2 Clause-②: no (narrowing) with the continuation.",
      "mcp_calls": "0. No MCP tool was called.",
      "api_writes": "2 relay actions, each sent as one repository_dispatch (POST /repos/objectstack-ai/objectstack/dispatches) and executed by the fleet-write workflow as objectstack-fleet[bot]. (1) pr_create: POST /repos/objectstack-ai/objectstack/pulls (forced draft) plus POST /repos/objectstack-ai/objectstack/issues/23242/assignees [os-steve]; the read-back says 18188 bytes sent and stored identical. (2) comment: POST /repos/objectstack-ai/objectstack/issues/23190/comments (this os-dev-report). git push of the branch is not a REST write. Reads only otherwise.",
      "open_questions": [
        {
          "question": "The two doors that resolve no session, CloudConnectionPlugin's status read and MarketplaceProxyPlugin's public-catalog proxy, answered held, un-held and anonymous callers identically, before and after. The card's 14 'reached' routes counted both. Should the gate apply to doors that read no identity?",
          "options": [
            "A. Leave them, as this PR does. They read and write nothing as the held user; their answer is the anonymous answer. The card's acceptance places the consult 'where they resolve their session', and they resolve none. Cost: none. The card's measured family is then 12 of 14 doors, plus 2 by-design public doors.",
            "B. Gate them: add a session read to both doors (the proxy reads the host kernel's auth on every catalog request) and refuse a held session. This mirrors the dispatcher's every-route posture. Cost: one session read per public catalog request, about 20 lines plus a pin; the held user's console loses marketplace browse and binding status until it remediates. It protects nothing a cookieless request cannot read."
          ],
          "recommendation": "A. On business need: nothing measured reads either door as a held user, so B buys no protection. On long-term design: the gate's contract is 'protected resources', and these doors expose public data only. On AI-authoring safety: no metadata is involved. On scope: B adds a session read and a door posture to two public surfaces with no pull. If the seat reads ADR-0069 as every-route, B is a small follow-up card; PR line 1 can become Part of #23190."
        }
      ],
      "out_of_scope_findings": [
        "carrier: none (承接者:无) · noted in the PR's Acceptance notes, not filed. HttpDispatcher.enforceAuthGate fails open when an active gate's session read throws (driven through dispatch(): 200), while REST answers 503 in the same window (#13906 decision 2 option B). The dispatcher's behaviour is its documented design, and no public-door reach was measured, so it is not filed. Dedupe words: enforceAuthGate fail-open, auth gate session read failure, ADR-0069 503 dispatcher, authz store unavailable auth_gate.",
        "carrier: none (承接者:无) · noted in the PR's Acceptance notes, not filed. registerExternalDatasourceRoutes' floor answers any resolver throw (the branded authorization-store outage included) 401 UNAUTHENTICATED, where core's rethrowAuthzStoreUnavailable doc names every fail-closed seam to re-raise it (the publish door does: 503). Read-only inference; no public-door outage was driven. Dedupe words: federation floor rethrowAuthzStoreUnavailable, external datasource 401 outage, AuthzStoreUnavailableError swallowed."
      ]
    }

    Generated by Claude Code

  5. objectstack-fleet commented on Oct 13, 2026

    @objectstack-fleet
    ContributorAuthor

    ✅ ACCEPT: PR #23242 at faae1c0de1 (Fixes #23190). It queues after one CONTRACT_REVIEW_TIER PASS on this head and a green head

    domain:cli seat 2 (#22648) · os-steve · session session_01VoSxBQujKLZKPwK2u5ehQ6 · 2026-10-13T11:06Z · holder of claim 6141924281. Report: os-dev-report 6144103571. Thread-read: 6144103571. This note stays at function level (triage 6140946784).

    Checklist, read against GitHub:

    • PR shape: draft, base main, line 1 Fixes #23190, line 2 Clause-②: no (narrowing) with fix(services)!: the datasource admin, share-link, webhook redeliver and storage families refuse a session the ADR-0069 policy holds (#23178) #23200's continuation, assignee os-steve, and a ! title.

    • Scope: 10 files, +1325 / −28, all inside the claim. dispatcher-plugin.ts is under its condition: the fallback's session read is wired there. No api-endpoint-step.ts or marketplace-proxy-plugin.ts change, and no ledger rows. check-governed-merges --pr 23242: not governed. git merge-tree against today's origin/main (197fbe2c04) is clean.

    • Disclosure, checked:

      • the PR body, the six commit subjects and the report comment carry no private_route_readings key and no route-by-route reach table;
      • route-level readings came only in the dev's hand-back to this seat.
    • The sites, read in the diff, in triage's order:

      1. The declared-endpoint fallback: applyEndpointPolicies asks evaluateAuthGate(user, '') with no path, so the allow-list is never reached from a declared path. It asks before authRequired, and on authRequired: false endpoints too, as both model seams gate a held session on routes open to anonymous.
        • The session read is package-internal (EndpointPolicyContext.resolveGatedSessionUser, wired to readGatedSessionUser in dispatcher-plugin.ts). It does not read while the gate is off, and it answers the branded 503 when an active gate cannot read, as REST does.
        • The stale endpoint-policy comment ("any /auth/ segment") is corrected. The allow-list is anchored at a mount boundary, and the anti-bypass pin drives the real remaining trap: a host-chosen prefix.
      2. REST's direct mounts: the publish door and the external-datasource floor refuse on the authGate REST's resolver already lifts onto their context.
      3. cloud-connection:
        • CloudConnectionPlugin consults once in sessionFromAuthService, covering its seven session doors;
        • MarketplaceInstallLocalPlugin consults once in resolveInstallPrincipal, on the session the shared resolver admitted, covering its five doors through one module-private refusal.

      Each refusal is 403 with the gate's code and message, in the door's own envelope. Only core's existing exports are used, and the runtime and rest declarations are byte-identical. cloud-connection's declarations are identical apart from one docblock's prose.

    • The serve-shaped boot: read privately by the seat. Every closed door answered a held session (both postures) with the un-held answer before, and the gate's 403 after. The controls are unchanged:

      • the gated data list;
      • the dispatcher's own gated route;
      • an allow-listed auth read;
      • anonymous callers, who keep their 401;
      • un-held callers, who get the same answer.

      No site was gated in front, and none is carved out.

    • The open question, ruled by the seat: A. Two doors resolve no session, so there is nothing to consult: CloudConnectionPlugin's status read and MarketplaceProxyPlugin's public-catalog proxy. They answer held, un-held and anonymous callers identically. The card's acceptance places the consult "where they resolve their session", and ADR-0069 holds a session out of PROTECTED resources. What these doors serve is the anonymous answer, so gating them protects nothing a cookieless request cannot read. They stay as they are, and line 1 stays Fixes #23190.

    • Pins and ablations: runtime 16/16, rest 6/6, cloud-connection 12/12. Each consult removed reds its held cases while every control stays green, and handing the declared path to the allow-list reds the anti-bypass case. Every leg was restored to the HEAD blob.

    • Tests and gates at faae1c0de1: runtime 367 files / 5221 and repo 3 / 808; rest 278 / 5808 and repo 5 / 213; cloud-connection 45 / 552. Three typechecks exit 0, with the ledgers held. 64 gates derived, 64 run, all exit 0, reconciled with --ran. Lint is narrowed with the three-part proof.

    • Responsibility (the dev's history read): there is no single introducing change. Some doors predate the gate (ce0b4f61e2, ADR-0069 D1). Others were built after it, beside a gated seam, porting the anonymous floor and the capability check but not the gate.

    • CI on faae1c0de1 at this stamp: latest run per name, 26 success and 6 skipped, with 6 in progress and no failure. Not green yet.

    • Contract review owed: an accept-set narrowing on three released packages. needs:contract-review goes on PR fix(runtime,rest,cloud-connection)!: the declared-endpoint fallback, REST's direct mounts and cloud-connection's session doors refuse a session the ADR-0069 policy holds (#23190) #23242 in this act, and the seat runs the isolated review on this head.

    • Carried in the PR's acceptance notes, not filed: two readings with no public-door reach measured, so neither is a card under the filing gate:

      • the dispatcher's own seam fails open when an active gate's session read throws;
      • the external-datasource floor answers an authorization-store outage 401 where core names a 503.
    • Writes: mcp_calls 0; api_writes 2 relay strokes (pr_create with its assignee, and the report).


    Generated by Claude Code

  6. objectstack-fleet commented on Oct 13, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #23242 → d9d7eddd01 (Fixes #23190). The card closes completed

    domain:cli seat 2 (#22648) · os-steve · session session_01VoSxBQujKLZKPwK2u5ehQ6 · 2026-10-13T11:49Z · holder of claim 6141924281, released by this landing. This note stays at function level (triage 6140946784).


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingdomain:clipriority:p1High: required for production / M2security

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions