<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Kaspars Jurjāns</title>
    <description>The latest articles on DEV Community by Kaspars Jurjāns (@kaspars-sharepoint).</description>
    <link>https://dev.to/kaspars-sharepoint</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3655130%2Fb42659b1-35c8-4e2f-a7e8-561878a7b902.png</url>
      <title>DEV Community: Kaspars Jurjāns</title>
      <link>https://dev.to/kaspars-sharepoint</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kaspars-sharepoint"/>
    <language>en</language>
    <item>
      <title>Index-Time ACLs or Live Checks: A One-Folder Test for SharePoint AI Assistants</title>
      <dc:creator>Kaspars Jurjāns</dc:creator>
      <pubDate>Wed, 04 Nov 2026 08:51:48 +0000</pubDate>
      <link>https://dev.to/kaspars-sharepoint/index-time-acls-or-live-checks-a-one-folder-test-for-sharepoint-ai-assistants-2an5</link>
      <guid>https://dev.to/kaspars-sharepoint/index-time-acls-or-live-checks-a-one-folder-test-for-sharepoint-ai-assistants-2an5</guid>
      <description>&lt;p&gt;Every assistant that answers from SharePoint works from an index: a copy of your documents, split into passages. What matters for access control is whether that index also stores who may see each passage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design 1: the permission list lives in the index
&lt;/h2&gt;

&lt;p&gt;Azure AI Search has a SharePoint indexer with permission metadata ingestion, a preview feature of Azure AI Search, not of Microsoft Copilot. During ingestion the indexer "computes the effective ACL for each file" and "stores the permissions as metadata on each indexed document". At query time the engine matches the asking person against that stored list.&lt;/p&gt;

&lt;p&gt;That list is a snapshot. Microsoft documents what refreshes it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Unique permissions: "Starting in the 2026-05-01-preview REST API, ACL changes for items with unique permissions are detected and refreshed on each successful indexer run."&lt;/li&gt;
&lt;li&gt;Group membership: Microsoft Entra group memberships and SharePoint site groups are resolved at query time, so removing someone from a group does not wait for an indexer run.&lt;/li&gt;
&lt;li&gt;Inheritance: "Parent-scope permission changes aren't picked up automatically on subsequent indexer runs." A change on a site, library, list or folder waits for a resync, a document reset or an edit to the file itself.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Inheritance is the SharePoint default; removing a person from a library is a parent-scope change. Microsoft's warning is literal: "If you change SharePoint permissions without triggering an update mechanism, the index serves stale ACL data for previously ingested files."&lt;/p&gt;

&lt;h2&gt;
  
  
  Design 2: no permission list in the index
&lt;/h2&gt;

&lt;p&gt;The other design stores nothing about access: after ranking, the tool asks Microsoft 365, for each candidate document, whether this person may open it now.&lt;/p&gt;

&lt;p&gt;The call runs as the person. In the OAuth 2.0 on-behalf-of flow "the intent is to pass a user's identity and permissions through the request chain", and in delegated access "the application can't access anything the signed-in user couldn't access".&lt;/p&gt;

&lt;p&gt;There is no copy to refresh; the costs move to question time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Time. Every question adds Microsoft Graph calls, so the check has to be limited to the documents that can reach the answer, and bounded.&lt;/li&gt;
&lt;li&gt;Failure. If Graph is slow or returns an error, a strict design leaves that document out, possibly the one that mattered most.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Azure AI Search also fails closed, for the whole query: "If ACL evaluation fails (for example, the Graph API is unavailable), the service returns 5xx and does not return a partially filtered result set."&lt;/p&gt;

&lt;p&gt;Neither design repairs permissions that are too wide: Microsoft's deployment guidance for Microsoft Copilot starts with "remediating oversharing".&lt;/p&gt;

&lt;h2&gt;
  
  
  The test: one person, one folder
&lt;/h2&gt;

&lt;p&gt;An afternoon's work, plus indexing time, and no vendor needed:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;In an indexed library, create a test folder. Grant permission on it directly, by name, to a test person with no other access to that site: no group, no single-file share.&lt;/li&gt;
&lt;li&gt;Add one document that answers a specific question, for example a fictional payment term. After the next indexing run, ask as that person and confirm the document appears in the answer or its sources.&lt;/li&gt;
&lt;li&gt;Remove the person's permission on the folder itself, without editing the file: only what the file inherits changes, the parent-scope case.&lt;/li&gt;
&lt;li&gt;Wait for the next indexing run, or longer than your vendor's stated refresh interval.&lt;/li&gt;
&lt;li&gt;Once SharePoint refuses the person the file, ask again as that person, in a new chat.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the document still appears, the assistant is most likely serving a stored permission list that nobody refreshed. If it is gone, note how long that took and whether anyone had to run anything.&lt;/p&gt;

