Move the documentation to the line that ships, and correct it against the code

The docs lived on one lineage and the fleet code on another. That is why six
pages described a shared fleet key as how an agent authenticates, why
COLLECTOR-AUTH had to warn that the per-PC credential code was not in the
repository it sat in, and why ADR-019 recorded a decision whose implementation
was somewhere else. Documentation that cannot see the code it documents goes
stale by construction, and no amount of editing fixes that.

So the corpus moves here: 84 files, 56 pages, 20 ADRs, the proposals, INDEX.md,
llms.txt and both gates. Filenames are unchanged, because a wiki page name is
derived from the filename and a rename breaks every link anybody saved.

THE POINT OF THE MOVE IS THE CORRECTION IT ENABLES. Every caveat that said "not
in this tree" was false the moment the files landed beside the code, so each was
rewritten from what is actually here:

- COLLECTOR-AUTH gained the real enroll request body, the two bounds on the
  unauthenticated route (per-IP rate limit, pending cap with stale rows dropped
  before a real PC is refused), and the rejected pending reason that binds to
  the endpoint identity rather than the nonce.
- ADR-019 now describes an implementation that is present, including its tests,
  and says plainly that what remains is the FLEET migration, not the code.
- proposals/device-identity claimed phase 1 was unbuilt. plugins/computers
  declares deviceid on the collector schema and prefers it over the older
  fallbacks. Corrected in the page, its summary and the index.

WINDOWS/IIS IS NOW DOCUMENTED AS THE LEGACY PATH, per the product decision, and
carefully: it is what production runs today. Every Windows page opens by saying
it is supported, not for a new site, and where a new site should go instead. No
procedure was cut - an operator on an IIS box at 2am needs these pages to work
exactly as well tomorrow. MIGRATE-TOPOLOGY stops being a footnote and becomes
the off-ramp, which is why it nearly doubled: what was there did not match how
the Docker stack really works here.

The Docker material that already lived on this line was kept and given
ownership rather than buried under the ported Windows-centric prose: DEPLOY now
describes the four services, the migrate service that runs the upgrade chain
before api starts, TLS terminated by nginx, and the plugin count corrected from
thirteen to seventeen - a number both lineages had wrong.

ROADMAP is rewritten from evidence rather than memory: what has shipped, read
out of the changelog, the ADRs and the manifests, and ahead of it exactly two
items - finalize a distributed release and polish the setup wizard.

PROJECT-MAP is generated and was stale; the GENERATOR was what was wrong.
--check passes now.

API-REFERENCE is retired to a pointer. /api/docs serves 573 operations with
derived auth, query parameters and harvested examples, and a hand-written page
competing with that is a liability. The file stays so its wiki page and its
inbound links survive.

