Revise GE-Enforce plugin plan after review: parity, integrity, snapshots
All checks were successful
CI / backend (push) Successful in 1m25s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s

Fold six review findings into docs/proposals/ge-enforce-plugin.md:

- Parity gate is behavioral equivalence, not byte-identity. Re-serialized JSON
  differs in key order/whitespace/_comment formatting, so a raw diff never
  converges; the test is same ordered entry set with identical detection/
  targeting/action per entry.
- Dedicated payloadsha256 column, independent of detectionmethod. DetectionValue
  is a SHA256 only for detectionmethod=Hash; MSIs with Registry/FileVersion
  detection carry no payload hash, so an HTTP/inline fetch would otherwise run
  unverified bytes. Client verifies fetched bytes against payloadsha256.
- Immutable published snapshots (manifestpublishedversions). Editing touches a
  draft only; publish freezes a snapshot; the client is always served the latest
  published snapshot, never the live draft; rollback republishes a prior
  snapshot (the post-cutover safety net once the on-share JSON is retired).
- Scope uniqueness is (scopename, phase), not scopename alone; preinstall is one
  flat scope gated internally by PCTypes, not per-pctype scopes.
- Alias graph: engine lib stays the single source of truth, shopdb only mirrors
  it for validation; do not invert to engine-fetches-from-shopdb.
- Desired-vs-observed needs a new collector field (the installedVersions status
  map), not existing data; flagged as a dependency.

Plus TLS trust for the SYSTEM-context client and importer skips .bak variants.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-12 15:34:40 -04:00
parent 1672e349e5
commit 9dd2aa3cc6

View File

@@ -111,15 +111,25 @@ convention):
- `manifestscopes` - one row per imaging PC type / scope. - `manifestscopes` - one row per imaging PC type / scope.
- `scopeid` PK - `scopeid` PK
- `scopename` (e.g. `gea-shopfloor-cmm`; unique) - `scopename` (e.g. `gea-shopfloor-cmm`)
- `computertypeid` FK -> `computertypes` (this REPLACES the thin
`pctypemap_<pxetype>` setting; the mapping becomes a column here)
- `phase` enum (`runtime` | `preinstall`) - `phase` enum (`runtime` | `preinstall`)
- UNIQUE (`scopename`, `phase`), NOT `scopename` alone: `common` exists in
runtime, and a scope name can appear in both phases. Note the phases are
shaped differently - runtime is many per-pctype scopes (one manifest file
each), preinstall is ONE flat manifest gated internally by `PCTypes`, so
preinstall is modeled as a single `phase=preinstall` scope, not per-pctype
scopes.
- `computertypeid` FK -> `computertypes` (this REPLACES the thin
`pctypemap_<pxetype>` setting; the mapping becomes a column here).
Runtime-scope only; null for the preinstall scope.
- `measuringtooltypeid` FK -> `measuringtooltypes`, nullable (metrology
scopes: what device this scope implies; keeps imaging + collector agreed,
see section 11).
- `manifestversion` (string, mirrors manifest `Version`) - `manifestversion` (string, mirrors manifest `Version`)
- `description`, `isactive` - `description`, `isactive`
- `iscommon` bool (the `common/` fleet-wide scope) - `iscommon` bool (the `common/` fleet-wide scope)
- `manifestentries` - one row per Applications[] entry. - `manifestentries` - one row per Applications[] entry (the working/draft copy).
- `entryid` PK, `scopeid` FK - `entryid` PK, `scopeid` FK
- `sortorder` int (preserves array order; the ordering contract) - `sortorder` int (preserves array order; the ordering contract)
- `name`, `entrytype` (MSI/EXE/.../Registry), `comment` - `name`, `entrytype` (MSI/EXE/.../Registry), `comment`
@@ -128,6 +138,12 @@ convention):
`regpath`, `regname`, `regvalue`, `regtype` `regpath`, `regname`, `regvalue`, `regtype`
- `payloadsource` enum (`smb` | `http` | `inline`) + `payloadref` - `payloadsource` enum (`smb` | `http` | `inline`) + `payloadref`
(see section 5) (see section 5)
- `payloadsha256` - integrity hash of the payload bytes, INDEPENDENT of the
detection method. Mandatory for `http`/`inline` payloads; optional for
`smb`. Do NOT reuse `detectionvalue` for this - `detectionvalue` is a
SHA256 only when `detectionmethod = Hash`; an MSI with `Registry`/
`FileVersion` detection has no payload hash, so an HTTP fetch would
otherwise run unverified bytes (see section 5).
- detection columns: `detectionmethod`, `detectionpath`, `detectionname`, - detection columns: `detectionmethod`, `detectionpath`, `detectionname`,
`detectionvalue`, `detectionpattern` `detectionvalue`, `detectionpattern`
- gates: `cmmversion`, plus child tables for the multi-value filters - gates: `cmmversion`, plus child tables for the multi-value filters
@@ -135,6 +151,15 @@ convention):
- preinstall flags: `preenrollment`, `killafterdetection`, `pctypesstrict` - preinstall flags: `preenrollment`, `killafterdetection`, `pctypesstrict`
- `isactive` - `isactive`
- `manifestpublishedversions` + `manifestpublishedentries` - immutable
published snapshots. Editing `manifestentries` never affects the fleet; a
"publish" action freezes the current draft into a new numbered snapshot. The
client is ALWAYS served the latest published snapshot for a scope, never the
live draft, so a half-finished edit can never reach a PC. Rollback = mark an
older snapshot current (this is the post-cutover safety net that replaces the
"revert the dispatcher" rollback once the on-share JSON is retired). Mirrors
the current `_meta/history/<date>-<scope>.json` backups, but authoritative.
- `manifestentrypctypes`, `manifestentryhostnames`, `manifestentrymachinenumbers` - `manifestentrypctypes`, `manifestentryhostnames`, `manifestentrymachinenumbers`
- child rows for the ANDed multi-value filters (one value per row, wildcards - child rows for the ANDed multi-value filters (one value per row, wildcards
stored verbatim as patterns) stored verbatim as patterns)
@@ -142,23 +167,27 @@ convention):
- `manifestinusechecks` + `manifestinusecheckprocesses` - `manifestinusechecks` + `manifestinusecheckprocesses`
- the nested InUseCheck object and its Processes[] child list - the nested InUseCheck object and its Processes[] child list
- `pctypealiases` - the old<->new name alias graph (seed from - `pctypealiases` - a MIRROR of the old<->new name alias graph from
`Install-FromManifest.ps1:463-475`), so the server can resolve/validate `Install-FromManifest.ps1:463-475`, for server-side resolve/validate only.
PCTypes the same way the engine does. The engine lib stays the single source of truth (see section 10); shopdb
never becomes the authority the client depends on for aliases.
The JSON the client receives is REBUILT from these rows in exact array order. The JSON the client receives is REBUILT from a published snapshot in exact
It must byte-for-byte match what the current engine expects (round-trip tested array order. Parity with the current engine is proven by BEHAVIORAL equivalence,
against the live manifests). not byte-identity (see section 9): re-serialized JSON will differ in key order
and whitespace, so the test is that both manifests parse to the same ordered
entry set with the same detection/targeting/action semantics.
## 5. Payloads: SMB and/or HTTP (both supported) ## 5. Payloads: SMB and/or HTTP (both supported)
The user asked whether payloads can be SMB and/or HTTP. Yes - per entry: The user asked whether payloads can be SMB and/or HTTP. Yes - per entry:
- `payloadsource = smb`: `payloadref` is the current relative path - `payloadsource = smb`: `payloadref` is the current relative path
(`apps/eDNC_6-4-5.msi`); the client resolves it against the mounted scope (`apps/eDNC_6-4-5.msi`); the client still mounts W: and resolves it against
root exactly as today. Zero client behavior change for these rows. This is the scope root exactly as today. The engine is unchanged for these rows (the
the default and the migration target for large binaries (MSIs are hundreds of mount + scope-root resolution still happen; an HTTP-only site skips the mount
MB; SMB streaming beats HTTP for those). because it has no `smb` rows). This is the default and the migration target
for large binaries (MSIs are hundreds of MB; SMB streaming beats HTTP).
- `payloadsource = http`: `payloadref` is a URL (absolute, or relative to a - `payloadsource = http`: `payloadref` is a URL (absolute, or relative to a
configured payload base). The client downloads to a local temp dir, verifies configured payload base). The client downloads to a local temp dir, verifies
the Hash/FileVersion detection value, then runs it. Good for small the Hash/FileVersion detection value, then runs it. Good for small
@@ -171,10 +200,22 @@ Manifest generation emits, per entry, whatever the client needs to fetch the
bytes. The engine's existing "stage network EXE to local temp first" logic bytes. The engine's existing "stage network EXE to local temp first" logic
(SYSTEM access-denied workaround) generalizes cleanly to HTTP download. (SYSTEM access-denied workaround) generalizes cleanly to HTTP download.
Hashing stays the integrity mechanism regardless of transport: `DetectionValue` Payload integrity uses the dedicated `payloadsha256` column, NOT `DetectionValue`.
carries the SHA256, the client verifies after fetch. This is the security- This is the correction to a subtle trap: `DetectionValue` is a SHA256 only when
critical bit - shopdb serves a manifest that says "install X with hash H"; a `DetectionMethod = Hash`. Most binaries detect by `Registry` or `FileVersion`
tampered payload from any transport fails the hash and does not run. and carry no payload hash at all, so relying on `DetectionValue` would let an
HTTP/inline-fetched MSI run unverified. Instead, publishing an `http`/`inline`
payload computes and stores `payloadsha256`, and the client verifies the fetched
bytes against it BEFORE running, independent of how the entry detects install
state. `smb` payloads may set it too (defense in depth) but the share ACL is
their primary trust boundary. Detection stays a separate concern: it decides
whether to act; the payload hash decides whether the bytes are trustworthy.
Transport security: the client fetches as SYSTEM, so the shopdb TLS cert must be
trusted machine-wide. Sites with a self-signed or air-gapped shopdb need the CA
in the machine trust store (provisioned by the same Azure DSC step that writes
the token). Plain HTTP is acceptable only inside a trusted segment, and even
then the `payloadsha256` check is what actually guarantees payload integrity.
## 6. API surface (`/api/geenforce/...`) ## 6. API surface (`/api/geenforce/...`)
@@ -184,19 +225,25 @@ Admin CRUD (gated by a new `geenforce.manage` permission via the plugin's
- `GET/POST /scopes/<id>/entries`, `PUT/DELETE /entries/<id>` - manifest entries - `GET/POST /scopes/<id>/entries`, `PUT/DELETE /entries/<id>` - manifest entries
- `PUT /scopes/<id>/entries/reorder` - the ordering contract, drag-to-reorder - `PUT /scopes/<id>/entries/reorder` - the ordering contract, drag-to-reorder
- `POST /entries/<id>/payload` - upload an inline/http payload (multipart), - `POST /entries/<id>/payload` - upload an inline/http payload (multipart),
compute + store its SHA256 into `detectionvalue` compute + store its `payloadsha256` (the integrity hash; NOT `detectionvalue`)
- `GET /scopes/<id>/preview` - the exact JSON a client would receive (for review - `POST /scopes/<id>/publish` - freeze the current draft into a new immutable
before publish) `manifestpublishedversions` snapshot (this is what the fleet gets)
- `POST /scopes/<id>/rollback/<version>` - mark an older snapshot current
- `GET /scopes/<id>/preview` - the draft JSON a client WOULD receive on next
publish (review before publish); `GET /scopes/<id>/published` shows the
currently-served snapshot
Client-facing (gated by a collector-style service token, `geenforce.fetch` Client-facing (gated by a collector-style service token, `geenforce.fetch`
scope, reusing the PAT + `X-API-Key` machinery already built for the collector): scope, reusing the PAT + `X-API-Key` machinery already built for the collector):
- `GET /manifest?pctype=<scope>&subtype=<s>&hostname=<h>&machinenumber=<n>` - `GET /manifest?pctype=<scope>&subtype=<s>&hostname=<h>&machinenumber=<n>`
Returns the assembled manifest JSON for that machine. The server can pre-apply Returns the latest PUBLISHED snapshot for that scope (never the live draft).
the PCTypes/hostname/machinenumber/cmmversion filters (thin client) OR return The server can pre-apply the PCTypes/hostname/machinenumber/cmmversion filters
the full scope and let the engine filter (fat client, matches today). Start (thin client) OR return the full scope and let the engine filter (fat client,
fat: return the scope manifest unchanged so the engine logic is untouched. matches today). Start fat: return the scope manifest unchanged so the engine
logic is untouched. Include the snapshot version + an ETag so the client can
cache and no-op when unchanged.
- Payload fetch for `http`/`inline` rows: `GET /payload/<entryid>` streaming the - Payload fetch for `http`/`inline` rows: `GET /payload/<entryid>` streaming the
bytes with the right content type. bytes; the client verifies them against `payloadsha256` from the manifest.
## 7. Frontend: expand `/settings/pctypemapping` ## 7. Frontend: expand `/settings/pctypemapping`
@@ -215,12 +262,17 @@ PC-type manager:
relevant Detection* fields. Filter chips for PCTypes/hostnames/machine numbers. relevant Detection* fields. Filter chips for PCTypes/hostnames/machine numbers.
InUseCheck sub-editor. Payload source selector (smb/http/inline) with upload InUseCheck sub-editor. Payload source selector (smb/http/inline) with upload
for the latter two. for the latter two.
- **Preview + publish**: show the generated JSON; "publish" bumps - **Draft, preview, publish**: editing changes only the draft; "publish" freezes
`manifestversion` and writes a history snapshot (mirrors the current an immutable snapshot (see section 4) and is what the fleet then gets. Show the
`_meta/history/<date>-<scope>.json` backups). draft-vs-published diff before publishing. Rollback republishes a prior
- **Desired vs observed**: because the collector already reports snapshot.
`installedversions`, the scope page can show, per entry, how many fleet PCs - **Desired vs observed**: the scope page can show, per entry, how many fleet
match the expected detection value. This is the payoff of unifying the two. PCs match the expected detection value. CAVEAT: this is NOT free with today's
collector - it reports `installedsoftware[]`, not the per-entry manifest
status map (`installedVersions` keyed `<scope>/<Name>` that GE-Enforce already
computes for status.json). Delivering this feature needs a new collector
payload field carrying that map. Worth it (it is the payoff of unifying
desired + observed state) but it is a dependency, not existing data.
This is an ADR-010 settings card contributed by the geenforce plugin, so it only This is an ADR-010 settings card contributed by the geenforce plugin, so it only
appears when the plugin is enabled. appears when the plugin is enabled.
@@ -253,11 +305,18 @@ The manifest is desired-state that runs as SYSTEM and installs software fleet-
wide. A bad cutover = a fleet-wide mis-install. Stage it: wide. A bad cutover = a fleet-wide mis-install. Stage it:
1. **Import + parity.** Write a one-shot importer that reads the current 1. **Import + parity.** Write a one-shot importer that reads the current
on-share manifests (common + every `gea-shopfloor-*` + preinstall.json) into on-share manifests (common + every `gea-shopfloor-*` + preinstall.json;
the new tables. Then generate JSON back out and `diff` against the originals skip `.bak` / `.pre-mtconnect.bak` variants) into the new tables. Then
until byte-identical for every scope. This proves the model is lossless generate JSON back out and prove BEHAVIORAL equivalence for every scope - do
before anything depends on it. (This is the same discipline as the ADR-001 NOT chase byte-identity. Re-serialized JSON will differ in key order,
data migration.) whitespace, and `_comment` formatting, so a raw `diff` would never converge.
The correct test: parse both the original and the regenerated manifest,
normalize, and assert the same ordered entry list with identical
detection/targeting/action fields per entry (ideally a small harness that
mimics the engine's filter+detect decisions and confirms the same entries
would fire in the same order on representative machine profiles). That, not
byte equality, is what proves the model is lossless. (Same discipline as the
ADR-001 data migration.)
2. **Shadow mode.** shopdb serves the manifest at a new endpoint; a canary PC 2. **Shadow mode.** shopdb serves the manifest at a new endpoint; a canary PC
fetches from shopdb but ALSO reads the share, and logs any diff. No install fetches from shopdb but ALSO reads the share, and logs any diff. No install
behavior changes. Run across one of each PC type for a few cycles. behavior changes. Run across one of each PC type for a few cycles.
@@ -272,8 +331,11 @@ wide. A bad cutover = a fleet-wide mis-install. Stage it:
in the shopdb UI and the on-share JSON is retired (or auto-exported as a in the shopdb UI and the on-share JSON is retired (or auto-exported as a
backup for break-glass). backup for break-glass).
Rollback at every stage is a one-line dispatcher revert, because the engine and Rollback during cutover (stages 2-4) is a one-line dispatcher revert, because
payload layout never stop working from the share. the engine and payload layout never stop working from the share. AFTER the share
JSON is retired (stage 5), that escape hatch is gone - post-cutover rollback is
republishing a prior `manifestpublishedversions` snapshot (section 4). Both
mechanisms must exist before stage 5, not just the dispatcher revert.
## 10. Risks / open questions ## 10. Risks / open questions
@@ -281,14 +343,22 @@ payload layout never stop working from the share.
what `Install-FromManifest.ps1` expects is a fleet-wide install bug. The what `Install-FromManifest.ps1` expects is a fleet-wide install bug. The
byte-identical round-trip test (step 1) is non-negotiable, and the plugin must byte-identical round-trip test (step 1) is non-negotiable, and the plugin must
pin which engine lib version it targets (>= 2.6 for `_CmmVersion`). pin which engine lib version it targets (>= 2.6 for `_CmmVersion`).
- **PCTypes alias graph** must be seeded and kept in sync with - **PCTypes alias graph** must be kept in sync with
`Install-FromManifest.ps1:463-475`. If the engine's alias map changes, ours `Install-FromManifest.ps1:463-475`. The engine lib stays the single source of
must too. Consider having the engine fetch the alias map from shopdb as well, truth; shopdb only MIRRORS the map for server-side validation. Do NOT invert
so there is one source. this to have the engine fetch aliases from shopdb - that would add exactly the
availability coupling the next bullet warns against. When the lib's alias map
changes, update shopdb's mirror as part of shipping that lib version.
- **Availability coupling.** GE-Enforce currently depends only on SMB. Adding an - **Availability coupling.** GE-Enforce currently depends only on SMB. Adding an
HTTP dependency on shopdb means shopdb downtime could stall enforcement - HTTP dependency on shopdb means shopdb downtime could stall enforcement -
hence the last-known-good local cache in section 8. Must be built in from day hence the last-known-good local cache in section 8. Must be built in from day
one, not bolted on. one, not bolted on. This is also why alias resolution and payloads stay
independent of a live shopdb wherever possible.
- **Transport trust.** The client runs as SYSTEM, so shopdb's TLS cert must be
in the machine trust store (self-signed/air-gapped sites need the CA
provisioned via the same DSC step as the token). `payloadsha256` verification
is the real integrity guarantee and holds even over plain HTTP inside a
trusted segment (section 5).
- **Secrets in payloads.** Some config drops (site-config, credentials) may - **Secrets in payloads.** Some config drops (site-config, credentials) may
contain secrets. `inline` payloads live in the shopdb DB - those must respect contain secrets. `inline` payloads live in the shopdb DB - those must respect
the existing "secrets stay in .env, not the settings table" rule. Likely keep the existing "secrets stay in .env, not the settings table" rule. Likely keep
@@ -297,10 +367,13 @@ payload layout never stop working from the share.
before enrollment). It may not have a shopdb token yet at that point in the before enrollment). It may not have a shopdb token yet at that point in the
imaging sequence. Preinstall may need to stay share-sourced longer than imaging sequence. Preinstall may need to stay share-sourced longer than
runtime, or fetch a bootstrap manifest anonymously over HTTP. runtime, or fetch a bootstrap manifest anonymously over HTTP.
- **This is a big build.** Realistically phased: (P1) model + importer + parity - **This is a big build.** Realistically phased: (P1) model + importer +
test; (P2) admin API + CRUD; (P3) frontend editor on /settings/pctypemapping; behavioral-parity test; (P2) admin API + CRUD + publish/snapshot/rollback;
(P4) client fetch + shadow mode; (P5) read cutover; (P6) payload migration. (P3) frontend editor on /settings/pctypemapping; (P4) client fetch + shadow
P1 is the gating de-risk - if the round-trip is not lossless, stop. mode; (P5) read cutover; (P6) payload migration. P1 is the gating de-risk - if
behavioral parity does not hold, stop. Snapshots (P2) must land before any
client points at shopdb (P4), since serving the live draft to the fleet is
unacceptable.
## 11. Relationship to existing work ## 11. Relationship to existing work
@@ -325,7 +398,12 @@ payload layout never stop working from the share.
Feasible and a strong architectural fit, but it is a multi-phase build with a Feasible and a strong architectural fit, but it is a multi-phase build with a
fleet-wide blast radius. The single most important gate is P1: import the real fleet-wide blast radius. The single most important gate is P1: import the real
manifests and prove byte-identical round-trip. Do not build the UI or touch a manifests and prove BEHAVIORAL parity (same entries fire in the same order with
client until that parity test is green. If and when we proceed, this warrants a the same detection/targeting), not byte-identity. Do not build the UI or touch a
new ADR (ADR-012: GE-Enforce manifest ownership) capturing the desired-state client until that parity holds. Three things separate a safe build from a
model, the SMB/HTTP/inline payload contract, and the fail-safe cache. dangerous one and must not be cut: behavioral-parity import (P1), immutable
published snapshots with rollback before any client points at shopdb (P2/P4),
and a dedicated `payloadsha256` for every HTTP/inline payload (section 5). If and
when we proceed, this warrants a new ADR (ADR-012: GE-Enforce manifest
ownership) capturing the desired-state model, the published-snapshot contract,
the SMB/HTTP/inline payload + integrity model, and the fail-safe cache.