Repository navigation
test(plugin-approvals): pin the two managerIsProvablyOutsideOrg screens to equal verdicts - #11618
Merged
Merged
Conversation
…ns to equal verdicts (#11286) `sys_user.manager_id` carries two independent organization screens — a private method on `ApprovalService` (#10153) and a module function in plugin-sharing's `team-graph.ts` (#10231). They share no code, and nothing enforced the mirroring they were written with, so a future edit could change the fail-open posture, the "provably outside" shape, or the `sys_member` read on one side alone, silently. Adds the cross-referencing contract test: 20 fixture rows, each run through BOTH screens, asserting parity first and the agreed verdict second. Measured — all 20 rows agree today, matching a verdict matrix predicted from both sources before the first run. The read escapes the package, so it is declared in CROSS_PACKAGE_TEST_INPUTS and mirrored into turbo.json's `@objectstack/plugin-approvals#test` inputs; without both, a change to the sharing screen would never re-run the pin. This PINS the duplication, it does not remove it. Collapsing the two into shared code is a cross-package dependency decision and #7497 is open on whether the two questions should converge at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4
Contributor
📓 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #11286
sys_user.manager_idcarries two independent organization screens, in two packages, with no shared code, and nothing enforced the mirroring they were written with. This adds the cross-referencing contract test that pins them to the same verdicts on the same fixture matrix.plugin-approvalsApprovalService.lookupManager(#10153)managerIsProvablyOutsideOrg— private methodplugin-sharingTeamGraphService.managerOf,BusinessUnitGraphService.managerOf(#10231)managerIsProvablyOutsideOrg— module functionCollapsing the two into shared code is a cross-package dependency decision and is deliberately out of scope here. #7497 — does approver routing imply record read visibility? — is open precisely on whether these two questions should converge at all, and fusing the implementations would prejudge it. The removal question stays open.
One correction to the finding's own text, measured on
main: it says "plugin-approvalsalready depends onplugin-sharing, so the direction exists". It does not —plugin-approvals/package.jsonlists neither a dependency nor a devDependency onplugin-sharing. That makes the shared-helper option strictly more expensive than the card assumed, and is worth knowing before the removal question is decided.Predicted first, then measured
The verdict for every row was derived by reading both implementations before the file was first run — that prediction is what makes a divergence a finding rather than a surprise.
true= "provably outside the organization" ⇒ drop the manager;false= leave routing/sharing exactly as it was.sys_memberrows foru_mgr)plugin-sharingplugin-approvals[org_b]undefined[org_b]null[org_b]''org_aorg_borg_a[org_b]org_a[org_b, org_c]org_a[org_a]org_a[org_b, org_a]org_a[organization_id: null]org_a[organization_id: '']org_a[null, org_b]org_a[null, org_a]org_aorg_aundefinedorg_anullorg_a[organization_id: 7](number)'7'[organization_id: 7](number)'8'org_aorg_aThe two screens agree on all 20 rows. Measured matrix == predicted matrix, exactly. The card anticipated a possible divergence; there is none on this tree. What the pin buys is that a future one-sided edit cannot land quietly.
Rows R4/R5/R10/R11/R14/R15/R16 are the absent-tenancy fail-open posture; R6/R7/R12 are the cross-org manager; R8/R9/R13/R17/R18 are the
sys_membershapes; R19/R20 bracket the declaredlimit: 1000from both directions.Three read-shape differences are expressed as verdict differences rather than asserted directly, because the fixture double applies
whereand projectsfields:organization_idreadsundefinedand fails open while the other screens out (R6/R8);user_idpredicate sees another user's rows (R5);limitflips R19, a wider one flips R20.No read shape is asserted directly — that would be a wider pin than this card, so the recorded reads are logged as diagnostics only.
The test can fail — measured on both arms
A parity test that passes on its first run deserves suspicion, so each screen was ablated separately: a test that only ever exercised one arm would still go red under a single mutation.
plugin-sharing:if (!orgs.length) return false→return true(fail-closed)plugin-approvals:if (orgs.includes(requestOrg))→if (!orgs.includes(...))git hash-object== HEAD blob on both filesBoth red sets were the ones predicted for those mutations. Restoration was proved by
git hash-objectagainst the HEAD blob (9d7edec4…sharing,4396ac0b…approvals), from an absolute path withgit -C, treating an empty hash as failure — not by atraphaving fired.No rebuild was needed between legs, and that is the point: both screens are reached by relative source path, so nothing resolves through a
dist/artifact and the mutation reaches the executed code directly. The red/green transition is itself the proof.(The first attempt at leg 1 was a silent no-op — a
/in the replacement text terminated perl'ss///program early. The on-disk literal counts caught it and the leg was re-run properly; it is recorded here rather than quietly retried.)Why the cross-package declaration
plugin-sharing's screen is reached by relative source path, and that is the only way in: it is deliberately not exported from that package's index (exporting it would hoist a security screen into another plugin's public API surface — the very decision this card is fenced out of), and the package publishes
.only, so there is no subpath to import.That read escapes the package, so it is declared in
CROSS_PACKAGE_TEST_INPUTSand mirrored into turbo.json's@objectstack/plugin-approvals#testinputs. Without both, a change to the sharing screen would never re-run this pin — the exact blind spot that gate exists for. One file, notplugin-sharing/src/**: nothing else in that package is read.No changeset —
skip-changesetThis PR publishes nothing: one test file, one gate declaration, and turbo
inputs. Perscripts/check-empty-changeset.mjs, a PR that releases nothing takes theskip-changesetlabel, and an empty-frontmatter changeset is rejected outright (it is a real input tochangesets/actionand an all-empty set stalls the release silently and greenly, #4898). The label is applied.Verification — all at
44c46aa213, the head of this branchGate union re-run on this commit — every one green, quoting each gate's own verdict line:
check:cross-package-test-inputs—OK: 16 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.check:where-matcher—293 matcher(s) discovered, 293 answer the combinator battery correctly or refuse it loudly (181 refuse). 0 silently-wrongcheck:engine-double-contract,check:query-options-erasure,check:slot-lookup,check:type-check-coverage,check:nul-bytes,check:test-source-alias,check:type-source-resolution,check:published-files,check:pnpm-filter-targets,check:entry-guard,check:parse-guard,check:i18n,check-plugin-teardown-shape,check-ci-filter-parity— all exit 0.check:where-matcherwent red on the first run and was repaired, not silenced: the fixture'smatchesWhereread a combinator as a field name. It now refuses the shapes it does not model. Because both screens wrap their read incatch { return false }, a bare throw would be swallowed into a fail-open verdict on both sides — parity green while the fixture asserted nothing — so the refusal is recorded in a collector and asserted empty before any verdict is read.check:i18ninitially reportedPREREQUISITE NOT MET — the workspace CLI is not built. That is not measured, so the CLI was built and it was re-run:OK (9 package(s) — all bundles in sync).Not measured locally:
check:type-check-debt --re-measure(needs the full workspace closure built — repo-wide, left to CI), and the repo-widepnpm lint. Both run in CI regardless.⛔ Not flipped ready, not enqueued, no auto-merge — landing is the dispatching seat's window.
Generated by Claude Code