v5.9 Updated 21 Oct 2026 5 screens live 1 designed, never built 📋 Screen YAML Vault →

Library & File Plan Manager

SharePoint permissions map (file-plan style, grouped by team) + an admin screen that creates document libraries correctly and registers them automatically, so nothing gets forgotten again.

Email a CSV of the library list: one button on Manage Libraries (v2.10)

Adds one button, Email CSV (current view), to your working Manage Libraries screen. Press it and a CSV file arrives in your inbox as an attachment, ready to forward. It contains exactly the libraries currently showing on screen, so with no filters set it is every tracked library, and with a filter such as “0 files” it is just those. Admin only: the button and its status line only appear for the emails in your varIsAdmin list, the same rule as the rest of this screen. Nothing is stored anywhere: no new list, no new library, no status system, and your stats flow and v2.9 screen are untouched.

Why email and not a straight browser download: Power Apps can only download a file that already exists at a real web address, and your own test confirmed it cannot turn text it builds on screen into a download. Email is the smallest route that needs no storage.

Columns: Library, Library URL, Team, Sub-Team, Limited (flagged or named _LTD/(LTD)), Created (dd/mm/yyyy), File count (blank if never counted, never shown as 0), Empty (Yes/No/Unknown), Review (Yes only when zero files and created at least six calendar months ago). Sorted by Team, Sub-Team, then name.

Apply these steps in order

  1. Download EmailLibraryCsv_flow_import.zip. Power Automate → My flows → Import → Import Package (Legacy). Leave the flow on Create as new, choose your Office 365 Outlook connection when asked, then import. It is a brand new flow called FP-EmailLibraryCsv(App); it does not replace any existing flow.
  2. Open the new flow in My flows and make sure it is turned on. Imported flows can arrive switched off, which is what caused the earlier WorkflowTriggerIsNotEnabled error.
  3. In Power Apps Studio, open the Data pane → Add data → search for FP-EmailLibraryCsv(App) and add it. Do this before pasting the screen, otherwise the new button shows an error.
  4. Open the v2.10 full-screen copy page, copy its YAML, delete scrManageLibraries, then paste the whole copied YAML as you normally do. No App.OnStart change is needed.
  5. Press Play and click Email CSV (current view) (right-hand end of the row just above the list). The button greys out and reads “Sending...” with a line beside it saying who it is going to. After a few seconds you get a green banner, and a status line stays on screen beside the button, for example “Sent to you@... at 10:42 (212 libraries)”, so you can still see it after the banner fades. If it fails you get a red banner and a red status line instead. The email should arrive within a minute or two. Open the attachment in Excel and check a library you know has zero files shows Empty = Yes.
Who gets it: the email goes from your own Outlook to your own address (whoever pressed the button), so each admin receives their own copy. It lists the names of Limited libraries, so only forward it to people who are allowed to see them. File counts come from the last saved stats scan, so run the stats flow and press Reload saved stats first if you need fresh numbers.
Already imported the first version? The attachment arrived as a block of random-looking text (base64) instead of a readable CSV. The data inside was correct (574 libraries, all columns), but the flow encoded it one time too many. You do not need to re-import. In Power Automate: open FP-EmailLibraryCsv(App) → Edit → open the Send_Library_Report_Email step → click into the Attachments Content - 1 box and delete what is in it → choose the Expression tab, paste the line below, press OK → Save. Then press the button again. New imports of the zip above already include this fix.
Attachments Content · Expression
base64ToBinary(base64(concat(uriComponentToString('%EF%BB%BF'), triggerBody()?['CsvText'])))
Proven live by you (21 Oct): the import, the Outlook connection, the button, the status feedback and the email send all worked, and the data inside the file is right. Not yet confirmed: that the corrected attachment opens as a normal CSV in Excel (accents and the padlock symbol in library names should display properly). Tell me what you see.

Change a library's URL: one-library test first

A new flow, FP-RenameLibraryUrl(App), changes the URL of one tracked library by moving its root folder to the new name, then updates that library's row in LibraryFilePlan so the app stays in step. It is a dry run unless you type RENAME, so you can see exactly what it would do first. It does not touch the library's display name, contents or permissions. There is no app button for it yet: this step is only to prove the rename works in your tenant before anything real is renamed.

Not proven yet. Renaming a library's root folder is the method community sources report working (through PowerShell). I found no documented example of doing it through the SharePoint folder-move call a flow uses, so the first run on a throwaway library is the real test. If SharePoint refuses it, the flow stops with an error before changing anything in the app, and I need the exact error text.

Try it on a throwaway library

  1. In the app, create a throwaway library (for example 14_test_oldname) so it is tracked in the file plan.
  2. Download RenameLibraryUrl_flow_import.zip. Power Automate → My flows → Import → Import Package (Legacy), leave it on Create as new, choose your SharePoint connection, import. Make sure the new flow is turned on.
  3. Open the flow → Test → Manually → Test. Fill in Current library URL as /teams/1598/14_test_oldname and New URL name as 14_test_newname. Leave the third box empty and run it. The run should succeed and its last step should say it was a dry run, showing the new URL it would use.
  4. Run it again with the third box set to exactly RENAME. This is the real rename.
  5. Check: the library opens at the new address, the old address no longer works, and after Reload saved stats on Manage Libraries (or reopening the app) the library shows the new URL. Then press Refresh Index on Group Search so permission rows pick up the new address.
  6. Tell me what happened, including the exact error text if any step failed.
What the flow refuses to do: rename anything not tracked in LibraryFilePlan, use a name with spaces, slashes or other unsafe characters, use a reserved name (such as Forms or Lists), or rename a library to its own current name. It only changes the last part of the address, never which site the library lives on.
What a real rename breaks, so plan for it: bookmarks and links that use the old address stop working, anyone syncing the library to their computer will need to set the sync up again, pages with a web part pointing at the library and other flows that use the old address need updating, and permission rows in the app stay stale until Refresh Index is run.

Why there is no on-screen flag yet

I tested the idea of flagging libraries whose URL does not match their name against your real list of 574. Your URLs follow a numbering scheme (for example 01_01_8_ACOS_WiP_LTD), so a plain name-versus-URL check would flag almost every library. Most of those are deliberate. The ones that are genuinely different fall into two groups: about 86 where the URL is only a code (for example 01_01_7), and about 106 where the words in the URL are different or cut off (for example 05_MedicalCentre_ManagementDefence_Awarding_Organi). Several libraries in the same team also share the same display name, so a name-only URL would clash. The flag waits until the naming rule is agreed.

Restore library housekeeping: Manage Libraries v2.9

20 October 2026: the latest Vault upload has no stats controls. This full-screen update restores them on that exact uploaded screen. The original stats flow was confirmed working on 26 September; its SharePoint ItemCount includes folders. This update adds a separate, file-only count, including documents inside nested folders.

Result: every expanded library row shows its actual creation date and file count. The entire row turns pale red only when there are zero files AND the library was created at least six calendar months ago. A populated library is never flagged merely for being old. The checkbox and selection border still show selection. Filters: all counts, zero, five or fewer, ten or fewer, or missing stats; combined with any age, six months or older, or one year or older. Search and Team filters continue to work alongside them.

Apply these steps in order

  1. On the main site, open the LibraryFilePlan list settings. Check that LibraryCreated already exists as Date and Time, and LibraryItemCount as Number. Reuse them if present; create them with those exact names if absent. LibraryCreated is the library's real creation date, not the list row's built-in Created.
  2. Create LibraryFileCount as a Number column, zero decimal places, not required, no default value. Leave existing rows blank. Do not default it to zero: blank means not checked, while zero is a measured empty library.
  3. Download RebuildLibraryStats_FileCounts_flow_import.zip. Power Automate → My flows → Import → Import Package (Legacy). Set the flow resource to Update the existing FP - Rebuild Library Stats, select your existing SharePoint connection, then import. The package retains the real exported resource ID. If Update is unavailable, stop rather than creating a duplicate.
  4. Run FP - Rebuild Library Stats in Power Automate and wait for a successful completed run. Leave the optional SiteUrl input blank. This version always scans the main site and CCF and writes stats only to already tracked rows in the main site's LibraryFilePlan. Untracked libraries still need Backfill first. The exact count scans item IDs/types in pages and may take longer than the old metadata-only scan.
  5. In Power Apps Studio, refresh the LibraryFilePlan data source so Studio sees LibraryFileCount. Open the v2.9 full-screen copy page, copy its YAML, delete scrManageLibraries, then paste the whole copied YAML. Studio recreates the screen with its name. No App.OnStart change or new app flow connection is needed.
  6. Open Manage Libraries and click Reload saved stats. Expand a Team/Sub-Team, or choose 0 files and 6+ months old to expand matching groups automatically. Review the counts and dates before publishing the app.
