Make main the lean product a site receives
main was 16 days and 259 commits behind the line the product is actually built on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It was neither current nor curated - just stale. This brings it forward and, at the same time, makes it mean something: main is what a receiving site installs, and nothing else. WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides, knowledgebase, machines, network, notifications, printers, slides, tools, warranty - plus applications, which is manifest-less core and always ships. The only manifest dependency inside that set is endpoints on computers, and both are in it, so the closure is self-contained. WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb stay on the site line. So do the Windows installer tree, the VM rehearsal helpers, the agent tooling config, one site's data loader, the internal audit and findings records, and the planner spreadsheet. 1,135 tracked files against 1,438. A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped: deploy/site-profile-universal.json still named all seventeen plugins. It is the file docs/DEPLOY.md tells a site to provision from, so a fresh install would have failed at `flask plugin apply-profile` with PluginError - the first thing a receiving site does, broken by the branch meant to serve them. It now names the twelve that exist, and its comment says the two must agree. agent/ was never refreshed. Whoever populated this branch copied shopdb/, plugins/, frontend/ and tests/ from the site line and left the agent tree at its September state: 22 C# and PowerShell sources missing, including AgentCredentials, EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the site line had deleted still present. The old tree is internally consistent, so it compiles, and nothing in CI builds it - every gate was green over a server that speaks enrollment and per-PC credentials to a bundled agent source that cannot. Resynced whole. DECISIONS THIS ENCODES, rather than defers: - The plugin-authoring lab builds printedparts, whose manifest depends on employees, so it could not stay. The lab remains on the site line and main ships no build-along tutorial. That is a real loss for an adopting developer and is recorded here rather than discovered later. - Recognition, Training and Recertification notifications are no longer seeded. Each splits one notification into a card per employee and renders a photo, which needs a plugin this branch does not bundle. Seeding them would offer an author a type that cannot resolve anybody. A site that installs employees gets them from that plugin's own seed. - Windows/IIS is documented as the legacy path throughout, and the README leads with Docker, because that is what a receiving site gets. The API description is regenerated for THIS branch - 347 paths and 483 operations, down from 415 and 573 - so the published contract describes what is here rather than what the site line serves. docs/CONFIG.md lost the two measuring-tool label keys the code no longer declares, which two lanes had decided opposite ways. Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run rather than skip, and every index line count is derived from the file it names.
This commit is contained in:
27
docs/proposals/README.md
Normal file
27
docs/proposals/README.md
Normal file
@@ -0,0 +1,27 @@
|
||||
<!-- 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 |
|
||||
| [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.
|
||||
179
docs/proposals/dashboard-live-fleet.md
Normal file
179
docs/proposals/dashboard-live-fleet.md
Normal 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.
|
||||
235
docs/proposals/device-identity.md
Normal file
235
docs/proposals/device-identity.md
Normal 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.
|
||||
346
docs/proposals/printer-assignment.md
Normal file
346
docs/proposals/printer-assignment.md
Normal 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.
|
||||
Reference in New Issue
Block a user