Skip to content

Unpublished object denied with "the security posture … could not be resolved" — the deny is right, the explanation sends readers to change permissions #10401

Closed

Description

@os-zhuang

What happens

A read against an object that exists only as an unpublished draft is refused by the
authorization middleware with:

[Security] Access denied: the security posture of object 'shyx_customer' could not be resolved for operation 'find'

Producer: packages/plugins/plugin-security/src/security-plugin.ts:1420 (the #3545
fail-closed branch, secMeta.unresolved). The sibling prose lives in
packages/plugins/plugin-security/src/explain-engine.ts:1121.

Why the message is wrong for this case

The deny is correct — nothing here argues with #3545's fail-closed stance. The
explanation is what misleads:

  1. It describes an internal resolution step, so every reader — human and model — reads it
    as a permissions problem and goes looking for a sharing rule to change. The object's
    declaration is fine; it simply has not been published.
  2. It is indistinguishable from the genuine #3545 case (an authenticated principal asking
    for an object whose declaration really is missing/corrupt). Two very different remedies,
    one string.

Measured downstream cost (cloud#1481): an end-user AI turn asked "how many customers do I
have?" against a draft-only object and spent seven tool calls / 31 seconds oscillating
between a metadata plane that said the object existed and this refusal, then told the user
the object "is missing its sharing/visibility setting" — confident, professional, and wrong.
On a free plan that single turn also exhausted the daily allowance.

Suggested shape

Where the posture is unresolvable because the object has a pending draft and no active
row
(the runtime already distinguishes these — that is what previewDrafts overlays),
deny with its own code and message, e.g. unpublished_object: "object 'X' is not published
yet"
. Keep the existing message for the true unresolvable case.

The consumer side was deliberately not patched: cloud's ask agent now answers the
draft-only case from the metadata plane before a query is attempted
(objectstack-ai/cloud#1481), but the data plane's own message is the producer's to fix and
is reachable from every other client too (REST, GraphQL, objectui).

Filed from objectstack-ai/cloud#1481 (cloud PR objectstack-ai/cloud#1485).

Activity

  1. os-zhuang commented on Aug 20, 2026

    @os-zhuang
    ContributorAuthor

    Claim: PM loop round 5 (epic objectstack-ai/cloud#1472 — derived card, half (2) of cloud#1481)
    Session: session_3f79f27a-67a1-44c5-b9e3-f9e08b0aba46
    Branch: claude/issue-10401-unpublished-object-deny-message
    Worktree: objectstack-issue-10401
    Domain: epic lane; lands in packages/plugins/plugin-security
    File surface: packages/plugins/plugin-security/src/security-plugin.ts (the #3545 fail-closed branch ~1420), packages/plugins/plugin-security/src/explain-engine.ts (~1121) + tests + changeset (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: opus
    Clause-②: no (error-message honesty; deny behavior unchanged)
    Serial constraints cleared: sibling in flight #10377 patch round edits packages/metadata-protocol — disjoint. cloud PRs #1485/#1486 (different repo).

  2. self-assigned this
    on Aug 20, 2026
  3. os-zhuang commented on Aug 20, 2026

    @os-zhuang
    ContributorAuthor
    {
      "issue": 10401,
      "status": "done",
      "branch": "claude/issue-10401-unpublished-object-deny-message",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/10423",
      "premise_still_valid": true,
      "summary": "Premise verified against origin/main: both file:line anchors were accurate (security-plugin.ts:1412-1423 fail-closed throw, explain-engine.ts:1121 sibling prose). The deny is untouched — same PermissionDeniedError, PERMISSION_DENIED, 403, and the load-bearing '[Security] Access denied' matcher prefix; only the explanation changed. A draft-only object now reads \"object 'X' is not published — a draft declaration exists but no published one … Publish the object to make it queryable. This is NOT a permissions problem …\"; the genuinely-unresolvable case keeps its pre-existing opening clause VERBATIM (so any surface pinning it still matches) followed by the same not-a-permissions-problem statement. PM assumption 1 CONFIRMED with evidence: the distinction is knowable at this layer with no new plumbing — sys_metadata keys drafts as state='draft', and the plugin already issues ~20 ql.findOne(..., { context: { isSystem: true } }) reads, with isSystem short-circuiting the middleware at line 1168 so the probe cannot re-enter the posture resolution. PM assumption 2 adopted as written (code/status unchanged, cause in the MESSAGE) — and independently confirmed by the repo's own recorded reasoning in errors.ts: `details` is not a reliable carrier across both transports (#7450), so the message is the only carrier; the #7474 precedent for a separate error class was considered and rejected in the PR body under the dispatched ruling. PM assumption 3 satisfied by making one module (unresolved-posture.ts) the single source for the throw, the explain prose and the operator log line, with a test asserting the throw is that module's output verbatim and that both surfaces carry the same remedy string.",
      "tests": "All on the pushed head ea29e7d58, clean tree, exit codes captured before any pipe (never read through tail). Build first: `pnpm --filter '@objectstack/plugin-security^...' build` EXIT=0. Suite: `pnpm --filter @objectstack/plugin-security test` EXIT=0 — 'Test Files 67 passed (67) / Tests 1318 passed (1318)', covering 14 new cases in unpublished-object-deny-message.test.ts plus the 11 pre-existing #3545 pins in metadata-unresolvable-posture.test.ts, which were NOT modified and still pass (their toContain('could not be resolved') sits on the unresolvable branch, whose clause is kept verbatim). Typecheck: `pnpm --filter @objectstack/plugin-security typecheck` EXIT=0 (one real TS2551 was found and fixed mid-run — the no-permission-sets stand-in literal needed the new key spelled out). Gate union derived from the actual diff by `node scripts/pm/dispatch-gates.mjs` with NO hand-supplied paths (it read merge-base fda3d67f9, 5 committed paths, 0 working-tree, 0 untracked); every gate it named ran green: check:authz-resolver, check:changeset-gate-self-tests, check:cross-package-test-inputs, check:objectui-changeset, check:slot-lookup, check:test-source-alias, check:type-source-resolution, check:query-options-erasure, check:engine-double-contract, check:where-matcher, check:type-check-coverage, check:nul-bytes ('OK (scanned 6125 text file(s) … no raw ASCII control bytes)'), scripts/check-adr-0087-registration.mjs, scripts/check-changeset-no-major.mjs, scripts/check-empty-changeset.mjs, scripts/check-cross-package-test-inputs.mjs, scripts/docs-audit/check-affected-docs.mjs. Two gates refused a prerequisite before passing, both satisfied rather than skipped: check:i18n needed the built CLI (built @objectstack/cli, then 'check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys)'), and check:type-check-debt --re-measure named exactly one unbuilt closure member, @objectstack/service-knowledge (built it, then 'OK — 33 ledger entr(ies) re-measured in 75.8s, 1924 raw tsc error(s) total, none above its recorded number. surplus: none'). NO ABLATION WAS RUN, deliberately and reported as such rather than templated: the discriminator's two branches are live negative controls inside the suite itself — the same harness with draftRow:false and with a throwing probe both produce the unresolvable wording and assert the unpublished wording is ABSENT, and a further test asserts an ALLOWED request issues zero sys_metadata reads — so a mutation leg would re-prove what the negative controls already prove. Message-pin sweep (the reverse-verification this card actually needed): in this repo exactly one file pinned the old text (metadata-unresolvable-posture.test.ts, unaffected as above) and no other source, doc, fixture or gate across 6125 tracked text files matches 'security posture of' or 'could not be resolved for operation'; a read-only grep of the local ../cloud and ../objectui checkouts at their current HEAD found zero pins of either phrase, noted in the PR body for a PM re-sweep at merge time since those checkouts may lag their origin/main.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #10424: getObjectSecurityMeta reads metadata.get(), which spec documents as ambiguous between MISS and OUTAGE and directs callers to getDiagnosed (#5840) — so a metadata-store outage is now reported with the 'declaration cannot be read' wording whose remedy ('check that the object is declared') is wrong for an outage. Same defect class as this card, one input over; slots into the new unresolvedCause discriminator as a third cause, but needs the optional getDiagnosed capability probed and its own wording review. Filed unassigned, no label."
      ]
    }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

pm:dispatchedDispatched to a dev agent by /pm-dispatch

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions