v4.7 Built 26 Sep 2026 3 screens live 2 ready to build (new) 1 more 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.

Start here — what's actually left right now (26 Sep 2026). Everything below this box is the full from-scratch build guide, kept for reference — you don't need any of it for today's update. This box is the only thing that changes as work progresses; ask for it to be updated whenever something's done or something new lands, rather than re-reading the whole guide.
  1. All 5 screens built and pasted — confirmed live by Allan, 26 Sep 2026: scrFilePlan, scrAdminCreateLibrary, scrGroupSearch, scrManageLibraries, scrEditLibraries. Allan also removed the dead "Manage Permissions" nav button himself in Studio and shifted Manage Libraries left to fill the gap — done, no further action needed on that.
  2. Two things left to paste, in this order: App.OnStart v3.14, then scrGroupSearch v3.11. Both are small additions on top of what's already live, needed to finish the "View Permissions" button Allan added himself to scrGroupSearch (opens a library's real SharePoint permissions page in a new tab).
  3. Two things left to paste: App.OnStart v3.15, scrGroupSearch v3.12 — done, confirmed working, per Allan 26 Sep 2026 ("works great"). Full three-bug story kept in the App.OnStart section for reference, in case a similar "data's right but the app won't show it" symptom turns up again.
  4. scrEditLibraries v2.3 (Team dropdown fix) — done, confirmed working, per Allan 26 Sep 2026.
  5. scrManageLibraries v2.4 (Team/Sub-Team accordion + filter) — done, confirmed working, per Allan 26 Sep 2026 ("really pleased so far").
  6. Sort-order fix: App.OnStart v3.16, scrFilePlan v2.11, scrAdminCreateLibrary v1.11, scrEditLibraries v2.4, scrManageLibraries v2.5 — done, confirmed working.
  7. Floated but not built: Allan asked whether Limited libraries should be grouped separately as a visual touch, on top of the alphabetical fix — framed as optional, not yet actioned, worth confirming the exact design before building.
  8. New, needs real setup before pasting: scrManageLibraries v2.6 — "housekeeping/audit" purpose per Allan: shows each library's creation date and current file count, empty ones flagged in red, so old unused libraries can actually be found and flagged for a "still need this?" conversation. This one needs real setup first, not just a paste: (1) add two new columns to LibraryFilePlan, LibraryCreated and LibraryItemCount (section 1); (2) build a brand new flow, FP - Rebuild Library Stats, from scratch in Designer — nothing to import, full numbered steps in section 2f; (3) add it as a data source and confirm its real Power Fx identifier before pasting the screen — the button currently uses a placeholder guess (RebuildLibraryStats), flagged clearly in the YAML and in 2f, don't trust it blindly. Built on top of Allan's own substantial cosmetic pass on this screen (100px rows, buttons moved onto the nav bar, a new Open button, a swap to ModernCheckbox@1.0.2) — see the screens section for a worth-testing note on that last one, not yet confirmed either way.
  9. Dual-signal LTD indicator on scrGroupSearch — confirmed live and working. Allan's already found a real example with it (a library carrying an SG_Cadets_LTD group whose IsLimited flag was false) — working as intended, a housekeeping signal rather than a strict pass/fail check.
  10. Refresh Index button — confirmed live and working. Also confirms the RebuildPermissionIndex data-source identifier survived the flow's rename to FP - Rebuild Permission Index fine, so that open question from earlier is settled.
  11. Deprioritised by Allan, come back to later: FP-DeleteLibraries(App)'s missing Response schema (every field under Results shows as type "Error" until the flow's been run once with a real sample — see section 3 for the fix). Not urgent, deleting libraries matters far less than creating them.
  12. Optional, whenever you want that specific feature: Register Existing Library flow surgery (2d) for the toggle on Create Library, and/or the Backfill name fix (2c) if you plan to run Backfill again.
  13. Once App.OnStart v3.14 and scrGroupSearch v3.11 are pasted and confirmed, tell me and this box shrinks right down.
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
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)

Cause: found 25 Sep 2026 while investigating why some libraries showed raw, ugly names in scrFilePlan instead of a proper Pretty Name. This flow's own Create_FilePlan_Row action was writing to DisplayName, not PrettyName — the exact same silently-dropped-write bug fixed in section 2 above for Create Library (App), but never applied here since the two flows only share a pattern, not the actual action. Any library that was ever registered by running Backfill (rather than created through the app's own Create Library button) has no Pretty Name at all as a result.

In Power Automate, open Backfill File Plan → Edit → find the Create_FilePlan_Row action inside the If_Not_Already_Tracked condition, inside Apply_to_each_Library → click into its Body box → select all, delete → paste this in as the replacement → Save.

v1.1 — Updated 25 Sep 2026 14:00 BST
{
  "__metadata": { "type": "SP.Data.LibraryFilePlanListItem" },
  "Title": "@{items('Apply_to_each_Library')?['Title']}",
  "LibraryUrl": "@{items('Apply_to_each_Library')?['Url']}",
  "PrettyName": "@{items('Apply_to_each_Library')?['Title']}",
  "SiteUrl": "@{coalesce(triggerBody()?['SiteUrl'], 'https://modgovuk.sharepoint.com/teams/1598')}",
  "Team": "",
  "SubTeam": "",
  "IsLimited": @{if(equals(items('Apply_to_each_Library')?['Unique'], true), 'true', 'false')}
}
This flow isn't added as a data source in the app (it's only ever run manually from Power Automate), so there's no Power Apps connection to refresh here — just Save the flow. This fix only affects libraries registered by a future run of Backfill; it does nothing for the 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 (reload, or hit any button that re-pulls it).

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

Needed for scrAdminCreateLibrary v1.9's new "Register an existing library only" toggle. This adds a second path through the same flow — nothing gets deleted or recreated, and no new flow needs registering as a data source in the app.

This is a genuine structural change, not a one-line field edit like the fixes above. Take it slowly, one step at a time, and test with the flow's own manual Test panel before touching Power Apps.
  1. Add two new trigger inputs. Open Create Library (App) → Edit → click the trigger (Power Apps) → under its inputs, add: RegisterOnly (type Yes/No, not required) and ExistingLibraryTitle (type Text, not required).
  2. Add two "Initialize variable" actions right after the trigger, before Select_Categories: varOutDisplayName (String, leave the value blank) and varOutLibraryUrl (String, leave the value blank).
  3. Add a Condition action right after those two, still before Select_Categories. Set it to check RegisterOnly is equal to true.
  4. Move the existing chain into the "No" branch. Select Select_Categories through Create_FilePlan_Row (everything except the final Response action) — multi-select them (click the first, shift-click the last, or use the "..." menu → Select actions), Cut, then click inside the Condition's No branch and Paste.
  5. At the end of the "No" branch (after Create_FilePlan_Row), add two Set variable actions:
    • Set varOutDisplayName to triggerBody()?['DisplayName']
    • Set varOutLibraryUrl to body('Get_New_Library_Url')?['d']?['RootFolder']?['ServerRelativeUrl']
  6. In the "Yes" branch, add these actions in order:
    1. An HTTP action (Send an HTTP request to SharePoint) named Get_Existing_Library_Url: GET _api/web/lists/getbytitle('@{triggerBody()?['ExistingLibraryTitle']}')/RootFolder/ServerRelativeUrl, Site Address = the same dataset expression already used elsewhere in this flow (coalesce(triggerBody()?['SiteUrl'], 'https://modgovuk.sharepoint.com/teams/1598')).
    2. Another HTTP GET, Get_Existing_Row: _api/web/lists/getbytitle('LibraryFilePlan')/items?$filter=LibraryUrl eq '@{body('Get_Existing_Library_Url')?['d']?['ServerRelativeUrl']}'&$select=Id&$top=1 — same dedupe check Backfill already uses.
    3. A Condition, If_Not_Already_Tracked: length(body('Get_Existing_Row')?['d']?['results']) is equal to 0.
    4. Inside its "Yes": an HTTP POST, Create_FilePlan_Row_Register, same URI as the existing Create_FilePlan_Row (_api/web/lists/getbytitle('LibraryFilePlan')/items), with this body:
      New action body
      {
        "__metadata": { "type": "SP.Data.LibraryFilePlanListItem" },
        "Title": "@{triggerBody()?['ExistingLibraryTitle']}",
        "LibraryUrl": "@{body('Get_Existing_Library_Url')?['d']?['ServerRelativeUrl']}",
        "PrettyName": "@{coalesce(triggerBody()?['DisplayName'], triggerBody()?['ExistingLibraryTitle'])}",
        "Team": "@{triggerBody()?['Team']}",
        "SubTeam": "@{triggerBody()?['SubTeam']}",
        "SiteUrl": "@{coalesce(triggerBody()?['SiteUrl'], 'https://modgovuk.sharepoint.com/teams/1598')}",
        "IsLimited": @{if(equals(triggerBody()?['IsLimited'], true), 'true', 'false')}
      }
    5. Still inside the "Yes" branch of If_Not_Already_Tracked, after that action: Set varOutDisplayName to triggerBody()?['ExistingLibraryTitle'], and Set varOutLibraryUrl to body('Get_Existing_Library_Url')?['d']?['ServerRelativeUrl'].
    6. In the "No" branch of If_Not_Already_Tracked (already registered): the same two Set variable actions, so the flow still responds cleanly instead of leaving the variables blank.
  7. Move the existing Response action out from directly after Create_FilePlan_Row to sit after the whole Condition block (both branches). Update its body to read from the variables instead of the old direct references:
    Updated Response body
    {
      "Success": true,
      "DisplayName": "@{variables('varOutDisplayName')}",
      "LibraryUrl": "@{variables('varOutLibraryUrl')}"
    }
  8. Save. Test both paths manually before touching Power Apps: once with RegisterOnly off (normal creation, unchanged), once with it on and a real existing library's title.
Same rule as every other flow edit: refresh Create Library (App)'s connection in the Power Apps Data panel afterward, or scrAdminCreateLibrary keeps calling the old version.
Why restructure this way instead of a simpler Terminate action in the "Yes" branch: a flow that never reaches its own Response action can surface as an error on the Power Apps side. Routing both paths through one shared Response, fed by variables set in whichever branch actually ran, is the same fix pattern already proven elsewhere in this app family for "flow needs one shared response after branching logic."

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.
This write-up missed two things the first two times round, both found only after Allan hit them for real: (1) Select_Libraries reshapes Get_Unique_Perm_Libraries's output down to just Title/Url before the loop ever sees it, so referencing Id further down did nothing until step 2 above was added. (2) Even with the SharePoint list column genuinely populated, the app's own cached schema for PermissionIndex didn't know the new column existed until refreshed in the Data panel (step 5 above) — same class of "data changed but the app's cached knowledge of it didn't" as every other flow-refresh callout in this guide, just triggered by a list-schema change instead of a flow-body change this time.
Since FP - Rebuild Permission Index deletes and rebuilds PermissionIndex from scratch every run, existing rows won't get LibraryId retroactively just from the flow change — run it again (Refresh Index on scrGroupSearch, or Rebuild Permission Index on scrAdminCreateLibrary) after saving, so every row actually carries the new field. Confirmed by Allan 26 Sep 2026: the PermissionIndex list's own LibraryId column is fully populated.
First version of this button's formula (above) was still wrong even once LibraryId itself was flowing correctly — found by Allan comparing it directly against a real working URL copied from SharePoint. Two problems: (1) it was missing the site path (varTenantHost is just https://modgovuk.sharepoint.com, the real page lives under /teams/1598) — hardcoded here the same way every flow in this app already hardcodes that exact site as its fallback; (2) it used a literal {...}, which isn't valid in a URL at all, and was missing the ,doclib suffix the real page needs. Corrected formula, paste straight over the button's OnSelect in Studio (it's Allan's own control, not part of a full screen paste):
Corrected — paste into the button's OnSelect
Launch(varTenantHost & "/teams/1598/_layouts/15/user.aspx?obj=" & EncodeUrl("{" & ThisItem.LibraryId & "}") & ",doclib&List=" & EncodeUrl("{" & ThisItem.LibraryId & "}"))
Built and live otherwise: the "View Permissions" button is on scrGroupSearch (Allan's own addition), and colLibraryGroups now carries LibraryId through to it correctly (App.OnStart v3.15 / scrGroupSearch v3.12, see sections 4 and 5).

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)).

