Skip to content

test(rest): measure identity resolution at GET /ui/view/:object/:type — seam, downstream producer, and the ratchet blind spot behind it - #13244

Draft
os-trump wants to merge 2 commits into
mainfrom
claude/issue-13214-ui-view-route-identity-reading
Draft

test(rest): measure identity resolution at GET /ui/view/:object/:type — seam, downstream producer, and the ratchet blind spot behind it#13244
os-trump wants to merge 2 commits into
mainfrom
claude/issue-13214-ui-view-route-identity-reading

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes #13214

MEASUREMENT ONLY. Nothing here repairs, gates or changes access-control behaviour.
Access control is a human floor in this repo. If a reading below is judged a defect, the
repair is a separate card with a human decision on it. What this PR lands is the instrument
and its readings, so nobody has to re-derive them from grep.

packages/rest/src/rest-server.ts is unchanged. The diff is one new test file.


Card premise: HOLDS, and is now measured on both halves

#13214 relayed four seam readings from #13160 / PR #13213 and explicitly marked one thing
UNMEASURED: whether getUiView applies authorization of its own downstream. Every relayed
reading reproduced on an independent harness (this file builds its own; it cites #13213
for nothing), and the unmeasured half is now driven.

① Does getUiView gate downstream? — No. Measured, not inferred.

Driven against the real ObjectStackProtocolImplementation mounted on the real
registerRoutes() table:

leg status body resolveExecCtx calls
absent execution context 200 full view 0
entitled execution context 200 byte-identical 0

The one field the producer drops (hidden) is dropped by declaration — identically for
both callers. salary is served to both. There is no caller-keyed narrowing anywhere on the
path, and there cannot be: §2 records the exact argument the seam hands the producer and it
is exactly { object, type } — no context, executionContext, ctx, userId, user,
tenantId, organizationId, principal, req or request, driven with an entitled
context in scope
so this is not "there was no identity to pass". §4 adds why the producer
could not gate even if it wanted to: resolveProtocol hands the same instance to two
different requests, so it holds no per-caller state, and the shipped getUiView declares
one parameter — the record §2 measured.

② Route gap, or RATCHET gap? — ⭐ RATCHET gap. This is the larger finding.

packages/qa/dogfood/test/authz-conformance.matrix.ts carries no row for this route, and
its header says a new ungated route is UNCLASSIFIED and breaks CI. The two candidate
explanations the card names are not equally true: discover()'s curated probe table
cannot reach registerUiEndpoints at all, so no row — named or unnamed — could be
covering it. Measured:

The number the card asked for. Driven on the real route table (§6 commits this half):

routes RestServer.registerRoutes() mounts 85
route registrars in rest-server.ts 17
registrars the probe table names 1 (registerMetadataEndpoints)
routes under that one probe key 19
routes outside every key the ratchet can mint for this file 66

registerUiEndpoints is one of 16 sibling registrars invisible to the ratchet —
registerCrudEndpoints, registerApprovalsEndpoints (12 routes), registerDataActionEndpoints
(9), registerReportsEndpoints (8), registerSharingRuleEndpoints (5) and the rest. It is
not special; it is simply the one a census happened to walk past. The ratchet's stated
guarantee — "a new ungated route is UNCLASSIFIED and breaks CI" — is true for the /meta
umbrella and false for 66 of the 85 routes this file mounts.

⚠️ §6 commits only the package-local half of that (route counts and registrar attribution).
Asserting the cross-package half inside a packages/rest test would widen this package's
real input set past its CROSS_PACKAGE_TEST_INPUTS declaration — the #7802 defect — so the
probe-table readings above are reported here and reproducible with the commands in the
issue-comment report.

isAuthGateAllowlisted — confirmed NOT the explanation

Verified by driving the predicate, not by reading the array: all four spellings of this
route (/api/v1/ui/view/:object/:type, the form variant, the environment-scoped mount, and
the query-string form) read false. Positive control on the same predicate:
/api/v1/auth/sign-intrue, undefinedtrue. So the false is a decision, not a
predicate that returns false for everything.


⚠️ Every reading stands next to a positive control

An instrument that can only produce one answer has measured nothing — and that cuts both
ways here. A 200 from a harness that could never have shown a refusal is not evidence of a
fail-open either.

  • §1 — the same instrument, same boot, refuses GET /api/v1/data/:object with
    401 UNAUTHENTICATED under an absent context (resolveExecCtx calls > 0), and serves it
    under an entitled one. So "200 with 0 calls" is a reading about this route.
  • §2 — the recorder captures a context key when one is present, so
    Object.keys(arg) === ['object','type'] is not a recorder silently dropping keys.
  • §3 control A — a rival producer that does gate is visible through this instrument as
    403 PERMISSION_DENIED. Had the shipped producer gated, this harness would have said so.
  • §3 control B — the real producer is genuinely invoked: an object absent from the
    registry makes it answer non-200.
  • §5 — the allowlist predicate demonstrably returns true for something.