Verification: the full-screen YAML passes Microsoft's parser, the property check against the latest uploaded live screen, the layout check and the Power Fx structure check. The flow passes package-identity, routing and structural checks plus synthetic pagination/count cases. The added variables/pagination use documented workflow primitives; the legacy package checker's old blanket ban on those primitives is replaced by explicit checks in tools/check_housekeeping.py. This revised flow has not yet been imported or run in the tenant, and this screen has not yet been pasted or visually tested in Studio.

These are saved snapshots, not continuously live counts. After documents change, rerun the flow to completion and click Reload saved stats. Missing values say not checked, never zero. A failed or incomplete paged count does not overwrite that library with a partial count; check flow run history before relying on the latest scan. Nothing is automatically deleted or persistently flagged in SharePoint.

The earlier multi-site checklist below is historical work with mixed confirmation status. For this housekeeping update, follow the six steps above.

Everything Left To Do — Work Through This Whole Section, Top To Bottom

Every single outstanding item — SharePoint changes, flow edits, and screen pastes — gathered into one place in the order to do them, so nothing needs hunting for elsewhere in the guide. Every zip file, code block, and link needed sits right next to the step that needs it. Once you've worked through everything below, this whole section shrinks back down to nothing.

1. Add the new SiteUrl column to PermissionIndex

Two-minute prerequisite for items 4 and 8 below — without it, the multi-site flow edits have nowhere to write the site each library actually lives on.

  1. SharePoint → your main site → PermissionIndex list → list settings (gear icon → List settings) → Create column.
  2. Name: SiteUrl. Type: Single line of text. Leave Not Required.
  3. Save. Nothing further needed here — the flow edit in item 4 below is what actually starts writing values into it.

2. Paste scrAdminCreateLibrary v1.13

Self-contained, no flow changes needed — safe to do any time, independent of everything else in this list. Auto-prefixes the Technical Name with the selected Team's own number (Team 4 → 04_), plus an optional "Extra prefix" field (Team 4 + SOP + extra field 04_03 → final name 04_04_03_SOP). v1.13 fixes real bugs found testing v1.12: the prefix now applies to the Display Name too, not just the Technical Name (so the library's visible title matches its URL, not just "Test" while the URL says 14_Test); the Team dropdown and every text field now have a visible white fill and border, instead of blending into the grey page background; the Sub-Team label no longer wraps and overlaps the field below it; and the queue list now shows the actual computed result under each entry (e.g. 14_04_03_SOP, or "registers <existing title>" for a Register-Only row), not just the team name.

  1. Power Apps Studio → open scrAdminCreateLibrary → delete it.
  2. New screen (blank) → open the link above → Copy YAML to Clipboard → paste into the tree view.
  3. Rename the new screen back to scrAdminCreateLibrary.
Re-import items 3, 5 and 6 again, even if you already did them once today. Found via Backfill actually failing live (30 Sep 2026): LibraryFilePlan only ever exists on the main site, never on CCF, but the actions that read/write it were wrongly pointed at whichever site was currently being scanned — so the moment the CCF pass started, every single library failed with a 404 looking for a list that isn't there. Item 4 (Rebuild Permission Index) was never affected. Fixed and re-validated in the zips below — re-import the same way, pick Update again.

3. Backfill File Plan — BackfillFilePlan_MultiSite_flow_import.zip

Built from a real export of your current flow (Aug 2026), with the CCF site loop added and the long-standing Pretty Name bug fixed (the flow's insert was silently writing to DisplayName, a field that doesn't exist on this list, instead of PrettyName). Updated 30 Sep 2026 — see the callout above.

  1. Power Automate → My flows → the "..." next to Backfill File Plan → Import → Import Package (Legacy).
  2. Upload the zip above. On the resource mapping screen, the flow row should show Update as an option (or let you pick the existing Backfill File Plan flow) — pick that, not "Create as new." The SharePoint connection row should already show your existing connection.
  3. Import. Nothing to refresh in Power Apps afterward — this flow is only ever run manually from Power Automate, it's not a data source in the app.
If the import screen only offers "Create as new" for the flow (no existing flow to match against), stop and tell me rather than importing — that would create a second, disconnected copy instead of updating the one you already have.

4. FP - Rebuild Permission Index — RebuildPermissionIndex_MultiSite_flow_import.zip

