test(triggers,plugins,services): the authz false-green sweep — five in-lane fixtures now provision the resolver's tables - #18067
Conversation
…cts two fixtures drive Both fixtures boot a real ObjectKernel over a real better-sqlite3 SqlDriver and register only their own test objects. Every write/decision then drives core's `resolveUserAuthzGrants`, whose five reads were refused by the driver because the tables were never provisioned. `tryFind` classifies a missing table as "not provisioned" and answers `[]`, so nothing went red: both suites resolved grants from reads that never happened. The refusals were invisible to a shared-log sweep because #11081 withholds and asserts them through `captureExpectedReadRefusals`. Registering the objects locally — with only the columns the reading path touches, so no dependency edge onto plugin-auth or plugin-security is added — makes the reads SUCCEED, which is the outcome that channel's own contract names. The declared-absent lists shrink to the tables that are still genuinely unprovisioned. No product code changes. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj
…jects three more fixtures drive The same defect the previous commit closed, in the three sites the repo-wide sweep found outside the two the withheld-noise channel was hiding: each boots a real ObjectKernel over a real better-sqlite3 SqlDriver, registers only its own test object, and then drives core's `resolveUserAuthzGrants` against tables that were never provisioned. Measured at 2f1a6f6, per file with --maxWorkers=1: before-update-flow-payload-reach 10, reentrant-start-condition 10, paused-run-visibility 5 — five tables each, no line of it visible as a failure because `tryFind` answers `[]` for a missing table. Registered locally with only the columns the reading path touches, so no dependency edge onto plugin-auth or plugin-security is added. No product code changes. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj
…thz-false-green-sweep
… seam `objectql.registry` is typed `EngineSchemaRegistryView` here, which declares no `registerObject`; the file already carries a structural `TestObjectRegistry` for exactly that and casts to it two lines up. tsc --noEmit TS2339, fixed by using the same cast. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj
📓 Docs Drift CheckNothing 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
Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
Contract review — Verdict: PASSServed-tier: claude-opus-5 Head reviewed:
Clause ② — re-derived from the DELIVERED diff, ⛔ not carried from the claim
⭐ The dev's own discriminating control is the right shape and I record it because a negative needs one: after a build, every identifier this diff introduces greps to 0 files in each
The card's hard prohibition, checked directlyThe card said: ⛔ "Do not silence, filter or re-level the log line. The count must fall because the read SUCCEEDS." Measured on the diff: 0 touches to No dependency edge added, as the card required: 0 Landing state
Noted, not blocking
Generated by Claude Code |
…lse-green class on this lane's nine sites (objectstack-ai#18082) Fixes objectstack-ai#18070 The false-green class, closed on the nine sites this lane owns: seven `@objectstack/client` fixtures where the refusals were **visible in the log**, and two `@objectstack/runtime` integration fixtures where the symptom was **pinned rather than closed**. Test-only: no product code is touched, and no dependency edge onto `plugin-auth` / `plugin-security` is added. ## What was wrong `core/src/security/resolve-authz-context.ts` reaches five `sys_*` tables through `tryFind`, which classifies a **missing table** as "not provisioned" and answers `[]`. A fixture that boots a real `ObjectQL` over a real `SqlDriver` but registers a narrower object set therefore gets a green it did not earn: the assertion passes because the read returned empty, not because the state was empty. A suite in that condition cannot turn red when grant resolution breaks. ## The card's counts, RE-MEASURED (not transcribed) Every number in the card was re-derived on `fb29f62ce`, classified by the `(table, filter, limit)` triple of the eight reads the resolver issues — so `sys_position ... where name in (...) limit 200` counts and `where name = ? limit 1` on the same table does not. | file | card | measured | after | |---|---|---|---| | `client/src/client.metadata-prefix.test.ts` | 95 | **95** | **0** | | `client/src/client.hono.test.ts` | 35 | **35** | **0** | | `client/src/client.data-prefix.test.ts` | 25 | **25** | **0** | | `client/src/client.batch-transaction.test.ts` | 25 | **25** | **0** | | `client/src/auth-get-session-envelope.test.ts` | 21 | **21** | **0** | | `client/src/client.environment-scoping.test.ts` | 20 | **20** | **0** | | `client/src/auth-login-register-envelope.test.ts` | 6 | **6** | **0** | | **sum** | **227** | **227** | **0** | Whole-package control, same tree, same command shape: `@objectstack/client` emitted **264** refused reads of which **227** were resolver-class; after, **37** of which **0** are. The 37 that remain are `sys_metadata` 25, `sys_setting` 7, `sys_metadata_history` 3, `sys_organization` 2 — the `probeInstallOrganizations` and boot-metadata classes, a different card. The seven summing to the whole-package 227 is what proves there is no eighth site. ## The instrument under-reads, and here is the proof The two `@objectstack/runtime` sites route their refusals through `captureExpectedReadRefusals` (objectstack-ai#10629 / objectstack-ai#11081), which **withholds the driver line**. Measured on `fb29f62ce`: `grep -c "refused a read on"` over a full run of either file reads a clean **0**, while the capture's own counter reads ``` notifications.hono.integration refusals {"sys_user":10,"sys_member":10,"sys_user_position":10, "sys_user_permission_set":10,"sys_position":10,"sys_setting":2} engineFrames identical → 52 total, 50 resolver-class notification-schema-conformance refusals {"sys_user":12,"sys_member":12,"sys_user_position":12, "sys_user_permission_set":12,"sys_position":12,"sys_setting":3} engineFrames identical → 63 total, 60 resolver-class ``` After: `{"sys_setting":2}` and `{"sys_setting":3}` — **110 resolver-class refusals closed**, and `sys_setting`, which is not resolver-class, deliberately left exactly as it was. Total across this PR: **337** refused authz-resolver reads closed (227 visible + 110 withheld). ## The count falls because the read SUCCEEDS Nothing is silenced, filtered or re-levelled. Proven positively by a one-off probe that seeded one row per table and printed what the read returns — injected, run, then restored under a `trap` and verified byte-exact with `git hash-object` against each path's HEAD blob: ``` [objectstack-ai#18070 PROBE ROWS] sys_user [{"id":"probe-user",...,"email":"probe@example.com"}] [objectstack-ai#18070 PROBE ROWS] sys_member [{"id":"probe-mem",...,"user_id":"probe-user","role":"admin"}] [objectstack-ai#18070 PROBE ROWS] sys_user_position [{"id":"probe-up",...,"position":"everyone"}] [objectstack-ai#18070 PROBE ROWS] sys_user_permission_set [{"id":"probe-ups",...,"permission_set_id":"probe-ps"}] [objectstack-ai#18070 PROBE ROWS] sys_position [{"id":"probe-pos",...,"name":"everyone","active":true}] RESTORED packages/runtime/src/notifications.hono.integration.test.ts blob=9f7158bc9... == HEAD-BLOB-of-the-same-path RESTORED packages/runtime/src/notification-schema-conformance.integration.test.ts blob=299cd3b37... == HEAD-BLOB-of-the-same-path RESTORED packages/client/src/client.environment-scoping.test.ts blob=2aa383b08... == HEAD-BLOB-of-the-same-path RESTORE VERIFIED: git diff HEAD empty over all three probed paths ``` A by-product worth naming: seeding those rows made the new runtime assertion go RED (`expected [ { id: 'probe-user', …(6) } ] to deeply equal []`). That is the assertion reading real state rather than a stub. ## ⭐ The `runtime` half: a passing assertion had to move, deliberately This is the review's sticking point, and it should be. Both runtime fixtures declared `ABSENT_AUTHZ_TABLES` and asserted `noise.silentChannels(ALWAYS_READ_AUTHZ_TABLES)` — an assertion that each of the five reads was **still being refused**. That pins the symptom. Closing the read necessarily falsifies it, and leaving it in place would leave a pin asserting a number that no longer describes reality. It is **replaced, not deleted**. What it asserted about behaviour — "these five reads really happen on this path" — is now asserted in the direction the fix runs, by `expectResolverAuthzReadsSucceed()`: each read SUCCEEDS and answers `[]` because the state is empty rather than because the table is missing. Same call sites, same `-t`-safety (per authed test in the hono file; in `afterAll` for the conformance file, with the shutdown moved into a `finally` so the old invariant — a failure here can never leave the kernel running — survives the reordering the live-engine read forces). `ABSENT_AUTHZ_TABLES` shrinks to `['sys_setting']`, which is the shared capture module's own prescribed repair: *"a table that started resolving means the fixture now provisions it"*. ⭐ Shrinking that list is also what keeps the capture from becoming a mute: `captureDriver` forwards an **unrecognised** refusal straight to `console.warn`, so a regression that stops provisioning one of the five is now LOUD as well as red — where, while the five were declared, the same regression would have been withheld and merely counted. ## Ablation — the new pin can fail Registration deleted, on-disk landing proven by anchor counts before/after (`rt2=1 cl1=1` → `rt2=0 cl1=0`, one `ABLATED` marker each), restored under a `trap` and verified byte-exact against HEAD: | ablated | result | |---|---| | `notification-schema-conformance.integration.test.ts` | **Test Files 1 failed** — while all 8 `Tests` pass. The failure is the new `afterAll` assertion, i.e. the pin catches exactly the regression it exists for. The five driver refusal lines are also **visible in the output**, confirming the loudness claim above. | | `client.environment-scoping.test.ts` | refusals back to **20 resolver-class** — and the suite still **passes (5/5)**. That is the false green itself, reproduced on demand. | ## Scope and fences - `resolve-authz-context.ts` lives in `packages/core/src/security/` (`domain:engine`) and is **not touched**. Nothing needed it. - `packages/spec` is **not touched**. The only new dependency on it is a `import type { ServiceObject }` — a type already re-exported from `@objectstack/spec/data`, which both packages already depend on. - Objects are registered **locally**, with only the columns the reading path touches (`id` is not declared anywhere — the registry supplies the primary key). `sys_position_permission_set` and `sys_permission_set` are deliberately absent: the resolver reaches them only after a `sys_position` row resolves, and measurement confirms neither appears in any of these files' refusals before or after. - Changeset: **`skip-changeset`**, measured rather than assumed. Both packages publish `files: ["dist","README.md","CHANGELOG.md"]`; `grep -rl` for the new symbols (`AUTHZ_RESOLVER_OBJECTS`, `expectResolverAuthzReadsSucceed`) over every one of those paths returns **zero hits**, against a positive control (`ObjectStackClient`, `createRestApiPlugin`) that hits `dist/`. No published artefact moves. ## Acceptance notes Out of scope, observed while measuring, filed as nothing: - **The non-resolver refusal classes are still there and are deliberately untouched.** After this PR `@objectstack/client` still emits 37 refused reads (`sys_metadata` 25, `sys_setting` 7, `sys_metadata_history` 3, `sys_organization` 2) and the two `runtime` fixtures still withhold `sys_setting` (2 and 3). None is resolver-class by the `(table, filter, limit)` classifier — they are the `probeInstallOrganizations` / boot-metadata-load / localization-settings classes, which objectstack-ai#18070's body and PR objectstack-ai#18067's own notes already separate out. Noted, not filed: the carrier is whoever picks up that class, which lands in these same files. - **The shared capture has no affordance for the positive direction.** `expected-read-refusal-noise.ts` prescribes the repair for "a table that started resolving" (drop it from the declared list) but offers nothing to assert the success that replaces the refusal, so each consumer hand-rolls it — this PR writes `expectResolverAuthzReadsSucceed()` twice. A design observation about a test helper, not a defect in it, and not a reproducible failure: noted, not filed, carrier none. ## Verification See the report comment on objectstack-ai#18070 for the full gate table and exit codes. Authored by Claude Code in session `session_01TbSMtGzMrtPwh925wDEZd5` — kept as prose because this body was EDITED after creation, and the edit channel appends its own footer block: a session-URL footer sent on an edit ends up with the platform's bare one beneath it, two footers where the form allows one. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #17985
Clause-②: no
The card handed this lane a static pointer — 73 test files outside
plugin-authnaming
resolveUserAuthzGrants/resolveAuthzContext— and asked for a measurement.Here is the measurement, the instrument's own blind spot (which it has, and which the
pointer could never have shown), the five in-lane sites it found, and the out-of-lane
ones this lane reports rather than touches.
The population, and how it was derived
Reproduced at the branch point
2f1a6f696, same greps the card used:*.test.tsoutsideplugin-authnaming the resolverplugin-auth*.ts, not just tests)*.test.tsoutsideplugin-authconstructingAuthManagerThe card's method is "run the candidate packages' suites". That was done — all 18 —
and then extended to every other package this lane owns, because a candidate list built
from a grep cannot name a fixture that drives the resolver without mentioning it (and
one of the five sites below is exactly that). 44 package suites in total, each a full
vitest run --maxWorkers=2, ~34k tests, all green.The instrument has a blind spot, and it is the one that mattered
Counting
refused a read on '...'in the shared log under-reads. Four fixtures routetheir refusals through
captureExpectedReadRefusals(#10629 / #11081), which withholdsthe driver line and asserts the count instead. Against those, a grep of the log reads
a clean zero while every read is still being refused.
A complete census of that channel: 24
captureExpectedReadRefusalscall sites in therepo; 4 of them declare the authz resolver's tables as expected-absent. Two are in
this lane, two are not. Both of this lane's two are real sites of the class, and neither
would have appeared in a log-grep sweep.
That is not a defect in #10629 — the channel names this outcome itself:
How a "site" is told apart from any other refused read
resolve-authz-context.tsissues eight reads with fixed filter shapes and limits, so arefusal is attributed to the resolver by the
(table, filter, limit)triple in the loggedstatement —
sys_position ... where name in (...) limit 200is the resolver's; thewhere name = ? limit 1on the same table is not.The discriminator earns its keep:
plugin-security's suite emits 585 refused reads and0 of them are resolver-class (they are
sys_permission_set/sys_position/sys_capabilityreads by name, a different fail-soft path), whileclient's 264contain 227 that are.
Controls — so the zeros mean something
plugin-auth's two known fixturesat
2f1a6f696: 7 lines,sys_user_position2 /sys_user_permission_set3 /sys_position2 — byte-for-byte the baseline PR test(plugin-auth): register the authz objects two sign-in fixtures drive #17982 measured independently. Theinstrument detects the class when the class is there.
plugin-security's 585 and 227 out ofclient's 264 — it is not "any refusal".refusalscounter, not through the log.The five in-lane sites — before and after
Before is at
2f1a6f696; after is this branch. Per file,--maxWorkers=1.triggers/trigger-record-change/src/record-change-integration.test.tsplugins/plugin-approvals/src/status-mirror-cascade.integration.test.tstriggers/trigger-record-change/src/before-update-flow-payload-reach.test.tstriggers/trigger-record-change/src/reentrant-start-condition.test.tsservices/service-automation/src/paused-run-visibility.test.ts121 refused authz reads, every one of them a grant resolution that answered
[]becausethe table was missing rather than because the state was empty.
Actual output. The two withheld ones, read off the capture's own counter by a one-off
console.logofnoise.refusals(injected, run, restored):The three visible ones, counted from the log:
Whole-suite, after:
plugin-approvals45 files / 738 tests / 32 refusals, 0 resolver-class ·trigger-record-change10 / 101 / 3, 0 resolver-class ·service-automation134 / 1581 / 8,0 resolver-class. All
sys_organizationand onesys_metadata_activation— theprobeInstallOrganizationsclass, not this card's.The count falls because the read SUCCEEDS
⛔ No log line is silenced, filtered or re-levelled: the diff is five test files, zero
product code. The declared-absent lists shrink — the tables leave them because the
fixture now provisions them, and
sys_organization/sys_approval_delegationstaydeclared because those probes are still genuinely unprovisioned.
The positive proof, PR #17982's shape: a one-off, non-committed assertion that seeds a
sys_user_positionrow and then asks the resolver for that principal's grants. Before thefix it cannot even reach the table; after it, the row comes back and the resolver's answer
carries it.
oneoff_rolereachedgrants.positionsonly because the resolver'ssys_user_positionread returned a real row — the leg that answered
[]before.Both legs are anchored on
HEAD(which carries the implementation), restored withgit checkout HEAD -- ..., and verified bygit hash-objectagainst the HEAD blob plus anempty
git diff HEAD:Each injection was proved to have landed on disk before the run (marker count grepped, not
inferred from the editor's exit code), and every mutation ran under a
trap ... EXIT INT TERM.The remedy, and the edge it does not add
Each fixture declares the objects locally, with only the columns the reading path
touches, so ⛔ no dependency edge onto
plugin-authorplugin-securityis added — thefind-envelope-limb-removal.test.tsprecedent PR #17982 applied:sys_useridfilter;emailis the RLS owner-email fallbacksys_membersys_user_positionsys_user_permission_setsys_positionisRowActive, tenant scope⛔
sys_position_permission_setandsys_permission_setare deliberately absent: theresolver reaches them only once a
sys_positionrow resolves and a permission-set id iscollected, and none of these fixtures seeds either. The ADR-0091 validity columns are absent
for the reason
sys_memberlacks them today —isGrantActivereads an absent bound asunbounded, so declaring them would change no verdict.
Out of lane — REPORTED, not touched
🔴 The population is repo-wide by construction and this lane owns part of it. These are
measured and left alone for routing.
@objectstack/clientdomain:cli@objectstack/runtimedomain:cliclient, per file — these seven account for 100% of the package's 227, so there is noeighth site in it:
runtime, by file:src/notifications.hono.integration.test.tsandsrc/notification-schema-conformance.integration.test.ts, each declaring['sys_user','sys_member','sys_user_position','sys_user_permission_set','sys_position','sys_setting']as
ABSENT_AUTHZ_TABLES.Every other swept package reads 0 resolver-class, including
core,spec,rest,mcp,dogfood,verify,cloud-connection,plugin-hono-server,organizations,plugin-security,plugin-sharingand all 16services/*, 4connectors/*and the othertwo
triggers/*.Checks
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackfrom the delivered change set at this head — see the report comment on sweep: do harnesses outside plugin-auth drive resolveUserAuthzGrants against a narrower object set? — the #17897 class is a latent FALSE GREEN, not just log noise #17985 for the verdict line.
check:type-check-debtfirst answered exit 3 (PREREQUISITE NOT MET, heap OOM), which isnot a pass; re-run under the
--max-old-space-size=6144ceiling its own script pins, itexits 0 with a real verdict (
5 ledger entries re-measured, none above its recorded number).pnpm --filter @objectstack/plugin-approvals --filter @objectstack/trigger-record-change --filter @objectstack/service-automation typecheck— exit 0, all threeDone.tracked files matching
eslint.config.mjs's own**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}minus its only global
ignores(NEVER_LINTED); 5 files linted, counted from--format json, 0 errors / 0 warnings; and the invariance that makes the narrowing ameasurement rather than a skipped run — this repo's config never enables type-aware
linting (no
parserOptions.project, no typed@typescript-eslintrules, stated andmeasured with a positive control at
eslint.config.mjs:327), so no edit in this diff canmove the verdict on a file it does not contain. The full-repo scan is CI's.
grep -naPcontrol-character scan over all five edited files — no matches.No changeset — measured, not assumed
All three packages ship
files[] = ["dist","README.md","CHANGELOG.md"]. After a build,grepping the shipped path for every symbol this diff introduces:
Nothing published moves, so this carries
skip-changesetrather than a changeset.Clause-② re-determination from the delivered diff:
no. The diff is five*.test.tsfiles. It adds no exported symbol reachable from any published entry (measured above: every
introduced identifier is absent from all three
dist/trees while a real published symbolis present in four files of each), and no new key on an already-published payload — the
object literals it adds are fixture-local
consts consumed only byregistry.registerObjectinside the same file.
Acceptance notes
Observations from the sweep, noted and not filed — none is in this card's class, and
each already has a home or no one to hand it to:
plugin-securityemits 585 refused reads, 0 resolver-class:sys_permission_set(471),
sys_organization(81),sys_position(24),sys_capability(7),sys_audience_binding_suggestion(2). Thesys_*ones are reads by name from adifferent fail-soft path; 14 more are a deliberate connection-abort fixture. Carrier: the
domain:servicesseat already owns the file surface, but no PR in flight touches it.probeInstallOrganizations/ seed-loader / metadata classes are the bulk of what isleft repo-wide (
rest362sys_metadata,dogfood126sys_migration,verify22sys_migration, andsys_organizationacross ~10 packages). That population is [finding] 15 more PASSING@objectstack/runtimetests print the samerefused a read onfeature — 134 lines after #10380 quiets its two, led bynotifications.hono.integration.test.ts(52) #10629's,not this card's.
trigger-record-change/src/record-change-integration.test.tsemits one refusal onsys_metadata_activationthat itscaptureExpectedReadRefusalslist does not declare —unchanged by this diff, present in both legs.
service-automation/src/notify-zero-delivery-visibility.integration.test.tsexits 1 whenrun as a single file under
--maxWorkers=1and passes in the package run. Pre-existing,untouched by this diff. Carrier: none identified.
Generated by Claude Code