Files
shopdb-flask/docs/proposals/printer-assignment.md
cproudlock 534f179ee6 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.
2026-09-19 11:24:40 -04:00

17 KiB

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.

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:

  1. 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.