Skip to content

fix(approvals): refuse cross-org targeting on directory-less approver types (ADR-0105 D9) - #3873

Merged
os-zhuang merged 1 commit into
mainfrom
claude/adr-0105-group-tenancy-posture-5womi2
Jul 28, 2026
Merged

fix(approvals): refuse cross-org targeting on directory-less approver types (ADR-0105 D9)#3873
os-zhuang merged 1 commit into
mainfrom
claude/adr-0105-group-tenancy-posture-5womi2

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Follow-up to #3824 (ADR-0105 D9). Found by cloud's group-posture dogfood, not by review.

The defect

user, field and manager return early in resolveApproverSpec — they name a person outright rather than expanding a directory:

const type = canonicalApproverType(String(a.type));
if (type === 'user')  { return this.applyOooDelegation(...); }   // ← returns here
if (type === 'field' && record) { ... return out; }              // ← and here
...
const directoryOrg = await this.directoryOrgFor(a, organizationId);   // D9 resolution — unreachable for the above

I put D9's resolution after those returns. So { type: 'user', value: 'u1', organization: '$root' } never reached the check and was silently inert.

That is exactly the behaviour ADR-0105 D9 rules out, and it contradicts two things #3824 shipped:

  • the ADR text — "an approver type that resolves no org-scoped directory refuses the declaration instead of ignoring it (an author who wrote it believed it did something)";
  • content/docs/automation/approvals.mdx"organization on those is refused at runtime and flagged by os lint".

The lint half was true. The runtime half was not — and the lint only helps at author time: a flow stored before the rule existed, or one assembled programmatically, got no signal at all.

The fix

Resolution moves to the top of resolveApproverSpec, above every early return. One line moved, one comment explaining why it must stay there.

The ordinary path is untouched and still free: with no organization declared the resolver returns the request's organization without reading anything — there's now a test pinning that too, so the guard can't start costing the common case.

Why the existing tests missed it

Both gaps are worth naming, because they're the reusable lesson:

  • approver-org-scope.test.ts calls resolveApproverDirectoryOrg directly. A unit test of a helper cannot see that its caller returns before invoking it.
  • approver-cross-org.integration.test.ts went through the service, but only ever with type: 'position' — an org-scoped type, which reaches the resolution fine.

The new case opens a real request for each of the three directory-less types and asserts the refusal, so the early-return path is covered by construction rather than by remembering to.

What the dogfood confirmed while finding this

The cloud e2e (cloud PR to follow) was written specifically to check the two links D9's unit tests structurally cannot — and both hold:

  • a cross-organization approver really does reach her queue, through D2's union wall, over real HTTP as herself;
  • decide() really does accept a foreign-org actor, and the request finalises.

So D9's core is sound end to end; this is the refusal path only.

Verification

plugin-approvals   12 files   315 passed   (4 new: 3 directory-less types + the no-declaration case)
check:nul-bytes · check:doc-authoring · check:role-word · check:authz-resolver   pass
eslint clean

Docs and ADR need no change — they already describe the behaviour this restores.

🤖 Generated with Claude Code

https://claude.ai/code/session_015FebXPaaGrLhGKw1LHPbpL


Generated by Claude Code

… types

`user`, `field` and `manager` return EARLY in `resolveApproverSpec` — they name
a person outright instead of expanding a directory. D9's org resolution (#3824)
sat AFTER those returns, so an `organization` declared on one of them never
reached the check and was silently INERT.

That is precisely the behaviour ADR-0105 D9 rules out and the authoring docs
promise against ("`organization` on those is refused at runtime"). The `os lint`
rule caught it at author time, but the runtime claim was false — and a flow
stored before the lint existed, or assembled programmatically, got no signal.

Resolution moves to the top of `resolveApproverSpec`, above every early return,
so the refusal reaches all three types. The ordinary path is unchanged and
still free: with no `organization` declared the resolver returns the request's
organization without reading anything.

Why the existing tests missed it: `approver-org-scope.test.ts` calls the
resolver DIRECTLY, so it never traverses the early return, and the service-level
integration test only covered `position`. Both gaps are closed — the new case
opens a real request for each of the three directory-less types and asserts the
refusal, plus one that a directory-less approver with NO declaration is
untouched, so the guard cannot start costing the ordinary case.

Found by cloud's group-posture dogfood on a real `group` boot. The two links
that dogfood was written to check both hold: a cross-organization approver does
reach her queue through the D2 union wall, and `decide()` does accept her.

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

vercel Bot commented Jul 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Jul 28, 2026 1:20pm

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/s labels Jul 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-approvals.

4 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/automation/approvals.mdx (via @objectstack/plugin-approvals)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-approvals)
  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-approvals)
  • content/docs/releases/v9.mdx (via @objectstack/plugin-approvals)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@os-zhuang
os-zhuang marked this pull request as ready for review July 28, 2026 13:30
@os-zhuang
os-zhuang merged commit d058594 into main Jul 28, 2026
16 checks passed
@os-zhuang
os-zhuang deleted the claude/adr-0105-group-tenancy-posture-5womi2 branch July 28, 2026 13:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/s tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants