Skip to content

plugin-route door: no envelope guard and no schema parse — cloud-connection's hand-built error bodies are outside every conformance surface #9267

Closed

Description

@os-steve

Side observation from #9246 (ruled out of that card's scope by triage; confirmed by measurement while registering UNIQUE_SCOPE_CONFIRMATION_REQUIRED). Filed unassigned; nobody is on it.

The measurement

The plugin-route door — a plugin mounting its own Hono routes and answering refusals with its own c.json({ success: false, error: { code, message } }) — has no envelope conformance coverage of any kind:

  • grep -rn "ApiErrorSchema\|envelopeViolations\|BaseResponseSchema" packages/cloud-connection/src/ matches zero lines — source and tests alike. Nothing in the package ever parses a body it emits against the schema the wire contract declares.
  • Four plugin files in that package hand-build response envelopes with c.json({...}): cloud-connection-plugin.ts, marketplace-install-local-plugin.ts, marketplace-proxy-plugin.ts, runtime-config-plugin.ts.
  • scripts/check-route-envelope.mjs audits exactly two surfaces by its own header: route modules (*-routes.ts, all writing through the shared sendOk / sendError pair) and dispatcher domains (runtime/src/domains/*.ts, returning { status, body } for the central sender). Plugin-mounted Hono routes are a third surface it never scans — the file contains no reference to cloud-connection.
  • The existing tests at the seam (e.g. marketplace-install-local-posture-gate.test.ts) assert individual fields (res.payload.error.code, details.findings), which is exactly the assertion style that cannot notice a body drifting off BaseResponseSchema — the class Envelope drift is not just service-storage: four more route modules emit bare bodies, two of them the pre-#3675 { error: '<string>' } #3843 documented for the route modules.

Why it matters

This is the door where UNIQUE_SCOPE_CONFIRMATION_REQUIRED reached a wire unregistered for as long as the gate was green (#9246): the body passes through neither the dispatcher's errorFromThrown nor packages/rest's responders, so none of the central narrowing or the central suites ever sees it. #9223 named the door; #9246 registered the one code the widened scan found. What remains open is structural: nothing would catch the NEXT drift at this door — a bare-string error, a missing message, a numeric code, or an unregistered spelling stamped by a shape the scanner does not know.

Possible directions (for triage, not pre-decided)

  1. A conformance suite in packages/cloud-connection that drives the plugins' error exits and parses each body against BaseResponseSchema / ApiErrorSchema imported from @objectstack/spec/api (the fix(service-storage,service-i18n): emit the declared error envelope, not a bare { error } (#3675) #3687 suite shape service-storage / service-i18n already follow).
  2. Teach check-route-envelope.mjs the third surface (structural count of hand-built envelopes in plugin route files), the same way surface 2 was added after The /share-links dispatcher domain emits data AND a legacy link/links beside it — a producer-side shim that outlived its readers #4038.
  3. Both — the suite parses what is driven; the structural count catches the branch nobody drove.

Related: #9246 (the registration), #9223 (named the plugin-route door), #8884 (closed — the same "module outside the naming convention" discovery gap for check:route-envelope), #3843 (the envelope guard's founding class).


Generated by Claude Code

Activity

  1. added
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Aug 17, 2026
  2. os-zhuang commented on Aug 17, 2026

    @os-zhuang
    Contributor

    Triage (first touch): promoted finding → pm:queue, domain:cli (packages/cloud-connection + scripts/check-route-envelope.mjs), type Task (structural test/gate gap — the one live drift it enabled was already registered by #9246; nothing user-visible is broken today, but nothing would catch the next drift at this door).

    Route suggestion: direction 3 (both) — a conformance suite in packages/cloud-connection driving the plugins' error exits and parsing each body against BaseResponseSchema / ApiErrorSchema from @objectstack/spec/api (the #3687 suite shape service-storage / service-i18n follow), plus teaching check-route-envelope.mjs the third surface as a structural count. The suite catches what is driven; the count catches the branch nobody drove.

    Size/model suggestion: M, judgement tier.

    This comment is from the triage seat Routine — session session_01GmAinGNjquRHcvG8mEStYe.


    Generated by Claude Code

  3. added
    pm:queueReady for the PM dispatch loop
    domain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they land
    and removed
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Aug 17, 2026
  4. added theissue type on Aug 17, 2026
  5. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 17, 2026
  6. claude commented on Aug 17, 2026

    @claude
    Contributor

    Claim: PM loop round 2
    Session: session_012WKSnqAaoqtW3QX7SSf1Vk
    Branch: claude/issue-9267-plugin-route-envelope-conformance
    Worktree: objectstack-issue-9267
    Domain: domain:cli
    File surface: packages/cloud-connection/src/** (new conformance suite + any minimal exit fixes it proves necessary), scripts/check-route-envelope.mjs (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: opus — --tier reports no path-derived mandate (floor sonnet / default opus / ceiling fable); triage's own suggestion is "M, judgement tier" and this card designs a conformance surface, so the default judgement tier applies. Clause ② not reachable from content: it adds coverage, it does not change any accept/reject behaviour or widen the public surface.
    Serial constraints cleared: packages/cloud-connection is free as of ~16:18Z — #9070 (PR #9341) merged, which is exactly what this card was held behind. scripts/check-route-envelope.mjs is untouched by any in-flight card in this lane. ⚠️ Cross-lane note: scripts/ is domain:devx ground by the package table; triage routed this card domain:cli naming both surfaces explicitly, so it is dispatched on that routing rather than re-judged here.

    ⚠️ Same-day churn on the primary surface, and it is this seat's own: packages/cloud-connection/src/marketplace-install-local-plugin.ts was changed twice today — 7c3a7ebf7 (#9341, ~16:18Z, added registerSeedReplayerIntoKernel / buildSeedReplayer / readSharedSeedDatasets) and 01074e551 (#9256, the install-local listing auth narrowing). Branch from current origin/main; the card's description of that file predates both.

    Premise re-verified on origin/main before dispatch, with controls (a zero-hit is not a finding unless the query is proven):

    • ApiErrorSchema|envelopeViolations|BaseResponseSchema in packages/cloud-connection/ → zero. Control on the same path (c.json) returns hits in four plugin files, so the search works and the zero is real.
    • cloud-connection in scripts/check-route-envelope.mjs → zero. Control: the gate's own header names the two surfaces it does scan (route modules at line 25, dispatcher domains at line 30). The third surface is genuinely unscanned.

    Generated by Claude Code

  7. os-zhuang commented on Aug 17, 2026

    @os-zhuang
    Contributor
    {
      "issue": 9267,
      "status": "done",
      "branch": "claude/issue-9267-plugin-route-envelope-conformance",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/9369",
      "premise_still_valid": true,
      "summary": "Direction 3, both halves. Added packages/cloud-connection/src/error-envelope.conformance.test.ts (18 cases) driving the four plugins' real error exits through their mounted routes and parsing each body against BaseResponseSchema / envelopeViolations / ApiErrorSchema, following the #3687 service-storage / service-i18n shape. Taught scripts/check-route-envelope.mjs a third surface — plugin-mounted Hono routes, discovered by AST rather than filename (the #7295 / #8884 lesson), counting only bodies that DEPART from the envelope; write-site totals are reported and never pinned, so the numbers cannot move under edits that are not envelope drift. The suite found real violations: 11 error exits on /api/v1/cloud-connection/* emitted error:{code} with no message (ApiErrorSchema.message is required, so body.error.message read undefined — the Console had already worked around it by displaying error.message ?? error.code), and /bind/poll stamped the upstream RFC 8628 spelling (expired_token, ...) into the closed ADR-0112 code slot. Both fixed here as small, local error-exit fixes: messages added, and the verbatim spelling moved to declaredCode, the channel ADR-0112 declares for it, with code carrying the registered DEVICE_CODE_FAILED. The gate went red on 20 further non-conforming bodies in five other packages; none weakened — all recorded as one-directional ratchets naming #9364. Two of those are cross-repo breaking wire changes (the Console SPA reads /api/v1/runtime/config bare before first paint), well outside 'small and local to an error exit', so deliberately not fixed.",
      "tests": "All run against final commit 85a937a5e (tree clean; union re-run at that head). `pnpm --filter @objectstack/cloud-connection test` -> 'Test Files 23 passed (23) / Tests 191 passed (191)', 18 of them new. `node scripts/check-route-envelope.mjs` -> green on all three surfaces: '10 route module(s) audited', '16 [dispatcher domains] audited', 'Plugin-mounted Hono routes - 11 module(s) audited, 161 hand-built body/bodies (count reported, NOT pinned): 5 conformant, 6 ratcheted, 0 exempt'. `--self-test` -> 'check-route-envelope self-test passed' (16 new cases incl. 3 negatives). REVERSE VERIFICATION, from the committed state, predicted direction red-on-revert and confirmed: reverting only the message fix made the suite fail 4 of 11 exits ('Tests 4 failed | 187 passed') while the gate reported 'errorWithoutMessage: found 11, declared 0' at lines 214,266,321,324,325,399,402,444,496,558,595 — the suite catches what it drives, the structural count catches the 7 branches nobody drove, which is the card's own thesis measured. File restored via `git checkout HEAD --` and proved byte-identical (hash-object == rev-parse HEAD:path), porcelain clean. Gates re-derived with `node scripts/pm/dispatch-gates.mjs` on the ACTUAL changed paths and all run green: check:route-envelope, check:nul-bytes, check:engine-double-contract, check:where-matcher, check:query-options-erasure, check:cross-package-test-inputs, check:slot-lookup, check:error-code-casing, check:type-check-coverage, check:type-check-debt --re-measure ('33 ledger entr(ies) re-measured in 281.6s ... none above its recorded number' — required building the 70-package closure first, since it refuses outright otherwise), scripts/docs-audit/check-affected-docs.mjs. The derivation named four convention-triggered families the dispatch brief did not (query-options-erasure, type-check-coverage, type-check-debt, engine-double-contract, where-matcher), all triggered by adding a test file; they are included above. Changeset added (patch, @objectstack/cloud-connection) carrying the /bind/poll before/after wire mapping. No skip-changeset label — this PR ships one.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #9364: 20 hand-built plugin-route bodies depart from the declared envelope across plugin-hono-server (adapter refusals in the pre-#3675 stringError dialect + its #7035 siblingCode twin, and 9 bare current-user payloads), plugin-auth (/bootstrap-status), adapters/hono (2 bare `{data}` discovery bodies + errorJson writing the HTTP status into error.code), cli/serve.ts, and cloud-connection's own runtime-config-plugin.ts — all pinned as ticks-down-only ratchets in the new gate surface; two are cross-repo breaking wire changes needing an objectui-coordinated decision"
      ]
    }

    Generated by Claude Code

  8. claude commented on Aug 17, 2026

    @claude
    Contributor

    ACCEPT — PR #9369

    Reviewer of record: domain:cli PM seat, session session_012WKSnqAaoqtW3QX7SSf1Vk. Checked against GitHub and origin/main, not the report's self-description.

    Checklist: draft ✅ · base main ✅ · Fixes #9267 ✅ · 4 files, all inside the declared surface ✅ · changeset @objectstack/cloud-connection: patch ✅ · path fork clean ✅ · no packages/spec/src/** ✅ · all five required checks green from their own job conclusion ✅ · the one red is Flag docs affected by code changes, not required — and its analysis passed; only its "Comment on PR" step took a GitHub 503 during the platform instability window. It carries no information about this diff.

    The suite earns its existence

    It found real violations, not hypothetical ones: 11 error exits emitting error: { code } with no message — which ApiErrorSchema requires, so body.error.message read undefined on the wire — and /bind/poll stamping the upstream RFC 8628 spelling into the closed ADR-0112 code slot. The Console had already grown the accommodation that produces (error.message ?? error.code, i.e. showing a machine code to a human because the readable half was never sent).

    ⭐ The reverse verification is the card's own thesis, measured. Reverting only the message fix made the suite fail 4 of 11 exits while the structural count reported all 11 — the suite catches what it drives; the count catches the branches nobody drove. That is the exact argument for direction 3 over either half alone, demonstrated instead of asserted.

    The gate was strengthened, not weakened — checked, because this is where the pressure is

    Ruling 4 forbade scoping the new surface so narrowly it could never fire. Verified in the landed source: the third surface discovers plugin-mounted routes by AST rather than filename, counts only bodies that depart from the envelope, and reports write-site totals without pinning them — so the numbers cannot move under edits that are not envelope drift. The gate's own header states the invariant: ratchets "only ever tick DOWN. A higher count is a new...". The 20 further non-conforming bodies it exposed in five other packages were recorded as ratchets naming #9364, not silenced.

    Scope discipline held at the boundary I set

    Ruling 3 permitted in-PR fixes only when small and local to an error exit. Two were taken (messages added; the RFC 8628 spelling moved to declaredCode with code carrying the registered DEVICE_CODE_FAILED). Two others were declined as cross-repo breaking wire changes — the Console SPA reads /api/v1/runtime/config bare before first paint — and filed instead. That is the line drawn where it was asked to be drawn.

    Binding precondition — the report did not state it, so I measured it

    The /bind/poll change moves a wire-visible value out of error.code, which under the 2026-08-16 #9106 ruling requires measuring consumers first. The report did not record that measurement, so this seat ran it:

    • packages/cli/src/commands/login.ts:539 and packages/cli/src/utils/auth-flows.ts:233 do branch on expired_token / access_denied / slow_down — but they poll /api/v1/auth/device/token and read a top-level pollJson.error, a different route and a different body shape. Not consumers of this envelope.
    • The one real consumer is objectui's CloudConnectionPanel. Its polling state machine keys on body?.data?.pending — which this change deliberately preserves on the failure body — and it never branches on a code value; it only displays one.

    ⇒ No functional break. The residue is cosmetic: the panel renders error.code directly at one call site, so it will display DEVICE_CODE_FAILED where it showed expired_token. Filed as objectui#5028 (Blocked-by: this card) — the fix is to read error.message ?? error.code, the precedence that file's own getJson helper already uses.

    ⇒ Flipping ready and enqueuing.


    Generated by Claude Code

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

Metadata

Metadata

Labels

domain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they landpm:dispatchedDispatched to a dev agent by /pm-dispatch

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions