Skip to content

test(plugin-approvals): pin the two managerIsProvablyOutsideOrg screens to equal verdicts - #11618

Merged
os-sam merged 1 commit into
mainfrom
claude/issue-11286-manager-org-screen-parity
Aug 24, 2026
Merged

os-sam merged 1 commit into
mainfrom
claude/issue-11286-manager-org-screen-parity

Conversation

@claude

@claude claude Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Part of #11286

sys_user.manager_id carries 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.

package entry point screen
plugin-approvals ApprovalService.lookupManager (#10153) managerIsProvablyOutsideOrg — private method
plugin-sharing TeamGraphService.managerOf, BusinessUnitGraphService.managerOf (#10231) managerIsProvablyOutsideOrg — module function

⚠️ This PR PINS the duplication. It does not remove it.

Collapsing 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-approvals already depends on plugin-sharing, so the direction exists". It does not — plugin-approvals/package.json lists neither a dependency nor a devDependency on plugin-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.

row fixture (sys_member rows for u_mgr) org arg predicted measured plugin-sharing measured plugin-approvals agree
R1 [org_b] undefined false false false ✅
R2 [org_b] null false false false ✅
R3 [org_b] '' false false false ✅
R4 (no rows at all) org_a false false false ✅
R5 rows for another user only, in org_b org_a false false false ✅
R6 [org_b] org_a true true true ✅
R7 [org_b, org_c] org_a true true true ✅
R8 [org_a] org_a false false false ✅
R9 [org_b, org_a] org_a false false false ✅
R10 [organization_id: null] org_a false false false ✅
R11 [organization_id: ''] org_a false false false ✅
R12 [null, org_b] org_a true true true ✅
R13 [null, org_a] org_a false false false ✅
R14 the read throws org_a false false false ✅
R15 the read returns undefined org_a false false false ✅
R16 the read returns null org_a false false false ✅
R17 [organization_id: 7] (number) '7' false false false ✅
R18 [organization_id: 7] (number) '8' true true true ✅
R19 1200 rows, caller-org row at index 999 org_a false false false ✅
R20 1200 rows, caller-org row at index 1100 org_a true true true ✅

The 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_member shapes; R19/R20 bracket the declared limit: 1000 from both directions.

Three read-shape differences are expressed as verdict differences rather than asserted directly, because the fixture double applies where and projects fields:

  • a side that stopped asking for organization_id reads undefined and fails open while the other screens out (R6/R8);
  • a side that dropped the user_id predicate sees another user's rows (R5);
  • a narrower limit flips 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.

leg mutation on-disk confirmation result
0 none (baseline) — 21 passed
1 plugin-sharing: if (!orgs.length) return false → return true (fail-closed) old literal 1→0, marker 0→1, mutated line printed from disk 6 failed — exactly R4, R5, R10, R11, R15, R16
2 plugin-approvals: if (orgs.includes(requestOrg)) → if (!orgs.includes(...)) old literal 1→0, marker 0→1, mutated line printed from disk 10 failed — exactly R6, R7, R8, R9, R12, R13, R17, R18, R19, R20
3 restored git hash-object == HEAD blob on both files 21 passed

Both red sets were the ones predicted for those mutations. Restoration was proved by git hash-object against the HEAD blob (9d7edec4… sharing, 4396ac0b… approvals), from an absolute path with git -C, treating an empty hash as failure — not by a trap having 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's s/// 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_INPUTS and mirrored into turbo.json's @objectstack/plugin-approvals#test inputs. Without both, a change to the sharing screen would never re-run this pin — the exact blind spot that gate exists for. One file, not plugin-sharing/src/**: nothing else in that package is read.

No changeset — skip-changeset

This PR publishes nothing: one test file, one gate declaration, and turbo inputs. Per scripts/check-empty-changeset.mjs, a PR that releases nothing takes the skip-changeset label, and an empty-frontmatter changeset is rejected outright (it is a real input to changesets/action and an all-empty set stalls the release silently and greenly, #4898). The label is applied.

Verification — all at 44c46aa213, the head of this branch

pnpm --filter @objectstack/plugin-approvals exec vitest run
      src/manager-org-screen-parity.contract.test.ts      Tests  21 passed (21)
pnpm --filter @objectstack/plugin-approvals test          Tests  606 passed (606)  (32 files)
pnpm --filter @objectstack/plugin-approvals typecheck     tsc --noEmit, clean

Gate 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-wrong
  • check: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-matcher went red on the first run and was repaired, not silenced: the fixture's matchesWhere read a combinator as a field name. It now refuses the shapes it does not model. Because both screens wrap their read in catch { 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:i18n initially reported PREREQUISITE 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-wide pnpm 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

…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
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 24, 2026
@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.

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

Labels

size/m 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.

0 participants