Different from every other zip in this guide, be aware: the other five were exported directly from Allan's own working flows in Power Automate. This one is a package built by hand — two real bugs found and fixed the hard way, 26 Sep 2026: (1) missing a second, differently-shaped manifest.json inside Microsoft.Flow/flows/, real import error MissingPackageManifest from Allan's own first attempt; (2) carried a full metadata block copied from a real Studio export, which the project's own already-confirmed hand-authored template (cadets-org-chart/downloads/OrgChart_ComputeDepth_Phase5.zip) doesn't have at all — found by actually diffing against that reference instead of assuming the real-export shape was close enough. Both bugs would have been caught by this project's own documented process for hand-authored packages (power-automate/power-automate-bible.md, "Hand-Authoring A New Import-Package") if it had been followed from the start. Now checked with a real structural cross-check tool, PowerApps/tools/validate-flow-package.py, not just "does it parse" — still worth treating the first real run as a genuine test, since structural correctness isn't the same as a confirmed live import.
Whichever way you build it, it does a live SharePoint scan across every library, same shape as FP - Rebuild Permission Index, just simpler (no permission expansion). How long it takes on your tenant is unmeasured — time it the first real run and let me know, same as the ~1m45s figure we got for the permission index.

What it does, or the manual build steps if the import doesn't work:

  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 below uses a placeholder, RebuildLibraryStats, for this flow's data-source identifier — a guess by analogy to RebuildPermissionIndex, not a confirmed fact. Replace it with whatever Studio actually shows once you've added this flow as a data source, before relying on the button.

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

Select the App object (top of the tree, not any screen) → OnStart property → paste this in full:

v3.15 confirmed live — pasted and working, per Allan, 26 Sep 2026. Includes v3.13's FlagSaysLimited (dual-signal LTD indicator, confirmed against a real mismatch) and v3.15's LibraryId fix for the View Permissions button, which took two attempts: v3.14's First(GroupedItems).LibraryId silently stayed blank even with everything upstream genuinely fixed, isolated with a debug label plus a control test proving the raw data was fine; v3.15 switched to the same LookUp pattern FlagSaysLimited already used successfully on the same line.
v3.16 draft, not yet pasted. Fixes library sort order inside each Team/Sub-Team group, reported by Allan as looking "completely random." Two real bugs in colFlatSource's build: (1) a library with no Sub-Team got SubSortKey = "", which sorts before any real Sub-Team name alphabetically, so no-Sub-Team libraries floated to the top of each team instead of the bottom — fixed to "zzz_none", same last-place trick already used for "Uncategorised" teams. (2) The final tiebreaker sorted by the library's raw internal Title, not EffectiveName (the pretty name actually shown) — looked shuffled on screen even though it was technically sorted, just by an invisible field. Both fixed here and in every other place that rebuilds the same shared colFlatSource/colManageFlatSource structure (scrFilePlan, scrAdminCreateLibrary, scrEditLibraries, scrManageLibraries all updated together, see their sections below).
v3.16 — Updated 26 Sep 2026 04:15 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))), LibraryTitle, SortOrder.Ascending));
Clear(colPermFlatSource);
Collect(colPermFlatSource, AddColumns(colLibraryGroups, RowType, "Library", SortKey, 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", SortKey, 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", SortKey, LibraryUrl, RankInGroup, 2, GroupSortKey, NestedUnderGroup));
ClearCollect(colExpandedLibs, Filter(Table({LibraryUrl: "seed"}), false));
ClearCollect(colExpandedGroups, Filter(Table({GroupKey: "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 — self-heals every library missing a pretty name, no data migration needed. varIsAdmin gates the Admin screen — edit the email list directly in this line if who's authorized ever changes. colPermissionIndex/colPermissionFiltered feed the Group Search screen — make sure you've run Rebuild Permission Index at least once first, or this loads empty.

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 — per Allan, 26 Sep 2026. Two small drafts outstanding: a sort-order fix affecting scrFilePlan/scrAdminCreateLibrary/scrEditLibraries/scrManageLibraries (v3.16 below), and scrManageLibraries' own UX overhaul (v2.5) — everything else below is for reference or re-pasting from scratch if a screen ever needs rebuilding.

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 single flat gallery with uniform row height (a Team/Library discriminator column decides what each row shows) rather than nested galleries. Search is instant and local, no spinner needed, and auto-expands every Team with a match. v2.4 added hover/press feedback on the team header rows (previously gave no visual response at all), a proper vector chevron icon in place of the old text character, and a friendly message when a search matches nothing. v2.5 fixed two real bugs found testing v2.4 against live data: the hover fill was fully opaque and sitting on top of the row text (blacked it out completely on hover, not just tinted it), and real library names using underscores instead of spaces had no natural wrap point, breaking mid-word and overflowing into the row below — underscores are now shown as spaces for display, and the row is taller to fit two lines comfortably. v2.6 is Allan's own cosmetic pass, pasted and confirmed live: stretched the gallery and row backgrounds to use the full screen width instead of leaving the right side empty, made the Copy button icon-only (with a Tooltip/AccessibleLabel), added an icon to the Open button, and disabled the dead "Manage Permissions" nav button (target screen was never built) instead of removing it. v2.7 (draft): a library with no Team is now grouped under an "Uncategorised" header instead of a blank one, force-sorted to always sit last regardless of alphabetical order, with a light grey tint on that header and its rows to read as lower-priority. Also fixed a real pre-existing bug found while making this change: the search-clear ("✕") button's collection rebuild was missing the EffectiveName column entirely, which would have shown blank library names after every use. v2.8: added a real third grouping tier — Team → SubTeam (only when set) → Library, same expand/collapse pattern as scrGroupSearch's Library → Group → Member. A library with no SubTeam behaves exactly as before, no extra header. Sorting now uses the real Title as the tiebreaker instead of the display name, so setting a PrettyName never reorders the list. v2.9: LTD text badge replaced with a small padlock icon (same SVG technique as the chevrons), plus a subtle red/salmon tint on the whole row for any Limited library — deliberately reusing the badge's existing red/salmon identity rather than grey, since grey is already claimed by the Uncategorised section elsewhere on this same screen.
scrAdminCreateLibrary: Sub-Team is free text rather than a suggestion dropdown (Modern Combobox has a known instability issue). The queue is entirely local until you hit Run. Run Queue disables and shows "Creating..." immediately on click. The Rebuild Permission Index button is fire-and-forget — it doesn't wait for the flow to finish or show progress, since the run duration is unpredictable and Power Apps' .Run() call has no true non-blocking mode (a slow response is caught and shown as a friendly message rather than a raw error, but the flow itself always runs to completion on its own regardless). The status line above it shows how long ago the index was last rebuilt, and flags how many Limited libraries have been created since — both computed live from data already loaded, no extra flow or list needed. v1.7 (draft): after Run Queue finishes, the refreshed file-plan collections now use the same "Uncategorised" grouping as scrFilePlan v2.7, so a newly-created library with no Team assigned doesn't fall back to a blank grouping until the next full app reload. v1.8: same update again for the SubTeam grouping tier added in scrFilePlan v2.8, for the same reason. v1.9 adds a "Register an existing library only" toggle (default off — unticking swaps Technical Name for an Existing Library Title field, hides Categories since no columns get created on this path), a "Manage Libraries" nav button, and disables the dead "Manage Permissions" nav button this screen's own copy had never had fixed. Needs the matching Create Library flow change described in the Flows section below — without it, ticking the box adds a queue entry but Run Queue won't actually register anything.
scrGroupSearch: only finds results for libraries that were in scope for the last Rebuild Permission Index run (broken/unique-permission libraries — see that flow's section above). A Normal library never shows up here since it just inherits site-wide permissions; that's expected, not a bug. Searching matches the group name itself, the library name, or (for a nested AD group) the SharePoint group it's nested inside. v3.0 rebuilt the results as a three-tier accordion — Library → directly-granted group (matches SharePoint's own native Permissions panel exactly) → nested member, each level collapsed by default. Still one flat gallery underneath, just a third RowType and a second expand-tracking collection (colExpandedGroups) rather than nested galleries. v3.1 fixed a real sort-order bug where member rows sorted after every group in a library instead of clustering under their own parent group — the sort's column priority had rank ahead of the group-clustering key instead of after it. v3.2 moved the counts directly into each chevron instead of a separate pill: 4 > on a Library row (how many groups directly granted), (2) > on a Group row (how many nested members) — visible whether the row is expanded or collapsed. v3.4 split the count back out into its own label and replaced the text chevron with a proper vector icon; added hover/press feedback on both Library and Group rows (previously gave no visual response); added a "Copy link" button on Library rows to match scrFilePlan; and added a friendly message when a search matches nothing. v3.5 fixed the same two bugs found on scrFilePlan v2.4: opaque hover fills blacking out row text instead of tinting it, and real library titles with underscores wrapping mid-word and overflowing into the row below (now shown with spaces, row made taller). v3.6 is a draft, not yet pasted: carries over the same full-width layout and icon-button treatment Allan applied to scrFilePlan v2.6, and disables the same dead "Manage Permissions" nav button. v3.7 (also draft): Security Group rows now get a distinct amber tint and no chevron/expand affordance at all — they never have enumerable members in this data (only SharePoint Groups do), so a chevron implying they could be expanded was misleading. Needs the matching App.OnStart update (v3.9) pasted too, which filters out "Limited Access" noise rows and individual User rows at the source so every screen reading colPermissionIndex benefits automatically. v3.8 adds a "Manage Libraries" nav button now that screen actually exists.
scrManagePermissions — never built, design only. Everything below describes the intended design, not a live screen.
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.
scrManageLibraries — built 25 Sep 2026, not yet pasted. This one has never been live in Studio before — the design below is new, not the old August snapshot (which had never actually been confirmed working).
scrManageLibraries: the delete button always sends Force: false on the first call — any empty library in your selection is deleted immediately, any non-empty one is skipped and reported back in one round trip, no separate "check" step needed first. If anything was skipped, an override panel lists exactly which ones and their item counts; "Delete Anyway" re-sends only those, this time with Force: true. Uses CheckBox@0.0.30 (per-row tick, read/write via its Checked property) and ModernButton's BasePaletteColor for the red delete buttons — Modern buttons don't have a direct Fill property, unlike Classic buttons used everywhere else in this app; light seed colours get overridden by Fluent 2's contrast rules, but the dark red used here renders correctly. Full-width layout and the same "Uncategorised"-aware collection rebuild used everywhere else in this app, so a delete here doesn't leave scrFilePlan showing stale groupings until next reload. Needs the "Delete Libraries" flow added as a data source before pasting. v2.1: same padlock-icon + red/salmon row tint treatment as scrFilePlan v2.9, replacing this screen's own LTD badge which had drifted to a different amber tone — now consistent across the app. v2.1 errored on paste, real errors, both fixed in v2.2: the flow reference was still the pre-rename name, 'Delete Libraries (App)' — confirmed current name is 'FP-DeleteLibraries(App)', now used in both .Run() calls. Also a genuine syntax bug: one branch of an If() wrapped a chain of statements in an extra pair of parentheses, which Power Fx doesn't support (parens group one expression, not a statement sequence) — removed, matching the plain semicolon-chain-with-no-wrapping-parens pattern already proven working elsewhere in this app.
scrManageLibraries, continued (v2.4-v2.6): v2.4 rebuilt the flat list into the same Team → Sub-Team → Library accordion as scrFilePlan, added a Team filter dropdown next to the search box, and made every library row show "Sub-Team: None" explicitly rather than leaving it blank. v2.5 carried the sort-order fix from scrFilePlan v2.11. v2.6 (housekeeping/audit purpose, Allan's own framing) adds each library's creation date and current file count — needs the new FP - Rebuild Library Stats flow (section 2f) and two new LibraryFilePlan columns (section 1) — with empty libraries flagged in red/bold, since the whole point is spotting old libraries nobody's using. Built on top of Allan's own substantial cosmetic pass on this screen: rows now 100px (was 72px, bumped further to fit the new stats line), Select All/Clear/Edit/Delete moved onto the nav bar row, an Open button added per library row, and the per-row checkbox swapped from CheckBox@0.0.30 to ModernCheckbox@1.0.2 (Allan enabled Modern themes/controls tenant-side, 26 Sep 2026).
Worth testing, not yet confirmed either way: ModernCheckbox@1.0.2 uses Default for its state, not Checked like CheckBox@0.0.30 did. Every other control on this exact pattern in this app (ModernDropdown.Default, Classic/CheckBox.Default) has turned out to read Default only once, at control creation, not reactively — confirmed in a different app in this family (Policy Tracker, known-bad-patterns.md) as the cause of a checklist that visually never ticked despite correct underlying data, specifically inside a Gallery where row instances can get recycled across an Items refresh. If ticking "Select All Visible" doesn't visually check the boxes (even though colSelectedLibraries is genuinely being updated underneath), that's this exact bug — the confirmed fix elsewhere was switching back to a control with a genuinely reactive property (Checked), not fighting Default further. Not changed here since it's Allan's own in-progress experiment with the newly-enabled Modern controls — flagging it rather than reverting it.
scrEditLibraries — built 25 Sep 2026, not yet pasted. This one has never been live in Studio before.
scrEditLibraries: the "edit cart" pattern — ticking libraries on scrManageLibraries and hitting Edit Selected copies them into a local collection (colEditQueue), shown here as one stacked card per library, all editable at once. Every field edit patches straight into that local collection as you type (no flow, no round trip); "Save All" is the only point anything actually writes to SharePoint, via a single ForAll + Patch. Library URL is deliberately not editable here — it's the join key this entire app relies on to survive renames, so changing it needs to stay a rare, manual, direct-in-SharePoint action, not a button. New: a "Limited access" tick box per card, so a wrongly-flagged LTD badge can actually be corrected now — note this only fixes the record, not real SharePoint permissions if those are genuinely mismatched. v2.0 errored on paste, real PA2108 errors, both fixed in v2.1: the dropdown's default-selection property was `DefaultSelectedItems` (a Combo box property, wrong control) instead of `ModernDropdown`'s actual `Default`; and the Limited-access control was a `Toggle`, which doesn't support any change-event property at all in this environment (confirmed — every real Toggle usage in the wider guide library only ever reads `.Checked` on demand, never reacts to it) — swapped for a `CheckBox`, same proven pattern already live in scrManageLibraries' own row-select tick boxes.

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.