Work Instructions (psi-sop)

Renders the IC Production work instructions — the controlled Word and PowerPoint masters — as mobile-friendly HTML, for training through the Paylocity LMS and for point-of-use reference at the machines.


Overview

The .docx and .pptx masters in SharePoint stay the single editable source. Every page this app serves is generated output — nothing is hand-copied, so a page cannot silently drift from the controlled document it came from.

FeatureDescription
Production URLsop.progressivesurface.comlive, VNet/VPN only. Reader at /wi/<slug>/
AuthenticationEntra ID app-layer MSAL via @progressivesurface/auth (redirect flow, localStorage, silent-acquire-then-redirect). SPA app reg PSI SOP Web4b462967-e117-42c0-86c5-34478c4c051d
DeploymentAzure App Service ps-sop (Linux NODE:24-lts, PS-WEBAPPS, shared asp-erp-migration-tool plan) + private endpoint 10.160.140.31, publicNetworkAccess=Disabled
RepositoryC:\git\psi-sopProgressiveSurface/psi-sop
MastersSharePoint — Work Instructions library. The DFS share is legacy (see Where the masters live)

Status

Live as of 2026-08-05 at https://sop.progressivesurface.com (and ps-sop.azurewebsites.net) — reachable from the VNet/VPN only, via the private endpoint. Run 144411916 passed its health gate against commit 47277e3 with /health → 200 and /api/_probe401 (fail-closed confirmed).

The reader is live at sop.progressivesurface.com (plant/VPN only), serving 46 work instructions with read-aloud, section-at-a-time reading and step ticking.

How fresh is it

Since 2026-08-11 the app re-renders a document on request rather than on a timer, so an edit in SharePoint appears on the next page load with no manual step.

A published page is stale if either the document or the renderer has moved, and both are checked. Each page carries two pieces of metadata: masteretag (the library eTag it was rendered from) and renderer (a fingerprint of the rendering code). Comparing only the eTag was a real defect — see below.

Two consequences worth knowing:

  • Manual blob uploads do not stick. A hand-uploaded page carries neither piece of metadata, so the app treats it as unpublished, re-renders from the deployed code, and overwrites it within seconds. The deployed renderer owns the published content: changing output means shipping code, not uploading files.
  • The first request after an edit pays the render cost (roughly 3–5 s for a large document). Subsequent requests are ~40 ms.

X-Wi-Refresh on the response says which happened: current, updated (the document moved), rerendered (the renderer moved), published (first time), or error.

How the re-render actually reaches an operator

Two mechanisms, and the split matters:

Background sweepHourly (SOP_SWEEP_MS), plus ~1 min after startup. Re-renders anything stale so the cost lands on the container, not a person. Sequential — one 125-photo render already pushes the box into swap.
On requestStill the correctness guarantee. If a reader beats the sweep they get the current revision, never a stale one.

The /wi/ INDEX used to be sweep-only — fixed 2026-08-26

Every individual document page followed request-keyed-first-with-sweep-as-backstop from the start. The library index at /wi/ did not: it only republished from the hourly sweep, so a brand-new, correctly-named, fully reachable document could sit off the index for up to an hour with nothing on the page indicating anything was wrong. Confirmed live against a real report (115.998.9, Proposal Engineering) — the document itself resolved correctly through the whole pipeline the entire time; only the index was stale.

ensureIndexFresh() (src/server/refresh.mjs) now runs on every bare /wi/ request, reusing the same 60s-TTL-cached master list the per-document check already pays for. Cheap — it never renders a document, just lists, hashes, and maybe writes one small blob — so there is no ~90s render risk on an index request. Same soft-failure behavior as everything else here: Graph unreachable degrades to serving the existing index, never a 500.

A reader is never made to wait on a blank screen. When a render is needed on request, the app returns a progress page immediately (~300 ms) that explains a newer revision was published, polls /wi/<slug>/status, and reloads itself when the document is ready. It carries no stylesheet, script file or image by design — it appears exactly when the document’s own assets may not exist yet.

Before this, a 125-photo work instruction held the connection for ~90 s and every other reader of that document queued behind the same render.

The reader never shows a superseded revision

Serving the previous revision during a re-render would have removed the wait too, and was rejected. For a controlled quality document, showing superseded steps without saying so is a worse failure than a visible, explained wait.

The sweep depends on Always On

Without it App Service unloads an idle container and the timer stops with it — silently, and looking exactly like a working system until someone notices a document is out of date. Enabled on ps-sop 2026-08-12. The sweep logs one line per run including when it finds nothing to do, so a missing heartbeat in default_docker.log is the signal that it has stopped.

The renderer fingerprint, and why it exists

Image rotation and side-by-side photo rows were fixed, merged and deployed — and appeared on no page at all. Freshness compared only the master’s eTag, and PRG Doc 120.1’s master had not been edited, so the page reported itself current and kept serving output from the previous build. A rendering fix that reaches nothing is worse than no fix, because it reads as shipped.

The fingerprint is a sha1 of the rendering source (renderDocument.mjs, postprocess.mjs, imagetransforms.mjs), not a hand-bumped version constant — a constant is one more thing to remember at exactly the moment attention is on the rendering change. source.mjs is deliberately excluded: it decides which documents exist, not how one is rendered, so editing it must not invalidate all 46 pages.

Deploying: three things that will bite

A green smoke test does not mean the artifact can boot

The deploy workflow’s smoke test must boot the artifact from outside the repository tree, and the workflow now asserts that. Node resolves bare imports by walking up the directory tree, so booting deploy/ in place lets any dependency missing from the production install resolve from the repo’s own node_modules — the full dev install. It reports green on an artifact that cannot start in wwwroot, which has no parent node_modules.

This caused the 2026-08-11 outage: fflate was a devDependency but is imported at runtime (refresh.mjsimagetransforms.mjs), pnpm install --prod left it out, the container exited code 1 during startup, and the site returned 503 for about 15 minutes. The smoke test was green throughout.