AND THE MOVE TURNED ON A SCRUB THAT HAD NEVER RUN HERE. test_docs_publishable
keys on docs/*.md, which this line did not have, so it skipped - its own comment
predicted what would surface. Porting the corpus activated it and it found 48
lines that must not reach a published wiki: branch names abbreviating the site,
assistant-tooling paths, the site name in two deployment pages, and an employee
ID in a Windows path that the scrub does not even look for.
This commit is contained in:
cproudlock
2026-09-19 09:10:15 -04:00
parent 68567d9a27
commit f9d6b57f0f
43 changed files with 5747 additions and 1066 deletions

29
docs/proposals/README.md Normal file
View File

@@ -0,0 +1,29 @@
<!-- docmeta
purpose: reference
audience: developer
status: current
summary: What each proposal designed, whether it was built, and which ADR decided it.
loadwith: none
-->
# Proposals
A proposal is a design written before the work, kept afterwards as the record of
why the thing looks the way it does. A proposal is NOT a decision record: where
a proposal and an ADR disagree, the ADR wins.
**None of these is current guidance.** Read one to understand why something
exists, not to learn how it works today - for that, read the page named in the
right-hand column.
| Proposal | What it designed | Built? | Read instead |
|---|---|---|---|
| [dashboard-live-fleet](dashboard-live-fleet.md) | A dashboard that shows what needs doing instead of row counts | Yes | [ADR-010](../adr/ADR-010-frontend-plugin-hooks.md) for the contract it introduced |
| [device-identity](device-identity.md) | One durable identity for the device a PC drives, ending in the registry | Phase 1 built (the collector `deviceid` field). Phases 2 and 3 not started | The proposal itself, for the remaining phases |
| [ge-enforce-plugin](ge-enforce-plugin.md) | GE-Enforce manifests as ShopDB data, served over HTTP | Yes | [ADR-012](../adr/ADR-012-geenforce-manifest-ownership.md), then [GE-ENFORCE](../GE-ENFORCE.md) |
| [printedparts-plugin](printedparts-plugin.md) | A 3D-printed-parts storefront, bin labels and a kiosk | Yes, as `plugins/printedparts/` | [PLUGIN-LAB-PRINTEDPARTS](../PLUGIN-LAB-PRINTEDPARTS.md) |
| [printer-assignment](printer-assignment.md) | Printers as a property of the machine, installed by the PC | Yes, v0.11.3 and v0.12.0 | [PRINTER-INSTALLER](../PRINTER-INSTALLER.md), [COLLECTOR-INTEGRATION](../COLLECTOR-INTEGRATION.md) |
A proposal that is adopted says so in its own status line, and names what
adopted it. A proposal nobody built stays here anyway: the alternatives it
rejected are the part worth keeping.

View File

@@ -0,0 +1,179 @@
<!-- docmeta
purpose: explanation
audience: developer
status: historical
summary: The 2026-08 case for a worst-first dashboard queue. Built; kept as the record of why.
loadwith: none
-->
# Proposal: a dashboard that shows the fleet, not the row count
- Status: IMPLEMENTED (a proposal, not a decision record - the
contract it introduced is ADR-010 and contract 0.19.0)
- Date: 2026-08-11
- Author: ShopDB maintainers
- Relates to: ADR-010 (frontend plugin hooks), ADR-013 / ADR-014 (lean per-site builds), ADR-006 (collector contract), ADR-012 (GE-Enforce manifest ownership)
## The problem
The dashboard reports totals: assets, machines, PCs, network devices, printers,
measuring tools. Those numbers are true every day and actionable on none of
them. Nobody opens the page to learn there are 417 assets.
Meanwhile the system already knows things worth acting on and shows them
nowhere:
- a bay returned 500 to every collector report for a day and a half, and the
only evidence was a log file on the PC
- eleven part markers' backups were overwriting each other for weeks
- a PC can stop reporting entirely and nothing says so
- GE-Enforce records enforcement failures, stale manifests and repeated
self-heals, and none of it surfaces outside a report modal
Every one of those was found by someone going to look. The dashboard should be
where they find you.
## What already exists
Two discoveries shaped this proposal.
**The plugin mechanism is built and dead.** `BasePlugin.get_dashboard_widgets`
exists, `GET /api/dashboard/widgets` aggregates across enabled plugins, skips
disabled ones, isolates a broken plugin in production and sorts by position.
Five plugins - notifications, network, machines, computers, printers - already
declare widgets.
**Nothing consumes it.** The frontend never calls the endpoint, and not one of
the declared components (`PrinterStatusWidget`, `ComputerStatusWidget`,
`MachineStatusWidget`, `NotificationsWidget`, `NetworkStatusWidget`) exists.
The declarations point at components that were never written.
So this is less "build a dashboard" than "finish one", plus a contract fix.
**The data is already collected.** Nothing here needs a new collector field:
| Signal | Source |
|---|---|
| PC reporting / silent | `computers.lastreporteddate` |
| Machine has a controlling PC | `controls` links, `collector:machine` |
| Backed up, stale, never | `backuprevisions` per asset and kind |
| Enforcement failed | `manifestenforcementreports.status`, `failedcount` |
| Which entry failed, and why | `manifestenforcementresults.action`, `exitcode`, `message` |
| Stale manifest | report `appliedversion` versus the scope's published version |
| Drift that will not stick | repeated `selfhealed` results for one entry |
| Enforcer version spread | `manifestenforcementreports.enforcerversion` |
| Installed software and versions | `computerinstalledapps` |
| Toner critical / low | printer supplies, Zabbix-fed |
| Warranty expiring | warranty plugin |
| Mis-numbered bays | the `check-shared-machines` query |
## Proposed change to the widget contract
The existing contract names a **component per widget**. That does not scale and
does not survive a lean build: every widget needs a bespoke Vue component, and a
plugin-provided component has to be staged into the frontend bundle to exist at
all.
ADR-010 already solved this for asset panels: a plugin declares `render: 'list'`
or `'keyvalue'` or `'tabs'` plus a `map` of fields, and a GENERIC core component
renders it. Do the same here. A plugin declares data and shape; core owns the
rendering.
```python
{
'id': 'geenforce-failures',
'title': 'Enforcement failures',
'endpoint': '/api/geenforce/dashboard/failures',
'render': 'exceptions', # generic renderer
'severity': 'critical', # critical | warning | info
'permission': 'geenforce.view',
'position': 10,
'empty': 'hide', # hide | line
'map': {
'title': 'hostname',
'detail': 'entryname',
'meta': [{'key': 'message'}, {'key': 'exitcode'}],
'link': '/pcs/{computerid}',
},
}
```
Three renderers cover everything listed above:
- `exceptions` - a list of things that are wrong, each linking to the thing
- `metric` - a single number with a trend or threshold, for the cases where the
count IS the story (toner critical: 3)
- `list` - recent items, for notifications and KB adds
## Principles
**An exception board, not a stat board.** The default view is what needs a
person today. A count appears only where the count is the story.
**Empty cards must shrink.** A card that says "nothing wrong" every day trains
people to stop reading the page. That is exactly how `ntlars-backup.log` reached
3,234 lines with 17 that mattered. `empty: 'hide'` is the default; `'line'` for
the few where absence is itself news.
**Every row links to the thing.** A dashboard that says three printers are low
without linking to them is a worse version of a report.
**Cards respect RBAC.** A card declares a `permission`; a user without it never
sees the card or its endpoint. Toner levels are not sensitive, but "which PCs
expose VNC" is, and the dashboard must not become a way around role gating.
**Thresholds are settings with neutral defaults** (ADR-015): quiet window, toner
percentage, warranty horizon. Every site will disagree with the numbers.
**One endpoint per card, lazily loaded, failing independently.** A hung Zabbix
call must not blank the page.
## Wave one
Cards whose data is already reliable, ordered by how actionable they are:
1. **Enforcement failures** - current reports with `status = failed`, showing
the failing entry and exit code.
2. **PCs not reporting** - `lastreporteddate` beyond the quiet window, and PCs
with no GE-Enforce report at all, which is a different and worse case.
3. **Backups missing or stale** - machines with no revision of a kind their
PC type should produce, and revisions older than the interval.
4. **Toner critical, then low** - links to the printer, then to its admin page.
5. **Warranties expiring** - within the configured horizon.
6. **Mis-numbered bays** - `check-shared-machines`, promoted from a CLI command
nobody will remember to run.
Then, lower and quieter: stale manifest versions, repeated self-heals, enforcer
version spread, active notifications, recent application and KB additions.
## Wave two: desired versus observed
The highest-value card and the most work. The manifest declares what a PC type
should have; `computerinstalledapps` records what it does; `filters.py` already
decides which entries apply to a given PC. Diffing those answers "which bays are
missing something they should have" - which nothing in the system answers today,
and which is the thing GE-Enforce exists to guarantee.
Deferred to wave two because it needs the resolver wired into a query path, and
because wave one needs nothing new.
## Risks
**Aggregate queries on the most-visited page.** These are fleet-wide scans on
every load. Cache 30-60 seconds, and log slow cards - a lazily-loaded card can
be slow for months before anyone mentions it.
**Cards become the new noise.** Six cards that are always empty are as useless
as one number that never changes. If a card is empty for a month, delete it.
**Plugin gating must be real.** A lean site without `printers` must render no
toner card. The existing endpoint already skips disabled plugins; the frontend
must not hardcode a card list alongside it.
## Non-goals
- Per-user dashboard customisation. Later, if asked for.
- Historical charting. This is a "what needs doing now" board; trends belong in
reports.
- Replacing the shopfloor TV dashboard, which is a different audience with
different needs.

View File

@@ -0,0 +1,235 @@
<!-- docmeta
purpose: explanation
audience: developer
status: historical
summary: Plan for one durable identity for the device a PC drives. Phase 1 is built here; phases 2 and 3 are not started.
loadwith: none
-->
# Proposal: one identity for the device a PC drives, ending in the registry
Status: PHASE 1 BUILT AND IN THIS TREE - `plugins/computers/plugin.py` declares
`deviceid` on the computers collector schema and prefers it over the older
per-family fallbacks. Phases 2 and 3 not started.
Author: planning session 2026-08-20.
## 1. What this is
A shop-floor PC often drives something: a CMM, a Keyence, a Genspect, a
wax-trace unit, a part marker. ShopDB needs to know WHICH one, and the answer
has to survive the PC being replaced.
Today that answer is assembled from whatever each bay family happens to have,
and two of the three sources are derived from the PC itself, so they do not
survive a swap. This proposal converges them on one identity, moves it from
files to the registry, and does it in three phases so no bay is stranded.
## 2. Why PC-derived identity fails
Two mechanisms stand in for a real identity today:
- **Reuse**: look for a prior collector link from THIS PC asset.
- **Mint**: build `<PC assetnumber>-<SUFFIX>`, and a PC's asset number is its
hostname.
Both survive a re-image. Neither survives a PC SWAP. A new hostname is a new PC
asset with no prior link and a predicted number that has never existed, so the
same physical device gets a SECOND record while the first keeps its config and
its backup history under a dead PC's name.
That is not hypothetical: minting from the hostname left 43 legacy `MT-####`
tools shadowed by minted twins, three records deep in places.
## 3. The rail that must not be crossed
`machinenumber` answers "WHICH BAY is this" and is what GE-Enforce
`TargetMachineNumbers` gates on. The device identity answers "WHICH DEVICE is
this". They are different facts and must stay in different fields.
Putting a device id into the machine-number channel would silently stop every
bay-gated manifest entry from matching - a failure that looks like nothing
happening, on every cycle, with no error anywhere.
## 4. Where identity comes from today
| Bay family | Identity source | Survives a PC swap |
|---|---|---|
| DNC / collections | `MachineNo` in the eDNC/NTLARS registry | yes |
| CMM | `C:\Enrollment\cmm\cmmid.txt` | yes, but see below |
| Keyence, Genspect, wax-trace | nothing, before phase 1 | no |
| Part markers | nothing, before phase 1 | no |
**`cmmid.txt` is not only an identity file.** It is a PATH KEY, and that is why
it cannot simply be retired:
| Consumer | Use |
|---|---|
| `resolve-cmm-bay-config.ps1` | writes it |
| `Verify-And-Heal-Staging.ps1` | stages `installers-post\cmm\backups\<cmmid>` |
| `09-Setup-CMM.ps1` | locates the bay's staged backup set |
| `Restore-CMM.ps1` | restores from `<BackupRoot>\<cmmid>\`, same-bay by design |
| collector | reads it as `machineNo` |
Only the last row is an identity use. The rest address directories by it.
It is also doing the job badly: for a CMM bay it reports the INSTRUMENT's id in
the `machinenumber` channel, and the server then resolves it back to a measuring
tool at step 4 of its own fallback chain. That round trip is what phases 2 and 3
remove.
## Phase 1 - one field, one file (DONE)
The collector accepts a single `deviceid`, read from `C:\Enrollment\asset-id.txt`
and resolved BEFORE anything PC-derived, for every device family.
- One file for every bay with no `MachineNo` registry to identify it.
- It names the asset and NOT its type: the pc-type already says what kind of
device it is, through `SUBORDINATE_DEVICE_MAP`, so a device type declared
later through `subordinatedevice_<pctype>` needs no new file and no client
change.
- Wire field is `deviceid`, not `assetid`, because `assetid` is already this
contract's RESPONSE field for a PC's integer primary key.
- `measuringtoolid` (0.12.0) is accepted as an alias and the older file is still
read, so a bay staged in that window keeps reporting.
- Guards: an unresolvable value warns rather than minting a phantom, and a value
resolving to the WRONG KIND of asset is refused with the asset named.
Nothing was removed. Every previous fallback still runs, one place lower.
## Phase 2 - imaging writes it, and `cmmid.txt` stops being an identity
Repository: the PXE/imaging repo, not this one. **Needs deploying to the live
server, not only committing** - the live boot image is authoritative.
1. `resolve-cmm-bay-config.ps1` writes `C:\Enrollment\asset-id.txt` ALONGSIDE
`cmmid.txt`. Two files, two questions: the identity, and the backup-set key.
`cmmid.txt` keeps every path use it has today.
2. Every other device pc-type writes `asset-id.txt` at imaging from its bay
config. Bays with no device write nothing, and the file's absence stays
meaningful.
3. The collector demotes `cmmid.txt` to last place in the machine-number chain
and stops being the intended path for CMM identity. It is still read, so a
grandfathered bay that never gets re-imaged keeps reporting.
4. The value written is the device's `assetnumber` as ShopDB holds it. Where a
CMM's asset number is already its bay id (`CMM3`), phase 2 is a no-op in
content and a change only in which file carries it.
Exit criterion: a report from a re-imaged bay of each device family carries
`deviceid`, and no bay of those families relies on the `cmmid.txt` fallback.
The collector response warnings are the check: an adopted device is silent, a
minted one is not.
## Phase 3 - identity moves to the registry
`HKLM:\SOFTWARE\GE\ShopDB\DeviceId`.
This is the destination because the registry survives the profile cleanup and
disk hygiene that eats `C:\Enrollment`, is readable as SYSTEM with no file ACL
to get wrong, and is already how a DNC bay is identified. It needs no new key
and no new read path: the collector already opens
`HKLM:\SOFTWARE\GE\ShopDB` (and the `WOW6432Node` variant) for `BaseUrl` and
`ApiToken`, and `Install-GEEnforce.ps1` already writes that key.
**One value, every family.** `DeviceId` is a single registry value, not a key
per device type. The client sends one field and the pc-type decides which sync
consumes it, exactly as `asset-id.txt` works in phase 1, so:
| Bay | `DeviceId` holds | Replaces |
|---|---|---|
| CMM | the instrument's `assetnumber` | `cmmid.txt` as IDENTITY only |
| Keyence | the instrument's `assetnumber` | nothing; had none |
| Genspect | the instrument's `assetnumber` | nothing; had none |
| wax-trace | the instrument's `assetnumber` | nothing; had none |
| part marker | the marker's `assetnumber` | minting from the hostname |
A device family added later through `subordinatedevice_<pctype>` is covered on
the day it is declared, with no client change and no new registry value.
Resolution order after phase 3:
1. `HKLM:\SOFTWARE\GE\ShopDB\DeviceId`
2. `C:\Enrollment\asset-id.txt`
3. `C:\Enrollment\measuringtool-id.txt` (0.12.0 window)
4. `C:\Enrollment\cmm\cmmid.txt` as `machineNo` (grandfathered CMM bays)
Then, and only then, the file branches can be deleted one at a time, newest
first, each when no bay has reported through it for a full inventory cycle.
**`cmmid.txt` survives phase 3 regardless**, as the CMM backup-set path key. It
is not on the deletion list; only its identity use is.
### PXE and GE-Enforce prefer it too, not just the collector
Phase 3 is not only "the collector reads a registry value". All three writers
and readers move together:
| Component | Change |
|---|---|
| PXE / imaging | writes `DeviceId` at enrollment instead of (or as well as) the file |
| GE-Enforce | resolves device identity registry-first, mirroring how it already resolves the machine number |
| collector | reads the registry ahead of the file |
**GE-Enforce already made this exact decision for the machine number, and wrote
down why.** `Install-FromManifest.ps1` resolves `TargetMachineNumbers` gating
from the eDNC registry BEFORE `C:\Enrollment\machine-number.txt`, because:
> the imaging-time `machine-number.txt` is written ONCE by `startnet.cmd` at the
> PXE menu and is NOT updated on reassignment, so it goes stale.
**`asset-id.txt` inherits that defect exactly.** It is written once at imaging
and nothing updates it when a device is swapped, moved between bays, or
replaced. The bay then reports an identity that was true on imaging day, and the
collector - which now trusts that value FIRST, ahead of every other source -
adopts the wrong device with no warning, because the value still resolves.
That makes phase 3 a correctness fix rather than a tidy-up. A file that only
imaging writes cannot be the top of a resolution order for something that
changes during a PC's life.
It also implies a reassignment path, the way `Set-MachineNumber` /
`Update-MachineNumber` exist for the bay number: something that rewrites
`DeviceId` when a device is swapped, and migrates any per-device state with it.
Without that, phase 3 moves the staleness from a file to a registry value rather
than curing it.
### Doing phase 3 early is cheaper than doing it late
If the registry read lands before imaging is changed, phase 2 can write the
REGISTRY for new bays and skip the file era entirely for them. The file then
exists only for bays staged in the window between phase 1 and phase 2, and the
eventual cleanup is deleting a code branch rather than sweeping the fleet.
The cost of adding the read now is one branch in the client and one row in the
docs. The cost of adding it after every bay has a file is a migration.
## 5. What this does not change
- `machinenumber` and its GE-Enforce gating. Untouched in all three phases.
- A part marker still files `partof` the operation from `machinenumber`. The
device identity says WHICH marker the PC controls, not which operation that
marker belongs to.
- Minting. It stays as the last resort for a bay that names nothing, because a
device nobody declared is still better recorded than not recorded. Anything
minted should be treated as a placeholder to be reconciled.
## 6. Before phase 2: find what is already duplicated
Phase 1 stops NEW duplicates. It does not merge the ones a PC swap has already
created. Before imaging starts writing files, run a read-only query for device
assets whose controlling PC no longer exists, or whose asset number carries a
hostname that is not a current PC. That list is the reconciliation backlog, and
it is much easier to read before the fleet starts reporting new identities over
the top of it.
## 7. Rollback
Each phase is independently reversible because nothing is removed until the
phase after it proves the replacement:
- Phase 1: stop sending `deviceid` and every previous fallback still runs.
- Phase 2: stop writing `asset-id.txt` and bays fall back to what they used
before.
- Phase 3: stop writing the registry value and bays fall back to the file.
The only irreversible step is deleting a fallback branch, which is deliberately
outside all three phases.

View File

@@ -0,0 +1,656 @@
<!-- docmeta
purpose: explanation
audience: developer
status: historical
summary: The 2026-07 design for GE-Enforce manifests as ShopDB data. Adopted by ADR-012 and built.
loadwith: none
-->
# Proposal: GE-Enforce as a shopdb plugin
Status: ACCEPTED / built - see ADR-012 and plugins/geenforce/.
Author: planning session 2026-07-12.
## 1. What this is
Today GE-Enforce is a PowerShell manifest engine that reads per-PC-type
`manifest.json` files off an SMB share (`\\shopdb.example.net\
shared\dt\shopfloor\`). Each logon, a scheduled task running as SYSTEM mounts
the share, reads the manifest for the machine's PC type, and installs or
self-heals apps, files, drivers, registry values, and scripts. A parallel
`preinstall.json` runs the same schema once at imaging.
This proposal turns the *manifest* into shopdb data: the authoritative manifest
lives in the shopdb database, is edited through the shopdb UI (an expansion of
`/settings/pctypemapping`), and is served to clients over HTTP as JSON. The
*payloads* (MSI/EXE/PS1/config bytes) stay on SMB, on HTTP, or both, referenced
by URL/path from the manifest rows. GE-Enforce.ps1 changes from "read a file on
W:" to "GET a manifest from shopdb, then fetch each payload from wherever the
row says."
The result: managing imaging PC types, their apps, scripts, files, registry
rules, and version gates becomes a first-class shopdb feature instead of hand-
edited JSON on a file share.
## 2. Why it fits shopdb
- shopdb already models the fleet (the collector ingests every PC's hostname,
pctype, installed software, versions). Making shopdb *also* own what SHOULD be
installed closes the loop: desired-state (manifest) and observed-state
(collector) live in one system and can be diffed.
- `/settings/pctypemapping` already maps `gea-shopfloor-*` PC types to
`ComputerType`. That page becomes the entry point for full imaging-PC-type
management.
- The plugin contract (per-plugin models, migrations, API prefix, settings
cards, collector hooks) is exactly the shape this needs.
- ADR-004 (per-site instances) matches: each site's shopdb owns each site's
manifest. No multi-tenant complication.
## 3. Grounding: the real manifest schema
Source of truth for these field names (do not invent others):
- Schema: `<imaging-share>/SHOPDBHOST-v2/shared/dt/shopfloor/_meta/manifest-schema.json`
- Engine: `<imaging-share>/common/lib/Install-FromManifest.ps1`
- Dispatcher: `.../shopfloor/common/GE-Enforce.ps1`
- Architecture: `pxe/docs/ge-enforce-v2-architecture.md`
A manifest is `{ "Version": str, "_comment": str, "Applications": [entry, ...] }`.
Only `Name` and `Type` are required per entry.
### Per-entry fields (complete set)
Identity / action:
- `Name` (required, unique, also the status-key `<scope>/<Name>`)
- `Type` (required): one of `MSI EXE CMD BAT PS1 INF File Registry`
- `_comment` (documentation, heavily used in practice)
Type-specific payload references (sparse; depends on Type):
- MSI/EXE/CMD/BAT/INF: `Installer` (relative path) + `InstallArgs`
- PS1: `Script` (relative path, falls back to `Installer`) + `Args`
- File: `Source` (relative) + `Destination` (absolute on-PC path)
- Registry: `RegPath` + `RegName` + `RegValue` + `RegType`
(`RegType` in `String DWord QWord MultiString ExpandString Binary`)
- Optional `LogFile`, `WaitTimeoutSec` (EXE hang kill), `InUseCheck`
Detection (decides whether the action fires / self-heals):
- `DetectionMethod`: one of
`Registry File FileVersion Hash MarkerFile ValueMatches pnputil Always`
- `DetectionPath`, `DetectionName`, `DetectionValue`, `DetectionPattern`
- Note: `DetectionValue` is method-dependent - SHA256 for Hash, a 4-part
version for FileVersion, a registry value for Registry, ignored for
Always/File. Same column, different meaning per method.
- No `DetectionMethod` = always installs.
Targeting filters (all ANDed; each is multi-value):
- `PCTypes` (array; `"*"` = all; alias graph expands old<->new names)
- `PCSubTypes` / subtype via `<pctype>-<subtype>` values
- `TargetHostnames` (array; exact + `-like WJS-*` wildcards)
- `TargetMachineNumbers` (array; per-bay)
- `_CmmVersion` (scalar; per-entry PC-DMIS version gate, needs lib >= 2.6)
Nested:
- `InUseCheck`: `{ Behavior, Processes: [{Name, ExePath, GracefulCloseTimeoutSec}] }`
Behavior in `Defer CloseAndReopen ForceClose ScheduleForReboot`
Parsed-but-inert today (model them, mark inert):
- `ApplyMode` (`Nightly Immediate ImmediateReboot`), `UpdateWindow` (`HH:MM-HH:MM`)
Preinstall-only extras (phase discriminator):
- `PreEnrollment`, `KillAfterDetection`, `PCTypesStrict`, `_pcTypesNote`
### Load-bearing behaviors the model must preserve
1. **Array order IS execution order.** Config-restore entries are deliberately
placed AFTER their vendor installer so a mid-cycle overwrite heals the same
cycle (eMxInfo.txt after eDNC; udc_webserver_settings after UDC). We MUST
store an explicit per-scope `sortorder`, not a set.
2. **PCTypes alias graph** is many-to-many old<->new names resolved by set
intersection, with a `PCTypesStrict` escape hatch. Not a simple FK.
3. **Polymorphic entry by Type** - sparse column set per type. DECISION: one
wide `manifestentries` table with an `entrytype` discriminator column and
nullable per-type columns. NOT SQLAlchemy STI subclasses, NOT a JSON blob.
Justification (section 4): the whole fleet is ~64 entries, so sparse columns
cost nothing; real columns get validated, indexed, field-diffed, joined
against collector data, and read in plain SQL by an IT tech - a JSON blob
hides all of that, and class-per-type STI is expert ceremony for no gain. A
`validate()` that switches on `entrytype` (mirroring the engine's own
`switch ($App.Type)`) is ~40 obvious lines.
4. **Two manifest phases** - runtime (self-heal, per logon) and preinstall
(once at imaging) share the schema. One table with a `phase` discriminator.
## 4. Data model (new `geenforce` plugin)
Per-plugin Alembic chain (ADR-008). Tables (lowercase concatenated per naming
convention). Sizing that shapes every decision here: the real fleet is 10
runtime scopes = 43 entries, plus 1 preinstall manifest = 21 entries, so ~64
rows total. That smallness is why this stays deliberately low-tech (one wide
table, JSON-document snapshots, no row-mirroring) - the design target is an
average site IT tech maintaining it, not a specialist.
- `manifestscopes` - one row per imaging PC type / scope.
- `scopeid` PK
- `scopename` (e.g. `gea-shopfloor-cmm`)
- `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`)
- `description`, `isactive`
- `iscommon` bool (the `common/` fleet-wide scope)
- `manifestentries` - one row per Applications[] entry (the working/draft copy).
- `entryid` PK, `scopeid` FK
- `sortorder` int (preserves array order; the ordering contract)
- `name`, `entrytype` (MSI/EXE/.../Registry), `comment`
- payload columns (nullable, per type): `installer`, `installargs`,
`scriptpath`, `scriptargs`, `sourcepath`, `destination`,
`regpath`, `regname`, `regvalue`, `regtype`
- `payloadsource` enum (`smb` | `http` | `inline`) + `payloadref`
(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).
- `regvalue` stores the RAW JSON literal (`1` vs `"1"`) and is emitted
verbatim on export. `RegValue` is untyped in the manifest schema and real
entries carry numbers; the engine string-coerces for `ValueMatches` but
`Set-ItemProperty -Type DWord` cares, so preserve the literal.
- detection columns: `detectionmethod`, `detectionpath`, `detectionname`,
`detectionvalue`, `detectionpattern`
- gates: `cmmversion`, plus child tables for the multi-value filters
- control: `logfile`, `waittimeoutsec`, `applymode`, `updatewindow`
(`applymode`/`updatewindow` are parsed-but-INERT in the engine today; the
UI must label them "not yet enforced" so a tech does not trust a dead gate)
- preinstall flags: `preenrollment`, `killafterdetection`, `pctypesstrict`
- `isactive`
- `manifestpublishedversions` - immutable published snapshots, SIMPLIFIED to
freeze the rendered JSON DOCUMENT in a single `manifestjson` column (drop the
row-mirrored `manifestpublishedentries` family the earlier draft proposed).
The only consumer of a snapshot is the client, and it consumes exactly that
document, so freezing the text makes immutability structural (no UPDATE path),
rollback a one-flag `iscurrent` flip, serving a single-row read, and version
diffing a plain text diff - all things average IT can debug; row-mirroring
would add ~6 shadow tables and a copy routine that can drift. Columns:
`publishedversionid`, `scopeid`, `versionnumber` (1,2,3 per scope),
`manifestjson` (MEDIUMTEXT, verbatim), `publishedat`, `publishedby`,
`iscurrent`, `notes`. Editing `manifestentries` never affects the fleet;
"publish" freezes a new snapshot; the client is ALWAYS served the current
snapshot, never the live draft. Rollback = flip `iscurrent` to an older
version (the post-cutover safety net once the on-share JSON is retired).
Mirrors today's `_meta/history/<date>-<scope>.json` backups, but authoritative.
Revision history: every publish is a permanent, immutable revision kept
indefinitely (snapshots are small JSON text, ~10 scopes - storage is a
non-issue). An OPTIONAL retention policy (keep last M per scope, or prune
older than N months) can be added later; default is keep-everything, off.
- Draft-edit audit trail (field-level history BETWEEN publishes): drafts
(`manifestentries`) are not versioned - editing overwrites the working copy.
To answer "who changed this entry and when" in the window between two
published revisions, log every draft mutation through the EXISTING core audit
system (no new table): on create/update/delete of a scope, entry, or child
row, write an audit record with the actor, timestamp, entry name, and the
changed field(s). This gives per-edit provenance for free and shows up in the
same Audit Logs UI IT already uses; the published snapshots remain the
coarse-grained "what the fleet actually got" record.
- `manifestentrypctypes`, `manifestentryhostnames`, `manifestentrymachinenumbers`
- child rows for the ANDed multi-value filters (one value + a `sortorder` per
row, wildcards stored verbatim as patterns)
- `manifestinusechecks` + `manifestinusecheckprocesses`
- the nested InUseCheck object and its Processes[] child list (leave
`gracefulclosetimeoutsec` nullable; do not bake the engine's default of 10
into the row, emit it only when set)
- `manifestpayloads` - inline payload bytes for `payloadsource = inline`
(`entryid`, `filename`, `contenttype`, `payloadbytes` LONGBLOB, `payloadsha256`,
`uploadedat`). App-enforced size cap ~1 MB; the upload UI rejects larger with
"use SMB for this" so nobody pastes an MSI into the database. Can ship empty
and unused until P6.
- `pctypealiases` - a MIRROR of the old<->new name alias graph from
`Install-FromManifest.ps1:463-475`, for server-side resolve/validate only.
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 a published snapshot in exact
array order. Parity with the current engine is proven by BEHAVIORAL equivalence,
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)
The user asked whether payloads can be SMB and/or HTTP. Yes - per entry:
- `payloadsource = smb`: `payloadref` is the current relative path
(`apps/eDNC_6-4-5.msi`); the client still mounts W: and resolves it against
the scope root exactly as today. The engine is unchanged for these rows (the
mount + scope-root resolution still happen; an HTTP-only site skips the mount
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
configured payload base). The client downloads to a local temp dir, verifies
the Hash/FileVersion detection value, then runs it. Good for small
config/script payloads and for sites with no SMB share.
- `payloadsource = inline`: for small text payloads (a `.ps1`, a config file, a
registry value), the bytes live in shopdb itself and are served in-band. No
external store at all. Best for scripts and File-type config drops.
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
(SYSTEM access-denied workaround) generalizes cleanly to HTTP download.
Payload integrity uses the dedicated `payloadsha256` column, NOT `DetectionValue`.
This is the correction to a subtle trap: `DetectionValue` is a SHA256 only when
`DetectionMethod = Hash`. Most binaries detect by `Registry` or `FileVersion`
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/...`)
Two permissions via the plugin's `get_permissions()` hook (split so day-to-day
techs can edit but only a lead ships to the fleet):
- `geenforce.manage` - create/edit/reorder scopes, entries, drafts, payloads.
- `geenforce.publish` - publish, rollback, export-to-share (the fleet-affecting
actions).
Draft editing (`geenforce.manage`):
- `GET/POST /scopes`, `GET/PUT/DELETE /scopes/<id>` - imaging PC types
- `GET/POST /scopes/<id>/entries`, `PUT/DELETE /entries/<id>` - manifest entries
- `PUT /scopes/<id>/entries/reorder` - the ordering contract; Move Up/Down in the
UI (plain buttons + visible `sortorder`), not a drag-and-drop dependency
- `POST /entries/<id>/payload` - upload an inline/http payload (multipart),
compute + store its `payloadsha256` (the integrity hash; NOT `detectionvalue`)
- `GET /scopes/<id>/preview` - the draft JSON a client WOULD receive on next
publish; `GET /scopes/<id>/published` shows the currently-served snapshot
- `GET /scopes/<id>/simulate?pctype=&subtype=&hostname=&machinenumber=&cmmversion=`
- the "what would this PC get" simulator: runs the entry list through the same
filter logic the engine uses and returns which entries apply and why the rest
are filtered out. Reuses the P1 parity harness's filter engine, so it is
nearly free, and it is the single most IT-empowering endpoint - it answers
"why did/didn't app X install on PC Y" without reading a PowerShell log.
Publishing (`geenforce.publish`):
- `POST /scopes/<id>/publish` - freeze the current draft into a new immutable
`manifestpublishedversions` snapshot (this is what the fleet gets)
- `POST /scopes/<id>/rollback/<version>` - mark an older snapshot current
- `POST /scopes/<id>/export-share` (or a `flask geenforce export-share` CLI) -
write the current published JSON to `<shareroot>/<scope>/manifest.json` after
copying the existing file to `_meta/history/<date>-<scope>.json`. This is a
first-class feature, not a footnote: it is the Milestone 1 product (author in
shopdb, engine untouched) and the permanent break-glass path.
Client-facing (gated by a collector-style service token, `geenforce.fetch`
scope, reusing the PAT + `X-API-Key` machinery already built for the collector):
- `GET /manifest?pctype=<scope>&subtype=<s>&hostname=<h>&machinenumber=<n>`
Returns the latest PUBLISHED snapshot for that scope (never the live draft).
The server can pre-apply the PCTypes/hostname/machinenumber/cmmversion filters
(thin client) OR return the full scope and let the engine filter (fat client,
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
bytes; the client verifies them against `payloadsha256` from the manifest.
## 7. Frontend: expand `/settings/pctypemapping`
The current page (`PCTypeMappingSettings.vue`, "Collector PC Types") is a read-
only-ish table of `pxetype -> ComputerType` dropdowns. It grows into the imaging-
PC-type manager:
- **Scopes list**: add/rename/delete imaging PC types; each still carries its
`ComputerType` mapping (that column moves from a setting into `manifestscopes`).
A `phase` toggle (runtime vs preinstall). Common scope flagged.
- **Scope detail / manifest editor**: an ordered list of entries with Move
Up/Down buttons and a visible `sortorder` (the ordering contract made visible;
NOT drag-and-drop - a drag library is the kind of dependency that breaks
silently and average IT cannot fix; add drag later if wanted). Each entry is a
typed form - the visible fields switch on `entrytype` (MSI shows
Installer+InstallArgs; PS1 shows Script+Args; File shows Source+Destination;
Registry shows the Reg* quartet), one line of help per detection method.
Filter chips for PCTypes/hostnames/machine numbers. InUseCheck sub-editor.
Payload source selector (smb/http/inline) with upload for the latter two.
`applymode`/`updatewindow` sit behind an "Advanced (not yet enforced by the
engine)" disclosure. Ship the editor in three usable-alone increments: (a)
scope list + entry table, (b) the typed entry form, (c) publish + diff. That
keeps the biggest chunk of the build from ballooning.
- **Simulator ("what would this PC get")**: a small form (pctype, subtype,
hostname, machine number, CMM version) that calls `GET /scopes/<id>/simulate`
and lists which entries apply and why the rest are filtered. The single most
IT-empowering piece of the UI.
- **Draft, preview, publish**: editing changes only the draft; "publish" freezes
an immutable snapshot (see section 4) and is what the fleet then gets. Show the
draft-vs-published diff before publishing. Rollback republishes a prior
snapshot.
- **Desired vs observed (BUILT: observed-state reporting)**: rather than extend
the collector, the plugin has its own reporting path. Each enforcement cycle a
PC POSTs `POST /api/geenforce/report` (geenforce.report service token) with the
published version it applied, the installed/skipped/failed/filtered counts, and
per-entry outcomes. Stored in `manifestenforcementreports` (latest-per-host +
history) and `manifestenforcementresults` (per-entry). Two payoffs fall out:
RECEIVED - `receivedlatest` compares the applied version to the scope's current
published version, so the fleet view shows which PCs picked up an update; and
SELF-HEAL - each entry's action (installed = drift corrected, skipped = already
good, failed) with any warning/error message. Admin reads: `GET /reports`
(fleet compliance) and `GET /reports/<id>` (per-entry detail). This is the
observed half that makes the manifest a closed desired-vs-observed loop.
This is an ADR-010 settings card contributed by the geenforce plugin, so it only
appears when the plugin is enabled.
## 8. Client change (minimal, staged)
`GE-Enforce.ps1` today: mount W:, read `<scope>\manifest.json`, hand to
`Install-FromManifest`. New path: GET the manifest from shopdb, write it to the
same local location the engine reads, then run the engine unchanged. That is the
smallest possible client delta - the engine, detection logic, self-heal, and
SMB payload resolution all stay identical. Only the *source of the JSON* moves
from file to HTTP.
Payloads: `smb` rows need no client change. `http`/`inline` rows need a small
fetch-and-verify helper (download to temp, check SHA256, then the existing
installer action runs against the local copy). The engine already stages network
EXEs to temp, so this is an extension, not a rewrite.
Auth: the client already has SFLD credentials in
`HKLM:\SOFTWARE\GE\SFLD\Credentials`. Add a shopdb service token (a
`geenforce.fetch` PAT) provisioned the same way (Azure DSC writes it to
registry), sent as `X-API-Key`. If shopdb is unreachable, the client falls back
to the last-known-good manifest cached locally (fail-safe: never leave a PC
unmanaged because the web app is down). This mirrors today's "creds missing =
exit 0, retry next cycle" resilience.
## 9. Cutover strategy
The manifest is desired-state that runs as SYSTEM and installs software fleet-
wide. A bad cutover = a fleet-wide mis-install. Stage it:
1. **Import + parity.** Write a one-shot importer that reads the current
on-share manifests (common + every `gea-shopfloor-*` + preinstall.json;
skip `.bak` / `.pre-mtconnect.bak` variants) into the new tables. Then
generate JSON back out and prove BEHAVIORAL equivalence for every scope - do
NOT chase byte-identity. Re-serialized JSON will differ in key order,
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
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.
3. **Read cutover, payloads still SMB.** Flip GE-Enforce to source the JSON from
shopdb (payloads stay `smb`). The blast radius is only "where the JSON comes
from"; the bytes and engine are unchanged. Keep the share manifests as the
rollback (revert the dispatcher one-liner).
4. **Payload migration (optional, per entry).** Move small scripts/configs to
`inline`/`http` opportunistically. Leave big MSIs on SMB indefinitely - SMB
is the right transport for them.
5. **Author in shopdb.** Once read-cutover is stable, new manifest edits happen
in the shopdb UI and the on-share JSON is retired (or auto-exported as a
backup for break-glass).
Rollback during cutover (stages 2-4) is a one-line dispatcher revert, because
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
- **The engine is the contract.** Any drift between shopdb's generated JSON and
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
pin which engine lib version it targets (>= 2.6 for `_CmmVersion`).
- **PCTypes alias graph** must be kept in sync with
`Install-FromManifest.ps1:463-475`. The engine lib stays the single source of
truth; shopdb only MIRRORS the map for server-side validation. Do NOT invert
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
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
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
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
any secret-bearing payload on SMB with ACLs, never inline.
- **Preinstall runner** is a separate consumer (`00-PreInstall-*` at imaging,
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
runtime, or fetch a bootstrap manifest anonymously over HTTP.
- **This is a big build.** Realistically phased: (P1) model + importer +
behavioral-parity test; (P2) admin API + CRUD + publish/snapshot/rollback;
(P3) frontend editor on /settings/pctypemapping; (P4) client fetch + shadow
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
- Replaces `plugins/computers/pctypemap.py` (the thin `pctypemap_<pxetype>`
settings) - the pctype -> ComputerType mapping becomes the `computertypeid`
column on `manifestscopes`. Two-source transition window: `pctype_mapping()`
must keep reading the settings until the geenforce plugin is enabled, then
fall back geenforce-table-first / settings-second, and only retire
`seed_pctype_settings` + the settings at Milestone 1 close. Also reconcile the
scope inventory: `pctypemap.py` lists `gea-shopfloor-display` but the share has
no such manifest dir, and the share has a `main/` legacy dir the model ignores
- the importer creates scopes only from what it finds (plus empty scopes for
mapped-but-absent pctypes), and the P1 gate review reconciles the list with
the floor team.
- Also folds in the metrology mapping now living in `pctypemap.py`
(`METROLOGY_TOOL_MAP`). The collector already auto-creates a MeasuringTool
asset and a directional PC->tool `controls` relationship when it sees a
metrology pctype (CMM / Keyence / Genspect / wax-and-trace); the PC stays a
shopfloor PC. A metrology scope in the manifest model should carry the
attached-measuring-tool type alongside its ComputerType so imaging and
collector agree on what device the scope implies.
- Reuses the collector's token machinery (PAT + `X-API-Key` + scopes) for the
client-facing endpoints.
- Reuses `get_permissions()` (contract 0.10.0) for `geenforce.manage` (edit
drafts) / `geenforce.publish` (publish, rollback, export) / `geenforce.fetch`
(the client service token).
- Pairs with the collector: desired-state (this plugin) + observed-state
(collector) enable a fleet compliance view.
## 12. Recommendation
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
manifests and prove BEHAVIORAL parity (same entries fire in the same order with
the same detection/targeting), not byte-identity. Do not build the UI or touch a
client until that parity holds. Three things separate a safe build from a
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.
## 13. Execution plan (build order, gates, milestones)
Governing constraint: every step must be runnable and maintainable by average
site IT, not just the original developer. Where an earlier draft implied expert
machinery, this section simplifies it (and the model above already reflects
those simplifications: one wide table, JSON-document snapshots, no row-mirroring).
### Phases and gates
- **P0 - Scaffold (S, ~0.5-1 day).** `flask plugin new geenforce`, structure
copied from `plugins/measuringtools/`. Unlike bundled plugins' no-op migration
anchors, this NEW plugin's `0001_geenforce_baseline` actually creates the
tables and registers them in `PLUGIN_TABLE_OWNERS` (ADR-008). Deploy stays the
standard `flask db upgrade` + `flask plugin upgrade-all`. Manifest:
`api_prefix: /api/geenforce`, `default_enabled: false`, tight `core_version`.
- **P1 - Model + importer + parity harness (M, ~1-1.5 wk). THE GATE.** Order
inside: tables -> `flask geenforce import-share` (reads common + every
`gea-shopfloor-*` + preinstall.json, skips `.bak`, idempotent) -> exporter
(rebuilds each scope's JSON from rows in `sortorder`) -> the parity harness
(below). **GATE A:** `flask geenforce parity` prints PASS for all scopes. If
it cannot pass, STOP the project. No API/UI/client work before Gate A.
- **P2 - Publish/snapshot/rollback + admin API + export-to-share (M, ~1.5-2 wk).**
Publish freezes rendered JSON into `manifestpublishedversions`. CRUD per
section 6. Plus `flask geenforce export-share` + an "Export to share" button
that writes each scope's published JSON to the share after backing up the old
file to `_meta/history/`. Engine, dispatcher, share layout, payloads, PCs all
untouched. **GATE B = Milestone 1** (below).
- **P3 - Frontend editor (L, ~2-3 wk; parallel with P4 after P2 API freezes).**
Expand `PCTypeMappingSettings.vue` per section 7, in three shippable
increments; Move Up/Down not drag; the simulator.
- **P4 - Client fetch + shadow mode (M effort + soak time; needs P2, not P3).**
Week-1 spike: a ~20-line PS1 on ONE canary PC proves SYSTEM-context HTTP auth +
TLS trust before any real client change. Then `GE-Enforce.ps1` fetches JSON to
a local cache and hands the file to `Install-FromManifest.ps1` unchanged;
shadow mode installs from the share but logs any diff vs shopdb; ETag +
last-known-good cache from day one. **GATE C:** zero shadow diffs across one PC
of every pctype for >= 20 cycles.
- **P5 - Read cutover (S effort, M calendar).** Per-scope flip, canary first via
`TargetHostnames`. Payloads stay `smb`. Rollback = dispatcher revert; share
export continues as break-glass. **GATE D:** all scopes cut over.
- **P6 - Payload migration (S per entry, optional forever).** Small configs to
`inline` (verified by `payloadsha256`); MSIs stay on SMB. Each entry
independently revertible (flip `payloadsource`).
Hard ordering: P0 -> P1 -> P2 -> rest. **Snapshots (P2) MUST precede any client
pointing at shopdb (P4).** P3 and P4 parallelize. Preinstall stays share-sourced
through at least Milestone 1 (no token pre-enrollment; export writes
`preinstall.json` too, so it is authored-in-shopdb for free with no client risk).
### The P1 parity harness (concrete, IT-re-runnable)
`plugins/geenforce/parity.py` + a CLI, also wrapped as a CI test. Two checks per
scope, output one readable line per scope (`entries N/N identical profiles M/M
same-fire PASS`), exit 0/1, prints the first differing entry/field on fail:
1. **Lossless field check (order-preserving).** Canonicalize each entry to
exactly the fields the engine reads (Name, Type, the payload fields, all
Detection*, the filter arrays, `_CmmVersion`, InUseCheck, preinstall flags);
exclude `_comment` and key order (documentation, not behavior). Compare the
ordered lists position by position.
2. **Same-entries-fire-in-same-order.** Re-implement in ~120 lines of Python the
engine's four filter functions exactly as written in `Install-FromManifest.ps1`
(`Test-PCTypeMatches` incl. the alias groups at lines 463-475, `"*"`, and
`<Type>-<SubType>`; `Test-HostnameMatches` exact + `-like`;
`Test-MachineNumberMatches`; `Test-CmmVersionMatches`). For each machine-
profile fixture, run BOTH manifests through it and assert the identical
ordered list of entry names that pass all filters. Detection itself is not
executed - check 1 already proved detection fields identical, so identical
inputs to detection are guaranteed. This pair proves losslessness without
byte-diffing.
Fixtures (`plugins/geenforce/parityfixtures.json`, ~16-18 profiles): one per
pctype; CMM version variants `2016/2019/2026`/empty; collections machine-number
variants (a credentialed bay, an MTConnect bay, neither); legacy-alias profiles
(`Standard`+`Machine`, `CMM`) to exercise the alias graph both ways; a `WJS-*`
hostname-wildcard profile; preinstall profiles including one that hits
`PCTypesStrict`. Watch-items the harness must handle: empty `Applications: []`
scopes (4 exist), entries with NO `DetectionMethod` (fire every run), and the
`regvalue` literal typing.
### First slice: one vertical through `gea-shopfloor-cmm`
Only 4 entries but hits every hard part - MSI type, Registry detection with and
without a pinned value, nested InUseCheck with Processes[], and the `_CmmVersion`
gate. Tables: scopes, entries, entrypctypes, inusechecks + processes,
publishedversions, pctypealiases. `flask geenforce import-share --scope
gea-shopfloor-cmm`; `flask geenforce publish gea-shopfloor-cmm`; one endpoint
`GET /api/geenforce/manifest?pctype=gea-shopfloor-cmm` serving the published
snapshot (fat-client, ETag, collector-style `X-API-Key`/PAT auth reusing
`shopdb/core/api/collector.py`). **Done =** parity PASS for cmm; the endpoint's
JSON fed to `Install-FromManifest.ps1` on a bench CMM PC logs `4 skipped`
identically to the share manifest; editing a draft does NOT change the served
bytes but publishing does, and rollback restores the prior published bytes;
unauth = 401, wrong-scope = 401.
### Milestone 1 (the recommended first stop)
End of P2 plus the publish/scope-list slice of P3: **manifests are authored and
published in shopdb, exported to the share by a button, and the engine,
dispatcher, share layout, payloads, and every PC are completely unchanged.**
That delivers the real pain relief - validated editing instead of hand-edited
JSON, version history, one-click rollback (republish + re-export), desired-state
data sitting next to collector data - at ZERO client risk, with a rollback any
IT tech already knows (restore the `_meta/history` backup file). Natural point to
write ADR-012 with real experience behind it. P4/P5 (HTTP fetch, cutover) are a
separately green-lit second milestone.
### Ranked risks / fail-fast
1. **Generated-JSON vs engine drift (fleet-wide mis-install).** Parity harness
first; CI re-proves parity against checked-in real manifests on every
exporter change; pin lib >= 2.6.
2. **Serving a half-finished draft.** Structural: client reads only
`iscurrent` snapshots; test asserts a draft edit leaves served bytes
unchanged. Must exist before P4.
3. **Availability coupling.** Last-known-good local cache in the first client
prototype; shadow test blocks shopdb and confirms enforce-from-cache + WARN.
4. **SYSTEM HTTP auth + TLS trust.** The ~20-line canary spike in P4 week 1,
before the real client change. Hours of cost; if it fails, Milestone 1 still
delivers full value.
5. **Alias-graph drift.** Seed pins a lib version; harness legacy-name profiles
fail loudly on divergence; new-lib runbook includes "update the alias seed".
6. **Preinstall has no pre-enrollment token.** Keep share-sourced through
Milestone 1/2; decide later.
7. **Editor scope creep.** Three shippable increments; buttons over drag; reuse
JSON preview.
### IT operability (day-to-day runbook, proving the design is manageable)
All in Settings > Imaging PC Types. No PowerShell, no SQL, no share edits.
- **Add an app to a PC type:** open the PC type, Add Entry, pick Type (fields
adapt), fill installer + detection + targeting, Move Up/Down to order, Preview
(+ simulator), Publish with a note. PCs pick it up next 5-min cycle.
- **Bump a version:** drop the new MSI in the scope's `apps/` on the share,
update the entry's Installer + Detection value, Preview, Publish.
- **Roll back a bad publish:** History -> pick last-good version -> Roll Back
(during Milestone 1 also click Export to Share).
- **Canary a risky change:** add the one test PC under Target Hostnames, Publish;
when happy, remove the filter and Publish again.
- **Check "did PC Y get app X":** the simulator with that PC's type/machine
number/CMM version shows exactly which entries apply and why others are filtered.
- **See revision history / who changed what:** the PC type's History tab lists
every published version (date, author, note) with a Roll Back on each; the
Audit Logs page shows the finer-grained draft edits (who touched which entry
field, when) between publishes.

View File

@@ -0,0 +1,219 @@
<!-- docmeta
purpose: explanation
audience: developer
status: historical
summary: The design behind the printedparts plugin and kiosk. Built; kept as the record of why.
loadwith: none
-->
# Proposal: printedparts plugin (3D-printed parts storefront + kiosk)
Status: BUILT. The plugin ships as `plugins/printedparts/`, kiosk route
included. This page is the design behind it, kept as the record of why, and it
is also the reference design for the plugin-development lab in
`docs/PLUGIN-LAB-PRINTEDPARTS.md`. It is NOT current guidance: where it and the
shipped plugin disagree, the code wins.
## 1. Problem
The 3D-printer engineers stock bins of printed parts (fixtures, clips, covers,
spacers). Anyone on the floor can take parts, so stock silently runs out and
nobody knows who took what or how fast items burn down. They need:
- a catalog ("storefront") of printable items: photo, description, quantity on
hand;
- a barcode label per item (1in x 0.5in) stuck on each bin;
- a touch-screen kiosk: scan the bin barcode, scan your badge, enter how many
you took, submit;
- restock and correction flows for the engineers;
- stock monitoring plus consumption metrics.
## 2. Shape: standalone model plugin, NOT an asset type
These are quantity-based consumables: one row represents a *kind* of part with
a count, not an individually tracked machine. ADR-001 assets are one-row-per-
physical-thing (a PC, a printer). So printedparts follows the
knowledgebase/usb shape - own tables, own blueprint, no AssetType row - and
does NOT join the asset-label TYPE_CONFIG; it ships its own print view the way
USB labels do.
Item identity: `itemcode`, generated `3DP-<zero-padded id>` (prefix
configurable via setting `printedparts_code_prefix`). Short, CODE128-friendly,
human-readable. This is what the bin label encodes.
## 3. Data model (2 tables, LOCKED naming, per-plugin Alembic)
### printeditems
| column | type | notes |
|---|---|---|
| printeditemid | int PK autoincrement | |
| itemcode | varchar(20) unique, indexed | generated on create |
| itemname | varchar(120) NOT NULL | |
| itemdescription | varchar(500) | brief description |
| imageurl | varchar(255) | served upload, models.py pattern |
| quantityonhand | int NOT NULL default 0 | cached; ledger is truth |
| lowstockthreshold | int NOT NULL default 5 | per-item, seeds from setting |
| binlocation | varchar(100) | where the bin lives |
| printnotes | mediumtext | material, print time, slicer file path |
| isactive | tinyint(1) | soft retire |
| createddate / modifieddate | datetime | AuditMixin/BaseModel |
### printeditemtransactions (the ledger - source of truth)
| column | type | notes |
|---|---|---|
| transactionid | int PK | |
| printeditemid | int FK -> printeditems CASCADE, indexed | |
| transactiontype | varchar(10) NOT NULL | take / restock / adjust |
| quantitychange | int NOT NULL | negative for take, signed for adjust |
| employeesso | varchar(20) NOT NULL, indexed | who (badge-resolved) |
| employeename | varchar(120) | resolved at write time (USB pattern) |
| reason | varchar(255) | required for adjust |
| transactiondate | datetime NOT NULL default naive-UTC, indexed | |
Invariants: `quantityonhand` = sum of `quantitychange` (enforced by writing
both in one session/commit; an `adjust` can never drive it below 0 - reject).
Every write records WHO via badge scan; there is no anonymous mutation.
Both tables registered in `PLUGIN_TABLE_OWNERS`
(`shopdb/plugins/alembic_template.py`); migration 0001 is a REAL baseline
(measuringtools pattern - hand-written `op.create_table`, no cross-schema FK
so `create_plugin_tables` would also work, but write the ops explicitly for
the exercise).
## 4. Badge resolution (reuse the USB contract exactly)
Same input shapes as `plugins/usb/api/routes.py`:
- all digits -> SSO;
- `0<digits>BZ` (case-insensitive) -> physical badge wrapping a PayNo;
- resolution to a display name via the employees plugin directory
(`DirectoryEmployee`, selfhosted mode) with graceful "" fallback.
Extract-or-copy decision for the lab: copy the small `_PAYNO_BADGE` regex +
lookup into the plugin (contract-pure, no cross-plugin import of usb).
Resolution happens SERVER-side on the kiosk endpoint - the kiosk client never
supplies a name, only the raw badge string.
Manifest `dependencies: ["employees"]` (name lookup). Badge that resolves to
no employee: configurable policy setting `printedparts_unknown_badge`
(`allow` = record SSO with empty name, `deny` = 422). Default deny.
## 5. API surface (blueprint at /api/printedparts)
Authenticated management (JWT + permission):
| route | method | permission |
|---|---|---|
| `/items` | GET list (search, paginate, lowstock filter) | open read (jwt optional) |
| `/items/<id>` | GET detail + recent transactions | open read |
| `/items` | POST create (mints itemcode) | printedparts.create |
| `/items/<id>` | PUT update | printedparts.edit |
| `/items/<id>` | DELETE soft-retire | printedparts.delete |
| `/items/<id>/image` | POST/DELETE upload/remove | printedparts.edit |
| `/image/<filename>` | GET serve | public (models.py pattern) |
| `/items/<id>/restock` | POST {quantity, badge} | printedparts.restock |
| `/items/<id>/adjust` | POST {quantitychange, reason, badge} | printedparts.restock |
| `/items/<id>/transactions` | GET history, ?format=csv | open read |
Kiosk (unauthenticated, notifications/employees open-endpoint precedent):
| route | method | body |
|---|---|---|
| `/kiosk/item/<itemcode>` | GET | item summary by scanned code |
| `/kiosk/take` | POST | {itemcode, badge, quantity} |
`/kiosk/take` validation: item exists + active; quantity 1..quantityonhand
(clamp/reject configurable? no - reject with clear message, kiosk shows it);
badge resolves per policy. Writes ledger row (negative) + decrements cached
quantity in one commit. Rate of abuse is low (plant floor), but the endpoint
only ever DECREMENTS stock with a recorded badge - it cannot edit the catalog.
Permissions declared via `get_permissions()`: printedparts.view/create/edit/
delete/restock (category `printedparts`), seeded on install/enable.
## 6. Frontend
Management pages (scaffold output, standard layout, master templates
PrintersList/PrinterDetail):
- `PrintedItemsList.vue` - table: image thumb, code, name, qty (red badge when
<= threshold), bin; filters: search, low-stock-only; row click -> detail.
- `PrintedItemDetail.vue` - hero image + fields, transaction history table,
restock/adjust buttons (modal w/ quantity + badge + reason).
- `PrintedItemForm.vue` - create/edit incl. image upload, threshold, bin.
- Router file `router/routes/printedparts.js`, list/detail plugin-gated only,
new/edit + requiresAuth (ADR-009, usb.js precedent).
- Nav via `get_navigation_items()` -> "3D Parts".
Kiosk (net-new, top-level route `/parts-kiosk`, NO requiresAuth, outside
AppLayout - shopfloor precedent):
- Full-screen, 3-step flow: (1) SCAN ITEM - a focused invisible input catches
the keyboard-wedge scan of the bin barcode, shows item card w/ photo + qty;
(2) SCAN BADGE - same wedge input pattern for the badge; (3) QUANTITY - big
touch keypad (0-9, clear, backspace - net-new component
`TouchKeypad.vue`) + TAKE button. Success screen w/ remaining count, auto
reset after a few seconds. All state client-side; one POST at the end.
- Scanner UX rule: keyboard-wedge scanners type the code + Enter. A hidden
always-focused input with @keydown.enter handles both scans; on-screen
prompt tells the user what to scan. Touch fallback: item search + manual
badge entry (small link, for damaged labels).
Labels (own print view, USBLabelBatch precedent):
- `/print/printedparts-labels` public print route.
- NEW physical size: 1in x 0.5in stock -> `@page { size: 1in 0.5in; margin: 0 }`
one label per page (label printers feed roll stock; per-page = per-label).
Layout: CODE128 barcode (JsBarcode, ~0.9in x 0.28in, displayValue false) +
itemcode text under it (~7pt) + optional item name truncated. QR variant
offered but barcode is default at this size (a 0.4in QR is at the edge of
scanner tolerance; CODE128 of `3DP-0042` is comfortable).
- Batch mode: pick items -> one label per page sequence for roll printers;
also a ULINE mini-grid fallback for sheet printers (reuse mini72 pattern).
## 7. Metrics / reports (get_reports hook)
- `printedparts-stock` - current stock levels w/ threshold flags (CSV).
- `printedparts-consumption` - takes per item over a date range (CSV).
- `printedparts-by-person` - takes grouped by employee (CSV).
- Dashboard widget via `get_dashboard_widgets()`: low-stock item count.
- Nice-to-have later: burn-rate (avg takes/week per item + weeks-to-empty
projection) - plain SQL over the ledger, add once basics work.
## 8. Settings (get_settings_cards, category printedparts)
| key | default | purpose |
|---|---|---|
| printedparts_code_prefix | 3DP | itemcode prefix |
| printedparts_default_threshold | 5 | seed for new items |
| printedparts_unknown_badge | deny | kiosk policy for unresolvable badges |
## 9. Manifest
name printedparts, version 0.1.0, api_prefix /api/printedparts,
core_version ">=0.12.0,<1.0.0", dependencies ["employees"],
default_enabled false (site opts in - USB precedent).
## 10. Explicitly out of scope (v1)
- Reservations/approvals, per-item cost, print-queue integration, multi-bin
per item, email low-stock alerts (the reports + dashboard widget cover
monitoring; alerting can ride the existing report-email endpoint later).
## 11. Risks / decisions taken
- Cached quantity vs ledger drift: single-commit writes + a reconcile query in
the stock report (flags items where cache != ledger sum).
### Decision: the kiosk take endpoint is an unauthenticated WRITE
This is the first open mutation in the product - every existing kiosk
endpoint (notifications, employees, shopfloor) is a read, and the closest
write (USB checkout) is JWT + permission gated. Accepted deliberately, on
these grounds, and any future open-write endpoint must meet the same bar:
1. Decrement-only: it can reduce stock of an active item, nothing else - no
catalog edits, no restocks, no reads it does not already expose.
2. Fully attributed: it refuses to act without a badge that resolves per the
site policy; every action lands in the ledger with SSO + name + time.
3. Bounded blast radius: worst case is stock counts driven low, which the
ledger makes visible and reversible (adjust with reason).
4. Physically rate-limited: it exists for a touch screen on the shop floor;
there is nothing to enumerate and nothing returned worth scraping.
- 1x0.5in QR marginal: default to CODE128 barcode.
- Not an Asset: no floor-map plotting or warranty for items. If a site later
wants bins on the floor map, revisit via get_map_overlays (ADR-010).

View File

@@ -0,0 +1,346 @@
<!-- docmeta
purpose: explanation
audience: developer
status: historical
summary: The 2026-08 design making printers a property of the machine. Shipped in v0.11.3 and v0.12.0.
loadwith: none
-->
# Proposal: assign printers to a machine in ShopDB, let the PC install them
Status: SHIPPED AND RELEASED. Built out 2026-08-18/19; the client script is
built too. The feature itself released in v0.11.3, the observation half and
`seed-from-observed` adoption in v0.12.0.
Author: planning session 2026-08-18.
One thing this proposal did NOT anticipate, resolved 2026-08-24 in `adc7ef4`:
the computers collector carried a `_sync_printer_links` that wrote
`defaultprinter` / `connectedto` rows straight from a PC report, which is the
ASSIGNED-vs-OBSERVED split this proposal rests on, broken from the other end.
It is retired; `defaultprinter` and `printers` are now ignored (not rejected) on
the collector payload. See [COLLECTOR-INTEGRATION.md](../COLLECTOR-INTEGRATION.md).
## 1. What this is
Today a printer reaches a shop-floor PC because a person walks up to it, opens
the printer installer, finds the printer on a floor plan and clicks it. That is
fine for someone choosing a printer, and wrong for a bay whose printers are a
property of the bay.
This proposal makes the assignment data. The printers belong to the MACHINE, not
to the box currently driving it: tick the printers that belong on the machine,
mark one default, and the assignment reaches whichever PC controls that machine.
The PC converges on its next GE-Enforce cycle - installing what is missing and
setting the default - and keeps converging, so a reimaged bay comes back with
its printers and a bay that drifts is corrected.
The point of putting the assignment on the machine is that a reimaged PC needs
no backup and no restore step. The asset register is the source of truth, and a
replacement PC that inherits the `controls` edge inherits the printers with it.
The map installer stays, for the case it is actually good at: a person at an
unmanaged or office PC picking a printer that nobody assigned.
## 2. Why it is worth doing
- **The assignment becomes a record.** "Which printers does that bay have" is a
question ShopDB can answer, and today it cannot.
- **A reimage stops costing a visit.** The bay reinstalls its own printers, from
the machine's record, with nothing saved off the old PC.
- **Swapping the PC keeps the printers.** They were never the PC's.
- **Drift is corrected, not just detected.** A queue deleted by a user comes
back.
- **It removes the walk-up from the common case.** The installer's map remains
for the uncommon one.
## 3. What already exists
Most of the model is in place, which is why this is a small feature rather than
a project.
| piece | state |
|---|---|
| PC to printer link | `defaultprinter` asset relationship, seeded by `flask seed reference-data` |
| Propagation mechanism | `RelationshipTypePropagation` (ADR-001): "type X propagates through connections of type Y" |
| A working precedent | `resolve_asset_position` walks `partof` then `controls` to give a PC the machine's map position |
| Default lookup | `GET /api/printers/pc-default?machine=NNNN` |
| Host lookup | `GET /api/computers/by-hostname/<hostname>` |
| Printer model | `Printer.modelnumberid` - populated for 44 of 44 printers at the reference site |
| Driver record | `PrinterDriver` (name, `location` as SMB path or URL, optional `modelnumberid`) |
| Per-printer install path | `Printer.installpath` |
| Batch install | `GET /api/printers/install-batch?printerids=1,2,3` |
| Client transport | GE-Enforce manifest entries, `Type=PS1`, running as SYSTEM every cycle |
| Silent driver staging | Proven in `PrinterInstaller.iss`: trust the catalog's signing cert, then `pnputil /add-driver` |
## 4. The data model
### 4.1 One new relationship type
`usesprinter`, directional, source -> printer, meaning "this printer is
installed here". It is seeded next to `defaultprinter`, which already exists and
means "which of them is the default". Both are seed data, not a migration, which
is how every other relationship type shipped.
### 4.2 Two propagation rails, consumed at READ time
`usesprinter` propagates through `controls`, and so does `defaultprinter`. Both
are rows in `relationshiptypepropagations`, the same mechanism map positions
use. Inventing a second mechanism for this was the alternative, and it was
rejected.
The rails are inert at write time on purpose. The create-time fan-out
(`propagate_relationship`) skips directional through-types, and `controls` is
directional, so assigning a printer to a machine does not copy rows onto its PC.
The walk happens when something asks, which is what makes the next rule possible.
### 4.3 Resolution order for a PC
1. The PC's OWN active `usesprinter` / `defaultprinter` rows, if it has any.
2. Otherwise, one hop out along its `controls` edges to the machines it drives,
and those machines' rows instead, tagged as inherited.
Own beats inherited, whole set at a time: a PC with its own assignment is
overriding the bay, not adding to it. An office PC controls no machine and still
works, because step 1 is the normal case for it.
The override is a real trap and the UI has to say so. A tech who "fixes" a bay
by editing the PC has shadowed the machine's record, and the machine will keep
disagreeing until someone clears the PC's own rows.
### 4.4 One default, optional, and never dangling
The unique constraint is `(source, target, type)`, which happily accepts two
different defaults. So the rule is enforced in the API on write:
- Exactly one `defaultprinter` per asset. Setting a default replaces the
existing one.
- A default is OPTIONAL. A bay with three printers and no default is valid.
- The default must be one of the assigned printers. Unassigning the printer that
is currently default clears the default rather than leaving it dangling.
### 4.5 One column on `printerdrivers`
`drivername` - the driver's exact name as the INF declares it, e.g.
`HP Universal Printing PCL 6`. `Add-PrinterDriver` matches on that string, not
on `name`, which is ours to choose, and a mismatch is the usual failure.
Deriving it by parsing the INF on hundreds of bays is fragile; a human
confirming it once in ShopDB is not.
It is a plugin-chain migration (`printers0003drivername`), nullable, guarded so a
re-run is a no-op. `printerdrivers` was created by a core migration but its DDL
moved to the printers chain at the ADR-008 cutover.
The `installmethod` column (`pnputil` or `dpinst`) proposed earlier is NOT being
built. See section 8: if production confirms no Brother printers, everything is
`pnputil` and the column has no second value to hold.
## 5. What has to be built
### 5.1 A resolution helper in core
The read-time walk of section 4.3, beside `resolve_asset_position` and exported
on the `shopdb.api` contract surface (an additive minor bump). It has to live in
core because `RelationshipTypePropagation` is not on the contract surface, and a
plugin may not reach past it (ADR-002).
The through-type comes from the seeded rails, not from a hardcoded `'controls'`,
so a site that adds a rail gets the behaviour without a code change.
### 5.2 Two endpoints
```
GET /api/printers/for-host/<hostname>
```
The desired printer set for one PC, resolved per section 4.3, each entry with
what a client needs to install it: queue name, host or IP, port, driver name,
driver location, and which one is default.
Resolved by hostname, not machine number: the collector already upserts PCs by
hostname, and an office PC has no machine number. Matched case-insensitively -
`COMPUTERNAME` is uppercase and MySQL forgives that where SQLite does not.
An unknown host, a site without the computers plugin, or nothing assigned all
return an empty set. That is the client's designed no-op and it must stay
indistinguishable from "assigned nothing".
```
PUT /api/printers/assignments/for-asset/<asset_id>
```
The whole assignment for one asset - machine or PC - in one call:
`{printerassetids: [...], defaultprinterassetid: N|null}`. It reconciles rather
than inserting: rows that went away are soft-deleted, rows that come back
REACTIVATE the soft-deleted row (the unique constraint spans inactive rows, so a
blind insert is an integrity error on assign, unassign, re-assign), new rows are
created, and the single default is replaced.
The rules in 4.4 hold here or nowhere. Row-at-a-time writes through the generic
relationships path leave two-default windows and know nothing of the subset rule.
**Removing an assignment NEVER uninstalls anything.** Server-side the row simply
goes: no cascade, no side effects, nothing queued for the client to undo.
**Per-PC assignments must NOT go in the manifest.** Manifests are keyed by scope
and PC type and sync broadly; putting per-PC rows there would leak every bay's
configuration to every bay and grow without limit. One manifest entry runs one
script that asks the API what THIS host gets - the mirror image of
`Report-AssetToShopDB.ps1`.
### 5.3 UI on the PC form
A printer multi-select plus a default dropdown whose options are only the
currently selected printers, clearing itself when its printer is deselected.
Saved through the reconcile endpoint against the PC's asset.
The computers plugin does not depend on the printers plugin and must not start:
the section hides itself when the printers API is not there.
The machine-side picker is out of scope for now, which means the machine's
assignment is editable only through the generic relationships card. That is the
side the design says is primary, so it is the obvious next piece of UI.
### 5.4 One client script, in two contexts
`Set-ShopdbPrinters.ps1`, shipped in `plugins/printers/client/` beside the
contract it consumes, and run as a manifest entry with `DetectionMethod=Always`.
*As SYSTEM, every cycle:*
1. `GET /api/printers/for-host/$env:COMPUTERNAME`
2. For each assigned printer with no queue: trust the driver catalog's cert,
`pnputil /add-driver`, create the port, create the queue
3. Write the desired default to `HKLM:\SOFTWARE\GE\ShopDB DefaultPrinter`
4. Ensure the per-user task exists
*In the user's context, at logon and on a repeat:*
5. Read that value, compare with the current default, set it if it differs, and
clear "Let Windows manage my default printer" - otherwise Windows silently
overrides the choice the next time someone prints elsewhere
The default printer is per-user state, which is the only reason this needs two
contexts. Everything else is machine state and belongs to the cycle.
Converge, do not reinstall: when the state matches, the script does nothing.
Nothing here needs the manifest to know when a printer changes, because the
desired state is fetched, not declared.
## 6. Desired state and observed state are not the same thing
Everything above is DESIRED state: what SHOULD be installed on a PC. Nothing in
this feature knows what IS installed on it. The client reads the desired state,
converges toward it, and reports nothing back.
Reporting the observed state is the obvious next feature and is deliberately not
this one. If the collector sent the installed queues per host - name, port,
driver, which is default - then comparing that against the resolved assignment
gives drift detection for free: "this bay is missing the label printer", "this
PC has three queues nobody assigned", "the default is not the assigned one".
Keeping them apart is a rule, not a preference:
- **Observed data never writes `usesprinter` rows.** A register that learns from
what it finds mirrors the drift instead of correcting it, and the fault
becomes the desired state.
- **Observed data belongs on the computer record, timestamped**, like the rest
of the collector payload. It is an observation with an age, not a decision.
- **An empty answer from the API means "nothing assigned", not "nothing
installed"**, which is exactly why section 5.2 refuses to make removal
uninstall anything.
- The two can disagree indefinitely and that is a report to read, not an error
to resolve automatically.
## 7. Decisions to take before writing the client
1. **Never remove a queue by default.** A transient API failure would otherwise
strip printers fleet-wide. Deletion is an explicit opt-in, per PC.
2. **Enforced or set-once for the default?** Re-applying every cycle overrides a
user who chose their own default - correct for a locked bay, irritating on an
office PC. Set-once is Active Setup or RunOnce. Make it a per-PC-type flag
rather than one global answer.
3. **Failure is silent and safe**: unreachable API means change nothing, log,
exit 0 - the convention `Report-AssetToShopDB.ps1` already follows.
Open on the server side, and each one changes the response contract:
4. **How a universal driver resolves.** `PrinterDriver` links to a printer by
exact `modelnumberid`, and the target state is roughly four rows dominated by
HP UPD and Xerox GPD, which match no single model. Either the driver row
gains a vendor, or a `modelnumberid IS NULL` row matches on the printer's
resolved vendor name. Until this is settled, `for-host` returns no driver for
41 of 44 printers.
5. **What `port` means when it is null.** RAW 9100 is the obvious default; whose
job it is to apply it - server or script - has to be written down once.
6. **Who may read `for-host`.** `pc-default` and `install-list` are anonymous;
the collector and the GE-Enforce fetch use scoped service tokens. This one
discloses per-PC configuration keyed by hostname.
7. **Two inherited defaults.** A PC can legitimately control both bays of a
dual-bay machine, or several machines. The union of assigned printers is
easy; the default needs a deterministic rule, or none when it is ambiguous.
8. **Legacy `defaultprinter` rows have no `usesprinter` row**, because they
predate the type. Either an active default implies assignment on read
(zero-touch, preferred) or a one-time backfill writes the missing rows.
Otherwise existing defaults vanish from `for-host` while still showing in
`pc-default`.
9. **Deletions through the generic relationships card bypass the reconcile
endpoint** and can strand an active default pointing at an unassigned
printer. Either the resolver drops dangling defaults or the core delete path
learns the rule.
## 8. What the fleet data says, and the one prerequisite
The reference site's 44 printers are HP 26, Xerox 15, Zebra 1, HID 1, Epson 1.
- **HP and Xerox are 41 of 44, and both have true universal drivers** (HP UPD,
Xerox Global Print Driver). One driver record each serves every queue of that
make.
- **There are no Brother printers at all**, yet the installer carries 208 files
of per-model Brother MFC-J inkjet drivers. Those are host-based GDI devices
with no Printer-class INF, which is the only reason a second staging method
(DPInst) exists. If production confirms no Brother, that payload and that code
path can both go - and with them the `installmethod` column.
- **Zebra, HID and Epson are one printer each**, and the HP DesignJet plotter is
a fourth special case - a PostScript device the UPD does not cover.
**Prerequisite: populate `printerdrivers`.** It currently holds ONE row, and it
points at a per-model folder (`HP LaserJet Pro M607 Driver`) rather than the
universal driver - the opposite of how a UPD should be used. The table needs
roughly four rows: HP UPD, Xerox GPD, one per oddity, and DesignJet when its
payload is restored. Each needs `drivername` copied verbatim from its INF.
Nothing in this proposal works until a printer can resolve to a driver.
## 9. Deployment constraint that shapes the design
**The SFLD share is mounted only during GE-Enforce's cycle.** Any work touching
a share path must run as a manifest entry inside that cycle, never as its own
scheduled task. The failure is silent - the task reports 0 processed, 0
installed, 0 failed - and it has cost a session before.
This is why driver staging belongs in the cycle even though the per-user default
does not, and why "the assignment script schedules a task that installs drivers"
is the wrong shape.
## 10. Upgrading an existing site
Three steps, and the third is the one that gets forgotten:
1. `flask db upgrade` - no core migration in this feature, but a deploy runs it.
2. `flask plugin upgrade-all` - applies `drivername`. Skipping it is the classic
1054 unknown-column error.
3. `flask seed reference-data` - REQUIRED. Without it the `usesprinter` type and
both propagation rails do not exist, and `for-host` resolves nothing, quietly,
because empty is also the healthy answer.
Pair the upgrade with a smoke check against a known bay. A site with reversed
legacy `controls` rows (machine -> PC) should run
`flask relationships fix-controls-direction` first, or inheritance resolves for
none of those PCs.
## 11. What this does not change
- The printer installer keeps working, for walk-up and self-service.
- Nothing about how printers are modelled, mapped or reported.
- The collector contract. Section 6 would change it; this feature does not.
- Sites not running GE-Enforce: the same endpoint suits an Intune remediation or
a DSC `Script` resource, since it is a plain HTTP GET and a PowerShell script.