&lt;p&gt;Removing the person from a group tests a different path: Azure AI Search resolves group membership at query time.&lt;/p&gt;

&lt;p&gt;Kaspars Jurjāns has worked with SharePoint since 2006 and builds Answergrove, an assistant that uses the second design. The Microsoft sources and what remains unknown are in the full article: &lt;a href="https://ksj.lv/en/sharepoint-permissions-ai-answers-after-indexing/?ref=devto" rel="noopener noreferrer"&gt;What Happens to AI Answers When SharePoint Permissions Change After Indexing?&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Drafted with AI assistance, checked against the cited Microsoft sources and approved by Kaspars Jurjāns.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>sharepoint</category>
      <category>azure</category>
      <category>security</category>
      <category>ai</category>
    </item>
    <item>
      <title>Read access to one SharePoint site needs FullControl first: how our AI indexer handles it</title>
      <dc:creator>Kaspars Jurjāns</dc:creator>
      <pubDate>Wed, 28 Oct 2026 08:20:06 +0000</pubDate>
      <link>https://dev.to/kaspars-sharepoint/read-access-to-one-sharepoint-site-needs-fullcontrol-first-how-our-ai-indexer-handles-it-3672</link>
      <guid>https://dev.to/kaspars-sharepoint/read-access-to-one-sharepoint-site-needs-fullcontrol-first-how-our-ai-indexer-handles-it-3672</guid>
      <description>&lt;p&gt;The easy way to let an AI assistant read SharePoint is one consent: the Microsoft Graph application permission Sites.Read.All. Microsoft describes it in one line: "Allows the app to read documents and list items in all site collections without a signed in user." The setup finishes, the crawler never meets a closed site, and nobody keeps a list of sites.&lt;/p&gt;

&lt;p&gt;The narrow way is Sites.Selected. It has one awkward step, and this post is about that step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sites.Selected starts at zero
&lt;/h2&gt;

&lt;p&gt;Selected scopes "require an explicit assignment action", so an app that has only been consented "would initially have no access." Three things must be true before the indexer can read a site:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The app is consented for Sites.Selected in Entra ID.&lt;/li&gt;
&lt;li&gt;The site is granted to that app with a role, through &lt;code&gt;POST /sites/{siteId}/permissions&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The token the app presents carries the scope.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Miss one and "the application doesn't have access." An indexer needs the &lt;code&gt;read&lt;/code&gt; role and nothing more: "Read the metadata and contents of the resource."&lt;/p&gt;

&lt;h2&gt;
  
  
  The awkward step
&lt;/h2&gt;

&lt;p&gt;Creating the site grant needs a very large key. The call requires Sites.FullControl.All, and Microsoft explains why: "Because you can grant full control permissions to a site collection by using Sites.Selected, this requirement is necessarily high."&lt;/p&gt;

&lt;p&gt;So to give one app read access to a few sites, something must hold full control for a moment. This is what our installation script does with that moment:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It creates a temporary app with Sites.FullControl.All and uses it only to make the grants.&lt;/li&gt;
&lt;li&gt;It posts one grant per chosen site:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;POST&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/sites/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;siteId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&gt;/permissions&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"roles"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"read"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"grantedToIdentities"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"application"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;indexer app id&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"displayName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Answergrove Indexer"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;It removes the temporary app's permission and secret, so that even a restored copy could do nothing.&lt;/li&gt;
&lt;li&gt;It deletes the app and then removes it from the deleted items. Microsoft keeps a deleted application restorable "for up to 30 days", and after a permanent delete it "cannot be restored."&lt;/li&gt;
&lt;li&gt;If any cleanup step fails, it stops with an error, names what is left and does not count the installation as finished.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The order matters. Take the permission away first and delete second, so that a failed permanent delete leaves only an empty shell in the recycle bin.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two identities, one rule
&lt;/h2&gt;