Reverse verification (ablation), on the committed implementation

Mutation leg — inserted resolveExecCtx + enforceAuth into registerUiEndpoints:

  • anchor occurrences 1 (unique); injected-text grep -c2; blob moved
    3eca151d8d0b18cb. ⛔ Mutation confirmed on disk, never from the editor's exit code.
  • Result: 6 failed / 11 passed — §1's seam reading, §1's source assertion and all three
    §3 downstream readings flip red. Predicted direction, observed.
  • ⚠️ §3 control A flips too, and honestly so: with the guard in place the route refuses at
    the seam before the producer is reached, so the downstream refusal it looks for no longer
    happens.

Restore leg — git checkout HEAD -- "$REPO_ROOT/packages/rest/src/rest-server.ts" (absolute
path, HEAD-pinned to bypass the index, under a trap ... EXIT INT TERM):

  • restored blob 3eca151d = HEAD blob 3eca151d; injected-text count 0;
    git diff HEAD empty; git status clean.
  • Restored tree re-run: 17 passed (17).

Verification

Gate union re-derived for the actual change set with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (the script takes the
change set from the merge base itself), then run at 0684c41b — the final commit on this
branch, so these verdicts are about the tree that is actually here. Verdict lines are quoted
from each gate's own output, and every exit code was captured before any pipe.

  • pnpm --filter @objectstack/rest exec vitest run --maxWorkers=2158 files / 2623 tests passed
  • pnpm --filter @objectstack/rest typecheck — exit 0. check:test-typecheck: OK — @objectstack/rest's test layer compiles under packages/rest/tsconfig.test.json. ⚠️ Confirmed not a
    NOT-MEASURED green: tsc -p tsconfig.test.json --listFiles lists this file, and it is what
    caught a 27-argument call to the 20-parameter RestServer constructor — a harness that ran
    fine while silently shifting three providers onto the wrong parameters (second commit).
  • pnpm check:authz-resolver✓ check:authz-resolver: single shared authorization resolver intact; both entry points delegate.
  • Green: check:cross-package-test-inputs, check:dispatcher-error-vocabulary,
    check:logger-receiver-detach, check:objectql-double-limit, check:page-declaration-shape,
    check:published-files, 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-ci-filter-parity,
    check-comment-mask-adoption, check-cross-package-test-inputs, check-keyed-text-bounds,
    check-plugin-teardown-shape, check-shard-attestation, check-undeclared-dep-imports,
    check-nul-bytes, docs-audit/check-affected-docs, docs-audit/check-drift-comment.
  • Built the workspace closure first (turbo run build --concurrency=2 --filter=./packages/* --filter=./packages/*/*, 70/70 successful), because the next two refuse outright on an
    unbuilt tree and that refusal is NOT MEASURED, never a pass:
    • ✓ check:dual-build-cjs-loads — 102 published require entry point(s) across 66 package(s) load; 610 emitted CommonJS file(s) parse
    • check-type-check-coverage --re-measure: OK — 30 ledger entr(ies) re-measured in 250.5s, 1560 raw tsc error(s) total, none above its recorded numbersurplus: none, so the
      new test file adds no type-check debt.
  • NOT MEASURED (one): scripts/check-test-completeness.mjs exits 3
    (PREREQUISITE NOT MET) with no argument — it grades a saved turbo run test log and
    cannot produce one. CI tees the log and passes the path, so CI measures it. ⛔ Recorded as
    NOT MEASURED, not as a pass and not as a red.

No changeset: the diff is a single test file, publishes nothing from any package and changes
no user-visible behaviour — skip-changeset.


Measured and written by Claude Code in session
https://claude.ai/code/session_01TvqBFLRzXdSPcbusDoED9k (kept in prose because a body edit
rewrites the footer link below back to its bare form).

Generated by Claude Code


Generated by Claude Code

claude added 2 commits August 29, 2026 13:56
…, at the seam and downstream

Measurement only -- nothing is repaired. Drives the route on the real
registerRoutes() table under an absent and an entitled execution context,
records the resolveExecCtx call count, and continues past the seam into the
real getUiView producer, which #13214 marks unmeasured. Every reading stands
next to a positive control on the same instrument.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvqBFLRzXdSPcbusDoED9k
The first draft passed 27 arguments to a 20-parameter constructor. It ran --
JS drops the surplus -- but silently shifted serviceExistsProvider,
requestEnvResolver and metadataServiceProvider onto the wrong parameters, so
the boot under measurement was not the boot the file describes. tsconfig.test.json
covers this file, which is what turned it into a compile error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvqBFLRzXdSPcbusDoED9k
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 0 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 9e0ba21a1bf863c44e341fdad1bbecb755d450c2packageMentionDocs.

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

Labels

size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] GET /api/v1/ui/view/:object/:type resolves a UI view with no identity resolution and no anonymous-deny at the REST seam

2 participants