# GE-Enforce v2 architecture

Ongoing fleet enforcement layer for GE Aerospace shopfloor PCs. Replaces the
v1 `Machine-Enforce.ps1` + `main\machineapps\machineapps-manifest.json`
arrangement that DSC-based deployment retired.

## What it does

On every user logon (and on a periodic schedule), every shopfloor PC mounts
the SFLD share with credentials provisioned by Azure DSC, reads the
manifest(s) for its PC type, and runs `Install-FromManifest.ps1` to install,
update, or repair anything whose detection rule fails. The detection-driven
design means a freshly-imaged PC in a known-good state no-ops; an out-of-
date or tampered PC self-heals on the next cycle.

The same entry-points handle:

- Initial shopfloor app install on first logon after PXE imaging.
- Drift correction (e.g. user uninstalled UDC, vendor patch tampered with a
  config file, manifest version bumped fleet-wide).
- One-shot per-bay actions like UDC data restore from a swap-source PC.

## Components

| Piece | Lives at | Role |
|-------|----------|------|
| `GE-Enforce.ps1` | `playbook/shopfloor-setup/common/GE-Enforce.ps1` | Logon dispatcher. Mounts share, finds manifests, calls Install-FromManifest. |
| `Register-GEEnforce.ps1` | `playbook/shopfloor-setup/common/Register-GEEnforce.ps1` | Registers the scheduled task (`GE Shopfloor Machine Apps Enforce`) at imaging time. |
| `Install-FromManifest.ps1` | `playbook/shopfloor-setup/common/lib/Install-FromManifest.ps1` | Manifest interpreter. Iterates entries, runs detection, fires installers. |
| `Mount-SFLDShare` | `playbook/shopfloor-setup/Shopfloor/lib/Restore-EDncReg.ps1` | Reads creds from `HKLM:\SOFTWARE\GE\SFLD\Credentials\*` and `net use`s the share with the SFLD user identity. |
| Manifests | SFLD share `<scope>\manifest.json` | Declarative list of entries to enforce. |
| Site config | `playbook/shopfloor-setup/site-config.json` (deployed to `C:\Enrollment\site-config.json`) | Per-PC-type share path overrides + startup/desktop/taskbar layout. |

## Lifecycle

```
Imaging time (PXE)
  startnet.cmd xcopies shopfloor-setup -> W:\Enrollment\
  Run-ShopfloorSetup.ps1 runs the imaging-phase scripts
  Register-GEEnforce.ps1 registers the scheduled task

First user logon (post-imaging, post-SFLD-DSC creds delivered)
  Scheduled task fires GE-Enforce.ps1
  GE-Enforce reads HKLM:\SOFTWARE\GE\SFLD\Credentials -> Mount-SFLDShare W:
  Reads pc-type from C:\Enrollment\pc-type.txt
  For each scope (common, then <pctype>):
    Reads <scope>\manifest.json
    Calls Install-FromManifest.ps1 -ManifestPath ... -InstallerRoot W:\<scope>
    Install-FromManifest iterates each Application entry:
      Apply PCTypes filter (skip if entry doesn't apply to this PC)
      Apply TargetMachineNumbers filter (skip if applicable)
      Run Detection - if installed at expected version/hash/state, skip
      Else run installer per Type (MSI/EXE/CMD/PS1/File/INF/Registry)
      Log result to C:\Logs\Shopfloor\enforce-YYYYMMDD.log
  Unmounts W:

Every subsequent logon
  Same dispatcher fires. No-ops on already-correct state. Self-heals drift.
```

## SFLD share layout (v2)

```
\\tsgwp00525.wjs.geaerospace.net\shared\dt\shopfloor\
  _meta\
    README.md                      layout doc
    manifest-schema.json           JSON schema for entries
    history\                       timestamped backup of each manifest on push
  _outputs\
    ntlars-backups\                eDNC INI backups per machine number
    logs\                          client-uploaded diagnostics

  common\                          applies to every PC type
    manifest.json
    apps\                          MSI / EXE / CMD installers
    configs\                       data files (XML, fonts, txt)
    scripts\                       PS1 / BAT / CMD helpers

  standard-machine\                Standard PC type, subtype Machine
    manifest.json
    apps\ configs\ scripts\

  cmm\ display\ genspect\ keyence\ lab\ waxandtrace\
                                   per-PC-type
```

`_meta`, `_outputs`, and per-PC-type dirs each have `apps/`, `configs/`,
`scripts/` siblings to manifest.json, referenced by relative paths in manifest
entries (`Source: scripts/Foo.ps1` => `<scope>\scripts\Foo.ps1`).

### v1 vs v2

v1 used `\shared\dt\shopfloor\main\machineapps\machineapps-manifest.json` as
the single Standard-Machine manifest. v2 reorganized to per-PC-type
manifests so CMM/Keyence/Display PCs get their own scope without filtering
the whole fleet manifest by `PCTypes`. The v1 path may still exist on the
share for backwards compat; v2 GE-Enforce ignores it. Do not edit the v1
manifest for v2-deployed sites.

## Manifest entry schema

```json
{
  "Version": "2.0",
  "_comment": "...",
  "Applications": [
    {
      "_comment": "Per-entry context: install path, version pin reasons, rotation procedure",
      "Name": "<unique label, also shown in log>",
      "Type": "<MSI|EXE|CMD|PS1|File|INF|Registry>",
      "Installer": "<path-relative-to-scope>",
      "Source": "<for Type=File: path-relative-to-scope>",
      "Destination": "<for Type=File: absolute disk path>",
      "Script": "<for Type=PS1: path-relative-to-scope>",
      "Args": "<optional: extra args>",
      "InstallArgs": "<optional: msiexec/exe args>",
      "DetectionMethod": "<File|Registry|FileVersion|Hash|MarkerFile|ValueMatches|Always>",
      "DetectionPath": "<absolute disk path, or HKLM:\\... for Registry>",
      "DetectionName": "<for Registry: value name>",
      "DetectionValue": "<expected value or hash>",
      "PCTypes": ["Standard", "CMM", "..."],
      "PCSubTypes": ["Machine"],
      "TargetMachineNumbers": ["1234", "5678"]
    }
  ]
}
```

### Type semantics (the part that's easy to get wrong)

| Type | What it does | Local artifact? | Self-heal pattern |
|------|-------------|-----------------|-------------------|
| `MSI` | `msiexec /i <share path>` + InstallArgs | Vendor footprint (uninstall reg key, files) | Detection on uninstall reg DisplayVersion or product File |
| `EXE` | Direct exec of installer EXE | Vendor footprint | Same |
| `CMD` | `cmd /c <share path>` (a wrapper script) | Whatever the wrapper deploys | Same |
| `PS1` | `powershell.exe -File <share path>` - **executes from share, no local copy** | None - runs in place | NOT NEEDED. Update share file = next logon runs new code. No Hash entry required because there's nothing on disk to drift. |
| `File` | `Copy-Item -Source <share> -Destination <disk>` | The file at Destination | **Hash detection IS needed.** Drift = re-copy. |
| `INF` | `pnputil /add-driver` | Driver staged | Detection via PnP |
| `Registry` | Detection-only (no install side; entry is purely a no-op trigger) | Whatever wrote the key | n/a |

**`Type=PS1` is for scripts that need to RUN every cycle, not be DEPLOYED.**
Common pattern: `DetectionMethod: Always` (always fires). Used for one-shot
cleanups (e.g. `Restore-UDCData.ps1` consumes a swap backup and self-no-ops
when none is waiting).

If you want a PS1 to ALSO be deployed to disk somewhere, that's a separate
`Type=File` entry copying it - very rarely useful since GE-Enforce already
runs it from the share.

### Detection methods

- **`File`** - `Test-Path` on `DetectionPath`. Optional `DetectionName` for property.
- **`Registry`** - `Test-Path` on registry key. With `DetectionName` + `DetectionValue`, must match.
- **`FileVersion`** - PE FileVersion of `DetectionPath` matches `DetectionValue` exactly.
- **`Hash`** - SHA256 of `DetectionPath` matches `DetectionValue`. Case-insensitive.
- **`MarkerFile`** - `Test-Path` on a marker file (engine creates it after a successful PS1 run). For PS1 entries that should NOT re-fire after one success.
- **`ValueMatches`** - Registry value exact match.
- **`Always`** - Never matches; entry fires every cycle. For per-cycle cleanup/check scripts.

### Filters