&lt;p&gt;The indexer runs on a schedule with nobody signed in. The part that answers people never holds the indexer's credential. Microsoft notes that application-only scenarios "have no user present and are considered higher risk", while with delegated access "the application can never exceed the current user's existing permissions."&lt;/p&gt;

&lt;p&gt;For the indexer that means three things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A certificate, not a client secret.&lt;/strong&gt; The token request carries &lt;code&gt;client_assertion&lt;/code&gt; instead of &lt;code&gt;client_secret&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The private key in Azure Key Vault&lt;/strong&gt;, where "key usage is logged in near real time."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A guard in the answering API.&lt;/strong&gt; In our build the API refuses to start, with exit code 78, if indexer credential settings are present where it runs.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Test the denial, not the grant
&lt;/h2&gt;

&lt;p&gt;A grant that works proves little. The useful test is the opposite one: with the indexer's own credential, request the drive of a site you never granted. The expected result is an access error, not a file listing.&lt;/p&gt;

&lt;p&gt;Our script ends with that test and records the result before anything is indexed. The test proves the state of the tenant at the minute it ran, so it has to be repeated after every change to the site list or the app registrations.&lt;/p&gt;

&lt;p&gt;One thing we have not measured: Microsoft states "Application access is lost as soon as a scope is revoked," but we do not know how quickly that holds for a token issued just before the revocation.&lt;/p&gt;

&lt;p&gt;The full article covers the Entra admin center checks and lists every Microsoft source: &lt;a href="https://ksj.lv/en/sharepoint-ai-indexer-permissions-sites-selected/" rel="noopener noreferrer"&gt;Before an AI Reads Your SharePoint: Which Permissions Its Indexer Really Needs&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Drafted with AI assistance, checked against the cited Microsoft sources and approved by Kaspars Jurjāns.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>sharepoint</category>
      <category>microsoftgraph</category>
      <category>security</category>
      <category>azure</category>
    </item>
    <item>
      <title>Citing a spreadsheet: why our SharePoint AI names the sheet and the rows, not the file</title>
      <dc:creator>Kaspars Jurjāns</dc:creator>
      <pubDate>Mon, 19 Oct 2026 11:57:07 +0000</pubDate>
      <link>https://dev.to/kaspars-sharepoint/citing-a-spreadsheet-why-our-sharepoint-ai-names-the-sheet-and-the-rows-not-the-file-1ma4</link>
      <guid>https://dev.to/kaspars-sharepoint/citing-a-spreadsheet-why-our-sharepoint-ai-names-the-sheet-and-the-rows-not-the-file-1ma4</guid>
      <description>&lt;p&gt;We run an AI assistant over a construction company's SharePoint: 865 sites, about 400,000 documents. For a year it answered from PDF and Word only. This month we added Excel and PowerPoint. The extraction took an afternoon. The citation format took the real thinking, and that is what this post is about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with "see Budget_2026.xlsx"
&lt;/h2&gt;

&lt;p&gt;An answer from a document assistant is only worth something if the reader can check it. For PDF that is easy: cite the page, the reader opens page 37 and compares. For a workbook, a link to the file is not a citation. Twelve sheets, four thousand rows, and the reader is expected to find the one cell the model used. Nobody does that. They either trust the number blindly or ignore the assistant. Both are bad.&lt;/p&gt;

&lt;p&gt;So the rule we set: &lt;strong&gt;every citation must let a person verify the answer in seconds.&lt;/strong&gt; Page for PDF. Sheet and row range for Excel. Slide number for PowerPoint.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a spreadsheet chunk looks like
&lt;/h2&gt;

&lt;p&gt;We read workbooks with &lt;code&gt;openpyxl&lt;/code&gt; in read-only mode and walk each sheet row by row. Rows are grouped into chunks, and each chunk carries where it came from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                 &lt;span class="c1"&gt;# the rows, rendered as text
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;source_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Budget_2026.xlsx&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sheet&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Q3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;row_from&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;118&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;row_to&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;142&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;doc_version&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;modified&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-09-30T08:12:00Z&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details that mattered in practice:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Header rows travel with every chunk.&lt;/strong&gt; A row like &lt;code&gt;45 | 1.2 | 2026-09-30&lt;/code&gt; means nothing without the column names. We repeat the sheet's header row at the top of each chunk, so the model sees &lt;code&gt;rate | factor | valid_from&lt;/code&gt; next to the values.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rows, not cells.&lt;/strong&gt; A cell reference like &lt;code&gt;Q3!C119&lt;/code&gt; is precise but useless for a human: the reader still has to find column C. A row range is enough to land on the right place, and the header tells the rest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A ceiling per sheet.&lt;/strong&gt; Some sheets are data dumps with 200,000 rows. We stop at 5,000 rows per sheet. Those sheets are rarely where a person's question lives, and indexing them makes every other answer slower.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For PowerPoint the slide number plays the role of the page: title, text frames, tables and notes are read slide by slide, and the citation names the slide.&lt;/p&gt;