Anything imported by src/server/** or pipeline/*.mjs is a runtime dependency and belongs in dependencies — that now includes sharp, mammoth and fflate.

az webapp deploy can report failure on a successful deploy

Pass --track-status false. az CLI ≥ 2.62 defaults it to true, which polls the site’s startup state and reads its LastError — and that field is sticky, still carrying a previous ContainerStartupFailure after a later deploy succeeds. On 2026-08-12 a deploy whose warmup probe succeeded in 18.1 s and which was serving /health 200 was still failed by the CLI ten minutes later, citing a crash from before it.

Startup is verified twice without it: the workflow’s Kudu poll, and the health gate that requires the running container to serve that commit’s sha rather than merely respond.

Keep container logging on

az webapp log config -n ps-sop -g PS-WEBAPPS --docker-container-logging filesystem --application-logging filesystem. Without it App Service captures no default_docker.log and a startup failure leaves nothing but the platform’s exit code 1 — which is what turned a five-minute diagnosis into a fifteen-minute outage.

Checking fidelity against Word

tools/compare/compare.mjs <slug> renders the master and the reader side by side, page by page, as images. Word via desktop Word over COM — the application the author actually uses.

Do not use Graph ?format=pdf as the reference

It is a server-side converter that can share a blind spot with this pipeline, and when both ignore the same thing the comparison produces a confident false negative. That happened: a Graph-vs-reader review reported “photo orientation clean” while photos were visibly sideways. Graph remains in the tool only as a labelled-weaker fallback.

Pagination does not align — Word paginates to Letter, the reader is one continuous column — so read the output as a content and orientation check, not a layout diff.

Routes

RouteServes
/wi/the corpus index — every document, with a Copy link button per row. /wi 301s here, so there is exactly one index URL
/wi/<slug>/the rendered work instruction, from Blob via managed identity. /wi/<slug> 301s here — without the trailing slash every relative image path on the page breaks
/speech/ttsAzure Speech synthesis, proxied server-side under the app’s managed identity
/health200
/api/*auth-gated; no routes yet, so an unauthenticated call correctly 401s

Links on a generated page must be absolute — fixed 2026-08-27

Every document link on the index 404’d as /wi/wi/<slug>/. Nothing inserted /wi twice: the index emitted a relative href (wi/<slug>/), and a relative href resolves against the directory of the page it sits on. At /wi that gives /wi/<slug>/ and works; at /wi/ it gives /wi/wi/<slug>/ and 404s.

The index is rendered by one function serving two consumers at different depths — the app serves it at /wi/, while pipeline/convert.mjs writes out/index.html beside out/wi/<slug>/. The hardcoded relative path suited the local one. The same defect made the ”← All work instructions” back link resolve to /, the SPA shell, which answers 200.

It stayed hidden because nothing pointed at the broken depth until the landing-page rebuild added a Work Instructions Reader nav link ending in /. Both link bases are now explicit per call site (docBase, indexHref), /wi is canonicalised to /wi/, and pipeline/renderIndex.test.js guards it — that file had no tests at all, which is how a broken link on every row shipped.

wi.css (../../wi.css) and the per-document images (img/001.webp) are correctly relative and were deliberately left alone.

Where the rendered content lives

Storage account pssopcontent (PS-WEBAPPS), container work-instructions - 754 blobs / 29.4 MB. allowSharedKeyAccess=false. The app’s managed identity holds Storage Blob Data Contributor: since 2026-08-11 the app renders on request and publishes the page itself, so it writes here. (An earlier revision of this page said Reader, “read-only by design, writing is a human running the render” — that stopped being true the day on-request rendering shipped. The controlled document is the SharePoint master, which the app can alter only in its derived list columns, never in content.)

Content and code therefore deploy independently - re-render, re-upload, and the revision is live with no deploy.

/wi/* and /speech/tts are NOT auth-gated

The auth middleware gates /api/ only. The reader is plain static HTML with no MSAL - it has to work from a QR scan on a shop-floor tablet - so it cannot present a bearer token. Both routes are reachable to anyone on the PSI network and unreachable off it (publicNetworkAccess=Disabled, private endpoint only). That is the plan’s internal-only-by-network-topology decision, and it is the reason a route under /api/tts would have 401’d every synthesis request and silently degraded to the device voice.

Read-aloud

No credential exists. The app exchanges its managed identity for a token per request - Cognitive Services Speech User on psi-foundry-dcooper. Measured cold 4.3s / 28,080 bytes of MP3, cached 146 ms. Guarded with a voice allowlist, a 3,000-character cap, 60 requests/minute per client and an LRU cache, because synthesis is billed per character. An Azure failure returns 503, which the reader treats as “degrade to the device voice”.

Two things that cost a debugging round trip

Synthesis goes to the regional host, northcentralus.tts.speech.microsoft.com/cognitiveservices/v1 - not the resource’s custom domain, which returns 404 Resource not found for an AIServices resource even though the docs show that host for a Speech resource and even though the token is accepted. The custom subdomain is still load-bearing: it is what permits Entra auth at all.

The token is not a plain bearer token. Speech requires Bearer aad#<resourceId>#<entraToken>, prefix and separators included.

PS-SPEECH1 / PS-SPEECH2 cannot be used for this: both are F0 with no custom subdomain, so Entra auth is impossible and a key would be the only option.

Note for anyone reading an earlier revision of this page: it said DNS blocked the deploy. It did not. The first deploy attempt failed on a wrong require.resolve() assertion in the workflow — @progressivesurface/bff is ESM-only with no require export condition — and once that was replaced with a real boot-and-probe of the packaged artifact, the deploy completed with no DNS records added.

Outstanding

Both need a person rather than code:

  1. Admin consent for PSI SOP Web has not been granted. PSI has user consent disabled tenant-wide, so until an admin consents every sign-in dead-ends in the admin-approval flow. This is the item that actually stops the app being usable.
  2. The runner’s resolver routing — the remaining half of psi-azure-admin#4. All six records for this app now exist, so psi-sop is unblocked, but the root cause is not fixed: ~privatelink.azurewebsites.net is not a routing domain on ps-cicd-runner’s wireserver scope (~privatelink.azure-devices.net is), so webapp privatelink lookups fall to the eth0 scope, which only knows the DC. Every future internal web app hits the same wall until that routing domain is added. See DNS.

Two items that were listed here are closed, and are recorded so they are not re-opened from an old copy of this page: the PR #20 rotation / side-by-side work was restored and deployed 2026-08-12 (which is the change that exposed the eTag-only freshness defect and produced the renderer fingerprint), and container logging is enabled on ps-sop (--docker-container-logging filesystem) — it is what made that diagnosis legible and it must stay on.

Procisely pattern rule 4 resolves ^(prg|ref)-[a-z0-9-]+$ to https://sop.progressivesurface.com/wi/{slug}/ — with the trailing slash, because without it every relative image path in the page resolves one directory too high and the document renders with no photos.


Azure resources

ResourceValue
App Serviceps-sop (PS-WEBAPPS, North Central US)
App Service Planshared asp-erp-migration-tool (B3 Linux)
RuntimeNODE:24-lts, startup command node server.mjs
Private endpointps-sop-pe10.160.140.31 (PS-ProdData, group-id sites)
VNet integrationPS-WebApps
Managed identity57ca0eb0-6fe0-499c-9405-938efbac1454 (system-assigned)
Key Vaultps-certificates-kv — MI holds get on secrets + certificates
Content storepssopcontent — MI holds Storage Blob Data Contributor (it publishes the pages)
SharePointGraph Sites.Selected, per-site grant on /sites/WorkInstructions — must be write for the derived columns (Revision, Steps, Sections) to follow the documents
Speechpsi-foundry-dcooper — MI holds Cognitive Services Speech User
Application Insightsshared psi-webapps-insights
TLSwildcard *.progressivesurface.com (8ECD7C39FA4BD44E10D3D89A80EF33F3922A291A, expires 2027-02-03) — bound, SniEnabled

Posture: httpsOnly=true, publicNetworkAccess=Disabled, SCM and FTP basic publishing credentials both allow=false.

PE IP allocation was verified against live NIC ipConfigurations across the whole PS-ProdData subnet, not just the privatelink.azurewebsites.net zone. Allocations ran .4.30 contiguous, so .31 was genuinely next free. This is the check deploy-to-azure insists on after psi-service was bitten on 2026-06-29 trusting the webapp DNS zone alone.


DNS

Per the two-zone rule, six records are needed. All six now exist, and the custom hostname is bound with the wildcard cert (SniEnabled):

RecordZoneIPState
ps-sopprivatelink.azurewebsites.net (Azure, PS-RG-01)10.160.140.31✅ auto-registered by the PE’s DNS zone group
ps-sop.scmprivatelink.azurewebsites.net (Azure, PS-RG-01)10.160.140.31✅ auto-registered
ps-sopprivatelink.azurewebsites.net (DC, AD-integrated)10.160.140.31✅ created 2026-08-05
ps-sop.scmprivatelink.azurewebsites.net (DC, AD-integrated)10.160.140.31✅ created 2026-08-05 — this is what unblocked the deploy
asuid.sop TXTprogressivesurface.com (Azure DNS)8F3F3AFB0C2C…F211F56A✅ created 2026-08-05
sop Aprogressivesurface.com (Azure DNS)10.160.140.31✅ created 2026-08-05 — overrides the wildcard

Measured on the runner, 2026-08-05 — the DC record is reliability, not a hard block

The Runner gotcha in dns-standards says the runner’s systemd-resolved drop-ins declare DNS= and Domains=~privatelink.* in global [Resolve] sections, which resolved merges into one server pool rather than split-routing privatelink to the wireserver — so whichever server is “current” answers, and the DC is authoritative for the zone and returns a definitive NXDOMAIN.

Measured on ps-cicd-runner with only the Azure-zone records present:

resolvectl query ps-sop.scm.azurewebsites.net   → 10.160.140.31   (reproducible across cache flushes)
nslookup … 168.63.129.16  (wireserver)          → 10.160.140.31
nslookup ps-sop.azurewebsites.net 10.160.0.5    → NXDOMAIN        (DC — expected, no record yet)
resolvectl status → Current DNS Server: 168.63.129.16

So the merged pool’s current server is presently the wireserver, which reads the Azure zone — and the deploy path resolves today. The DC records remove a coin-flip rather than unblocking a hard failure. If the current server rotates to 10.160.0.5, that same query returns NXDOMAIN and Kudu becomes unreachable mid-pipeline. Do not treat “it resolved” as “it is fixed” — the 2026-06-25 note claiming the runner reads the Azure zone by design was retracted for exactly this reason.

sop.progressivesurface.com already resolves — to the wrong host

The zone carries a wildcard *.progressivesurface.com A record → 170.249.213.26 (TTL 1800), so the name resolves now, before any sop record exists. It does not NXDOMAIN. Anyone testing the URL early gets a 200-or-error from an unrelated host and can easily read that as “the app is broken” rather than “the record isn’t there yet”. The explicit sop A record must be created; it takes precedence over the wildcard.

Verify from a VNet machine, never an Umbrella workstation (workstation-gotcha-cisco-umbrella):

nslookup ps-sop.azurewebsites.net 10.160.0.5      # → 10.160.140.31
nslookup ps-sop.scm.azurewebsites.net 10.160.0.5  # → 10.160.140.31
nslookup sop.progressivesurface.com 10.160.0.5    # → 10.160.140.31, NOT 170.249.213.26

Authentication

Approved profile: client/app-layer auth (Approved auth profiles). No EasyAuth. main.tsx builds MSAL through createMsalConfig() from @progressivesurface/auth, which encodes the standard’s defaults — redirect flow, localStorage cache, silent-acquire-then-redirect, PII-suppressed logging — and <AuthGuard> redirects an unauthenticated user straight to Entra. There is deliberately no “Sign in” button.

PropertyValue
App registrationPSI SOP Web
Application (client) ID4b462967-e117-42c0-86c5-34478c4c051d
Application ID URIapi://4b462967-e117-42c0-86c5-34478c4c051d
Exposed scopeaccess_as_user (b265fd80-269a-4493-8d9f-9627d6da0499)
SPA redirect URIshttps://sop.progressivesurface.com, https://ps-sop.azurewebsites.net, http://localhost:5173
Declared permissionsGraph User.Read, openid, profile, email, offline_access + self access_as_user
Admin consentnot granted

Consent is not optional here

PSI has user consent disabled tenant-wide, so every declared scope — including the OIDC ones and the self access_as_user — needs an admin grant. Until then sign-in dead-ends in the admin-approval flow for everyone. Grant with az ad app permission admin-consent --id 4b462967-e117-42c0-86c5-34478c4c051d, then verify the two AllPrincipals grants exist (one on Graph, one on the app itself).

Two other SOP app registrations exist and are not this one — PSI SOP Reader (masters, read-only) and PSI SOP Records Writer are app-only identities used by the SharePoint pipeline.

Feature access

@progressivesurface/access is fail-closed and resolves /api/feature-access/me same-origin. Until the BFF proxies that to the Portal gateway, <ChannelGate> renders nothing. That is designed behaviour rather than a defect, but it means the shell shows no beta content.


Deployment

GitHub Actions, .github/workflows/deploy.yml, runs-on: [self-hosted, psi-internal] with az login --identity. A GitHub-hosted runner cannot resolve or reach a private endpoint, and publish-profile / local-git deploys are non-compliant.

Why there is a server.mjs

A Vite build is a directory of static files; App Service Linux Node runs a process. createBffApp() from @progressivesurface/bff supplies static serving, SPA history fallback and /health, so the entry is about six lines. It is deliberately not pm2 serve --spa — the Node containers stopped auto-starting PM2 after Node 14 LTS, so that route’s failure mode is a container that boots and serves nothing.

The auth middleware is wired before any /api/* route exists, so the first one that lands is gated by default. Audience comes from VITE_AZURE_CLIENT_ID and never AZURE_CLIENT_ID — at runtime DefaultAzureCredential reads the latter as a user-assigned MI client id and system-assigned MI silently stops working.

Workflow guardrails

Each of these is a failure that already cost another PSI app a production incident:

GuardrailWhy
az webapp deploy --async true + Kudu pollingA synchronous deploy holds one call open through extraction and restart; Front Door caps it at ~4 min and 504s with the deploy half-applied
concurrency + cancel-in-progress: falseKudu takes one zip at a time and 409s the loser; cancelling a half-uploaded zip corrupts wwwroot
Health gate compares build-info.json sha to GITHUB_SHAThe old container answers 200 for the whole swap window, and an SPA catch-all returns 200 text/html for a missing route — a status-code gate proves nothing
Gate asserts /health 200 and /api/_probe 401/403Matches the intended auth posture: anonymous health, fail-closed API
No secrets.* inside a step if:That is an HTTP 422 parse error on GHE which fails the whole workflow, not the step
pnpm --prod --frozen-lockfile --ignore-scripts with node-linker=hoistedpnpm’s default symlinked layout does not survive being zipped into wwwroot

pnpm is pinned to 9.15.9 via packageManager; pnpm 10+ fails install here with ERR_PNPM_IGNORED_BUILDS: esbuild. The deploy .npmrc carries ${NODE_AUTH_TOKEN} by reference and is deleted before the zip, so no token ships inside the artifact.

Repo variables

VariableValue
AZURE_CLIENT_ID4b462967-e117-42c0-86c5-34478c4c051d
AZURE_TENANT_IDa83ae943-0a50-49cc-83c3-479b7a44b7fb
APP_KEYpsi-sop
HEALTH_BASE_URLhttps://ps-sop.azurewebsites.net — switch to the custom domain once bound

Where the masters live

SharePoint is the master as of 2026-08-05. The Work Instructions library holds the editable .docx files; \\ad.ptihome.com\DFS\Data\Dept\JobShop\IC Production\Work Instructions 2018\Work Instructions is the legacy source and should no longer be edited.

Document numbering and slugs

dept.xxx.x is the standard. 125.120.1 is document 120.1 owned by department 125 (Process Services); 160.160.1 is document 160.1 owned by department 160 (Material Flow). The slug follows the number with hyphens for dots — prg-125-120-1 — because a dot in a slug is rejected before any business logic runs.

The prefix is not cosmetic. Document numbers are only unique within a department, so two departments can both own a “160.1” — and two did. A Lab Inspectors Word document under 125 and an order-management PDF under 160 both claimed prg-160-1, so one was necessarily served at the other’s address. For a work instruction at a machine that is a safety problem, not an inconvenience.

The file name must carry the full number. PRG Doc 125.120.1 Thermal Spray 4.0.docx. When the name and the DocNo column disagree, nobody can tell which is authoritative — which was the state of all 47 files between the renumber and 2026-08-13.

Legacy slugs are a department-125 concession, not a general mechanism

procisely.com/prg-120-1 still reaches 125.120.1, because department 125 was numbered before departments were part of the number and its slugs are printed on QR labels. Only department 125. Every department since is dept.xxx.x from the start, so its links never need an alias.

There are no per-slug exceptions. A pin sending prg-160-1 to the Material Flow PDF was added and removed: one slug meaning something different from every other unprefixed slug is how a standard stops being one. prg-160-1 resolves to the 125 document that owns that number, and a link circulated for the PDF has to be reissued as prg-160-160-1.

PRG Doc 000.0 lives at the library root, numbered 000.0 with no department prefix, because it defines the rules for every department rather than belonging to one. It documents this scheme — it is the authoritative statement of it, not this page.

Library views

ViewShapeWhy
All Documents (default)folders, sorted by nameFolders carry no DocNo, so a DocNo sort leaves them in arbitrary order. By name the department folders read in number sequence.
By numberflat, sorted by DocNoOne list of every document in number order, 000.0 first. Not the default, so folder browsing is preserved.

Course Building view removed 2026-08-18 — it never actually worked

The view existed so Shawn (and anyone building a Paylocity course) could grab a document’s SopUrl (procisely.com/<slug>). It couldn’t: modern SharePoint list views intercept right-click at the row level regardless of cell content, so right-click always opens SharePoint’s own item menu, and its “Copy Link” hands out a sharing link, not the calculated column’s value. Drag-select is blocked the same way. Confirmed against Microsoft’s own formatting syntax reference: column formatting’s customRowAction supports only defaultClick/share/delete/editProps/ openContextMenu/setValue — there is no clipboard action, so no declarative fix exists on the SharePoint side.

The fix lives where we actually control the DOM: sop.progressivesurface.com/wi/, the reader’s own corpus index, now has a real Copy link button per document (navigator.clipboard.writeText, PR #31). The site home page and Quick Launch nav point there instead — the home page was rebuilt again 2026-08-25/26 into a single numbered flow (template → write it → leave Draft → get the link → paste into the course), with a separate “Browse the library directly” button and nav entry restored after the first flow-only version accidentally dropped direct library access.

SopUrl is deleted, not just hidden — final as of 2026-08-25, after going through clickable-column → formatting-cleared → hidden-field first. DocNo is also no longer Required: that flag only ever produced SharePoint’s own false “missing metadata” nag once pipeline/source.mjs started deriving DocNo from the filename when the column is blank (derivedDocNo(), 2026-08-25) — reachability was never gated on the column, so un-requiring it only stops SharePoint lying about broken documents that already worked. A structural template lives at Work Instructions/Templates/New Work Instruction Template.docxPRG Doc 000.0 promised one with linked purpose/scope/ safety/quality text that never actually existed anywhere in the corpus (checked: zero LINK/INCLUDETEXT fields in two real in-force documents); the new template leaves that content as explicit placeholders rather than inventing it, since that’s EHS/ Quality’s call, not engineering’s.

000.0 still appears below the folders in the default view: SharePoint always places files after folders. Pin to top is the only way above them, and it is UI-only — not in the REST surface.

As of 2026-08-12 the library is organised into department folders100 - Facilities, 125 - Process Services, 160 - Material Flow and so on. All 47 IC Production documents live under 125 - Process Services, with its own In Progress subfolder.

The pipeline is unaffected by that: it enumerates list items, not folders, so nesting does not change what it sees (50 items, 46 renderable, 4 parked). Parked detection keys on the parent folder being named In Progress, which still holds when it is nested inside a department folder.

The library columns are three different kinds of thing, and which way the truth flows differs for each (2026-09-10):

KindColumnsWho maintains it
Set by a personStatus, ProcessAreaThe author. Status is the one column an author is asked to touch.
Column wins, filename is the fallbackDocNo, Slug, TitleA filled column overrides the filename and is never re-derived; a blank one is derived from the filename (derivedDocNo() / derivedSlug() / fileTitle()). 51 of 53 are filled, so in practice renaming a file does not move a document.
Derived from the document by the appRevision, Steps, SectionsThe app, on every render (syncDerivedColumns() in refresh.mjs): the document’s own Revision Date: line and the renderer’s counts are written to the columns. Nobody types these. A typed value is overwritten the next time the document renders, and the reader shows the document’s line, not the column.

The third row exists because the Revision column was hand-typed and therefore mostly not typed: 16 of 50 were blank, and 000.0’s column said 2018 while its own text said 2026. A column a person has to maintain is a second copy, and the whole point of this system is that there is one copy. The write-back needs the app’s Graph Sites.Selected grant on the site to be write (it was read); with read the publish still succeeds and the container log says could not update library columns.

The In Progress folder is what marks a document as parked — it holds no slug, is visible only to PSI SOP Authors, and is never rendered or published.

The pipeline once derived everything from filenames and got one wrong: slugs were assigned alphabetically, so canonical prg-180-1 went to the parked (add Hexis) copy while SharePoint had given it to the governing document. That is why a filled column wins today, and why the filename derivation is a fallback for a blank cell rather than the rule.

Watching the legacy share

Anyone who edits the DFS copy out of eight years of habit gets no error — the change simply never reaches an operator. tools/check-drift.mjs in the repo makes that visible, and is run daily during the cutover window:

CheckNeeds a baseline?Catches
Staleness — is the share file newer than the library item’s Modified?NoAn edit the library never received, including one made before the baseline was taken
Baseline — SHA-256 of every share file at cutoverYesAny later edit to the legacy source; keeps working once people author in SharePoint

It found a real case on the first run: PRG Doc 120.1 Thermal Spray 4.0.docx was edited on the share six hours after its library copy was uploaded — one of the three Active pilot documents, so the master and the rendered page were both behind.

Do not compare sizes or content hashes between the share and the library. SharePoint rewrites the .docx on upload to embed the library’s column values, so all 50 files differ from their share original by a near-constant ~7.5–10 KB. Every file always looks changed. This is a dead end, recorded so it is not retried.

Reading the library

The pipeline authenticates with a delegated token from the sharepoint-admin skill’s Get-PsiSpToken.ps1:

$env:SP_TOKEN = ((& "$env:USERPROFILE\.claude\skills\sharepoint-admin\scripts\Get-PsiSpToken.ps1") -join '').Trim()
node pipeline/convert.mjs

Delegated is correct while a human runs it. App-only (Graph Sites.Selected, the PSI SOP Reader (masters, read-only) identity) is required only once the BFF runs this unattended, and that grant is still outstanding. The pipeline is strictly read-only against SharePoint — a defect in the render must not be able to damage a master.


The conversion pipeline

Build-time only; output is generated, never committed.

As measured against the library on 2026-08-05: 50 items, of which 46 render (4 are parked in In Progress), 705 embedded images / 268 MB, rendered down to 29.3 MB (−89%) across 261 sections, averaging 0.64 MB per page.

These counts are lower than the earlier share-based figures (41 root docs, 784 images, 453 stitched lists) for one reason: the render now excludes parked documents, because the library says they are not the governing copy. Fewer documents, not a regression.

Findings that shaped it, each a reason the conversion is not a one-liner:

  • 394 fragmented ordered lists stitched. Word numbers a procedure 1..N continuously, but an interstitial image paragraph makes mammoth close the <ol> and open a new one, so pages read 1,2,3,4,1,1,2,1. This was the reported defect and it was corpus-wide.
  • 117 hand-typed "1.<tab>" paragraphs are not Word lists at all. Rebuilt as real <ol> with explicit value= per item so an author’s own numbering shows verbatim — including a mistake. A work instruction must never be silently renumbered by a browser.
  • EMF images (20 rasterised in the current set; 17 across 5 documents on the share). Browsers cannot render EMF and it cannot be recompressed. Each holds exactly one bitmap and zero text elements, so rasterising loses nothing and SVG export is pointless (one 19.85 MB EMF produced a 7.97 MB SVG). Rendered at 2448 px and autocropped — LibreOffice’s 96 DPI default under-renders by 3×.
  • Some EMFs are whole pages flattened to bitmap, making step text unsearchable pixels. PRG Doc 000.0 instructs authors to do exactly this. The source documents still exist, so nothing needs retyping.
  • 58 dead file:// links neutralised to marked text; 26 of 46 targets no longer resolve because files were renamed. Browsers block file:// from an http page anyway.
  • LINK field transclusion is broken in 4 documents, not 24111.0, 115.0, 130.3, 165.1 point at a 2018 intern’s Desktop.

PowerPoint masters

.pptx is a supported authoring format as of 2026-08-31. The library’s SERVED map decides what each extension means: .docx and .pptx convert to HTML, .pdf is served verbatim.

A deck is converted, not served as a file. A browser renders a PDF and will not render a .pptx, so publishing one as-is hands an operator at a machine a download prompt. Converting via Graph’s ?format=pdf was rejected for the opposite reason — it would demote every deck to the PDF experience, and a deck is the format that suits the reader best: a slide is already one step.

One slide becomes one section, so a deck lands in the reader as a slide show — one slide at a time, arrow keys or Next/Previous, “Slide 3 of 9”, with the contents sidebar built from the slide titles. That is presentation only; the conversion meets the .docx path immediately after and every downstream behaviour (lists, images, side-by-side rows, read-aloud, step ticking) is shared.

Three places a slide hides its content

Verified by diffing every text run in ppt/slides/** against the converter’s output, per deck — not by looking at a rendered page.

  • SmartArt text is not on the slide. It lives in ppt/diagrams/dataN.xml, reached through <dgm:relIds>. Reading only the slide dropped 188 of 804 text runs (23%) in one real deck.
  • <mc:AlternateContent> stores the same shape twice, once for modern PowerPoint and once as a legacy fallback — so both copies render unless the fallback is stripped. The .docx side of this pipeline was already bitten by the same thing.
  • A chart cannot be rendered from the package; it is drawn at display time from data in another part. Counted and reported rather than silently missing.

Eight real decks now audit clean: zero text lost, zero duplicated.

Speaker notes are NOT published

The notes pane is the presenter’s script, not the instruction, and a controlled quality document carries what the slide says and nothing else. On real decks the notes are mostly template residue — PSI’s own LDS Training.pptx carries Microsoft’s stock “To reproduce the SmartArt effects on this page…”. ppt/notesSlides/** is not read at all. If a deck’s notes genuinely hold procedure, the fix is in the master: move it onto the slide, where the person approving the document can see it.

Two further behaviours worth knowing:

  • Slide order comes from <p:sldIdLst>, never from the file names. slide7.xml is not the seventh slide — PowerPoint keeps a part name when a deck is reordered. Sorting numerically would silently reorder a procedure.
  • A slide with no text at all — a full-page screenshot, and some decks are mostly this — has no title to find, so its contents entry reads “Slide 12”. The render reports how many, because only the author can fix it.

One undecodable picture used to fail the whole document

PowerPoint stores some pictures as a Windows BMP under a .png name — the extension lies, the BM magic is the truth — and libvips has no BMP loader. That threw, so the publish failed and the slug went on serving the previously published page.

Two fixes, both needed. Any picture sharp cannot open now degrades to a marked placeholder and is counted, exactly as an un-rasterised EMF does — one bad image must never fail a document. And BMPs are decoded (pipeline/bmp.mjs, wrapping bmp-ts), so the picture actually appears. A 126 MB deck that failed outright renders 125 slides and 156 images with no placeholders.

Never ask that decoder for RGBA. For a 24bpp bitmap — which is what PowerPoint writes — it returns alpha 0 on every pixel, so the obvious integration renders every real BMP fully transparent, with no error anywhere.

.wdp (JPEG XR) needs nothing: all 216 occurrences across the corpus are <a14:imgLayer> effect layers on pictures whose real bytes are an ordinary PNG, so the renderer never reads one.

Validated against 167 real .pptx files, the largest 103 MB, with no conversion failures.


Reader UI

No framework; no network beyond the TTS route.

  • Focus mode is the default — one section at a time, ending on a completion card pointing the operator back to Paylocity for the knowledge check.
  • Back matter excluded from the flow — 71 Reference Documents / Revision History / Signatures sections stay reachable in a collapsed sidebar but are not something you click through to “finish”. The rule is deliberately conservative: an earlier version also matched /^appendix/ and swallowed “Appendix A: Point of Assurance”, which is work an operator performs.
  • Read aloud — Azure Speech (en-US-EmmaNeural) through a server-side proxy so the key never reaches the browser, per block so playback starts in ~1s, with automatic fallback to the device voice.
  • The step being read is marked but never auto-ticked. A tick means “I did this”, not “this was read to me” — audio runs ahead of the physical work, and auto-ticking would let a tablet complete a procedure unattended.
  • Styling uses real PSI Design System tokens. One documented deviation: --ps-scale: 1.28 (~18 px body, 44 px tap targets) because these are read at arm’s length through safety glasses.

Paylocity owns training completion. Read-tracking here is supplementary evidence of reading, never certification, and its endpoint stays inert until the BFF route exists.