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
Commit ce18e73
Browse filesBrowse the repository at this point in the historyBrowse files
fix(metadata-protocol): a widget probe refused by read admission names the caller's read access, not a dataset edit (#25560)
<!-- claude-projects-attribution: {"github_login":"os-sales"} -->
_Requested by **Sales** · [project
thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuDWJsBGQpadCH8JR2xZQ5re)_Fixes#25544
Clause-②: no
Metadata-protocol: none
## What was wrong
`POST /api/v1/packages/:id/publish-drafts` probes each dashboard widget
by running `queryDataset` as the publishing caller. A publisher who may
not read the dataset's object gets an error-tier `widget_query_failed`
issue. Its message is right (`[Analytics] Access denied: reading
"task_item" is not permitted for this user.`). Its `fix` was not: the
widget plane's `catch` gave one fix for every throw, "Fix the dataset
definition (or the widget's values/dimensions) so the query compiles,
then republish." The dataset was correct. An author or agent who
followed the fix would edit a working dataset.
## What changed
`packages/metadata-protocol/src/build-probes.ts`, the widget plane's
`catch` only:
- **The discriminator is the error's ADR-0112 `code`, not its message.**
The analytics service's read admission throws `code:
'PERMISSION_DENIED'`, `status: 403`, `object: OBJECT_NAME`
(`read-admission.ts`), and the field-level gate throws the same code
with `fields` (`field-read-admission.ts`). `readAdmissionRefusal(e)`
matches on `code === PERMISSION_DENIED` (typed as `StandardErrorCode`)
and reads `object` and `fields` from the error.
- **An access refusal gets an access fix.** The fix says dataset
`DATASET_NAME` is not the cause and needs no change, that the publisher
(or the role the dashboard is for) needs read access to the refused
object (and the refused fields when the error names them), and that the
finding is about this caller: a viewer who has that access can read the
widget. The object comes from the error first, because a joined object
can be the one refused. The dataset's `object` is the fallback.
- **One `PERMISSION_DENIED` stays on the dataset fix.**
`fieldReadUnjudgeableError` (a member that is not a column reference)
carries `member`, and its remedy is a dataset change. A `member` on the
error therefore keeps the old fix.
- **Unchanged:** `code` (`widget_query_failed`), `severity` (`error`),
`message`, and every other probe plane.
## Tests
-
`packages/metadata-protocol/src/build-probes-widget-refusal-fix.test.ts`
(new) runs `runBuildProbes` with errors built the way the analytics
service builds them. Six cases: object refusal → access fix;
joined-object refusal → names the joined object; field refusal → names
the fields; `DATASET_INVALID` → dataset fix; `PERMISSION_DENIED` with
`member` → dataset fix; bare `Error` → dataset fix. Each case also
checks that `code`, `severity`, `ref` and `message` did not change.
-
`packages/runtime/src/domains/packages-publish-drafts-probe-caller-context.test.ts`
exercises the real path: the door, `publishPackageDrafts`,
`runBuildProbes` and a real `AnalyticsService`. The non-reader case now
checks that the fix names `read access to object "task_item"` and is not
the dataset fix. A new third case publishes, as the reader, a dataset
whose measure reads a relationship path missing from `include`. The real
compiler refuses it (`DATASET_INVALID`), and the dataset fix is kept.
**Ablation, run once (runtime imports `@objectstack/metadata-protocol`
through `dist/`, so it was rebuilt):** `node
scripts/ablation-replace.mjs` changed `err.code !== PERMISSION_DENIED`
to `err.code !== 'ABLATION_25544'`. Its output: `ok mutation landed:
anchor 1 -> 0, blob b4e89c9 -> 2de0ef3cb9ab`. After `pnpm --filter
@objectstack/metadata-protocol build`, `ablation-dist-preflight.mjs`
printed `✓ dist/: marker present in 2 built files`. The tsup JS emitted;
the dts step exited 1 because the now-unused constant tripped
`noUnusedLocals`, and the JS is what the suites import.
- Unit file: `3 failed | 3 passed (6)`. The three access arms went red;
the three dataset-fix arms stayed green.
- Runtime pin: `1 failed | 2 passed (3)`. The non-reader case went red.
Restore: `ok restored: blob == HEAD (b4e89c9) and git diff HEAD is
empty`. After a rebuild, the preflight with `--absent` printed `✓ dist/:
marker absent from all 24 built files`. Both suites were green again: `6
passed (6)` and `3 passed (3)`.
## Local verification
All readings on the final head `967be5c033`.
- **Build closure (①):** `pnpm --filter '@objectstack/runtime^...'
--filter '@objectstack/metadata-protocol' build` under
`scripts/pm/os-verify-lock.sh` — `VERDICT command-exit 0`.
- **Affected packages (②):** `pnpm --filter
@objectstack/metadata-protocol test` — `Test Files 263 passed | 3
skipped (266)`, `Tests 21466 passed | 19 skipped (21485)`. `pnpm
--filter @objectstack/runtime test` — `Test Files 440 passed (440)`,
`Tests 7035 passed | 19 skipped (7054)`. `pnpm --filter
@objectstack/metadata-protocol typecheck` and `pnpm --filter
@objectstack/runtime typecheck` — both exit 0, in the same locked
`VERDICT command-exit 0`.
- **Derived families (③):** `node scripts/pm/dispatch-gates.mjs
--commands --repo objectstack-ai/objectstack` derived 69 commands; every
one was run with its exit code captured before any pipe. All 69 exited
0. `node scripts/pm/dispatch-gates.mjs --ran ran.list --repo
objectstack-ai/objectstack` → `✓ dispatch-gates --ran: 69 derived
famil(ies) accounted for — 69 run, 0 NOT-MEASURED (a DERIVED zero — all
69 recorded an exit code and none of them is 3)`.
- Two of the 69 were red on an earlier head and are green here: `pnpm
check:error-code-casing` (exit 1 — the new test's
`'widget_query_failed'` literal needed the ADR-0112 D6c `adr0112-ok:`
reason the sibling pin already carries; added, now `✓ no unlisted
lowercase error codes in 9427 scanned file(s)`) and `pnpm
check:dual-build-cjs-loads` (exit 3 — `PREREQUISITE NOT MET`, 38
packages had no `dist/` in this worktree; green once the closure was
built).
- **Repo scan:** `pnpm lint` — exit 0.
- **NOT MEASURED:** the 52 artifact-roster families, the 10 declared
wide-population families, the 5 path-scheduled CI jobs (`Test Core`,
`Build Core`, and the three `main`-only batteries), the 4 CI type-check
lanes and the always-runs tail — each outside the derived set and owned
by CI, per the tool's own accounting. `pnpm check:dev-prereqs` and `pnpm
check:skill-frame-freshness`: not derived and not owed (no
`packages/spec` build and no four-axis frame file in the diff).
- **Dogfood battery:** n/a — no `!` in the title and `Clause-②: no`
carries no `(narrowing)` arm.
## Acceptance notes
- The analytics service gives three different `PERMISSION_DENIED`
refusals the same structured shape: `code`, `status` and `object` and
nothing else. They are the object read-admission denial
(`readAdmissionDeniedError`), "the field-level read permissions could
not be resolved" (`fieldReadUnresolvedError`) and "the API exposure
declaration could not be resolved" (`exposureUnresolvedError`). A
consumer cannot tell "the caller has no grant" from "the gate could not
reach a verdict" without reading the prose. This PR does not read the
prose. The two unresolved cases therefore get the access fix, which is
right about the dataset ("not the cause") and only partly right about
the remedy (an operator fault, not a missing grant). A stable
discriminator such as a `reason` field belongs to
`packages/services/service-analytics`. That is the services seat's
surface and is not edited here.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01CxBKycaiUpA1yGx1XyoGqX)_
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01CxBKycaiUpA1yGx1XyoGqX
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(metadata-protocol): a publish build probe refused by read admission tells the publisher to get read access, not to change a correct dataset
6
+
7
+
Clause-②: no
8
+
Metadata-protocol: none
9
+
10
+
The dashboard-widget probe runs the widget's dataset query as the publishing caller. A publisher who may not read the dataset's object got a `widget_query_failed` issue whose `fix` said to fix the dataset definition so the query compiles. The dataset was correct; the cause was the publisher's read access.
11
+
12
+
-**An access refusal names the access.** When the analytics service refuses the query with `PERMISSION_DENIED` (an object or a field the caller may not read), the `fix` now says the dataset is not the cause, and that the publisher (or the role the dashboard is for) needs read access to the refused object, and the refused fields when the refusal names them. The refusal is told apart by the error's code, not its message.
13
+
-**Every other failure keeps the dataset fix.** A selection the dataset does not declare, and a member the field-level gate cannot judge (also `PERMISSION_DENIED`, but its remedy is a dataset change), keep "Fix the dataset definition (or the widget's values/dimensions) so the query compiles, then republish."
14
+
-**Nothing else changes.** The issue's `code` (`widget_query_failed`), `severity` (`error`) and `message` are the same, and so is every other probe plane.
0 commit comments