&lt;h2&gt;
  
  
  The citation in the answer
&lt;/h2&gt;

&lt;p&gt;The answer shows the source name, the sheet and the rows, and a link that opens the file in SharePoint. If content approval is enabled on the library, the approval status is shown too, because a draft workbook and an approved workbook can contain different numbers for the same thing.&lt;/p&gt;

&lt;p&gt;This is the same contract we already had for PDF: document, version, exact location, and, through the signed-in user's own permissions, only documents that person may open.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why bother
&lt;/h2&gt;

&lt;p&gt;Because a fluent answer from the wrong place is worse than no answer. A reader who can check in seconds will check, and will catch the model when it picks the draft or the old version. A reader who cannot check will either stop trusting the tool or, worse, keep trusting it.&lt;/p&gt;

&lt;p&gt;I wrote up the full set of checks we apply before an answer can be trusted, with the SharePoint and Graph details behind each one: &lt;a href="https://ksj.lv/en/document-ai-answers-version-approval-page-permission/?ref=devto" rel="noopener noreferrer"&gt;Copilot, SharePoint and 400,000 construction documents: four checks before you trust an AI answer&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The assistant is &lt;a href="https://ksj.lv/en/answergrove/?ref=devto" rel="noopener noreferrer"&gt;Answergrove&lt;/a&gt;, built at SIA KSJ in Latvia. It runs alongside Microsoft 365 Copilot and does not need a licence per user.&lt;/p&gt;

</description>
      <category>sharepoint</category>
      <category>microsoft365</category>
      <category>ai</category>
      <category>python</category>
    </item>
    <item>
      <title>No file may disappear silently: what indexing 400,000 SharePoint documents taught us about the Graph delta link</title>
      <dc:creator>Kaspars Jurjāns</dc:creator>
      <pubDate>Sun, 18 Oct 2026 16:47:00 +0000</pubDate>
      <link>https://dev.to/kaspars-sharepoint/no-file-may-disappear-silently-what-indexing-400000-sharepoint-documents-taught-us-about-the-3a9l</link>
      <guid>https://dev.to/kaspars-sharepoint/no-file-may-disappear-silently-what-indexing-400000-sharepoint-documents-taught-us-about-the-3a9l</guid>
      <description>&lt;p&gt;&lt;em&gt;"No changes" can be the most dangerous answer an AI index gives. In a large SharePoint environment, documents can drop out of the index without any error anyone sees. In our case the fix was a rule about when not to move the Microsoft Graph delta link forward.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://ksj.lv/en/sharepoint-graph-delta-link-silent-document-loss/" rel="noopener noreferrer"&gt;ksj.lv&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The setting
&lt;/h2&gt;

&lt;p&gt;We run a retrieval index over a production SharePoint environment belonging to a construction group. It covers 865 sites, about 400,000 documents and four languages. Indexing everything again each day is not realistic at that size, so the indexer keeps itself current with the Microsoft Graph driveItem delta API. Each document library has one saved deltaLink, and each cycle asks only for what has changed since that link.&lt;/p&gt;

&lt;p&gt;Microsoft documents this pattern clearly. You call delta, follow &lt;code&gt;@odata.nextLink&lt;/code&gt; until it stops appearing, and then receive an &lt;code&gt;@odata.deltaLink&lt;/code&gt;, which is "used to read the next set of changes in the future". Microsoft's guidance on scanning at scale names search indexers as a target scenario and gives one instruction: always keep the URL returned in &lt;code&gt;@odata.deltaLink&lt;/code&gt; so you can check for changes later.&lt;/p&gt;

&lt;p&gt;The detail that matters is easy to miss. The delta API page says that once you have received all the changes, you may apply them to your local state. The deltaLink describes what Graph has delivered. It does not say whether you managed to store any of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What went wrong
&lt;/h2&gt;

&lt;p&gt;Between 24 August and 2 September 2026, about 30 documents and about 20 deletions never reached the index. Nothing reported it. The vector index was a SQLite-based database stored on an Azure Files SMB share, and writes to it started failing with "database is locked". The delta job logged each failed file, skipped it and carried on. When the library was done, it saved the new deltaLink anyway.&lt;/p&gt;

&lt;p&gt;Graph then never returned those changes again. As far as Graph knew, they had been delivered. The delta feed shows the latest state of each item, not a history of changes. An item that has been delivered once comes back only when it changes again. A document that nobody edits after that point stays missing until someone notices.&lt;/p&gt;

&lt;p&gt;Nobody noticed it as missing documents, either. What we saw was slow cycles, taking between 20 and 87 minutes. One cycle reached the job's replica timeout of 5,400 seconds and was killed. In Azure Container Apps jobs, &lt;code&gt;replicaTimeout&lt;/code&gt; is the maximum time in seconds to wait for a replica to complete. The alarm said "cycle crashed". That was true, and it pointed us at a symptom rather than the cause.&lt;/p&gt;

&lt;h2&gt;
  
  
  The storage cause
&lt;/h2&gt;

&lt;p&gt;The first fix, on 2 September, was in the storage layer. Microsoft's recommended mountOptions for Azure Files SMB shares include &lt;code&gt;nobrl&lt;/code&gt;, described as disabling byte range lock requests to the server, for applications that have trouble with POSIX locks. Azure Container Apps takes &lt;code&gt;mountOptions&lt;/code&gt; on an Azure Files volume as a comma-separated string and links to that guidance. We added the option.&lt;/p&gt;

&lt;p&gt;We should be honest about the wider picture. Azure App Service documentation says Microsoft does not recommend storage mounts for local databases such as SQLite, or for anything else that relies on file handles and locks. The mount option removed the immediate failure. It does not make a network share a good home for a database, and the indexer must not assume its writes always succeed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule: hold the cursor when a write fails
&lt;/h2&gt;

&lt;p&gt;The fix that matters more was made the same day, in the indexer. If any file in a library fails for an infrastructure reason, that library's deltaLink is not moved forward. The next cycle starts from the old link, and Graph returns the same changes again. Processing has to be safe to repeat for this to work. The delta documentation already says the same item may appear more than once in a feed and that you should use the last occurrence, so an indexer that follows the documentation is already built for this.&lt;/p&gt;

&lt;p&gt;Getting the lost documents back took separate work. Delta would never return them, so we found the roughly 30 documents by file name through Microsoft Search and indexed them directly. If you advance a cursor too early, you have to go and find what you skipped by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Not every failure should hold the cursor
&lt;/h2&gt;

&lt;p&gt;The rule had a weakness of its own. If one file can never be processed, holding the deltaLink forever stalls the whole library. On 11 September we started classifying failures.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Broken files&lt;/strong&gt;, whose content cannot be read, go to a visible list with the reason attached, and the library moves on. They are retried when SharePoint changes them. They no longer disappear quietly, and they no longer block the files around them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Temporary failures&lt;/strong&gt;, meaning HTTP 5xx, 429 and network errors, are retried in later cycles, up to 12 cycles by default. This fits Microsoft's throttling guidance. Graph returns 429 Too Many Requests with a Retry-After header, and the SharePoint scanning guidance treats 429 and 503 alike: wait for the Retry-After duration and pause further requests while you are throttled.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything else&lt;/strong&gt; counts as infrastructure and holds the deltaLink.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The default sits on the safe side. A failure we have not classified does not get to advance the cursor.&lt;/p&gt;

&lt;h2&gt;
  
  
  The silent zero
&lt;/h2&gt;

&lt;p&gt;The third change, on 21 September, dealt with a quieter version of the same problem. Sometimes no site answers at all. The certificate the app authenticates with has expired, or its Sites.Selected access has been removed. Sites.Selected needs an explicit grant on each resource, and Microsoft documents two ways an administrator can take that access away: delete the permission on the resource, or revoke the scope consent in Entra ID. Either way the application loses access.&lt;/p&gt;