- `PCTypes` - applies only if `C:\Enrollment\pc-type.txt` matches one of the listed values. Skips otherwise.
- `PCSubTypes` - same, against `C:\Enrollment\pc-subtype.txt`.
- `TargetMachineNumbers` - applies only if local `udc_settings.json` MachineNumber is in the list. Used for variant-specific (Fanuc/Okuma/Makino) entries.

## Credential context (the trap that bit us 2026-05-01)

GE-Enforce runs as `NT AUTHORITY\SYSTEM` (scheduled task at startup +
logon). SYSTEM authenticates to remote SMB as the **computer account**
(`DOMAIN\HOSTNAME$`), not as a user.

The SFLD share's ACL grants top-level enumeration to authenticated computers
but file-level reads only to a specific SFLD user. So `Test-Path` on a UNC
path from SYSTEM:

- **Top-level dir** like `\\tsgwp00525...\shared\dt\shopfloor\backup\udc\` -> True
- **Bay-level subdir** like `...\backup\udc\3207\` -> True (depending on ACL)
- **File-level** like `...\backup\udc\3207\CurrentData.json` -> **`$false`**, indistinguishable from "file not found"

Symptom: scripts that `Test-Path` raw UNC paths from SYSTEM context silently
log "absent" / "no work" while the files actually exist. Real cause is
access denied returning False. Failure mode is invisible.

**Always** use `Mount-SFLDShare` (in `Shopfloor\lib\Restore-EDncReg.ps1`)
before file access. It reads creds from `HKLM:\SOFTWARE\GE\SFLD\Credentials\*`
and `net use W: \\... /user:<sflduser> <pw> /persistent:no`. Subsequent
file operations on `W:\...` succeed.

If creds are missing in the registry (e.g. SFLD-DSC bootstrap didn't run),
`Mount-SFLDShare` returns `$false`. Scripts should fail-fast with a clear
ERROR rather than continue with raw-UNC access.

## C:\Enrollment\shopfloor-setup\ vs SFLD share

Two separate copies of overlapping content with different roles:

| Path | Source | Used by | Updated when |
|------|--------|---------|--------------|
| `C:\Enrollment\shopfloor-setup\` | PXE imaging copy from `\\10.9.100.1\enrollment\shopfloor-setup\` | Imaging-flow scripts: `Run-ShopfloorSetup.ps1`, `Stage-Dispatcher.ps1`, `Set-MachineNumber.ps1` -> `Update-MachineNumber.ps1` | Re-image only |
| SFLD share `\<scope>\` | Direct upload | GE-Enforce.ps1 / Install-FromManifest.ps1 (every logon) | Direct file upload to share |

Implication for hot-fixing scripts: a fix to `Restore-UDCData.ps1` needs to
land on the SFLD share (`standard-machine/scripts/Restore-UDCData.ps1`) for
the manifest engine to pick it up. Updating only the PXE enrollment share
helps NEW imaging but not already-imaged PCs.

## site-config.json paths

Defines per-PC-type share roots used by Update-MachineNumber.ps1 and
Backup-UDCData.ps1 logic outside the manifest engine:

| Field (under `pcProfiles.Standard-Machine`) | Used for |
|---------------------------------------------|----------|
| `machineappsSharePath` | v1 legacy machineapps share (mostly unused in v2) |
| `ntlarsBackupSharePath` | per-machine `.reg` backups (eDNC) |
| `udcBackupSharePath` | per-bay live UDC data backup (CurrentData.json + ArchivedData/) |
| `udcSettingsSharePath` | per-bay `udc_settings_<n>.json` (UDC settings overrides) |

These paths are read by Update-MachineNumber.ps1 placeholder->real
transition logic to mount the right share + restore the right data when a
tech sets a real machine number on a previously-9999 PC.

## Update workflow

### Bumping a vendor app version

1. Drop new `.msi` / `.exe` in `<scope>\apps\` on the share.
2. Update the matching manifest entry: `Installer` filename + `DetectionValue`.
3. Save `<scope>\manifest.json`.
4. Optional: copy current manifest to `_meta\history\<date>-<scope>.json`.
5. Every PC of that scope picks up the change next logon.

### Rotating a deployed config (eMxInfo.txt, udc_webserver_settings.json)

1. Overwrite the file in `<scope>\configs\` on the share.
2. Recompute SHA256:
   ```powershell
   (Get-FileHash .\udc_webserver_settings.json -Algorithm SHA256).Hash
   ```
3. Paste into the manifest entry's `DetectionValue`.
4. Save manifest.

### Hot-fixing a script (Restore-UDCData.ps1, etc.)

1. Patch script in repo.
2. Copy to `<scope>\scripts\<name>.ps1` on the SFLD share.
3. (No manifest edit needed - `Type=PS1` runs from share.)
4. Every PC of that scope runs the new version next logon.

### Adding a new entry

1. Decide scope: cross-PC-type goes in `common/`, PC-type-specific in
   `<pctype>/`. Verify the entry isn't already present in either.
2. Pick `Type` based on what the entry is: vendor installer, config file
   deploy, or per-cycle script run.
3. For `File` type: include source on the share; pick `DetectionMethod=Hash`
   with a precomputed SHA256 so drift triggers re-deploy.
4. For `PS1` type: place script in `<scope>\scripts\`; use
   `DetectionMethod=Always` for per-cycle, or `MarkerFile` for one-shot.
5. For installer types: provide `Installer` path, `Type`, `InstallArgs`,
   detection rule that distinguishes "installed at this version" from
   "missing or wrong version".
6. Add a `_comment` field documenting WHY this entry exists, where the
   target installs, and how to rotate the version.
7. Save + push.

### Rolling back

History snapshots live at `_meta\history\<date>-<scope>.json`. Manual revert:
copy old snapshot back over `<scope>\manifest.json`. Engine picks it up next
cycle.

## Logs

Per-PC log file: `C:\Logs\Shopfloor\enforce-YYYYMMDD.log`

Format:

```
[YYYY-MM-DD HH:MM:SS] [INFO] Mounted \\... as W:
[YYYY-MM-DD HH:MM:SS] [INFO] standard\manifest.json not on share - no type-specific apps
[YYYY-MM-DD HH:MM:SS] [INFO] ---- Processing scope: common ----
[YYYY-MM-DD HH:MM:SS] [INFO] Manifest lists N app(s)
[YYYY-MM-DD HH:MM:SS] [INFO] ==> Adobe Acrobat Reader DC
[YYYY-MM-DD HH:MM:SS] [INFO]   Already installed at expected version - skipping
[YYYY-MM-DD HH:MM:SS] [INFO] ==> WJF Defect Tracker
[YYYY-MM-DD HH:MM:SS] [INFO]   msiexec: W:\common\apps\WJF_Defect_Tracker.msi
[YYYY-MM-DD HH:MM:SS] [INFO]   verbose log: C:\Logs\Shopfloor\msi-WJF_Defect_Tracker.log
[YYYY-MM-DD HH:MM:SS] [INFO]   Exit 0 - SUCCESS
```

Per-installer logs go alongside (`msi-*.log`, per-script transcripts).
PS1 entry stdout/stderr surfaces in the main enforce log.

If detection succeeds first time on a freshly-imaged PC, every entry logs
"Already installed at expected version - skipping" - the no-op path
proves the manifest is in sync with the image.

## Common audit failures and their cause

| Symptom | Likely cause |
|---------|-------------|
| `<vendor>` MSI redeploys every cycle | Detection rule's path or DetectionValue doesn't match the actual install footprint. Verify PE FileVersion is 4-part vs DisplayVersion is 3-part. Verify 32-bit installers go under `WOW6432Node`. |
| Script logs "absent" or "no work" repeatedly while file is visible interactively | Running as SYSTEM with raw-UNC access. Switch to `Mount-SFLDShare`. |
| New script change doesn't take effect | Edited the wrong copy. C:\Enrollment\ is imaging-time; SFLD share is runtime. |
| Manifest entry skipped silently | `PCTypes` / `PCSubTypes` filter excludes this PC. Check `C:\Enrollment\pc-type.txt`. |
| Two PCs racing for the same per-bay backup | Both have the same `udc_settings.json` MachineNumber. Find + decommission the second one. |

## See also

- `playbook/shopfloor-setup/_meta/README.md` (on the SFLD share local mirror) - canonical share layout doc
- `pxe-images/Audit-SFLDShare.ps1` - audit script that walks the share and
  validates layout + canonical hashes + detection-rule sanity
- `playbook/shopfloor-setup/Shopfloor/lib/Monitor-IntuneProgress.ps1`
  - imaging-time progress monitor that watches for the upstream signals
    (SFLD reg key, DSCDeployment.log, Consume Credentials task) before
    GE-Enforce can even start