Built from a real export of your current flow (25 Sep 2026, already has the $top=5000 pagination fix). Adds the CCF site loop (the "wipe everything" step still runs once, before the loop, so a CCF run can never delete the main site's rows) and folds in the LibraryId field you added by hand in Designer for the View Permissions button — that fix is already baked into this zip, you don't need to re-add it.

  1. Power Automate → My flows → "..." next to FP - Rebuild Permission Index → Import → Import Package (Legacy).
  2. Upload the zip above. Pick Update/the existing flow on the mapping screen, same as item 3.
  3. Import. Then in Power Apps: Data panel → find this flow → Refresh.

5. FP - Rebuild Library Stats — RebuildLibraryStats_MultiSite_flow_import.zip

Adds the CCF site loop. Rebuilt 30 Sep 2026 from a real export of your current live flow (thanks for sending it over) — the first version of this zip was hand-built from scratch and Power Automate couldn't match it to the existing flow, only offering "Create as new." This version carries the flow's real ID, so it should now offer Update like the others. Updated again, same day — had the same LibraryFilePlan-site bug as Backfill (see the callout above item 3), now fixed.

  1. Power Automate → My flows → "..." next to FP - Rebuild Library Stats → Import → Import Package (Legacy).
  2. Upload the zip above. Pick Update/the existing flow on the mapping screen.
  3. Import. Then in Power Apps: Data panel → find this flow → Refresh.

6. Create Library (App) — CreateLibrary_MultiSite_flow_import.zip

Built from a real export of your current flow (Aug 2026). Adds both the "Register an existing library" path (needed for scrAdminCreateLibrary's toggle) and CCF routing (every site-address field now sends Team 09_Combined Cadet Force to the CCF sub-site, everything else to the main site or whatever SiteUrl is passed in). Built without Set/Initialize Variable actions — instead of converging the two paths into one shared Response at the end, each path (create new / register existing / already registered) now ends with its own Response action, so nothing changes about what Power Apps receives back, just how the flow gets there internally. Updated 30 Sep 2026 — the actions that write to LibraryFilePlan (both the normal create path and the register path) had the same site-routing bug as Backfill (see the callout above item 3): they were following the CCF-routing logic instead of always targeting the main site, which would have made creating any CCF library fail at the very last step, after the real document library and its permissions were already set up. Never actually tested live before this was caught, so no real CCF library was affected — but re-import before testing CCF creation.

This is the single most safety-critical flow in the app — the one that locks down permissions on Limited libraries. Don't skip the test plan below.
  1. Power Automate → My flows → "..." next to Create Library (App) → Import → Import Package (Legacy).
  2. Upload the zip above. Pick Update/the existing flow on the mapping screen, same as item 3.
  3. Import. Before refreshing it in Power Apps, test all three paths in the flow's own Test panel:
    1. Normal create, RegisterOnly off, Team a normal one (e.g. Test) — confirm the library lands on the main site as before.
    2. Normal create, Team 09_Combined Cadet Force — confirm it lands on the CCF sub-site instead.
    3. RegisterOnly on, a real existing (untracked) library's title — confirm it gets a LibraryFilePlan row without creating a new document library.
    Delete any throwaway libraries and their LibraryFilePlan rows once confirmed, then re-check Library Settings → Permissions on the Limited test library exactly as before (only SG_Cadets-Admin-Ltd_MEM and SG_IHUB, no extras).
  4. Then in Power Apps: Data panel → find this flow → Refresh.

7. Paste App.OnStart v3.18

Select the App object (top of the tree, not any screen) → OnStart property → select all → paste this in full, replacing everything. v3.18 adds Team/Sub-Team grouping to Group Search's own data (new colGSTeamGroups/colGSSubTeamGroups/colGSExpandedTeams/colGSExpandedSubTeams), needed by item 8 below — paste this one first.

App.OnStart v3.18 — Updated 30 Sep 2026 16:30 BST
Set(varTenantHost, "https://modgovuk.sharepoint.com");
ClearCollect(colFilePlan, 'LibraryFilePlan');
ClearCollect(
    colFilePlanFiltered,
    AddColumns(colFilePlan, TeamName, If(IsBlank(Team.Value) || Team.Value = "", "Uncategorised", Team.Value), SubTeamName, Coalesce(SubTeam.Value, ""), EffectiveName, If(IsBlank(PrettyName) || PrettyName = "", Title, PrettyName))
);
ClearCollect(
    colTeamGroups,
    Sort(
        AddColumns(
            GroupBy(colFilePlanFiltered, TeamName, GroupedItems),
            ItemCount, CountRows(GroupedItems),
            TeamSortKey, If(TeamName = "Uncategorised", "zzz_" & TeamName, TeamName)
        ),
        TeamSortKey,
        SortOrder.Ascending
    )
);
ClearCollect(
    colSubTeamGroups,
    AddColumns(
        GroupBy(
            Filter(colFilePlanFiltered, !IsBlank(SubTeamName) && SubTeamName <> ""),
            TeamName, SubTeamName, GroupedItems
        ),
        ItemCount, CountRows(GroupedItems)
    )
);
Clear(colFlatSource);
Collect(colFlatSource, AddColumns(colTeamGroups, RowType, "Team", SortKey, TeamSortKey, SubSortKey, "", RankInGroup, 0));
Collect(colFlatSource, AddColumns(colSubTeamGroups, RowType, "SubTeam", SortKey, If(TeamName = "Uncategorised", "zzz_" & TeamName, TeamName), SubSortKey, SubTeamName, RankInGroup, 1));
Collect(colFlatSource, AddColumns(colFilePlanFiltered, RowType, "Library", SortKey, If(TeamName = "Uncategorised", "zzz_" & TeamName, TeamName), SubSortKey, If(IsBlank(SubTeamName) || SubTeamName = "", "zzz_none", SubTeamName), RankInGroup, If(IsBlank(SubTeamName) || SubTeamName = "", 1, 2)));
ClearCollect(colExpandedKeys, Filter(Table({TeamName: "seed"}), false));
ClearCollect(colExpandedSubTeams, Filter(Table({SubTeamKey: "seed"}), false));
Set(varIsAdmin, Lower(User().Email) in ["allan.hamilton775@mod.gov.uk", "dan.smith391@mod.gov.uk"]);
ClearCollect(colQueue, Filter(Table({TechnicalName: "seed", DisplayName: "", Team: "", SubTeam: "", IsLimited: false, Categories: "", RegisterOnly: false, ExistingLibraryTitle: ""}), false));
Set(varQueueRunning, false);
Set(varQueueTotal, 0);
Set(varQueueDone, 0);
Set(varIndexRunError, false);
Set(varIndexRefreshing, false);
ClearCollect(colPermissionIndex, Filter('PermissionIndex', PermissionLevel <> "Limited Access" && PrincipalType <> "User"));
ClearCollect(colPermissionFiltered, colPermissionIndex);
ClearCollect(colLibraryGroups, Sort(AddColumns(GroupBy(colPermissionFiltered, LibraryUrl, LibraryTitle, GroupedItems), ItemCount, CountRows(GroupedItems), FlagSaysLimited, With({_lu: LibraryUrl}, Coalesce(LookUp(colFilePlan, LibraryUrl = _lu, IsLimited), false)), LibraryId, With({_lu: LibraryUrl}, LookUp(colPermissionFiltered, LibraryUrl = _lu, LibraryId)), SiteUrl, With({_lu: LibraryUrl}, Coalesce(LookUp(colPermissionFiltered, LibraryUrl = _lu, SiteUrl), "https://modgovuk.sharepoint.com/teams/1598")), TeamName, With({_lu: LibraryUrl}, With({_t: LookUp(colFilePlan, LibraryUrl = _lu, Team.Value)}, If(IsBlank(_t) || _t = "", "Uncategorised", _t))), SubTeamName, With({_lu: LibraryUrl}, Coalesce(LookUp(colFilePlan, LibraryUrl = _lu, SubTeam.Value), ""))), LibraryTitle, SortOrder.Ascending));
ClearCollect(colGSTeamGroups, Sort(AddColumns(GroupBy(colLibraryGroups, TeamName, GroupedItems), ItemCount, CountRows(GroupedItems), TeamSortKey, If(TeamName = "Uncategorised", "zzz_" & TeamName, TeamName)), TeamSortKey, SortOrder.Ascending));
ClearCollect(colGSSubTeamGroups, AddColumns(GroupBy(Filter(colLibraryGroups, !IsBlank(SubTeamName) && SubTeamName <> ""), TeamName, SubTeamName, GroupedItems), ItemCount, CountRows(GroupedItems)));
Clear(colPermFlatSource);
Collect(colPermFlatSource, AddColumns(colGSTeamGroups, RowType, "Team", GSTeamSort, TeamSortKey, SubSortKey, "", TeamRank, 0, LibSortKey, "", RankInGroup, 0, GroupSortKey, "", SubTeamName, ""));
Collect(colPermFlatSource, AddColumns(colGSSubTeamGroups, RowType, "SubTeam", GSTeamSort, If(TeamName = "Uncategorised", "zzz_" & TeamName, TeamName), SubSortKey, SubTeamName, TeamRank, 1, LibSortKey, "", RankInGroup, 0, GroupSortKey, ""));
Collect(colPermFlatSource, AddColumns(colLibraryGroups, RowType, "Library", GSTeamSort, If(TeamName = "Uncategorised", "zzz_" & TeamName, TeamName), SubSortKey, If(IsBlank(SubTeamName) || SubTeamName = "", "", SubTeamName), TeamRank, If(IsBlank(SubTeamName) || SubTeamName = "", 1, 2), LibSortKey, LibraryUrl, RankInGroup, 0, GroupSortKey, "", DirectGroupCount, With({_lu: LibraryUrl}, CountRows(Filter(colPermissionFiltered, LibraryUrl = _lu && (IsBlank(NestedUnderGroup) || NestedUnderGroup = ""))))));
Collect(colPermFlatSource, AddColumns(Filter(colPermissionFiltered, IsBlank(NestedUnderGroup) || NestedUnderGroup = ""), RowType, "Group", GSTeamSort, With({_lu: LibraryUrl}, LookUp(colLibraryGroups, LibraryUrl = _lu, If(TeamName = "Uncategorised", "zzz_" & TeamName, TeamName))), SubSortKey, With({_lu: LibraryUrl}, LookUp(colLibraryGroups, LibraryUrl = _lu, If(IsBlank(SubTeamName) || SubTeamName = "", "", SubTeamName))), TeamRank, With({_lu: LibraryUrl}, LookUp(colLibraryGroups, LibraryUrl = _lu, If(IsBlank(SubTeamName) || SubTeamName = "", 1, 2))), TeamName, With({_lu: LibraryUrl}, LookUp(colLibraryGroups, LibraryUrl = _lu, TeamName)), SubTeamName, With({_lu: LibraryUrl}, LookUp(colLibraryGroups, LibraryUrl = _lu, SubTeamName)), LibSortKey, LibraryUrl, RankInGroup, 1, GroupSortKey, GroupName, MemberCount, With({_gn: GroupName, _lu: LibraryUrl}, CountRows(Filter(colPermissionFiltered, NestedUnderGroup = _gn && LibraryUrl = _lu)))));
Collect(colPermFlatSource, AddColumns(Filter(colPermissionFiltered, !IsBlank(NestedUnderGroup) && NestedUnderGroup <> ""), RowType, "Member", GSTeamSort, With({_lu: LibraryUrl}, LookUp(colLibraryGroups, LibraryUrl = _lu, If(TeamName = "Uncategorised", "zzz_" & TeamName, TeamName))), SubSortKey, With({_lu: LibraryUrl}, LookUp(colLibraryGroups, LibraryUrl = _lu, If(IsBlank(SubTeamName) || SubTeamName = "", "", SubTeamName))), TeamRank, With({_lu: LibraryUrl}, LookUp(colLibraryGroups, LibraryUrl = _lu, If(IsBlank(SubTeamName) || SubTeamName = "", 1, 2))), TeamName, With({_lu: LibraryUrl}, LookUp(colLibraryGroups, LibraryUrl = _lu, TeamName)), SubTeamName, With({_lu: LibraryUrl}, LookUp(colLibraryGroups, LibraryUrl = _lu, SubTeamName)), LibSortKey, LibraryUrl, RankInGroup, 2, GroupSortKey, NestedUnderGroup));
ClearCollect(colExpandedLibs, Filter(Table({LibraryUrl: "seed"}), false));
ClearCollect(colExpandedGroups, Filter(Table({GroupKey: "seed"}), false));
ClearCollect(colGSExpandedTeams, Filter(Table({TeamName: "seed"}), false));
ClearCollect(colGSExpandedSubTeams, Filter(Table({SubTeamKey: "seed"}), false));
ClearCollect(colSelectedLibraries, Filter(Table({LibraryUrl: "seed"}), false));
ClearCollect(colManageFiltered, colFilePlanFiltered);
ClearCollect(colEditQueue, Filter(colFilePlanFiltered, false));
ClearCollect(colDeleteSkipped, Filter(Table({LibraryUrl: "seed", Title: "seed", ItemCount: 0, Deleted: false}), false));
Set(varDeleteBusy, false);
Set(varDeleteStatus, "");
Set(varConfirmOverrideVisible, false);
Set(varDeletedCount, 0);
Set(varEditSaving, false);
Set(varEditSaveStatus, "")
EffectiveName is PrettyName when filled in, otherwise falls back to Title. varIsAdmin gates the Admin screen — edit the email list directly in this line if who's authorized ever changes.

8. Paste scrGroupSearch v3.16

v3.15 (Team/Sub-Team grouping, the Group-row View Permissions button, three-state badge) is confirmed pasted and live, per Allan, 01 Oct 2026. v3.16 is a follow-up fix on top of that, needed because the Team/Sub-Team/Library row sizes and indents didn't read as a hierarchy — Library text was bigger than Sub-Team text, and the Library row's own icon/count badge didn't shift inward when nested under a Sub-Team (only its title text did), so nested libraries looked misaligned rather than indented. Fixed: Sub-Team text now an explicit 13pt, Library text dropped from 14pt to 12pt (so size now steps down Team 15 → Sub-Team 13 → Library 12, matching depth), and the Library row's count badge + chevron icon now shift inward together with the title whenever a Sub-Team is present, instead of only the title moving.

  1. Power Apps Studio → open scrGroupSearch → delete it.
  2. New screen (blank) → open the link above → Copy YAML to Clipboard → paste into the tree view.
  3. Rename the new screen back to scrGroupSearch.
The "35 libraries" count you'll see here is correct, not a bug — Group Search only ever indexes libraries with genuinely unique/broken permissions (mostly Limited ones). Every normal library inherits identical site-wide permissions, so there's nothing library-specific to search there; indexing all 500+ would just be noise.

9. scrManageLibraries v2.8 — confirmed pasted, per Allan 01 Oct 2026

Adds the third badge state that Group Search structurally can't show: a library flagged or named Limited whose permissions are still fully inherited — a real inconsistency worth catching, shown as a red triangle, distinct from the red padlock (genuinely Limited) and the amber pencil (permissions customised, nothing marks it Limited). A new legend line under the screen title explains all three. Nothing outstanding here — the paste page stays up for reference, or for re-pasting from scratch if the screen ever needs rebuilding.

10. FP-DeleteLibraries(App) — optional, low priority

Not urgent — deprioritised by Allan ("deleting libraries matters far less than creating them"), included here so it's not lost, do it whenever. This flow's Response action has no declared schema, so Power Apps can't infer the shape of anything inside Results until the flow has actually produced one real sample to learn from — every field shows as type "Error" until then, which is exactly the error scrManageLibraries hit the first time this flow was ever called. DeleteLibraries_SchemaFix_flow_import.zip adds the schema directly — no manual clicking required.

  1. Power Automate → My flows → "..." next to FP-DeleteLibraries(App) → Import → Import Package (Legacy).
  2. Upload the zip above. Pick Update/the existing flow on the mapping screen if offered.
  3. Import. Then in Power Apps: Data panel → find FP-DeleteLibraries(App) → Refresh.
  4. Test on scrManageLibraries with a real throwaway empty library before trusting it for anything real.
Same caveat as item 5 above: this flow was originally hand-built by me rather than round-tripped from a real export of your tenant, so the import screen might only offer "Create as new" instead of "Update." If so, cancel and tell me — the fallback is the one-click Designer edit: open the flow → click the final Response action → under its Body field, click "Use sample payload to generate schema" → paste in:
Sample Response payload
{
  "Results": [
    { "LibraryUrl": "/teams/1598/04_Training_ZZTest", "Title": "ZZTest", "ItemCount": 0, "Deleted": true }
  ]
}
Action Checklist — full from-scratch build, not needed for today's update:
  1. SharePoint lists — create LibraryFilePlan and PermissionIndex via the CSV templates (fastest — creates every column for you), or manually per the schema tables. Column is PrettyName, not DisplayName — SharePoint silently drops writes to that name on this list.
  2. Flow field fix — in Create Library (App), edit Create_FilePlan_Row's Body so it writes PrettyName instead of DisplayName. One line, no re-import.
  3. Flow field fix (missing libraries) — in Rebuild Permission Index, add &$top=5000 to Get_Unique_Perm_Libraries's Uri so it stops capping out around 20-30 libraries. One line, no re-import.
  4. Backfill name fix (optional) — same DisplayName→PrettyName bug as above, in Backfill File Plan. Only matters if you ever run Backfill again.
  5. Register Existing Library (optional) — adds a second path through Create Library (App) for registering a library that already exists, without creating a new one. Needed for scrAdminCreateLibrary v1.9's new toggle.
  6. Import all four flows — Create Library (App), Backfill File Plan, Rebuild Permission Index, Manage Library Permissions. Test Create Library (App) manually before touching Power Apps.
  7. Data connections + App.OnStart — add both SharePoint lists and the flow as data sources, paste App.OnStart in full.
  8. All three live screens — delete and paste fresh (screens have had controls added since first built, so a partial paste over the top won't apply cleanly). Then scrManageLibraries and scrEditLibraries — brand new, paste as new blank screens, nothing to delete first. scrManagePermissions remains just a design, not built — see the Screens section.
  9. Navigation bar — select each screen in the Tree view, paste that screen's snippet as an add-on (not a full replacement). Nudge existing top controls down afterward to clear the bar.
Current state: all six planned screens are built. File Plan and Admin: Create Library are tested and working. Group Search and Manage Permissions are newer — not yet as heavily tested. This was the last screen in the original plan.

1. SharePoint List: LibraryFilePlan

Location: your main site — https://modgovuk.sharepoint.com/teams/1598/.

LibraryFilePlan_list_template.csv — download this, then Site Contents → New → List → From CSV (or Excel) → upload it. SharePoint reads the embedded schema and creates every column with the correct type and Team's choices already filled in — no manual column-by-column setup. Delete the single placeholder row afterward, and rename the list to exactly LibraryFilePlan if it doesn't pick that up from the filename.

Column name (exact, no spaces)TypeNotes
TitleSingle line of textBuilt-in — the flow writes the library's pretty display name into it automatically
LibraryUrlSingle line of textThe real join key — a library's server-relative URL, survives display-name renames
TeamChoiceTop-level file-plan grouping. Fixed set — type in your known team names as the choices, no "Allow fill-in values"
SubTeamChoiceOptional nested grouping. Tick "Allow fill-in values" — same pattern as the CAT column, so new sub-teams can be added on the fly
PrettyNameSingle line of textOptional override; falls back to live library Title if blank. Not "DisplayName" — SharePoint silently drops writes to a column with that exact name on this list (collides with a reserved sub-property on the two Person-or-Group columns, Created By/Modified By)
SiteUrlSingle line of textWhich of your sites the library is on
SortOrderNumberOptional — controls order within a group
IsLimitedYes/NoTrue if the library was created as Limited — lets you set conditional formatting on the list view to colour-code Limited rows
LibraryCreatedDate and TimeNew, added 26 Sep 2026 for the housekeeping/audit view on scrManageLibraries. Snapshot of the real SharePoint library's own creation date — deliberately named away from the built-in reserved Created column every list already has (that one records when this row was added, a different thing entirely). Populated by FP - Rebuild Library Stats, see its own section below — blank until that's been run at least once.
LibraryItemCountNumberNew, added 26 Sep 2026, same reason. How many items are in the real library right now — the whole point is spotting old libraries with zero files, so nobody's actually using them. Same population/staleness rules as LibraryCreated.
Mark every column Not Required. Type names exactly as shown, no spaces — SharePoint silently converts spaces to _x0020_ internally, and the flows' requests need the plain names to match exactly.
The CSV was built by hand to match the schema format seen in a real export from this exact list (same embedded-schema mechanism, not guessed) but hasn't been round-tripped through SharePoint's import wizard directly. If "From CSV" doesn't pick up the column types correctly, fall back to creating the columns manually per the table above — same end result either way.

SharePoint List: PermissionIndex

Location: your main site, same as LibraryFilePlan.

PermissionIndex_list_template.csv — same idea: Site Contents → New → List → From CSV → upload, delete the placeholder row, rename to exactly PermissionIndex if needed.

Column name (exact, no spaces)TypeNotes
TitleSingle line of textBuilt-in — set to the same value as GroupName, so the list is readable at a glance
LibraryUrlSingle line of textWhich library this permission row belongs to — joins back to LibraryFilePlan.LibraryUrl
LibraryTitleSingle line of textDisplay name, so search results don't need a lookup back to LibraryFilePlan
GroupNameSingle line of textThe actual SG_/DG_ group name (or username) this row represents — what the group-search screen filters on
PrincipalTypeSingle line of text"SharePoint Group", "Security Group", or "User"
PermissionLevelSingle line of texte.g. "Full Control", "Contribute", "Read"
NestedUnderGroupSingle line of textBlank for a group/user granted directly on the library. If this row is a DG_ AD group found nested inside an SG_ group, this holds that SG_ group's name
LibraryIdSingle line of textThe library's SharePoint List ID (GUID). Powers the View Permissions button on scrGroupSearch.
SiteUrlSingle line of textNew, added 29 Sep 2026 for multi-site support (section 2g). Which real site the library actually lives on — needed so View Permissions opens the correct site's permissions page, not always the main one.
Mark every column Not Required, exact names, no spaces — same rule as LibraryFilePlan.

2. Flow Field Fix: Create_FilePlan_Row

In Power Automate, open Create Library (App) → Edit → click Create_FilePlan_Row to expand it → click into its Body box → select all, delete → paste this in as the replacement → Save. Existing action, existing field, no re-import needed.

Saving the flow is not enough on its own — a Power App that already calls this flow keeps running the old logic until its own connection to the flow is refreshed. After saving, go into Power Apps Studio → Data panel → find Create Library (App) under your data sources → refresh it (remove and re-add if there's no direct refresh option). Do this after every flow edit, not just this one.
v2.6 — Updated 12 Aug 2026 08:30 BST
{
  "__metadata": { "type": "SP.Data.LibraryFilePlanListItem" },
  "Title": "@{triggerBody()?['DisplayName']}",
  "LibraryUrl": "@{body('Get_New_Library_Url')?['d']?['RootFolder']?['ServerRelativeUrl']}",
  "Team": "@{triggerBody()?['Team']}",
  "SubTeam": "@{triggerBody()?['SubTeam']}",
  "PrettyName": "@{triggerBody()?['DisplayName']}",
  "SiteUrl": "@{triggerBody()?['SiteUrl']}",
  "IsLimited": @{if(equals(triggerBody()?['IsLimited'], true), 'true', 'false')}
}
Only the SharePoint field name changed (DisplayName → PrettyName). The value is still populated from triggerBody()?['DisplayName'] — that's the Power Apps trigger's own parameter name, unrelated to the SharePoint column, and it hasn't changed.

2b. Flow Field Fix: Get_Unique_Perm_Libraries (missing libraries bug)

Cause: this action asks SharePoint for every library with unique permissions, but never told it how many to return. SharePoint's _api/web/lists endpoint defaults to a page size of around 100 items per response and doesn't automatically continue to the next page — with enough lists on the site (including hidden/system ones counted before the filter narrows them down), the real Limited libraries near the end of that list never got reached, so the index silently stopped at whatever fit in the first page.

In Power Automate, open Rebuild Permission Index → Edit → click Get_Unique_Perm_Libraries to expand it → click into its Uri box → add &$top=5000 to the end → Save. Existing action, one field, no re-import needed.

v4.2 — Updated 13 Aug 2026 09:26 BST
_api/web/lists?$filter=BaseTemplate eq 101 and Hidden eq false and HasUniqueRoleAssignments eq true&$select=Title,Id,RootFolder/ServerRelativeUrl&$expand=RootFolder&$top=5000
This changes the flow's own logic, same as any other flow edit — refresh Rebuild Permission Index's connection in the Power Apps Data panel afterward, then run the Rebuild button on the Admin screen once to repopulate the index with every library this time.
The downloadable flow zip for a fresh install already has this fix baked in — this section is only needed if you imported before 13 Aug 2026.

2c. Flow Field Fix: Backfill File Plan (same DisplayName bug as 2)

This exact fix is now step 1 of work item 1 in the Outstanding Flow Work checklist, bundled together with the CCF multi-site changes to the same flow — do it there, not here, so you're not doing overlapping edits to the same action twice.
This fix only affects libraries registered by a future run of Backfill; it does nothing for ugly names already sitting in LibraryFilePlan right now. For those, export the list, fill in Pretty Name yourself (AI-assisted or by hand), re-import, then refresh colFilePlan in the app.

2d. Flow Surgery: Create Library (App) — Register Existing Library

Now Part A of work item 4 in the Outstanding Flow Work checklist, done together with the CCF routing surgery (Part B) since both touch the same flow — do it there, not here.

2e. Flow Field Fix + New Column: Open Permissions Link

Want: a button on scrGroupSearch's library rows that opens that specific library's real SharePoint permissions page directly, in a new tab — no more hunting for it manually.

How it's possible: SharePoint's classic "Permissions for this document library" page is {site}/_layouts/15/user.aspx?obj={GUID}&List={GUID}, where the GUID is that specific list's internal ID. Confirmed 26 Sep 2026 from a real URL Allan copied out of Studio. Nothing we currently store has that GUID — LibraryFilePlan and PermissionIndex both only have the server-relative URL — but FP - Rebuild Permission Index already fetches it for every library it scans (its Get_Unique_Perm_Libraries step selects Id alongside Title and the URL), it just never saves it anywhere. Cheapest fix: capture it.

  1. Add one column to PermissionIndex: SharePoint list settings → Create column → LibraryId, type Single line of text, Not Required.
  2. Fix Select_Libraries first. This action runs right after Get_Unique_Perm_Libraries and reshapes its results down to just Title and Url before Apply_to_each_Library ever sees them — Id gets fetched from SharePoint one step earlier, then thrown away here. Open Select_Libraries → its Select box already has Title and Url mapped → add a third field:
    New field in Select_Libraries
    Id → @item()?['Id']
  3. Edit the flow. Power Automate → FP - Rebuild Permission Index → Edit → find both Create_Index_Row and Create_Nested_Index_Row (inside Apply_to_each_Assignment → If_Not_System_Group, and inside the nested If_SharePoint_Group → Apply_to_each_Nested_Member) → click into each one's Body box → add one new line before the closing brace:
    New line for both actions
      "LibraryId": "@{items('Apply_to_each_Library')?['Id']}"
    Same expression in both actions — Apply_to_each_Library is the outer loop both actions already run inside, so its Id is available in each without any new lookup, once step 2 above has put it there.
  4. Save the flow.
  5. Refresh the PermissionIndex data source in Power Apps too, not just the flow. Adding a column directly to a SharePoint list doesn't make the app aware of it — Power Apps caches a data source's column schema separately, so colPermissionIndex keeps behaving as if LibraryId doesn't exist (silently blank, not an error) until this is done. Studio → Data panel → find PermissionIndex → Refresh (or remove and re-add if there's no direct refresh option) → reload the app so App.OnStart re-runs.
Confirmed live and working, per Allan, 26 Sep 2026. The button's formula:
Live on scrGroupSearch
Launch(varTenantHost & "/teams/1598/_layouts/15/user.aspx?obj=" & EncodeUrl("{" & ThisItem.LibraryId & "}") & ",doclib&List=" & EncodeUrl("{" & ThisItem.LibraryId & "}"))
Took three real bugs to get here, worth remembering if a similar "data's right but the app won't show it" symptom turns up again: a Select action silently dropping a field nothing downstream could see; First(GroupedItems).Field failing to surface a newly-added field even once genuinely fixed upstream (switched to a direct LookUp instead); and a URL needing the real site path plus proper percent-encoding, not just the tenant root and a literal {guid}.

2f. New Flow: FP - Rebuild Library Stats

Why: scrManageLibraries is meant to be a housekeeping/audit view — Allan's own framing: spot libraries created a long time ago with zero files in them, so nobody's actually using them, and flag them for a "are you going to use this?" conversation. Needs two new facts about each real library that nothing currently tracks: when it was actually created, and how many items are in it right now.

RebuildLibraryStats_flow_import.zip — import this like every other flow (My flows → Import → Import Package (Legacy)). Confirmed working, per Allan, 26 Sep 2026 — this one was hand-authored rather than exported from a real flow, and took two real structural bugs to get right (a missing second manifest.json, and a stray metadata block copied from the wrong kind of reference). Documented process and a real structural checker for this now exist (power-automate/power-automate-bible.md "Hand-Authoring A New Import-Package", PowerApps/tools/validate-flow-package.py) — use them first next time, don't hand-build from memory.

What it does (or the manual build steps below if a future re-import ever needs one):

  1. Create a new flow. Power Automate → My flows → New flow → Instant cloud flow → name it FP - Rebuild Library Stats → trigger: Power Apps (V2).
  2. Trigger input (optional, not required): SiteUrl, type Text — same optional override pattern every other flow here uses.
  3. Get_Libraries — HTTP request to SharePoint (or "Send an HTTP request to SharePoint" action): GET, Site Address = coalesce(triggerBody()?['SiteUrl'], 'https://modgovuk.sharepoint.com/teams/1598'), Uri:
    Get_Libraries Uri
    _api/web/lists?$filter=BaseTemplate eq 101 and Hidden eq false&$select=Title,Created,ItemCount,RootFolder/ServerRelativeUrl&$expand=RootFolder&$top=5000
    &$top=5000 is included from the start this time — the missing-libraries bug fixed in section 2b happened because an earlier flow forgot this on a similar query.
  4. Select_LibraryStats — a Select action, From: body('Get_Libraries')?['d']?['results'], Map:
    Select_LibraryStats map
    Url: item()?['RootFolder']?['ServerRelativeUrl']
    Created: item()?['Created']
    ItemCount: item()?['ItemCount']
    All three fields explicitly mapped — the same class of bug fixed in section 2e happened because a Select action silently dropped a field nothing further downstream could then see. Don't repeat that here.
  5. Apply_to_each_Library, foreach body('Select_LibraryStats'):
    1. Get_Existing_Row — HTTP GET, same Site Address as above: _api/web/lists/getbytitle('LibraryFilePlan')/items?$filter=LibraryUrl eq '@{items('Apply_to_each_Library')?['Url']}'&$select=Id&$top=1 — same dedupe-lookup pattern Backfill/Register-Existing-Library already use.
    2. If_Tracked: length(body('Get_Existing_Row')?['d']?['results']) greater than 0.
    3. Inside "Yes": Update_LibraryFilePlan_Row — HTTP POST, Uri _api/web/lists/getbytitle('LibraryFilePlan')/items(@{body('Get_Existing_Row')?['d']?['results'][0]?['Id']}), Headers Accept: application/json;odata=verbose, Content-Type: application/json;odata=verbose, IF-MATCH: *, X-HTTP-Method: MERGE (standard SharePoint REST update syntax, not specific to this app), Body:
      Update_LibraryFilePlan_Row body
      {
        "__metadata": { "type": "SP.Data.LibraryFilePlanListItem" },
        "LibraryCreated": "@{items('Apply_to_each_Library')?['Created']}",
        "LibraryItemCount": @{items('Apply_to_each_Library')?['ItemCount']}
      }
    4. Nothing needed in "No" — a library that isn't tracked in LibraryFilePlan yet is a Backfill problem, not this flow's job.
  6. No Response action needed — same as FP - Rebuild Permission Index, this is a fire-and-forget scan the app just reloads its own data after, it doesn't need anything handed back.
  7. Save. Add it as a data source in Power Apps Studio (Data panel → Add data → Power Automate flows). Check the exact Power Fx identifier Studio gives it in the Insert/Data panel before wiring the button — don't assume it matches the display name exactly, the same lesson already learned twice this session (Delete Libraries, and the still-unconfirmed Create Library identifier).
The button's OnSelect on scrManageLibraries uses RebuildLibraryStats as the data-source identifier — confirmed correct, working live, per Allan 26 Sep 2026.

2g. Flow Surgery: Multi-Site Support (CCF Sub-Site)

Fully written out as work items 1–4 in the Outstanding Flow Work checklist near the top of this guide — Backfill, Rebuild Permission Index, Rebuild Library Stats, and Create Library (App), in that order, with copy-ready code blocks for each. Work through them there.
Confirmed before any of it was written, not assumed: checked Backfill File Plan's real definition directly. Its only write action sits entirely inside the "0 existing rows found" branch — the "already tracked" branch is a genuinely empty {"actions": {}}. There is no code path anywhere in that flow that can touch an existing row's Team, Sub-Team, or PrettyName. The site loop only widens what gets discovered, never what gets overwritten.

3. Download & Import Flows

CreateLibraryApp_flow_import.zip — creates a library, registers it into LibraryFilePlan. The core flow everything else builds on.

BackfillFilePlan_flow_import.zip — one-time scan of every existing library on the site, registers anything not already tracked.

RebuildPermissionIndex_flow_import.zip — scans libraries with broken permissions and builds a fast, searchable index of their groups.

ManageLibraryPermissions_flow_import.zip — lists, adds, or removes a group directly on a chosen library, called live from the Admin screen.

DeleteLibrariesApp_flow_import.zip — checks each library for files, deletes the ones that are safe to delete (or all of them if overridden), and cleans up their LibraryFilePlan/PermissionIndex rows. Called live from the Manage Libraries screen.

RebuildLibraryStats_flow_import.zip — scans every library's real creation date and file count, writes them onto the matching LibraryFilePlan row. Powers the housekeeping/audit view on Manage Libraries (empty old libraries flagged for review). New 26 Sep 2026 — see the callout in section 2f before trusting this one blindly.

All six: Power Automate → My flows → Import → Import Package (Legacy).

What Create Library (App) does

Triggered from Power Apps (or manually, for testing). Per library:

  1. Creates the library (BaseTemplate 101) with the correct sensitivity label for Normal vs Limited
  2. Adds a CAT choice column (fill-in-your-own enabled) and a Status choice column (Draft / Under Review / Reviewed / Published / Final / Superseded / Expired)
  3. Creates WiP view (items without a retention label) and Records view (items with one), both grouped by CAT
  4. Renames the library's Title to the pretty display name
  5. If Limited: breaks role inheritance completely, grants SG_Cadets-Admin-Ltd_MEM and SG_IHUB Full Control (fixed — nothing to fill in on the form, since a separate team owns creating/adding any other group later), then removes the flow-runner's own account (SharePoint auto-adds whoever breaks inheritance as a lockout safety net; the flow strips that back out)
  6. Registers the new library into LibraryFilePlan

Site URL defaults to the main site if left blank — every action falls back to it via coalesce().

Testing Create Library (App)

Power Automate → My flows → Create Library (App) → Test → Manually.

FieldFirst test — plainSecond test — Limited
Site URLleave blankleave blank
Technical NameZZTest_LibraryZZTest_LTD_Library
Display NameZZ Test LibraryZZ Test LTD Library
Is Limitedfalse / offtrue / on
TeamTestTest
SubTeam, Categoriesleave blankleave blank

First test: check the library exists with CAT + Status columns and WiP/Records views, its Title shows the pretty name, and a new row appeared in LibraryFilePlan with PrettyName populated.

Second test is the one part of the whole system that touches security directly. After it runs, manually open Library Settings → Permissions for that document library and confirm only SG_Cadets-Admin-Ltd_MEM and SG_IHUB are there — no inherited groups, no extra groups, no direct user entry for whoever ran the flow.
Clean-up: delete both test libraries and their two rows from LibraryFilePlan once both tests pass.

Backfill Existing Libraries

LibraryFilePlan only tracks libraries created after this system existed. This flow seeds it from what already exists: scans every real document library (skipping SharePoint's own system libraries), and for anything not already tracked, adds a row with LibraryUrl, PrettyName, Title, SiteUrl filled in automatically, and IsLimited guessed from whether the library already has broken permission inheritance. Team, SubTeam are left blank — fill those in by hand afterward via SharePoint's grid/quick-edit view. Safe to run more than once — only adds what's missing.

Scans your whole library list — with ~500 libraries, expect several minutes. Runs 20 at a time in parallel. Let it finish; don't re-run while it's still going.

Rebuild Permission Index

Power Apps can't read SharePoint permissions directly, and a search screen can't afford to live-query every library's permissions on every keystroke. This flow scans permissions once and writes a flat, fast-searchable copy into PermissionIndex; the group-search screen just filters that list.

Only scans libraries with broken/unique permissions (mostly your Limited libraries) — Normal libraries all inherit the exact same site-wide permissions, so indexing each individually would be pure noise. If a group is a native SharePoint Group, it also looks one level inside that group's own membership — this is how a nested DG_... AD group shows up in the index too, without needing Microsoft Graph.

Deletes the entire contents of PermissionIndex and rebuilds it from scratch every run — intentional, avoids stale/duplicate rows.
Excludes SharePoint's own auto-generated "Limited Access System Group" — this isn't a real business group, it's internal bookkeeping SharePoint creates whenever anything inside a library (even a single folder or file) has broken permissions. Since it's technically a SharePoint Group, expanding its membership floods the index with individual named users nobody would search for by name. Confirmed live: a first run without this exclusion produced ~500 rows, nearly all of them this noise. Also excludes "System Account" wherever it shows up as a nested member — SharePoint's own internal service account, never a real permission grantee.

Manage Library Permissions

This is the write counterpart to Rebuild Permission Index / Group Search, which are both read-only snapshots. This flow is called live, on demand, from the Admin screen — one flow, one trigger, an Action field ("List" / "Add" / "Remove") decides what it does:

  • List — live role assignments for one library, straight from SharePoint (not the cached index), so it's always current
  • Add — grants a SharePoint group (SG_...) a permission level on that library, reusing the exact same Get_Group_Id → Get_Role_Def_Id → Add_Role_Assignment pattern already proven in Create Library (App)
  • Remove — removes a group's entire access to that library in one call (roleassignments/getbyprincipalid(id) with an HTTP DELETE), rather than needing to know which specific permission level to unwind
Only manages native SharePoint groups (SG_...) directly on a library — matches how every Limited library in this system is actually set up: a DG_... AD group is never granted directly, it's always nested inside an SG_... wrapper group. If a genuinely new AD group needs adding, the other team creates it and its SG_ wrapper first, same as always — this screen doesn't change that workflow, just removes the "someone has to do it by hand in native SharePoint" step.

Delete Libraries (App)

Called from the Manage Libraries screen with a JSON array of library URLs, an Action (currently always "Delete" from the app — "Check" is accepted too but every branch other than Delete just reports counts and changes nothing, so it's there if you ever want a preview-only call), and a Force flag.

Per library: gets its live ItemCount via _api/web/GetList(url) (server-relative URL, not title — the one join key in this whole system guaranteed not to drift if someone renames a library directly in SharePoint). If Force is false and ItemCount > 0, that library is skipped and reported back, untouched. Otherwise it deletes the document library itself, its LibraryFilePlan row, and every PermissionIndex row that pointed at it.

There is no undo. A skipped (non-empty) library only gets deleted if the app is told to override — but once that override is sent, the flow deletes exactly what it's told to, no second check.

4. Building the Power App

Create a new blank canvas app in Power Apps Studio.

Data connections

Data panel → Add data → add these before pasting any screen YAML:

v4.3 — Updated 13 Aug 2026 10:23 BST
'LibraryFilePlan'          (SharePoint → your main site → LibraryFilePlan)
'PermissionIndex'          (SharePoint → your main site → PermissionIndex)
'Create Library (App)'     (search Power Automate flows in Add data)
'Rebuild Permission Index' (search Power Automate flows in Add data)
'Manage Library Permissions' (search Power Automate flows in Add data)
'Delete Libraries (App)'   (search Power Automate flows in Add data)

App.OnStart

v3.18 is confirmed pasted and live, per Allan, 01 Oct 2026 — adds the Team/Sub-Team-aware Group Search collections (colGSTeamGroups, colGSSubTeamGroups, colGSExpandedTeams, colGSExpandedSubTeams) that scrGroupSearch v3.14+ depends on. Full code is item 7 in the Everything Left To Do checklist near the top of this guide if it ever needs re-pasting from scratch.

5. Screens

For each: delete the existing screen if you already have one pasted, New screen (blank) → open the link → Copy YAML → paste. Rename the screen to match the name shown.

All 5 screens confirmed live and fully working — per Allan, 26 Sep 2026. Everything below is for reference, or for re-pasting a screen from scratch if it ever needs rebuilding — nothing here is currently outstanding.

Designed, never built

This one still doesn't exist in Studio, no current plan to build it. The YAML below is a design snapshot from August 2026, never confirmed live.

scrFilePlan: one flat gallery, a Team/Sub-Team/Library discriminator column decides what each row shows, rather than nested galleries. Search is instant and local, auto-expands every Team with a match. Full-width layout, icon-only Open/Copy buttons, "Uncategorised" bucket for libraries with no Team (force-sorted last, light grey tint), a real third grouping tier (Team → Sub-Team → Library), padlock icon + red/salmon row tint for Limited libraries, and library rows sorted alphabetically by the pretty display name within each group.
scrAdminCreateLibrary: Sub-Team is free text (Modern Combobox has a known instability issue, avoided deliberately). Queue is entirely local until Run Queue, which disables itself and shows progress. The Rebuild Permission Index button is fire-and-forget — doesn't wait for the flow, shows how long ago the index was last rebuilt and flags how many Limited libraries have appeared since. A "Register an existing library only" toggle swaps Technical Name for an Existing Library Title field, for libraries that already exist outside the app. Needs the matching Create Library flow change in section 2d before that toggle actually registers anything — not yet done.
scrGroupSearch: only finds libraries that were in scope for the last Rebuild Permission Index run (broken/unique-permission libraries) — a Normal library never shows up here, that's expected. Three-tier accordion (Library → directly-granted group → nested member), Security Group rows get a distinct amber tint with no chevron (no enumerable membership in this data). Red padlock/purple warning dual-signal LTD indicator per library (a housekeeping signal, not a strict correctness check — a purple flag means "worth a look," not "this is wrong"), a Refresh Index button, and a "View Permissions" button (Allan's own addition) that opens SharePoint's real permissions page for that library in a new tab.
scrManageLibraries: housekeeping/audit view, Allan's own framing. Team → Sub-Team → Library accordion with a Team filter dropdown, "Sub-Team: None" shown explicitly rather than left blank. Tick libraries to bulk-delete (empty ones go straight away, non-empty ones get an override prompt showing exactly which ones and why) or send to the edit cart. Each library row shows its real creation date and current file count, empty libraries flagged in red/bold — the actual point of the screen, spotting old libraries nobody's using. Checkboxes are per-library only by design; Team/Sub-Team headers are grouping/collapse only, no bulk-select-by-group.
Worth testing, not yet confirmed either way: this screen's per-row checkbox is ModernCheckbox@1.0.2 using Default for its state (Allan's own swap after enabling Modern controls tenant-side). Every other control on this exact pattern in this app family has turned out to read Default only once, at creation, not reactively — confirmed elsewhere as the cause of a checklist that visually never ticked despite correct underlying data. If "Select All Visible" updates the data but doesn't visually check the boxes, that's this exact bug; the confirmed fix elsewhere was a control with a genuinely reactive property (Checked) instead.
scrEditLibraries: the "edit cart" — tick libraries on Manage Libraries, Edit Selected copies them into a local collection, one stacked card per library, all editable at once with no round trip until "Save All" (a single ForAll + Patch). Library URL is deliberately not editable — it's the join key this whole app relies on to survive renames. Includes a "Limited access" tick box per card (fixes the record only, not real SharePoint permissions if those are genuinely mismatched) and a working Team dropdown, pulling the real matching Choice record rather than a hand-built one.
scrManagePermissions — never built, design only. Everything below describes the intended design, not a live screen. No current plan to build it.
scrManagePermissions: unlike Rebuild Permission Index's fire-and-forget button, Add/Remove here wait for the result and immediately re-load the list — a single group operation is fast enough that blocking is the better UX, and there's a real result (success or a specific error) worth showing rather than "check back later." Uses ThisItem.Title (the library's real current SharePoint Title) when calling the flow, not PrettyName/EffectiveName — SharePoint's getbytitle() needs the actual current Title, and Create Library (App) renames every library's Title to the pretty name as its last real step, so by the time a library shows up here, its Title already is the pretty name.

7. Future Idea (Scoping Only, Not Started): Limited Sub-Site Migration

Nothing built yet. Discussed and scoped with Allan 26 Sep 2026, explicitly not to be actioned until he asks for it directly. Written up here so a future session can start from this brief instead of re-deriving it. Don't build any part of this without Allan's explicit go-ahead, even if a related task seems to touch it.

The problem: a separate sub-site (called "Limited") holds a large number of document libraries, every one of them genuinely Limited (its own permission groups, its own category setup), all created before this app's current build conventions existed. Microsoft is pushing tenants toward a flat site structure, so these need to move up to the main site (/teams/1598), not just sit on a sub-site indefinitely.

Hard constraint: no Microsoft Graph access. Allan has zero access to Graph, so nothing in this project can rely on it — not Graph's site-content-move-job APIs, not Graph-based file operations, nothing. Every flow in this app already only ever uses the SharePoint REST connector (_api/web/... via shared_sharepointonline) and that's the only mechanism available here too. This doesn't block the plan below — the one genuinely Graph-adjacent piece (bulk cross-site content transfer) is handled by SharePoint's own native browser UI, driven by Allan directly in his own session, not by an API call needing Graph permissions.

Confirmed technical facts, not guessed (via Microsoft Q&A / community sources, 26 Sep 2026): SharePoint's native "Move to" command genuinely preserves version history and metadata when moving files between sites in the same tenant — but it does not preserve the source library's own item-level/library-level permission structure; moved files just inherit whatever permissions already exist on the destination. Full permission-preserving cross-site migration is what tools like SPMT (SharePoint Migration Tool) are for, which is a separate, heavier tool outside this app's own flow-based approach. This is exactly why the plan below creates the new library with its real permissions already correctly in place before any documents move, rather than relying on the move operation to carry permissions across.

Decided direction: copy, not move. Allan's explicit call — leave every original library fully in place and untouched, copy its documents to a newly-created library on the main site, verify the copy is complete and correct, and only then flag (never auto-delete) the original for later manual deletion.

Allan's own curiosity, answered: do links break?

A SharePoint file's URL (and its internal unique ID) belongs to that specific file object, not to "the document" in some abstract sense. Since a copy leaves the original completely untouched, any existing link to the original — a "Copy link" share, a bookmarked URL, a link pasted in an email months ago — keeps working exactly as it does today, for as long as the original still exists. A link to the new copy is a brand-new URL; nothing carries old links over to it automatically, so anyone needing to reach the new copy needs a freshly generated link. The only point anything actually breaks is if/when the original is eventually deleted for real (whenever that happens, on Allan's own timeline, likely well after this) — at that point any old link still pointing at it will 404, same as deleting any file would do regardless of how it got migrated. Worth a quick real test (copy one file, confirm the original's existing link still resolves) before relying on this for anything that matters, rather than trusting this write-up alone.

What the new library needs, beyond a raw structural clone

  • Full modern treatment, not a copy of the old setup. Built via the same logic as Create Library (App) — CAT column (fill-in-your-own), Status column, WiP/Records views, and critically the correct sensitivity label for Limited libraries, which a lot of the old sub-site libraries currently lack entirely. This is explicitly an upgrade on migration, not a like-for-like clone.
  • Category values, not just the column. The old library's CAT column has real values already in use across its documents — these need identifying (distinct values actually used, not a guess) and pre-seeding onto the new column's choices, and each copied document needs its CAT value re-applied to match on the new side so nothing loses its category during the move.
  • Same display name, but conformed to current naming (numeric team prefixes, matching every other library built since). Team/Sub-Team assignment per migrated library is a human decision Allan makes at migration time, not something to infer automatically from the old sub-site's own structure.
  • Permissions replayed exactly, not recreated from scratch. Every source library's real grants already exist in PermissionIndex (GroupName/PermissionLevel per library) once a rebuild's been run against the sub-site too. The underlying SG_/DG_ groups already exist tenant-wide, so this is just re-applying existing group references at the same permission levels on the new library — no new groups need creating, matching Allan's own read of it ("permissions are not going to change... they exist already").

Verification before flagging anything for deletion

Given every one of these libraries is genuinely Limited, getting this wrong is a real security question, not just a UX one. Two checks, both cheap to build and both non-negotiable before a library gets flagged:

  1. Permissions diff — re-scan the new library's real live permissions (same mechanism FP - Rebuild Permission Index already uses) and compare against what was intended to be replayed from the source. Any mismatch blocks flagging, full stop.
  2. Content diff — confirm every document that existed on the source made it to the new library (file count and name comparison at minimum) before treating the copy as complete.

Only once both pass: cosmetically rename the old library's Title (its URL stays untouched, so nothing that already points at it breaks) to visibly flag it — something like appending "(Migrated — pending deletion)" — and add a matching field to LibraryFilePlan so it surfaces on scrManageLibraries with its own tick-box. Not every flagged library will necessarily get deleted on the same timeline, or at all — some may just get left, so this needs to be an explicit per-library opt-in Allan controls, never an all-or-nothing switch.

Open questions, not yet decided

  • Separate small standalone tool, or a new screen bolted onto this app? Allan floated both as genuinely live options — this is a largely one-off bulk migration job, not a recurring day-to-day feature, so a cheap bespoke tool with just a handful of buttons might suit it better than extending the main app. Worth deciding at the start of the actual work, not guessing now.
  • Exact automation boundary for the document copy step itself — whether it's driven entirely by Allan using SharePoint's native "Move to"/"Copy to" UI per library (simplest, safest, no new flow risk) or whether enough volume exists to justify scripting bulk copies via the SharePoint REST connector instead. Given "loads of them," worth a real count before assuming either way.

Recommended first step, whenever this actually starts (safe, read-only, useful on its own)

A pure audit/discovery pass before touching anything: scan every library on the Limited sub-site and produce a CSV (or a page in this guide) listing each one's name, its real current permission grants, the CAT values actually in use, document/version counts, and whether it already has a proper sensitivity label or not. Same read-only-scan shape as Rebuild Permission Index/Backfill File Plan already proven in this app — genuinely low-risk, and gives Allan and Claude something concrete to review together before any migration logic gets designed in detail.

Issues

Found a problem? Report it here.