&lt;p&gt;Before the change, a cycle like that ended as "0 files, 0 deleted", which looked exactly like a quiet day with no changes. Meanwhile the answering service kept answering confidently from an index that was getting older. Now a cycle in which no site answered raises an alarm. "Nothing changed" and "nothing answered" produce the same counts, but they mean very different things for anyone relying on the answers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The general lesson
&lt;/h2&gt;

&lt;p&gt;In incremental sync, "the cursor moved" and "the work was done" have to be the same event. Move the cursor only after the write has succeeded. Treat a failure you cannot classify as a reason to stay where you are. Make the difference between "nothing changed" and "nothing answered" explicit, both in code and in alerts.&lt;/p&gt;

&lt;p&gt;For administrators evaluating a search or RAG solution over SharePoint, these points turn into questions worth asking any implementer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;When a document fails to index, where does it show up, and who can see it?&lt;/li&gt;
&lt;li&gt;Is the delta position saved before or after the index write is confirmed?&lt;/li&gt;
&lt;li&gt;What does the system report when an expired credential or a removed site permission makes every site return nothing?&lt;/li&gt;
&lt;li&gt;Does an alert fire on the cause, or only on a crash that happens hours later?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is exotic. Microsoft describes the delta semantics, the throttling behaviour and the storage caveats. The failure happens in the space between them, where a log line says "skipped" and the cursor moves on regardless.&lt;/p&gt;

&lt;h2&gt;
  
  
  How this applies to our work
&lt;/h2&gt;

&lt;p&gt;Holding the delta link after an infrastructure failure, keeping broken files visible, and alarming on a silent zero are now part of how Answergrove, developed by SIA KSJ, indexes SharePoint. More about the approach is at &lt;a href="https://ksj.lv/en/answergrove/" rel="noopener noreferrer"&gt;ksj.lv/en/answergrove&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/graph/api/driveitem-delta" rel="noopener noreferrer"&gt;graph/api/driveitem-delta&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/onedrive/developer/rest-api/concepts/scan-guidance" rel="noopener noreferrer"&gt;onedrive/developer/rest-api/concepts/scan-guidance&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/graph/throttling" rel="noopener noreferrer"&gt;graph/throttling&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/troubleshoot/azure/azure-kubernetes/storage/mountoptions-settings-azure-files" rel="noopener noreferrer"&gt;troubleshoot/azure/azure-kubernetes/storage/mountoptions-settings-azure-files&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/azure/container-apps/storage-mounts" rel="noopener noreferrer"&gt;azure/container-apps/storage-mounts&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/azure/app-service/configure-connect-to-azure-storage" rel="noopener noreferrer"&gt;azure/app-service/configure-connect-to-azure-storage&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/azure/container-apps/jobs" rel="noopener noreferrer"&gt;azure/container-apps/jobs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/graph/permissions-selected-overview" rel="noopener noreferrer"&gt;graph/permissions-selected-overview&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>sharepoint</category>
      <category>microsoftgraph</category>
      <category>azure</category>
      <category>rag</category>
    </item>
    <item>
      <title>I built an AI assistant that answers from SharePoint and shows where each answer came from</title>
      <dc:creator>Kaspars Jurjāns</dc:creator>
      <pubDate>Sun, 28 Jun 2026 08:21:34 +0000</pubDate>
      <link>https://dev.to/kaspars-sharepoint/i-built-a-private-copilot-alternative-that-never-lets-your-data-leave-your-microsoft-365-tenant-363h</link>
      <guid>https://dev.to/kaspars-sharepoint/i-built-a-private-copilot-alternative-that-never-lets-your-data-leave-your-microsoft-365-tenant-363h</guid>
      <description>&lt;p&gt;&lt;em&gt;Update, October 2026: the product was first called Privault. It is now Answergrove.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For the last while I've been building &lt;a href="https://ksj.lv/en/answergrove/" rel="noopener noreferrer"&gt;Answergrove&lt;/a&gt;, a private AI assistant that answers questions from a company's own SharePoint. Every answer names the document it came from, its SharePoint version and, for PDFs, the page. SharePoint permissions are checked before anything is shown, and the data stays in the EU.&lt;/p&gt;

