Skip to content

fix(plugin-security): report a metadata-store outage as an outage, not an absent declaration - #10707

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-10424-metadata-outage-unresolved-cause
Aug 21, 2026
Merged

fix(plugin-security): report a metadata-store outage as an outage, not an absent declaration#10707
os-warren merged 1 commit into
mainfrom
claude/issue-10424-metadata-outage-unresolved-cause

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#10424

SecurityPlugin.getObjectSecurityMeta resolved an object's posture and set
unresolved: !obj, where obj came from IMetadataService.get. That read is
ambiguous by construction — its own TSDoc says undefined means "not found"
and "every loader that could hold it failed", and directs callers to
getDiagnosed (#5840) wherever the difference could change a decision. The
verdict was already being computed and thrown away: MetadataManager defines
get as (await getDiagnosed(…)).data.

So a metadata-store outage and a genuinely absent declaration arrived wearing
the same sentence, and that sentence said "Check that the object is declared
and published on this runtime."
Correct for an absent object; actively wrong
mid-incident, where the declaration is fine and the store is not.

Measured before the change, not inferred

Three inputs through one tree in one run — an absent declaration, a store
reporting degraded: true, and a service with no getDiagnosed at all —
produced byte-identicalmessage and log line, with PERMISSION_DENIED /
403 in all three. After the change the first and third are unchanged verbatim
and only the outage diverges.

What changed

  • UnresolvedPostureCause gains a third member, 'metadata_unavailable', and
    the four wording surfaces in unresolved-posture.ts state it distinctly:
    the refusal names the store, says the declaration may well be fine, and
    the operator log line carries a grep-able DEGRADED / metadata-store OUTAGE. A metadata-store outage is an incident and a query against a missing
    object is routine; they were the same line.
  • A separate best-effort probeMetadataOutage, run only on the path that is
    already refusing.

What deliberately did NOT change

The resolving read is untouched byte for byte. The tempting shape — swapping
metadata.get for getDiagnosed and taking .data — was rejected: obj is
the input to a fail-closed deny (#3545), so sourcing it from an optional
member would put an externally observable accept/reject decision at the mercy
of every third-party IMetadataService whose getDiagnosed disagrees with its
own get or throws where get succeeds. An object that resolves today would
then be refused. This is an explanation change and stays one. The same trade
was made once before, for the same reason (#6055, written down in #6505): buy a
second read on the miss path rather than presume an equivalence the contract
never made.

The deny is unchanged in every case — same PermissionDeniedError, same
PERMISSION_DENIED, same 403, still fail-closed — and the accept/reject set
does not move. Pinned separately from the wording, in both directions.

The fail-safe direction

getDiagnosed is optional, and the contract is explicit that a consumer
probing for it must keep reading get when it is absent. A service that does
not implement it reports 'unknown' and keeps the previous wording — never
'metadata_unavailable'. Publishing "I don't know" as "the store is down"
would manufacture an incident out of a missing capability and send an operator
to read healthy dashboards. 'metadata_unavailable' is asserted on a positive
degraded === true and on nothing else; a probe that throws, returns a
non-boolean degraded, or resolves to nothing lands on 'unknown'.

A degraded read also outranks the draft probe: "a draft exists but no published
one" has a second half the outage made unknowable, since the store is exactly
what could not answer.

Tests

21 new cases. The three-way pin (absent → existing wording, outage → new
wording, no-capability → 'unknown') plus the fail-safe variants, the
precedence rule, the #3545 refusal envelope in all three cases, and four
accept-side cases proving the resolving read did not move.

Two ablations, predicted before running, both matching exactly:

AblationPredictedObserved
drop the outage leg from the cause resolver3 red — outage collapses back onto the absent wording; absent / no-capability / all deny pins stay green3 failed, 18 passed — exactly the three predicted
make the missing-capability fallback claim an outage1 red — only the third pin1 failed, 20 passed — exactly the third pin

The second is the one that matters: a suite pinning only absent and outage goes
green on an implementation that reports an outage for everything. Both files
restored byte-identically (git hash-object equal before and after).

Gates

Union derived with node scripts/pm/dispatch-gates.mjs (no path arguments)
after the final commit on a clean tree, at f14fd1cde. All green: the 11
path-derived families, the six convention-triggered ones (including
check:type-check-debt --re-measure with the workspace closure built, and
check:i18n reporting plugins/plugin-security in sync (4 bundle(s))), and
check:nul-bytes.

Note for the reviewer: the derivation named neither check:route-envelope nor
check:dispatcher-error-vocabulary; both were run explicitly, with
--self-test, and both pass.

Generated by Claude Code


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/permissions/field-level-security.mdx(via SecurityPlugin (symbol))
  • content/docs/permissions/index.mdx(via SecurityPlugin (symbol))
  • content/docs/plugins/packages.mdx(via SecurityPlugin (symbol))
  • content/docs/ui/forms.mdx(via SecurityPlugin (symbol))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx(via SecurityPlugin (symbol))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 45 of 221 client-bound route-ledger rows — the other 176 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 14 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 86a8ec93e03763f3a35f61a0c91a9c7231b50c6bpackageMentionDocs.

Which tree this was computed on

This run read content/docs from eb7c0468ed0fc84187276c26bfc21900df0b1559 — the merge of head f14fd1cde35515e71e756ba46d97bf53981e7c65 into base 86a8ec93e03763f3a35f61a0c91a9c7231b50c6b, 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 eb7c0468ed0fc84187276c26bfc21900df0b1559 && git checkout eb7c0468ed0fc84187276c26bfc21900df0b1559
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 86a8ec93e03763f3a35f61a0c91a9c7231b50c6b f14fd1cde35515e71e756ba46d97bf53981e7c65 && git checkout -B drift-repro 86a8ec93e03763f3a35f61a0c91a9c7231b50c6b && git merge --no-ff f14fd1cde35515e71e756ba46d97bf53981e7c65
node scripts/docs-audit/affected-docs.mjs --json 86a8ec93e03763f3a35f61a0c91a9c7231b50c6b

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 21, 2026
@os-warren
os-warren marked this pull request as ready for review August 21, 2026 09:22
@os-warren
os-warren enabled auto-merge August 21, 2026 09:22
@os-warren
os-warren added this pull request to the merge queueAug 21, 2026
Merged via the queue into main with commit b419135Aug 21, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-10424-metadata-outage-unresolved-cause branch August 21, 2026 09:40
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Security posture: a metadata-store OUTAGE is reported as an absent declaration — getDiagnosed's verdict is computed and discarded

1 participant

@os-warren