You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Unpublished object denied with "the security posture … could not be resolved" — the deny is right, the explanation sends readers to change permissions #10401
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:
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.
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).
{
"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."
]
}
Activity
os-zhuang commentedon Aug 20, 2026
Claim: PM loop round 5 (epic objectstack-ai/cloud#1472 — derived card, half (2) of cloud#1481)
Session:
session_3f79f27a-67a1-44c5-b9e3-f9e08b0aba46Branch:
claude/issue-10401-unpublished-object-deny-messageWorktree:
objectstack-issue-10401Domain: epic lane; lands in
packages/plugins/plugin-securityFile 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: opusClause-②: 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).fix(plugin-security): say "not published" when an unpublished object …
os-zhuang commentedon Aug 20, 2026
{ "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." ] }fix(plugin-security): say "not published" when an unpublished object …
fix(security): explain answers 404 OBJECT_NOT_FOUND for an object tha…