&lt;p&gt;Here's the architecture and the judgment calls behind it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;Document-heavy organisations (construction, engineering, 50+ knowledge workers) want an AI that actually knows their SharePoint. For many EU companies the first questions are not about capability:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Where does the data go?&lt;/li&gt;
&lt;li&gt;Which version of a document is in force, and was it approved?&lt;/li&gt;
&lt;li&gt;Can every employee use it, without a per-user licence?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Retrieval:&lt;/strong&gt; semantic search over SharePoint document libraries and lists through Microsoft Graph. Each answer cites the document, its version and the page for PDFs. Where a library uses content approval, the answer shows the approval status.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Permissions:&lt;/strong&gt; checked before anything is shown, so each person sees only what they are allowed to open.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Actions:&lt;/strong&gt; Answergrove also prepares e-mails, tasks and meeting invitations from the chat, in your own name. You check each one and send it yourself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Languages:&lt;/strong&gt; any European language. A Swedish procedure can answer a question asked in English.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Models:&lt;/strong&gt; Azure OpenAI in the EU Data Zone, for both reasoning and embeddings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where it runs:&lt;/strong&gt; a two-week pilot runs in our Azure environment in Sweden Central (EU). After rollout the index, the AI and the logs run in the customer's own Azure subscription.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Answers are logged and cited, which turns "is the AI trustworthy?" into something you can actually audit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The judgment call: pro-code vs low-code
&lt;/h2&gt;

&lt;p&gt;This is where most "just use Power Automate" advice falls apart. Power Automate is great until you need real retrieval, custom tool calls, and control over how the model grounds its answers. That's a Python service. Knowing &lt;em&gt;which&lt;/em&gt; one a given workflow needs is the whole game. Getting it wrong is how you end up with a flow nobody can maintain.&lt;/p&gt;

&lt;h2&gt;
  
  
  What surprised me
&lt;/h2&gt;

&lt;p&gt;The first production environment, a construction group with 865 SharePoint sites, about 400,000 documents and four languages, showed that the hardest part wasn't the RAG. It was &lt;strong&gt;grounding + citations&lt;/strong&gt; under messy, real-world input: bilingual requests, half-formed questions, documents that contradict each other. A demo that parses clean input is easy. Production is where "correct and in the EU" gets earned.&lt;/p&gt;

&lt;p&gt;Answergrove runs alongside Copilot without interfering with it, and gives every employee, with or without a Copilot seat, answers from the documents they must get right. More on &lt;a href="https://ksj.lv/en/answergrove/" rel="noopener noreferrer"&gt;Answergrove here&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>microsoft365</category>
      <category>ai</category>
      <category>azure</category>
      <category>rag</category>
    </item>
    <item>
      <title>How We Automate SharePoint and Microsoft 365 Workflows for Small Businesses</title>
      <dc:creator>Kaspars Jurjāns</dc:creator>
      <pubDate>Wed, 10 Dec 2025 10:30:34 +0000</pubDate>
      <link>https://dev.to/kaspars-sharepoint/how-we-automate-sharepoint-and-microsoft-365-workflows-for-small-businesses-18jm</link>
      <guid>https://dev.to/kaspars-sharepoint/how-we-automate-sharepoint-and-microsoft-365-workflows-for-small-businesses-18jm</guid>
      <description>&lt;p&gt;Many small and medium businesses still use Microsoft 365 and SharePoint only as simple file storage or document folders.&lt;br&gt;
However, with the right configuration, SharePoint + Power Automate can replace entire manual workflows.&lt;/p&gt;

&lt;p&gt;Here are some examples from my recent projects:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;invoice approval flows built on SharePoint lists and Power Automate&lt;/li&gt;
&lt;li&gt;secure document routing and version-controlled processes&lt;/li&gt;
&lt;li&gt;automated notifications and reminders for approvers&lt;/li&gt;
&lt;li&gt;custom forms (Power Apps) and Power BI reports on top of SharePoint data&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These solutions reduce manual work, eliminate human errors and improve productivity inside an organization.&lt;/p&gt;

&lt;p&gt;If you're interested in practical automation ideas or need a Microsoft 365 / SharePoint solution for your company, this is my approach (in Latvian): &lt;a href="https://ksj.lv/sharepoint-un-microsoft-365-automatizacijas-risinajumi/" rel="noopener noreferrer"&gt;SharePoint and Microsoft 365 automation&lt;/a&gt;&lt;/p&gt;

</description>
      <category>microsoft365</category>
      <category>sharepoint</category>
      <category>automation</category>
      <category>powerautomate</category>
    </item>
  </channel>
</rss>
