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

View File

@@ -0,0 +1,200 @@
<!-- docmeta
purpose: explanation
audience: developer
status: current
summary: Why Asset, not the legacy Machine model, is the surface plugin authors may depend on.
loadwith: none
-->
# ADR-001: Asset model is the platform contract
- **Status:** ACCEPTED
- **Date:** 2026-05-08
- **Deciders:** ShopDB maintainers
- **Supersedes:** none
## Context
shopdb-flask is being shaped as a framework that sister GE Aerospace facilities can adopt. The framework defines a stable core; sites install plugins for the asset classes they care about (equipment, computers, printers, measuring tools, network gear, etc.).
The codebase ran two parallel object models:
1. **Legacy `Machine` model** in `shopdb/core/models/machine.py`. Original schema inherited from the classic-ASP shopdb. Tables: `machines`, `pctypes`, `machinetypes`. Plugins like printers stored extension data via `PrinterData` keyed by `machineid`.
2. **New `Asset` model** in `shopdb/core/models/asset.py`. Generic asset abstraction with `AssetType`, `AssetStatus`, `AssetRelationship`. Plugins also exposed asset-based tables keyed by `assetid`.
For the framework to be adoptable, the platform contract has to be one model, documented, versioned, and stable.
## Decision
**`Asset` is the platform contract.** Plugin authors target the asset-based API. The `Machine` model and its dependents (`PCType`, `MachineType`, `PrinterData` keyed by `machineid`) are legacy and will be retired through a tracked migration.
### Platform contract surface
The following are the public, versioned surface. Plugin authors may depend on them. Breaking changes require a major version bump per ADR-002.
#### Models
- `Asset` (core entity)
- `AssetType` (asset classification, registered by plugins)
- `AssetStatus` (active / inactive / decommissioned / retired)
- `AssetRelationship` + `RelationshipType` (cross-plugin links)
- `Vendor`, `Location`, `LocationType`, `BusinessUnit`, `Model`, `OperatingSystem` (shared reference data)
#### Helpers
- `AuditLog` API: `audit_log(action, entitytype, entityid, ...)` for plugins to record audit entries with consistent schema
- `Setting` API: `plugin.get_setting(key)` and `plugin.set_setting(key, value)` for plugin-scoped config persisted via the core `Setting` model
- `resolve_asset_position(asset)` for the documented position-resolution algorithm
#### Import surface (`shopdb.api`) - expanded in __contract_version__ 0.3.0
`shopdb.api` is the ONLY core module plugins may import (plus `shopdb.plugins.base`
for the ABC). Deep imports of `shopdb.core.*`, `shopdb.extensions`, or
`shopdb.utils.*` are contract violations enforced by the
`test_plugins_only_import_contract_surface` test. The surface re-exports:
- Infrastructure: `db`, `cache`
- Model bases: `BaseModel`, `AuditMixin`
- Core models: `Asset`, `AssetType`, `AssetStatus`, `Vendor`, `Model`,
`Communication`, `CommunicationType`, `Location`, `Setting`, `AuditLog`,
`Application`, `AppVersion`, `OperatingSystem`
- Responses: `success_response`, `error_response`, `paginated_response`, `ErrorCodes`
- Pagination: `get_pagination_params`, `paginate_query`
- Helpers: `audit_log`, `resolve_asset_position`; legacy `employee_connection`
Adding a name here is a minor (additive) contract change; removing one is major.
#### Plugin contract
- `BasePlugin` ABC and its hooks (navigation, dashboard widgets, collector schema + `apply_collector_payload`). Note: `get_searchable_fields` was removed in contract 0.4.0 - global search is a core concern over the asset model, not a per-plugin hook.
#### Excluded from the contract for v1
- Event bus (`get_event_handlers` removed from `BasePlugin`). Add later if a real use case appears.
### Relationship types (seeded core values)
`RelationshipType` is seeded with three rows. Plugin authors do not add new types in v1.
| Type | Meaning | Boundary rule |
|------|---------|---------------|
| `partof` | Composition, siblings, sub-assemblies | Parent dies without it |
| `controls` | One asset has operational authority over another | PC commands a machine, measuring tool, etc. |
| `connectedto` | Network or data link without operational authority | Cable, switch port, NAS mount |
### AssetRelationship columns
```
AssetRelationship
- relationshipid PK
- sourceassetid FK to assets
- targetassetid FK to assets
- relationshiptypeid FK to relationshiptypes (one of partof, controls, connectedto)
- label text, free description (e.g., "ethernet PoE", "DNC feed")
- inheritsposition bool, true means resolved-position walk follows this edge
- propagatesthroughid FK to relationshiptypes, nullable, see propagation below
- notes text
- isactive bool
- createddate, modifieddate
```
Free-text `label` carries the domain nuance (`controls` with label "DNC feed" vs `controls` with label "operator workstation"). Avoids inflating the type list.
### Sibling propagation
When a relationship is created or deleted, the framework checks `RelationshipType.propagatesthroughid`. If set, it finds all assets related to the source via the propagation type and applies the same change.
Default seeding:
| RelationshipType | propagatesthroughid |
|------------------|---------------------|
| `partof` | null (the propagation rail itself) |
| `controls` | `partof` (controls relationships propagate across siblings) |
| `connectedto` | null (network paths don't propagate) |
Cycle protection: max walk depth 3, visited-set during traversal.
### Position resolution
Resolved map position for any asset follows this priority chain:
1. Asset-specific override (`assets.mapx`, `assets.mapy` if non-null)
2. Walk relationships where `inheritsposition=true`, ordered by relationship type priority (`partof` first, then `controls`), recursively resolve the related asset's position with cycle detection
3. Fall back to the asset's location coords (`locations.mapx`, `locations.mapy` via `assets.locationid`)
4. If none of the above, asset is "unplaced" and rendered in a tray, not on the map
API exposes resolved position with a source indicator: `{"mapx": 234, "mapy": 567, "positionsource": "self|related|location|none"}`.
### Hierarchical locations
`locations` is extended with `parentlocationid` (self-FK, nullable). Sites define their own location tree (cells, sub-cells, network closets, meeting rooms, labs).
`locationtypes` lookup is added with seeded values: `section`, `cell`, `subcell`, `meetingroom`, `lab`, `office`, `storage`, `hallway`, `networkcloset`, `building`. Sites can extend.
Asset-to-location is the existing `assets.locationid` FK only (single primary location for v1). Multi-location and transient placement are deferred.
## Migration scope (narrowed)
Only one class of legacy data migrates: physical manufacturing equipment with a `machinenumber` (5-axis mills, lathes, broachers, heat treatment, CMMs, etc.).
Migration filter:
```sql
SELECT * FROM legacy.machines
WHERE category = 'Equipment'
AND machinenumber IS NOT NULL
```
Rows without `machinenumber`: **skipped.** Add manually post-migration if any matter.
Migrated rows become `assets` with `assettype='Equipment'` plus a row in the equipment plugin's `equipment` table. Legacy `machinetypeid` is preserved on the equipment row to enable later reclassification (some "Equipment" rows are actually measuring tools, see ADR-005).
Skipped from migration:
- PCs (rebuilt via PXE collector pipeline, see ADR-006)
- Printers, USB devices, network gear, notifications, KB articles (operational or re-collectable, decision per class deferred until needed)
## Consequences
### Positive
- Plugin authors at sister sites have a single, documented contract to target
- Cross-plugin features (relationships, search, map) work uniformly
- Three relationship types are easy to learn; free-text label captures nuance
- Sibling propagation handles dual-path machines without code changes per use case
- Position resolution gives users one map without per-plugin map code
### Negative
- Migration of equipment data must run cleanly; `migrating-asset-schema` skill owns the procedure
- Frontend `views/machines/` will be repointed or replaced; effort tracked in Phase 5
- Legacy `core/api/machines.py` (641 LOC) becomes deprecated; deletion or shim deferred until equipment is migrated
### Neutral
- `Machine`, `PCType`, `MachineType`, `PrinterData` remain in the schema until the migration completes, behind a deprecation notice. New code does not touch them.
## Alternatives considered
1. **Keep both models indefinitely.** Doubles test surface, confuses contract docs, breaks cross-plugin features. Rejected.
2. **Make `Machine` the contract; retire `Asset`.** `Machine` has shop-floor-specific assumptions that don't generalize. Rejected.
3. **Define a higher abstraction above both.** Yet another layer. `Asset` is already the abstraction. Rejected.
4. **More relationship types** (`controls`, `operates`, `monitors`, `dncfeeds`, `mountedon`, `connectedvia`, `inspects`, `siblingbay`). Eight types proved unwieldy. Collapsed to three with free-text labels and per-row inheritance/propagation flags.
## Open questions deferred
- Lifecycle relationship type (`replaces`, `replacedby`). Add when first needed.
- Multi-location asset placement, transient placement (calibration trip, off-site repair). Add when first needed.
- Map editing UI (drag locations onto floor plan). Phase 6 polish.
- Plugin-extensible relationship types beyond the three seeded. Add when a real cross-plugin case can't be expressed via labels.
## References
- `shopdb/core/models/asset.py`
- (core `Machine` model retired per this decision; machine data now owned by the machines plugin at `plugins/machines/models/machine.py`)
- `shopdb/plugins/base.py`
- ADR-002 (versioning of the surface)
- ADR-003 (plugin distribution)
- ADR-004 (deployment topology)
- ADR-005 (equipment vs measuring tools split)
- ADR-006 (collector contract pattern)

View File

@@ -0,0 +1,68 @@
<!-- docmeta
purpose: explanation
audience: developer
status: current
summary: How the plugin contract is versioned and what core_version a plugin must declare.
loadwith: none
-->
# ADR-002: Plugin contract versioning
- **Status:** ACCEPTED
- **Date:** 2026-05-08
- **Deciders:** ShopDB maintainers
- **Supersedes:** none
## Context
Once sister sites start writing their own plugins (or pulling community plugins), the framework's plugin contract becomes a public API. Without a versioning story, any change to `BasePlugin` or the core platform models can silently break installed plugins at remote sites.
The existing `BasePlugin` and `PluginMeta` already declare a `core_version` field (default `">=1.0.0"`), but it is not enforced anywhere. The plugin loader does not check it before instantiation.
## Decision
The framework adopts **semantic versioning for the plugin contract**, declared in two places:
1. **Framework version** (`shopdb/__init__.py`): a single `__contract_version__` constant. This is the version of the platform contract as defined in ADR-001. Bumped according to semver:
- **Major**: breaking change to `BasePlugin` ABC, `PluginMeta` schema, or any model in the platform contract (`Asset`, `AssetType`, `AssetStatus`, `AssetRelationship`, `Vendor`, `Location`, `BusinessUnit`, `Model`, `OperatingSystem`).
- **Minor**: additive change (new optional hook, new field on a contract model with default).
- **Patch**: bug fix, no contract surface change.
2. **Plugin requirement** (`plugins/<name>/manifest.json`): the existing `core_version` field, expressed as a semver range (e.g., `">=1.0.0,<2.0.0"`).
The plugin loader (`shopdb/plugins/loader.py`) checks `core_version` against `__contract_version__` at load time. Mismatch in dev = re-raise (fail loud). Mismatch in prod = log error, mark plugin as incompatible, exclude from registration.
The `__contract_version__` starts at **`1.0.0`** when ADR-001 is accepted and the `Machine` retirement migration is complete (whichever comes later). Until then, the framework is pre-1.0; plugins should declare `core_version: ">=0.1.0,<1.0.0"`.
## Consequences
### Positive
- Sister sites can pin a known-good framework version. They will not be silently broken when the framework is upgraded.
- Plugin authors know what counts as a breaking change because the contract surface is enumerated in ADR-001.
- The loader fails predictably: a mismatched plugin is reported, not silently disabled.
### Negative / cost
- Discipline required: every change to the contract surface must be classified (major / minor / patch). Adding a `version-bump` skill (or a check in code review) reduces the chance of mis-classification.
- `__contract_version__` becomes a coupling point. Forgetting to bump it after a breaking change means downstream plugins crash silently at runtime instead of failing at install.
### Neutral
- Existing plugins (`plugins/printers/`, etc.) ship as part of the framework, so their `core_version` is always the current `__contract_version__`. The discipline matters mostly for external / sister-site plugins.
## Alternatives considered
1. **No versioning, just trust.** Works for an in-tree-only world. Fails the moment a sister site ships its own plugin. Rejected.
2. **Calendar versioning** (e.g., `2026.05.0`). Easier to bump, harder to communicate breaking changes. Rejected; semver is the industry standard for library-like contracts.
3. **Per-hook versioning.** Each hook has its own version. Too granular; plugins still couple to multiple hooks. Rejected.
## Open questions
- When does the framework declare `1.0.0`? Tied to ADR-001 (Asset retirement of Machine) and the framework being deemed "ready for sister sites". Best-effort target: end of Phase 5 in the refactor plan.
- Should `core_version` accept commercial-grade ranges (`^1.0.0`) or stick to PEP 440 / npm-style ranges? Recommend pip-style (`>=,<`) to match Python ecosystem.
## References
- `shopdb/plugins/base.py` (PluginMeta declaration)
- `shopdb/plugins/loader.py` (where the version check belongs)
- ADR-001 (defines what is in the contract)

View File

@@ -0,0 +1,76 @@
<!-- docmeta
purpose: explanation
audience: developer, adopter
status: current
summary: How a plugin reaches a site: bundled in-tree, or dropped into plugins/ from its own repo.
loadwith: none
-->
# ADR-003: Plugin distribution model
- **Status:** ACCEPTED
- **Date:** 2026-05-08
- **Deciders:** ShopDB maintainers
## Context
Sister sites adopting shopdb-flask need a way to:
1. Install the framework
2. Pick which plugins they want
3. Build their own plugins for site-specific equipment
4. Receive updates to both framework and plugins
Today, every plugin lives in the `plugins/` directory of the framework repo. There is no separation between framework code and plugin code, no install / uninstall, and no way for a site to develop a plugin without forking the whole repo.
Three viable distribution models:
| Model | How a site installs a plugin |
|---|---|
| **In-tree only** | Fork the framework repo, add plugin under `plugins/`, run their own deploy. No separation. |
| **Pip-installable plugins** | Each plugin published as a Python package. Site does `pip install shopdb-printers shopdb-network` etc. Discovery via Python entry points. Framework loads any installed plugin that registers itself. |
| **Git-based plugins** | Each plugin lives in its own git repo. Site clones / submodules into `plugins/<name>/`. Loader picks them up from the directory. |
## Decision
Use a **hybrid model** with two clearly-labeled paths.
1. **Bundled plugins**: a small set of plugins ships with the framework, in-tree at `plugins/`. These are the reference implementations and the default install (printers, computers, network, equipment, usb, notifications). A site that wants only what's bundled needs no extra work.
2. **External plugins**: sister sites or third parties build plugins in their own git repos. The site running the framework drops the plugin into `plugins/<name>/` (clone, submodule, or symlink) and runs `flask plugin install <name>`. No pip packaging required for v1.
Pip-installable plugins (Python entry-point discovery) are deferred to v2. The complexity is not justified until at least two sites are running their own plugins.
## Consequences
### Positive
- v1 is simple: filesystem-based discovery (already implemented in `shopdb/plugins/loader.py`), works for both bundled and external plugins.
- Sites can develop plugins without changing the framework repo.
- The `plugins/` directory is already the canonical location, so no architectural change is needed.
### Negative / cost
- No automatic update path for external plugins. Sites must `git pull` in each plugin directory manually. Acceptable for v1; revisit when plugin count grows.
- Multiple plugin authors writing in parallel can collide on namespace (e.g., two plugins both registering an `AssetType` named "Equipment"). Need a naming policy: plugin names and asset-type names should be prefixed with the site or org if not in the bundled set.
### Neutral
- The existing in-tree pattern keeps working. This decision just formalizes it and clarifies the path for outside-the-tree plugins.
## Alternatives considered
1. **Pip-installable from day one.** Cleaner for the long term but adds packaging, entry-point registration, and CI steps. Premature for current scale (one site running, no sister-site plugins yet).
2. **In-tree only forever.** Forces every site to fork. Doesn't scale beyond two or three sites.
3. **Submodules only.** Forces git-submodule discipline on every adopting site. Submodules are notoriously fiddly. Rejected.
## Open questions
- For external plugins, should there be a manifest field (`source_url`) declaring where the plugin can be cloned from, so `flask plugin install` could pull it for the site? Defer; manual clone is fine for v1.
- Naming convention for non-bundled plugin directory names: prefix with site? (`gea-wjsf-shipping`)? Adopt if and when we hit a name collision.
## References
- `shopdb/plugins/loader.py` (filesystem discovery)
- `shopdb/plugins/cli.py` (plugin install / uninstall command)
- ADR-001 (defines what plugins target)
- ADR-002 (defines plugin version compatibility)

View File

@@ -0,0 +1,100 @@
<!-- docmeta
purpose: explanation
audience: adopter
status: current
summary: Why every site runs its own instance instead of one multi-tenant deployment.
loadwith: none
-->
# ADR-004: Deployment topology (per-site instances)
- **Status:** ACCEPTED
- **Date:** 2026-05-08
- **Deciders:** ShopDB maintainers
## Context
shopdb-flask manages shop-floor inventory. If multiple GE Aerospace sites adopt it, the deployment can take one of two shapes:
| Model | How it works |
|---|---|
| **Per-site instances** | Each site runs its own Flask + MySQL + Vue stack. Each site has its own DB, its own users, its own enabled-plugin list, its own deploy. Sites are isolated. |
| **Multi-tenant single instance** | One central Flask + MySQL + Vue stack serves all sites. A `siteid` foreign key on every asset partitions data. Auth distinguishes which site a user belongs to. |
The codebase today is single-tenant per deployment. There is no `siteid` column, no tenant filter, no cross-site auth model. Plugins can be enabled / disabled but only globally for the running instance.
## Decision
**Per-site instances.** Each adopting site runs its own dedicated stack. The framework does not support multi-tenancy.
Each site:
- Owns its database (own credentials, own backup policy, own retention). The
database charset is part of the contract: it must be **utf8mb4**
(`utf8mb4_unicode_ci`). The migration chain creates every table utf8mb4, and
the connection pins `?charset=utf8mb4`. A site that creates the database with
a different default charset (older MySQL defaults to latin1) gets a schema
that silently diverges from every other site. See `docs/DEPLOY.md`.
- Picks its own enabled plugins
- Configures its own JWT secret, CORS allowlist, Zabbix integration, Active Directory binding
- Deploys at its own cadence
The framework provides:
- A `Dockerfile` and `docker-compose.yml` template suitable for a single-site deploy
- A `.env.example` listing all required environment variables
- A `docs/DEPLOY.md` walking through a fresh-site install
## Consequences
### Positive
- Simpler code: no tenant filter on every query, no cross-tenant auth, no shared-state partitioning bugs.
- Sites are independent. A schema change at one site does not affect another. A plugin crash at one site does not blast radius to other sites.
- Clear ownership: each site's IT team owns their own stack and data. Compliance and audit boundaries match operational boundaries.
- Aligns with how GE Aerospace sites already operate (independent IT, independent shop floors).
### Negative / cost
- No cross-site reporting out of the box. If GE corporate ever wants a fleet-wide view, it has to be built on top (e.g., a roll-up dashboard that queries each site's API). That layer is out of scope for the framework.
- Each site administers its own stack. Higher operational overhead than a single central instance, but each site already runs its own infrastructure.
- Updates require visiting each site's deploy. Fine for the current adoption model; revisit if dozens of sites adopt.
### Neutral
- No `siteid` column needed. The existence of one DB per site is the partition.
## Alternatives considered
1. **Multi-tenant single instance.** Lower operational overhead at scale, easier cross-site reporting, but adds significant code complexity and risk: every query needs a tenant filter, auth gets complex, schema migrations affect every site at once, and a bug at one site can leak data across sites. Rejected for v1; revisit if and only if more than five sites adopt and operational overhead becomes painful.
2. **Hybrid: per-site DB but central app server.** Adds the operational complexity of multi-tenancy without isolating the failure domain (one app crash = all sites down). Rejected.
## Migration strategy (resolved)
Deploys run two commands: `flask db upgrade` then `flask plugin upgrade-all`
(lean/ADR-014 sites add an optional `flask plugin prune-schema` at initial
provisioning). The core Alembic chain applied by `flask db upgrade` creates the
full core AND bundled-plugin schema through the chain head (this includes
migration `7c04_fold_plugin_schema`). But every bundled plugin still carries its
own Alembic chain per ADR-008: `flask plugin upgrade-all` stamps each plugin's
own chain (the `alembic_version_<plugin>` tables) and applies any plugin-specific
migrations added after the ownership cutover. The earlier Phase 7B state that
folded everything into core with no per-plugin chains was superseded by ADR-008's
per-plugin ownership. A fresh `flask db upgrade` reproduces the live core schema
exactly (verified on a scratch DB).
External (out-of-tree) plugins per ADR-003 ship their own migrations too; the
framework runs the same per-plugin chain mechanism (ADR-008) for both bundled and
external plugins.
## Open questions
- Should the framework provide an optional **read-only fleet roll-up** mode where a "central" instance can pull aggregate metrics from each site's API? Defer. Out of scope for v1.
- Backup strategy per site: framework recommendation, or each site decides? Framework should publish a recommended backup runbook (mysqldump + offsite copy) but not enforce.
- Auth federation: each site has its own user table, or sites can share an LDAP / SSO? Recommend documenting the LDAP config knob in `.env.example` so sites can plug in their own auth without code change.
## References
- `shopdb/config.py` (currently single-tenant, no `siteid`)
- ADR-001 (asset model is per-site, not cross-site)
- ADR-003 (plugin distribution per site)

View File

@@ -0,0 +1,163 @@
<!-- docmeta
purpose: explanation
audience: developer
status: historical
summary: The 2026 split of machinery from metrology tools into two plugins. Superseded by ADR-018.
loadwith: none
-->
# ADR-005: Equipment plugin scope vs measuringtools plugin
- **Status:** SUPERSEDED by [ADR-018](ADR-018-metrology-instruments-are-machines.md)
- **Date:** 2026-05-08
- **Deciders:** ShopDB maintainers
- **Supersedes:** none
> **Do not build against this.** ADR-018 (2026-09-04) retired the
> `measuringtools` plugin and made metrology instruments machines. The split
> below was sound for the population it described, and that population never
> arrived: the plugin held zero rows. Kept as the record of why the split was
> made and why it was undone.
## Context
ADR-001 narrowed the migration to physical manufacturing equipment with a `machinenumber`. In practice, the legacy `category='Equipment'` rows contain two distinct asset classes:
1. **Manufacturing machinery** (5-axis mills, lathes, broachers, heat treatment ovens). These produce parts.
2. **Metrology and inspection instruments** (CMMs, Keyence vision systems, wax-and-trace surface profilometers, GenSpec instruments). These measure parts.
Both share `Asset` properties (vendor, model, location, controller). They differ in domain fields (axes vs measurement accuracy, cycle time vs calibration interval, controller protocol vs measurement software).
Mixing them under one plugin pollutes the schema and confuses cross-plugin queries ("show me all measuring tools" requires an enumeration of measuring-instrument equipmenttype values, which scales badly).
## Decision
Two plugins, separate concerns, shared platform contract.
### `equipment` plugin
Tracks manufacturing machinery. Bundled, in-tree.
Schema (per ADR-001 contract):
```
equipment
- assetid FK to assets, PK
- equipmenttypeid FK to equipmenttypes (5-axis mill, lathe, broacher, heat treat, ...)
- vendorid FK to vendors (platform)
- modelid FK to models (platform)
- controllertypeid FK to controllertypes (equipment plugin)
- controllerosid FK to controlleros (equipment plugin)
- (other shared fields: spindle count, axes, max workpiece size, ...)
equipmenttypes (lookup, equipment plugin)
- equipmenttypeid, name (5-axis mill, lathe, broacher, heat treat, ...)
controllertypes (lookup, equipment plugin)
- controllertypeid, name (Fanuc 31i, Siemens 840D, Mitsubishi M70, Heidenhain TNC640, ...)
- vendorid (FK to vendors)
controlleros (lookup, equipment plugin - separate from PC OS)
- controllerosid, name (FAPT, VxWorks, embedded Windows, Linux RT, ...)
equipmentfocas (subtype, optional, present only when FOCAS-equipped)
- assetid PK, FK to equipment
- focasipaddress text
- focasport integer
- focasversion text
- focasmachinenumber text
equipmentclm (subtype, optional, present only when CLM-equipped)
- assetid PK, FK to equipment
- (CLM-specific: address, port, station ID - finalize when plugin is built)
equipmentmtconnect (subtype, optional, present only when MTConnect-equipped)
- assetid PK, FK to equipment
- mtconnectagenturl text
- mtconnectdevicename text
```
The `equipment.protocol` enum field is deliberately **not** included. Presence or absence of a subtype row indicates which protocol applies. Avoids a denormalized field that can drift out of sync.
### `measuringtools` plugin
Tracks metrology and inspection instruments. Bundled, in-tree (built in Phase 3-4 of the refactor as the first new plugin built using the framework scaffold).
Schema (initial draft, refined when plugin is built):
```
measuringtools
- assetid FK to assets, PK
- measuringtooltypeid FK to measuringtooltypes (CMM, vision system, profilometer, surface tester, ...)
- vendorid FK to vendors (platform)
- modelid FK to models (platform)
- measurementaxes integer (e.g., 3 for a 3-axis CMM)
- accuracyspec text (e.g., "+/-0.5um")
- calibrationintervaldays integer
- lastcalibrationdate date
- nextcalibrationdate date (computed)
- (other domain fields as needed)
measuringtooltypes (lookup, measuringtools plugin)
- measuringtooltypeid, name (CMM, vision system, surface profilometer, gage block, ...)
```
Future extension: subtype tables for measurement-software integrations (PC-DMIS, Keyence, GenSpec). Same pattern as equipment subtype tables.
### Subtype-table pattern (general)
Both plugins use the same pattern for protocol- or software-specific fields:
- Core plugin table carries shared, common fields
- Optional subtype tables (one per protocol or software) hold extension fields
- Each subtype table is keyed by `assetid` (PK), one-to-one with the parent
- Subtype row exists if and only if the asset uses that protocol or software
- Sister sites add new subtype tables for their own integrations without touching core
## Reclassification of legacy data
ADR-001's migration moves all legacy `category='Equipment' AND machinenumber IS NOT NULL` rows to `assets` with `assettype='Equipment'` and into the equipment plugin's `equipment` table. This includes both manufacturing machinery and measuring tools.
After the equipment migration, when the measuringtools plugin is built:
1. Build a mapping table: legacy `machinetypeid` values that are measuring tools (CMM type, Keyence type, etc.)
2. Run a reclassification script:
- For each `assets` row where the original `machinetypeid` is in the measuring-tool mapping
- Change `assets.assettype` to `'MeasuringTool'`
- Move the row from `equipment` to `measuringtools`
- Map domain fields where they differ (e.g., legacy `axes` field maps to `measurementaxes`)
3. Verify counts pre- and post-reclassification
4. Audit log entry per reclassified row
Reclassification is one-shot, run once, archived. Like the original migration script.
## Consequences
### Positive
- Manufacturing machinery and measuring tools are first-class plugins, each with appropriate domain fields
- Sister sites can install one or both depending on what they track
- Subtype-table pattern is the canonical example for protocol-specific data and extends naturally to other plugins
- Building `measuringtools` mid-refactor validates the plugin scaffold tooling against a real new plugin
### Negative
- Reclassification is a second migration step. Lower risk than the initial migration because it is data-only (no schema change beyond moving rows between two tables that share the same `assetid` link).
- Sites that adopt the framework before `measuringtools` ships need to either keep measuring tools in `equipment` (workable but suboptimal) or wait for the plugin
### Neutral
- Legacy `machinetypeid` is preserved on the equipment row during migration to enable reclassification
## Alternatives considered
1. **Single equipment plugin with sub-typed assets.** Use `equipment.equipmenttypeid` to discriminate manufacturing vs metrology. Rejected: domain fields differ enough that a single table is wide and full of NULLs.
2. **Migrate split (build mapping before initial migration).** Cleaner end state but requires the `measuringtools` plugin to exist before the migration runs, which delays Phase 5. Rejected.
3. **JSON blob for protocol data instead of subtype tables.** Considered for both plugins. Rejected: weak typing, awkward queries, no schema validation.
## References
- ADR-001 (Asset is platform contract)
- ADR-002 (versioning of the surface)
- `plugins/machines/` (equipment plugin, renamed per ADR-011)
- `plugins/computers/` (existing example of plugin pattern)

View File

@@ -0,0 +1,156 @@
<!-- docmeta
purpose: explanation
audience: developer, integrator
status: current
summary: How a plugin declares a collector schema so the framework wires the ingest endpoint and auth.
loadwith: none
-->
# ADR-006: Plugin collector contract pattern
- **Status:** ACCEPTED
- **Date:** 2026-05-08
- **Deciders:** ShopDB maintainers
- **Supersedes:** none
## Context
PC inventory data was collected by PowerShell scripts pushing to `/api/collector/pc` (`shopdb/core/api/collector.py`, ~374 LOC). The endpoint is hardcoded for PCs: it accepts a fixed schema and writes to the legacy `Machine` model.
Per ADR-001, `Machine` is being retired in favor of `Asset`. Per the project shift to PXE-driven imaging, PC inventory is moving to a new collection pipeline (PXE / GE-Enforce / manifest engine produces JSON about each PC). Other asset classes may want similar collector pipelines (printers via Zabbix, network gear via SNMP scan).
This calls for a generalizable contract: any plugin that wants to accept external collector input declares a JSON schema, and the framework wires the endpoint, auth, and idempotency.
## Decision
`BasePlugin` gets two new hooks (added in __contract_version__ 0.2.x -> the surface is carried at 0.3.0):
```python
def get_collector_schema(self) -> Optional[dict]:
"""Return JSON Schema describing the collector payload for this plugin.
Return None if the plugin does not accept collector input.
The schema must include:
- 'identityfield': name of the field that uniquely identifies an asset
across submissions (e.g., 'hostname' for PCs, 'macaddress' for network
devices). Used for idempotent upsert.
- 'fields': JSON Schema definitions for the rest of the payload.
"""
return None
def apply_collector_payload(self, payload: dict) -> dict:
"""Idempotently upsert an asset from a validated collector payload.
Called by /api/collector/<pluginname> after identity validation.
CONDITIONAL hook: required only when get_collector_schema returns
non-None. Default raises NotImplementedError (the dispatcher returns
500) so a schema-without-upsert fails loud. Returns a dict with at
least 'action' ('created'|'updated'|'noop'), 'assetid', 'warnings'.
"""
raise NotImplementedError
```
The pairing (schema present => apply implemented) is enforced by the
`test_schema_declaring_plugins_implement_apply` contract test.
A single dynamic dispatch route `/api/collector/<pluginname>` serves every
plugin that returns a schema (rather than registering a blueprint per plugin),
because Flask forbids `register_blueprint` after the first request and plugins
can be enabled at runtime. Auth is API-key, separate from JWT. Per-plugin keys via env vars:
- `COLLECTOR_API_KEY_<PLUGINNAME>` (preferred, plugin-specific)
- `COLLECTOR_API_KEY` (fallback, shared)
### Idempotent upsert
The endpoint uses the `identityfield` to find an existing `Asset` for the same identity. Found = update. Not found = insert. Existing relationships are preserved on update.
### Response contract
```json
{
"status": "ok",
"action": "created" | "updated" | "noop",
"assetid": 12345,
"identityvalue": "PC-1234",
"warnings": []
}
```
### Audit logging
Every collector submission produces an audit log entry: `{action, plugin, identityvalue, before/after diff}`. Audit retention per site policy.
### Schema discovery
The framework exposes the registered schemas at `/api/collector/_schemas` (read-only, JWT-protected) so external collector authors can introspect what payloads are accepted by which plugins.
## Concrete first user: computers plugin
The `computers` plugin is the first to implement `get_collector_schema`. The PXE pipeline conforms.
Initial computers collector schema (sketch, finalized when plugin is built):
```json
{
"identityfield": "hostname",
"fields": {
"hostname": "string, required",
"macaddress": "string, optional, secondary identity",
"osname": "string",
"osversion": "string",
"lastboottime": "datetime",
"currentuser": "string",
"ipaddress": "string",
"memorygb": "number",
"cputype": "string",
"imagename": "string (PXE image deployed)",
"imageappliedat": "datetime",
"installedsoftware": "array of {name, version}"
}
}
```
The PC re-image case is handled by the identity field: a freshly imaged PC keeps its hostname, so the existing `Asset` row is updated rather than duplicated. Existing `AssetRelationship` rows pointing at that PC (e.g., `controls` to a machine) are preserved across re-images.
## Migration of the existing endpoint
`shopdb/core/api/collector.py` (`/api/collector/pc`) is **deprecated** in v1 and **removed** before v1.0.
Migration path:
1. Implement `get_collector_schema` on the `computers` plugin. New endpoint `/api/collector/computers` is auto-registered.
2. Run both endpoints in parallel for one cycle of PXE imaging across the floor. PXE pipeline switches to `/api/collector/computers`.
3. Remove `shopdb/core/api/collector.py` and the legacy blueprint registration.
## Consequences
### Positive
- Generalizable across plugins. Sister sites adopting `printers`, `network`, etc. can wire their own collectors with no core change.
- Identity-based idempotency makes PC re-imaging safe by default.
- Audit logging is uniform across plugins.
- Schema discovery enables external tools to validate before submission.
### Negative
- Plugin authors must write a JSON schema. Slight learning curve, but JSON Schema is widely understood and the framework can ship a few examples.
- The `/api/collector/_schemas` endpoint plus per-plugin endpoints expand the public API surface; minor maintenance cost.
### Neutral
- API-key auth pattern stays as it is today (separate from JWT). Sites manage their own collector keys per plugin via env vars.
## Alternatives considered
1. **Keep `/api/collector/pc` and add new plugin-specific endpoints alongside.** Two ways to send PC data, plugin authors confused. Rejected.
2. **Use JWT for collectors instead of API key.** Collectors are headless processes (PXE pipeline, scripts), not interactive users. JWT lifecycle (refresh tokens, expiry) is the wrong tool. API key is simpler. Rejected.
3. **Plugins write directly to the database, no collector endpoint.** Skips audit logging and schema validation. Rejected.
## References
- `shopdb/core/api/collector.py` (legacy endpoint to be removed)
- `shopdb/plugins/base.py` (`get_collector_schema` + `apply_collector_payload` hooks)
- ADR-001 (asset model the collectors target)
- ADR-002 (collector schema is part of plugin contract; changes to the hook signature are major bumps)
- The PXE project (the PXE imaging project) which feeds the computers collector

View File

@@ -0,0 +1,123 @@
<!-- docmeta
purpose: explanation
audience: developer, adopter
status: current
summary: How the product is versioned and released, separately from the plugin contract version.
loadwith: none
-->
# ADR-007: Product versioning and releases
- **Status:** ACCEPTED
- **Date:** 2026-07-10
- **Deciders:** ShopDB maintainers
- **Supersedes:** none
## Context
ADR-002 established semantic versioning for the plugin contract via
`__contract_version__` in `shopdb/__init__.py`. That number answers one
question only: is a given plugin compatible with this platform build. It
says nothing about the state of the product as a whole. A sister site
standing up its own instance needs a different answer: which release am I
running, and what changed since the last one.
Until now the product had no version, no tags, no changelog, and no CI.
ADR-002's pinning model implicitly assumes that a downstream site can
pin a known-good build, but there were no tags to pin to. Adopters had no
release record to read before upgrading, and no automated gate confirming
that a given commit builds and passes tests.
## Decision
The product carries its own release version, separate from the plugin
contract version.
1. **Product version** (`shopdb/__init__.py`): a single `__version__`
constant. This is the version of the shopdb-flask product as a whole.
It follows semantic versioning of the product's user-visible and
operator-visible behavior. It is deliberately NOT part of the
`shopdb.api` contract surface, so it is never re-exported through
`shopdb.api`; exporting it would itself be a contract change under
ADR-002.
2. **Plugin contract version** (`__contract_version__`): unchanged from
ADR-002. It moves only when the plugin contract surface changes, per
the major/minor/patch rules in ADR-002.
The two are distinct series with independent bump rules. They happen to
coincide at `0.5.0` for this release; that is a coincidence of timing,
not a coupling. A product release that changes no contract surface bumps
`__version__` while leaving `__contract_version__` fixed, and vice versa.
3. **Release record** (`CHANGELOG.md`, repo root): the canonical,
human-readable record of what changed in each release, in
Keep-a-Changelog format. Every release has an entry; work in flight
accumulates under `## [Unreleased]`.
4. **Git tags**: each product release is tagged `vX.Y.Z`, where `X.Y.Z`
matches `__version__` at the tagged commit. Tags are what ADR-002's
downstream-pinning model pins to.
5. **Frontend version** (`frontend/package.json`): kept in lock-step with
`__version__` so the shipped single-page app reports the same product
version as the backend that serves it.
### Release procedure
To cut release `X.Y.Z`:
1. Bump `__version__` in `shopdb/__init__.py` to `X.Y.Z`.
2. Bump `version` in `frontend/package.json` to the same `X.Y.Z`.
3. Move the accumulated `## [Unreleased]` notes in `CHANGELOG.md` into a
new `## [X.Y.Z] - YYYY-MM-DD` section, leaving a fresh empty
`## [Unreleased]` above it.
4. If the plugin contract surface changed this release, bump
`__contract_version__` per ADR-002 (independently of `__version__`).
5. Commit, then tag: `git tag -a vX.Y.Z -m "shopdb-flask X.Y.Z"`.
6. Push the commit and the tag.
## Consequences
### Positive
- Adopters have a real release to name in bug reports and a changelog to
read before upgrading.
- ADR-002's pinning story finally has tags to pin to.
- Product and contract can evolve at their own pace without one dragging
the other into a misleading bump.
### Negative / cost
- Two version numbers to keep straight. The comment in
`shopdb/__init__.py` and this ADR exist to keep the distinction clear.
- Release discipline: the changelog and both version constants must be
updated together, or the tagged build misreports itself.
### Neutral
- The internal CI workflow runs the backend tests, the naming/style
gate, and the frontend build on push and PR. It is best-effort: the internal CI
Actions availability on the host is unverified, so the workflow is
config-only until a runner is confirmed.
## Alternatives considered
1. **Reuse `__contract_version__` as the product version.** Conflates two
independent concerns; a docs-only or UI-only release would either
falsely bump the contract or leave the product looking unchanged.
Rejected.
2. **Calendar versioning for the product** (e.g. `2026.07.0`). Easy to
bump but poor at signaling breaking operator-facing changes. Rejected
for the same reasons ADR-002 rejected it for the contract.
3. **No product version, rely on git SHAs.** Opaque to adopters and
unpinnable in any human-meaningful way. Rejected.
## References
- ADR-001 (defines the contract surface)
- ADR-002 (plugin contract versioning; `__contract_version__` bump rules)
- `shopdb/__init__.py` (`__version__`, `__contract_version__`)
- `CHANGELOG.md` (release record)
- `frontend/package.json` (frontend version, kept in lock-step)
- the internal CI workflow (CI gate)

View File

@@ -0,0 +1,131 @@
<!-- docmeta
purpose: explanation
audience: developer
status: current
summary: Why each plugin owns its own Alembic chain instead of adding tables to the core chain.
loadwith: none
-->
# ADR-008: Plugin migration ownership (per-plugin chains from the cutover)
- **Status:** ACCEPTED
- **Date:** 2026-07-10
- **Deciders:** ShopDB maintainers
- **Supersedes:** the "Migration strategy (resolved)" section of ADR-004
## Context
ADR-004 resolved a Phase 7B footgun by folding every bundled plugin's tables
into the single core Alembic chain (migration `7c04_fold_plugin_schema`), and
later core migrations (`7d04`..`7d16`) kept adding plugin schema directly to the
core chain. At the time this was the safe choice: bundled-plugin baselines and
the core baseline had both been creating the same tables, so
`flask plugin upgrade-all` would collide with `flask db upgrade`.
That resolution left the per-plugin Alembic engine
(`shopdb/plugins/migrations.py`, `shopdb/plugins/alembic_template.py`, the
`alembic_version_<plugin>` version tables, and `flask plugin upgrade-all`) fully
built but unused for the bundled plugins. The framework is the product (per the
project's charter); a plugin that cannot own its own schema is not really a
plugin. Sister sites that adopt or fork a plugin need its schema history to
travel with the plugin, not be entangled in the host's core chain. Keeping every
future plugin table change in the core chain also means the core chain grows
without bound and a plugin can never be cleanly removed.
The blocker ADR-004 worried about (double table creation) only exists while a
plugin's own migration tries to CREATE tables the core chain already created.
That is avoidable: history is immutable, so the tables already built by the core
chain stay owned by the core chain; only NEW schema needs a new home.
## Decision
Ownership splits at a fixed cutover, the current core-chain head.
1. **Core chain owns history through its head.** The core Alembic chain
(baseline `68b3947ae14f` .. head `7d16_directoryemployees`) remains
authoritative for every table that exists at the cutover, including the
bundled-plugin tables it created. Those migrations are immutable and are not
rewritten. `flask db upgrade` continues to reproduce the full schema.
2. **Plugin chains own plugin schema going forward.** From the cutover forward,
any change to a plugin's schema lands as
`plugins/<name>/migrations/versions/000N_*.py` in that plugin's own chain,
never in the core chain. The core chain is reserved for core tables.
3. **Every table-owning bundled plugin gets a `0001` anchor.** Each such plugin
carries a migration chain whose first revision is a stamp-only no-op:
`upgrade()` does nothing because the core chain already created the tables.
The anchor exists so the plugin chain has a base that
`flask plugin upgrade-all` can stamp into the per-plugin version table
`alembic_version_<plugin>`. Blueprint-only plugins that own no tables get no
chain.
4. **Deploy and upgrade sequence.** A deploy runs `flask db upgrade`
(core chain, creates everything through the head) then
`flask plugin upgrade-all` (stamps every plugin anchor and applies any later
per-plugin migrations). The same two commands upgrade an existing install;
both are idempotent. The registry (`instance/plugins.json`) tracks each
plugin's applied revisions in `migrations_applied`.
5. **Table-ownership registry.** `PLUGIN_TABLE_OWNERS` in
`shopdb/plugins/alembic_template.py` is the explicit map of which tables each
plugin owns; it is kept in sync with the plugins' `__tablename__` declarations
and pinned by `tests/test_plugin_migrations.py`.
External (out-of-tree) plugins per ADR-003 already shipped their own chains;
this ADR brings the bundled plugins onto the same model, so there is one rule
for all plugins.
## Consequences
### Positive
- A plugin's schema history travels with the plugin. Adopting or forking sites
get the plugin's migrations, not a slice of someone else's core chain.
- The core chain stops accreting plugin schema; it stays about core tables.
- A plugin can be evolved (or, with its own downgrade, removed) independently.
- The long-built per-plugin Alembic engine is finally exercised on every deploy,
so it cannot silently rot.
### Negative / cost
- Two migrate commands per deploy instead of one. Documented in `docs/DEPLOY.md`
and `docs/UPGRADE.md`; both are idempotent so the cost is one extra safe call.
- A plugin author must now put new tables in the plugin chain and register them
in `PLUGIN_TABLE_OWNERS`, rather than autogenerating into the core chain. The
`flask plugin new` guidance and this ADR spell that out.
- The cutover is a discontinuity: tables created before it are core-owned,
tables created after it are plugin-owned. The line is the core-chain head at
this ADR's date, recorded here so it is unambiguous.
### Neutral
- No schema changes and no data migration: the anchors are no-ops. A fresh
install and an existing install converge to the same state.
## Alternatives considered
1. **Keep everything in the core chain (status quo per ADR-004).** Simplest
operationally but defeats the plugin-as-product goal: plugin schema cannot
travel, the core chain grows without bound, and plugins can never be cleanly
removed. Rejected.
2. **Rewrite history so plugin tables move out of the core chain into plugin
`0001` CREATE migrations.** Would make each plugin chain self-contained from
empty, but breaks the immutability rule, forces every existing site to
re-run a rewritten chain, and re-introduces the exact double-creation footgun
ADR-004 fixed. Rejected.
3. **Anchor that CREATEs tables with `IF NOT EXISTS` guards.** Lets a from-empty
install build plugin tables from the plugin chain, but then two chains both
claim the same tables and drift can diverge silently. The no-op anchor keeps
a single authoritative creator (the core chain) for cutover-era tables.
Rejected.
## References
- ADR-003 (plugin distribution; external plugins already ship chains)
- ADR-004 (deployment topology; this ADR supersedes its migration-strategy note)
- `shopdb/plugins/alembic_template.py` (`PLUGIN_TABLE_OWNERS`, shared env runner)
- `shopdb/plugins/migrations.py`, `shopdb/plugins/cli.py` (`upgrade-all`)
- `plugins/<name>/migrations/` (per-plugin chains and `0001` anchors)
- `tests/test_plugin_migrations.py` (ownership + chain + idempotency guards)
- `docs/DEPLOY.md`, `docs/UPGRADE.md`, `docs/PLUGINS.md` (deploy sequence)

View File

@@ -0,0 +1,156 @@
<!-- docmeta
purpose: explanation
audience: developer
status: current
summary: Why the SPA gates plugin routes, so a disabled plugin URL redirects instead of breaking.
loadwith: none
-->
# ADR-009: Frontend plugin gating
- **Status:** ACCEPTED
- **Date:** 2026-07-10
- **Deciders:** ShopDB maintainers
- **Supersedes:** none
## Context
The backend plugin system (ADR-002, ADR-003) lets an operator disable a
plugin. Disabling it unregisters the plugin's API blueprint, drops its
rows from global search (see `test_search_disabled`), and removes its
entry from `get_navigation_items()`, so the disabled feature's sidebar
link disappears.
The frontend told a different story. Every plugin's Vue routes and views
ship inside the core bundle. `frontend/src/router/index.js` auto-discovers
them with `import.meta.glob('./routes/*.js')`, so a route like `/usb` or
`/printers/3` is registered regardless of whether the owning backend
plugin is enabled. A user who typed the URL, followed a stale bookmark,
or clicked a cross-link reached a page whose API calls all 404, landing
on a broken shell instead of a clean redirect. The navigation was already
data-driven; direct-URL reachability was the gap.
There is no frontend plugin system yet. The product is packaged as one
core Flask app plus backend-only plugins; the Vue app is monolithic and
build-time static. Any gating has to live in core frontend code because
that is the only place the plugin routes exist.
## Decision
### Step 1 (this ADR, implemented): route-level gating
Plugin-owned frontend routes are gated against the backend's enabled-plugin
list. This is the whole of what ships now.
1. **Enabled-list endpoint.** A new `GET /api/plugins/enabled`
(`jwt_required(optional=True)`) returns a flat JSON array of enabled
plugin name strings and nothing else. It is a cheap registry read
(`registry.get_enabled_plugins()`), no database access. Exposing it to
anonymous callers is safe because `GET /api/dashboard/navigation`
already leaks the same enabled/disabled signal, and unauthenticated
kiosk routes (`/tv`) need the answer too. It carries no metadata, so it
reveals strictly less than the equally-anonymous but metadata-carrying
`GET /api/plugins` (also `jwt_required(optional=True)`; only the
`PUT /api/plugins/<name>` toggle is admin-gated).
2. **Route tagging.** Every plugin-owned route carries `meta.plugin =
'<pluginname>'`. This covers the per-plugin route modules
(`routes/computers.js`, `routes/equipment.js`, ...) and the
plugin-owned routes that physically live in core files: the
PC-relationships and toner reports, the slide manager and `/tv`
dashboard (slides), the printer-QR and USB-label print pages, and the
employee-detail page. Genuinely core routes (dashboard, search, map,
applications, reference-data settings) stay untagged and are never
gated.
3. **Cached fetch, fail-open.** A composable
(`composables/enabledPlugins.js`) fetches the list exactly once behind
a cached promise. If the fetch fails or returns a non-array, the code
fails **open**: every plugin is treated as enabled. A transient API
error must never brick navigation. The cost is that a disabled
plugin's page is briefly reachable during an outage, which is
acceptable because its API calls would fail anyway and the next
successful fetch closes the gap.
4. **Router guard.** `router.beforeEach` awaits the cached fetch when the
target route has `meta.plugin`. If that plugin is not enabled it
redirects to `/` and raises an info toast. The endpoint is
jwt-optional, so the guard works for both authenticated pages and the
unauthenticated `/tv` kiosk route.
This is intentionally a thin layer. It does not change the plugin
contract surface, so `__contract_version__` does not move: adding a core
HTTP endpoint and tagging core-shipped routes are not plugin-contract
changes. Plugins still ship no frontend code of their own.
### Non-goals for step 1
- No build-time or runtime loading of plugin-authored Vue code.
- No per-permission or per-role route gating (that stays with the
existing `requiresAuth` / `requiresAdmin` meta flags).
- No removal of disabled routes from the route table; they remain
registered and are intercepted by the guard. Keeping them registered
avoids a rebuild when a plugin is toggled and keeps the redirect path
simple.
## Future direction (PROPOSED, not implemented)
Step 1 gates routes that core already owns. The longer-term goal is a
real frontend-plugin contract where a plugin ships its own frontend and
core discovers it, mirroring the backend model. Sketch:
1. **Plugin-owned frontend tree.** Each plugin gains a
`plugins/<name>/frontend/` directory holding its route module, views,
and any plugin-specific components. Core stops carrying `views/usb`,
`views/printers`, and so on.
2. **Build-time discovery.** The Vite build discovers plugin frontends
with a glob over `plugins/*/frontend/routes.js` (analogous to today's
`import.meta.glob('./routes/*.js')`), so a plugin's presence in the
tree is what puts its routes in the bundle. Combined with the step-1
enabled-list gate, a plugin that is absent from the build ships no
code and a plugin that is present-but-disabled is route-gated at
runtime.
3. **Shared component + registration contract.** Plugins register into
named extension points instead of editing core files: an `iconMap`
registration for nav/asset icons, asset-detail panels, map-marker
renderers, and search-result renderers (the "Frontend hook contract"
already listed as deferred in docs/ROADMAP.md). Core exposes a
stable set of shared components (form controls, detail-page shells,
table primitives) as the plugin frontend's only allowed core imports,
the frontend analogue of the `shopdb.api` namespace.
4. **Versioned frontend contract.** The shared-component and
registration surface would be versioned the same way the backend
contract is (ADR-002), so a plugin frontend can declare the core
frontend range it needs.
### Tradeoffs of the future direction
- **Pro:** true plugin self-containment; a site can drop in or remove a
plugin (frontend and backend together) without patching core; smaller
core; clearer ownership.
- **Con:** significant build-system work (per-plugin Vite entry
discovery, code-splitting, dev-server HMR across the plugin tree); a
new versioned frontend contract to maintain and document; a migration
that moves ten plugins' worth of views out of core; risk of a leaky
shared-component surface becoming an accidental contract. The payoff
only matters once external (out-of-tree) plugins with their own
frontends are a real requirement. Until then, step 1's route gating
delivers the user-visible correctness (no reachable dead pages) at a
fraction of the cost.
## Consequences
- Disabling a backend plugin now makes its frontend routes redirect to
the dashboard instead of loading a broken shell. Behavior matches the
already-dynamic navigation.
- One extra lightweight request at app boot (`GET /api/plugins/enabled`),
cached for the session.
- Fail-open means gating is a UX guardrail, not a security control. It is
not a substitute for backend authorization: the API still enforces auth
and the disabled plugin's endpoints are simply unregistered. Never rely
on route gating to protect data.
- The future frontend-plugin contract remains open work; this ADR records
the direction and its cost so a later decision can pick it up.

View File

@@ -0,0 +1,326 @@
<!-- docmeta
purpose: explanation
audience: developer
status: current
summary: The hook contract a plugin uses to contribute UI without hand-editing core Vue files.
loadwith: none
-->
# ADR-010: Frontend plugin hook contract
- **Status:** ACCEPTED
- **Date:** 2026-07-11
- **Accepted:** 2026-07-11
- **Deciders:** ShopDB maintainers
- **Supersedes:** none
## Context
The backend plugin contract is settled (ADR-002 surface, ADR-006 collector,
ADR-008 migrations, ADR-009 route gating). The one undefined piece before 1.0 is
the frontend: how a plugin contributes UI without hand-editing core Vue files.
CONTRACT-STABILITY.md names this the single biggest churn item ("no server-side
hook for asset-detail panels, map markers, or search-result rendering; a plugin
that needs custom UI still hand-edits the Vue frontend").
The `measuringtools` plugin (bundled 2026-07-11, whose construction is narrated
in docs/PLUGIN-GUIDE.md) is the fresh evidence. Integrating it needed exactly
**two hand edits to core frontend files**:
1. `frontend/src/api/index.js` - an api-client block appended after
`warrantyApi` (PLUGIN-GUIDE.md section 9). The scaffolder now emits a
paste-in snippet, but it is still a hand edit to a shared, churn-heavy file.
2. `frontend/src/views/settings/settingsNav.js` - a card entry in the hardcoded
`settingsGroups` catalog (the "Measuring Tools" group, ~line 51), consumed by
`SettingsLayout` (left rail) and `SettingsIndex` (landing overview).
Everything else integrated with **zero** core edits, because a mechanism already
existed for it:
| Capability | Existing mechanism |
|---|---|
| Views / routes | Auto-discovery: `frontend/src/router/index.js` globs `router/routes/*.js` via `import.meta.glob`; a route starting `settings/` auto-nests under the settings shell. |
| Disabled-plugin gating | `meta: { plugin: '<name>' }` per ADR-009; router guard redirects when the backend plugin is off. |
| Sidebar / dashboard / reports | Declarative backend hooks rendered by core: `get_navigation_items`, `get_dashboard_widgets`, `get_reports` on `BasePlugin` (shopdb/plugins/base.py), merged by `shopdb/core/api/dashboard.py` and `shopdb/core/api/reports.py`. |
| Map placement | Data-driven off asset types + resolved positions (ADR-001); a typed, positioned asset appears on the map with its type color and no plugin-side map code. |
Capabilities a plugin **cannot have at all today** - no hook exists, and adding
one would mean forking a core view:
3. **Asset-detail extension panels.** A warranty-coverage section on a PC or
printer detail page, a calibration-status card on a measuring tool. Today
`WarrantyPanel.vue` is composed in by hand-editing each detail view.
4. **Map marker / overlay contributions.** A calibration-due badge on the
shop-floor map (`frontend/src/views/MapView.vue`). The map draws type
colors but has no plugin decoration path.
5. **Search-result rendering / routing for plugin asset types.** Global search
returns assets, but core hardcodes how each type renders and where its detail
link points; a plugin asset type has no way to declare its icon or route.
There is also an unsolved distribution wrinkle from ADR-003: external plugins
symlink into `plugins/<name>/` backend-only, but any frontend file they carry
must be physically copied into `frontend/src/`, because Vite compiles the tree
at build time and cannot reach outside it.
## Options considered
### A. Runtime dynamic component registration
Plugins ship real Vue components that core loads and mounts at runtime into
named extension points (an `iconMap` registration, a panel registry, a marker
renderer registry). This is the richest model and the one ADR-009's "Future
direction" sketched.
- Pro: a plugin can render anything; no core generic-renderer ceiling.
- Con: requires runtime module loading of plugin-authored code (dynamic import
of built chunks), a versioned shared-component surface that becomes an
accidental contract the moment it leaks, and it does nothing about the
build-time-only reach of Vite for external plugins. It is the most code for
the least near-term payoff. ADR-009 already priced this and deferred it.
### B. Declarative data-only hooks rendered by core generic components
Plugins return plain dicts from new `BasePlugin` hooks; a generic core component
renders them. This is the exact precedent already proven three times:
`get_navigation_items`, `get_dashboard_widgets`, and `get_reports` (0.6.0) all
return dicts that a core consumer endpoint merges and a core Vue component
renders. No plugin ships frontend code.
- Pro: additive, minor-bump changes under ADR-002; identical access pattern to
the existing consumers (skip disabled, fail-loud in dev, isolate in prod);
works untouched for external symlink-only plugins, because the data crosses
the wire and core owns the renderer.
- Con: bounded to what a generic renderer can draw. A panel that needs a bespoke
chart or a custom-interaction map overlay does not fit.
### C. Build-time file-convention discovery
Extend the `import.meta.glob` precedent from `./routes/*.js` to a plugin-owned
frontend tree (`plugins/*/frontend/`), so a plugin's real components (full
list/detail/form views, its api-client module) live with the plugin and the
build picks them up by convention. This is ADR-009 "Future direction" steps 1-2.
- Pro: true self-containment for genuine components; the natural home for the
api-client block (friction 1) and full views.
- Con: significant build-system work (per-plugin Vite entry discovery,
code-splitting, dev-server HMR across the tree) and it does not by itself
solve the external-plugin wrinkle - a symlinked out-of-tree `frontend/` is
still outside Vite's compiled root. Only pays off once out-of-tree plugins
with frontends are a real requirement.
### Hybrid
The evidence splits cleanly. The five friction points fall into two buckets:
surfaces where a generic renderer fed by plugin data is sufficient (2, 3, 4, 5),
and surfaces where a real component is genuinely unavoidable (full views, and
the api-client module in friction 1). Option B fits the first bucket exactly and
is cheap and additive. Option C is the right long-term answer for the second but
is expensive and, per ADR-009, gated on external-plugin demand. Option A buys
nothing B does not, at the highest cost. The decision is B now, C deferred, A
not pursued.
## Decision
**DECISION:** adopt a hybrid. Add **data-only declarative hooks** (Option B) for
the four presentation surfaces a generic core renderer can serve, and keep
**file-convention glob discovery** (Option C) as the deferred mechanism for the
residual cases where a real component is unavoidable. Do not pursue runtime
component registration (Option A).
### New hooks (Option B, data-only, additive)
Each is a new optional `BasePlugin` method returning a list of dicts, merged by
a core consumer endpoint using the same pattern as `get_reports` (inject
`plugin` name, skip disabled, re-raise in dev/test, log-and-isolate in prod),
and rendered by a generic core component. Icon values are string keys mapped to
Lucide components core-side, exactly like `get_navigation_items`.
**`get_settings_cards`** - resolves friction point 2 (settingsNav.js hand edit).
```
{
'group': 'Measuring Tools', # rail group title (created if new)
'to': '/settings/measuringtooltypes',
'icon': 'ruler', # string key, mapped core-side
'title': 'Measuring Tool Types',
'description': 'Manage measuring-tool subtypes + map colors',
'position': 22, # order within the group
}
```
Consumer: a new `GET /api/settings/cards` merges enabled plugins' cards into the
core `settingsGroups` catalog; `SettingsLayout` and `SettingsIndex` read the
merged catalog instead of the hardcoded JS array. The catalog's own core groups
stay in `settingsNav.js`; plugin groups are appended.
**`get_asset_panels`** - resolves friction point 3 (asset-detail panels).
```
{
'id': 'calibration',
'title': 'Calibration',
'assettypes': ['measuring_tool'], # detail pages it appears on; ['*'] = all
'endpoint': '/api/measuringtools/{assetid}/calibration-panel',
'render': 'keyvalue', # 'keyvalue' | 'table' | 'badge'
'position': 20,
}
```
Consumer: `GET /api/assets/{assetid}/panels` returns the panels whose
`assettypes` match that asset's type; a generic `AssetPanel` component on the
detail page fetches each `endpoint` and renders it in the declared style. This
covers the warranty-coverage and calibration-status cases. A panel that needs
bespoke UI (a chart) is out of scope for the data-only hook and falls to the
deferred component mechanism below - stated honestly, not hidden.
**`get_map_overlays`** - resolves friction point 4 (map marker/overlay badges).
```
{
'id': 'calibration-due',
'label': 'Calibration due', # legend label
'endpoint': '/api/measuringtools/map-overlay', # -> [{assetid, color, label}]
'style': 'badge', # 'badge' | 'ring'
'legend': True,
}
```
Consumer: `MapView.vue` fetches enabled plugins' overlay endpoints and decorates
the already-placed markers; legend entries append to the existing legend. The
map stays data-driven; plugins add decoration data, not map code.
**`get_asset_presentation`** - resolves friction point 5 (search rendering /
routing) and, as a bonus, removes the `AppLayout.vue` iconMap hand edit called
out in PLUGIN-GUIDE.md section 9.
```
{
'assettype': 'measuring_tool', # AssetType.assettype key the plugin owns
'icon': 'ruler',
'label': 'Measuring Tool',
'route': '/measuringtools/{assetid}', # detail-route pattern
}
```
Consumer: `GET /api/assets/presentation` returns the type-to-presentation map;
global-search result rows and any asset cross-link use it to pick the icon and
build the detail link, so core never hardcodes a plugin's route or icon.
### Friction map
| Friction point | Mechanism | Hook / change |
|---|---|---|
| 1. api-client block in api/index.js | C (deferred) | plugin-owned `frontend/` tree, glob-discovered; scaffolder snippet is the near-term mitigation |
| 2. settingsNav.js card entry | B (now) | `get_settings_cards` |
| 3. asset-detail panels | B (now) | `get_asset_panels` (bespoke panels -> C, deferred) |
| 4. map markers / overlays | B (now) | `get_map_overlays` (bespoke overlays -> C, deferred) |
| 5. search-result rendering / routing | B (now) | `get_asset_presentation` |
| views (full list/detail/form) | C (deferred) | already glob-discovered under core `routes/*.js`; long-term move to `plugins/*/frontend/` per ADR-009 |
### Deferred: file-convention frontend tree (Option C)
Extending `import.meta.glob` from `./routes/*.js` to `plugins/*/frontend/` (so
views and the api-client module live with the plugin) is ADR-009 "Future
direction" steps 1-2. It is deferred for the same reasons ADR-009 gave: it is
build-system-heavy and the payoff only lands once out-of-tree plugins with their
own frontends are a real requirement. Until then, friction 1 stays mitigated by
the scaffolder snippet, and full views keep shipping in the core bundle and
gated by ADR-009 `meta.plugin`.
This is also the honest limit on the external-plugin wrinkle. The four data-only
hooks need **zero** frontend files from a plugin, so an external symlink-only
(backend) plugin gets settings cards, detail panels, map overlays, and search
presentation with no copy-into-`frontend/src/` step at all. The wrinkle survives
only for the residual component-backed cases, which is exactly the deferred
Option C work; pushing external-repo frontend distribution to that later ADR
matches ADR-003's posture of deferring out-of-tree packaging until two sites run
their own plugins.
### Contract-version impact (ADR-002)
Each of the four hooks is a new optional `BasePlugin` method - an additive
change, so a **minor** bump per ADR-002, the same classification `get_reports`
took at 0.6.0. Landing all four together is a single minor bump (proposed
**0.7.0**); landing them incrementally is one minor bump each. Adding the core
consumer endpoints and the generic renderer components is core-internal and does
not itself move `__contract_version__`. The deferred Option C introduces a
separate, versioned **frontend** contract (ADR-009 step 4), tracked apart from
the backend `__contract_version__`; it is not part of this proposal's bumps.
## Consequences
### Positive
- Closes the CONTRACT-STABILITY.md "single biggest gap" for the four surfaces a
generic renderer can serve, using the already-proven declarative pattern - low
risk, low cost, additive-only.
- External symlink-only plugins get four presentation surfaces with no
copy-into-core step, shrinking (not yet eliminating) the ADR-003 wrinkle.
- Removes three of the two-plus hand edits the exemplar needed (settingsNav
card, plus the AppLayout iconMap edit from section 9), moving them to data.
- Docs-drift guard forces documentation: `tests/test_docs_contract.py` fails if
any new public `BasePlugin` hook is missing from `docs/PLUGIN-HOOKS.md`, so the
hooks cannot ship undocumented.
### Negative
- Four more hooks and four more core consumer endpoints to maintain, each with
the skip-disabled / fail-loud-in-dev / isolate-in-prod discipline.
- The generic renderers set a ceiling: bespoke panels and overlays still have no
home until Option C lands, so the contract is honest-but-partial, not total.
- One extra request per surface on the pages that use it (a detail page fetches
its panels; the map fetches overlays), on top of ADR-009's enabled-list fetch.
### Neutral
- Bundled plugins keep their current in-core views; migrating them to the hooks
is opt-in and incremental (see Adoption plan), not a flag-day rewrite.
- `meta.plugin` route gating (ADR-009) is unchanged and still gates the views;
these hooks add presentation, not routing.
- Contract tests (`tests/test_plugin_contract.py`) already assert every public
hook is exercised; the new hooks slot into that harness.
## Adoption plan
Prove the contract on bundled plugins before declaring it settled for sister
sites.
1. **`get_asset_panels` first, via `warranty`.** Warranty is asset-general and
already composes `WarrantyPanel.vue` onto multiple detail pages by hand, with
a clean per-asset endpoint behind it. Migrating it to a declarative
`get_asset_panels` entry rendered by the generic `AssetPanel` is the
lowest-risk proof and immediately removes hand edits from every detail view
that shows warranty. This is the recommended first migration.
2. **`get_settings_cards`, `get_map_overlays`, `get_asset_presentation` via
`measuringtools`.** The guide exemplar already needs a settings card, a
calibration-due map badge, and search routing for its `measuring_tool` type,
so it exercises all three at once and its PLUGIN-GUIDE.md walkthrough becomes
the reference for the new hooks.
3. Only after both plugins run on the hooks in a real build: bump
`__contract_version__` to 0.7.0, document the hooks in
`docs/PLUGIN-HOOKS.md`, and mark this ADR ACCEPTED.
## Open questions
- Should `get_asset_panels` `render` styles stay a small closed set
(`keyvalue` / `table` / `badge`), or grow? A closed set keeps the renderer
generic; growth pressure is the signal that a case actually needs Option C.
- Should the four consumer endpoints collapse into one bundled
`GET /api/plugins/frontend-contributions` call to save round-trips, or stay
separate per surface for cache locality? Defer until the per-surface request
cost is measured.
- When Option C lands, does the api-client module move under
`plugins/*/frontend/` or get replaced entirely by a generated client from the
backend blueprint? Out of scope here; belongs to the ADR-009 frontend-contract
follow-up.
## References
- ADR-001 (asset model as the map/search data source)
- ADR-002 (bump classification for the new hooks)
- ADR-003 (external-plugin distribution posture)
- ADR-009 (route gating; "Future direction" is the deferred Option C)
- docs/PLUGIN-GUIDE.md sections 9-10 (the measuringtools frontend hand edits)
- docs/CONTRACT-STABILITY.md (the expected-churn line this ADR answers)
- shopdb/plugins/base.py (existing declarative hooks this pattern extends)
- shopdb/core/api/dashboard.py, shopdb/core/api/reports.py (consumer precedent)

View File

@@ -0,0 +1,75 @@
<!-- docmeta
purpose: explanation
audience: developer
status: current
summary: Why the equipment domain was renamed to machines and machinetypes became modeltypes.
loadwith: none
-->
# ADR-011: Rename the equipment domain to machines; retype the models catalog with modeltypes
- **Status:** ACCEPTED
- **Date:** 2026-07-11
- **Deciders:** ShopDB maintainers
- **Relates to:** ADR-005 (equipment vs measuringtools scope), ADR-008 (plugin migration ownership)
## Context
ADR-005 split the old "equipment" concept: gage-lab instruments moved to the
measuringtools plugin, leaving the equipment plugin holding exactly one thing -
shop-floor machines (lathes, mills, CMMs, grinders, furnaces). The UI, the
routes (`/machines`), the frontend view folder (`views/machines/`), and even
the data vocabulary (asset numbers headed "MACHINE #") already said machines.
Only the code identity still said equipment: the plugin name, the API prefix,
the `equipment`/`equipmenttypes` tables, and the `assettype='equipment'` data
value. Every future contributor would pay a small permanent translation tax.
A display-label-only swap was shipped first (2026-07-11) and immediately showed
the dissonance: UI Machines over code equipment forever.
Separately, the legacy `machinetypes` table survived the Machine-model
retirement for one real reason: it types the vendor **models catalog**
(`models.machinetypeid`, 95 of 127 models) with fine-grained, cross-category
types (Lathe, Switch, Laser Printer). Its name was a misnomer - it never typed
machines specifically - and it squatted on the name the machines plugin's
subtype table wants.
The economics favor doing this now and never again: the plugin contract is
pre-1.0 (breaking changes are cheap per ADR-002), no sister site has adopted
yet, there is no production instance, and the test suite plus CI give the
strongest safety net the project has had.
## Decision
Rename the domain in one coordinated pass:
| Current | New | Rationale |
|---|---|---|
| `plugins/equipment/`, `/api/equipment`, `equipment.*` perms | `plugins/machines/`, `/api/machines`, `machines.*` | plugin identity matches the domain |
| `equipment` table, `equipmentid` | `machines`, `machineid` | main entity |
| `equipmenttypes`, `equipmenttypeid`, `equipmenttype` | `machinetypes`, `machinetypeid`, `machinetype` | subtypes take the freed name |
| legacy `machinetypes`, `models.machinetypeid` | `modeltypes`, `models.modeltypeid` | role-accurate: it types the models catalog, losslessly (no collapse to the 4 asset types) |
| `assettypes.assettype = 'equipment'` (data) | `'machine'` | the string compare sites flip with it |
Migration placement follows ADR-008: the modeltypes rename and the data flips
land in the core chain (core tables, core data); the plugin table renames land
in the machines plugin's own chain, idempotently guarded so fresh installs
(whose core chain still creates the old names at `7c04`) rename on first
`flask plugin upgrade-all`. The `alembic_version_equipment` version table is
renamed to `alembic_version_machines` by the core migration so the plugin chain
resumes seamlessly on existing installs. The plugin registry auto-migrates an
`equipment` key to `machines` on load.
Historical migrations and accepted ADRs are not rewritten; old ids and ADR-005
wording remain as history.
## Consequences
- Upgrading instances run `flask db upgrade` then `flask plugin upgrade-all`,
same as every deploy; both migrations are idempotent.
- `/api/equipment` and `equipmentApi` cease to exist - a breaking change made
while there is nobody to break. After contract 1.0 this class of rename
would be a major-version event; this is the last cheap moment.
- The "Machine Types" settings card becomes "Model Types" and finally says
what it does; the machines plugin's subtype card owns the machine-types name.
- Contributors stop translating between UI vocabulary and code vocabulary.

View File

@@ -0,0 +1,131 @@
<!-- docmeta
purpose: explanation
audience: developer, adopter
status: current
summary: Why ShopDB owns GE-Enforce manifests as data while the enforcement engine stays external.
loadwith: none
-->
# ADR-012: GE-Enforce manifest ownership in shopdb
- **Status:** ACCEPTED
- **Date:** 2026-07-13
- **Deciders:** ShopDB maintainers
- **Relates to:** ADR-002 (plugin contract versioning), ADR-004 (per-site
deployment), ADR-006 (collector contract), ADR-008 (per-plugin Alembic chains)
## Context
GE-Enforce is a desired-state enforcement system for shopfloor PCs: a PowerShell
engine (`Install-FromManifest.ps1`) reads per-PC-type `manifest.json` files off
an SMB share every logon and installs / self-heals what they declare. Authoring
those manifests today means hand-editing JSON on a file share, and there is no
central view of what each PC actually did.
We want shopdb to own the manifests as data (author, version, publish, roll
back) and to observe fleet compliance, while NOT taking on the GE-Enforce engine
itself (which is the GE-Enforce framework's, maintained separately) and NOT
dictating any site's imaging path (per ADR-004, each site is single-tenant with
its own provisioning - PXE at the reference site, OOBE provisioning packages at
others).
The manifests are an enforcement PROGRAM, not an application inventory: entry
`Type` is not an app/config discriminator and entry `Name` is a manifest label,
not a Windows ARP DisplayName. Any design that treats them as an app catalog is
wrong.
## Decision
Build a bundled `geenforce` plugin that owns the manifest as shopdb data, with a
client kit and a deployment bootstrap. Specifically:
1. **Data model.** One wide `manifestentries` table with an `entrytype`
discriminator and nullable per-type columns (not SQLAlchemy STI, not a JSON
blob - the fleet is ~64 entries, so sparse columns are free and stay
queryable). Scopes are `manifestscopes`, unique on `(scopename, phase)`;
runtime is per-pctype scopes, preinstall is one flat scope. Multi-value gates
(PCTypes / hostnames / machine numbers) and the nested InUseCheck are child
tables. `sortorder` is the execution-order contract. RegValue is stored as a
raw JSON literal so DWord-vs-string typing survives. Per-plugin Alembic chain
(ADR-008).
2. **Published snapshots.** Editing touches a DRAFT only. Publish freezes the
rendered JSON document into an immutable `manifestpublishedversions` row; the
client is ALWAYS served the current published snapshot, never the draft;
rollback flips `iscurrent` to an older version. Freezing the document (not
row-mirroring) makes immutability structural.
3. **Behavioral-parity gate, not byte-identity.** A DB-free harness
(`parity.py`) imports each real manifest and renders it back, then proves
BEHAVIORAL equivalence (same ordered entries with identical detection /
targeting, and the same entries fire across machine-profile fixtures) - never
byte equality, which re-serialization would never satisfy. This gates any
build that touches the model.
4. **Filter mirror; engine is the single source of truth.** `filters.py`
mirrors the engine's four gate functions and alias graph for the "what would
this PC get" simulator and parity. The engine lib stays authoritative;
shopdb mirrors it (never the reverse). PCTypesStrict is honored only for the
preinstall phase, matching the runners.
5. **Payload integrity is separate from detection.** For `http`/`inline`
payloads a dedicated `payloadsha256` is verified before running - independent
of `DetectionMethod` (DetectionValue is a hash only for `Hash` detection).
`smb` payloads keep the share ACL as their trust boundary. Large binaries
stay on SMB; small config/scripts may move to http/inline later.
6. **Observed-state reporting.** Each PC POSTs its enforcement result;
`manifestenforcementreports` (+ results) records the applied version
(received-latest) and per-entry self-heal / failure. Status derives from
explicit self-heal flags only, never the raw installed count (Always/no-
detection scripts install every cycle without being drift corrections).
7. **Service-token auth.** Client endpoints authorize via managed service
tokens scoped `geenforce.fetch` / `geenforce.report`, through a new
`service_token_authorized(scope)` on the `shopdb.api` contract surface
(contract 0.11.0). Admin CRUD uses `geenforce.manage` / `geenforce.publish`.
8. **Client + deployment, engine referenced not vendored.** shopdb ships the
fetch/report kit (`plugins/geenforce/client/`) and a site-neutral bootstrap
(`Install-GEEnforce.ps1`) that provisions a PC's identity
(`C:\Enrollment\pc-type.txt` etc. - what determines the PC type; there is no
auto-detection, the provisioner supplies it), the shopdb registry config, and
the scheduled task. The GE-Enforce ENGINE is referenced (`-EngineSource`),
not carried by shopdb. Deployment is provisioning-path independent (PXE step,
OOBE ppkg, Intune, manual); the runtime task is fail-safe.
9. **Milestone 1 = export to share; staged cutover.** Until a site cuts its
client over to shopdb-sourced manifests, the plugin publishes and EXPORTS the
manifest to the share (with a `_meta/history` backup, atomic write); the
unchanged engine picks it up. Cutover is staged: shadow mode (fetch from
shopdb AND read the share, log diffs, install from share) then read cutover.
10. **No application auto-seeding.** The core Applications catalog already
tracks these apps (from the classic-shopdb migration) with version
histories; auto-creating Applications from manifest labels produced
duplicates and misclassified config drops. Application linkage, if wanted, is
a curated manifest-entry -> existing-Application link, not label scraping.
## Consequences
- **Positive.** Manifests become validated, versioned, publishable data with
one-click rollback and a fleet-compliance view; desired-state and observed-
state live in one system. The parity gate + published snapshots + separate
payload hash make a fleet-wide-SYSTEM system safe to author. The plugin is
provisioning-agnostic, so any GE Aerospace site can adopt it regardless of
imaging path. Validated end to end: parity green against the real manifests,
and the client kit + installer proven on a Windows VM (PS 5.1) and Linux
pwsh 7.
- **Boundaries / risks.** The engine remains the GE-Enforce framework's, so
shopdb's parity mirror must be kept in sync with the lib (guarded by the parity
fixtures; the plugin pins lib >= 2.6 for `_CmmVersion`). Provisioning writes
the PC identity - shopdb cannot set a PC's type at imaging (a PC is unknown
until it enrolls and reports). Manifest-label vs ARP-name mismatch means the
catalog link, when built, needs a curated alias layer.
- **Deferred.** Desired-vs-observed per-entry compliance (needs a collector
installedVersions field); curated manifest-entry -> Application linking; the
live client cutover (a site operational decision); inline payload upload.
See `docs/proposals/ge-enforce-plugin.md` (design + cutover), `docs/GE-ENFORCE.md`
(concepts + imaging timeline), `docs/GE-ENFORCE-CLIENT.md` (fetch/report
contract), and `docs/GE-ENFORCE-DEPLOY.md` (agent deployment).

View File

@@ -0,0 +1,421 @@
<!-- docmeta
purpose: explanation
audience: developer, adopter
status: current
summary: How a site gets only the plugins it chose, and why curation plus signing is the security model.
loadwith: none
-->
# ADR-013: Plugin Catalog, Curated Shelf, and Lean Per-Site Builds
- Status: ACCEPTED
- Date: 2026-07-18
- Deciders: ShopDB maintainers
- Relates to: ADR-002 (contract versioning), ADR-003 (plugin distribution), ADR-004 (per-site instances), ADR-008 (per-plugin migrations), ADR-009 (frontend plugin gating), ADR-010 (frontend hook contract)
## Context
Every site today ships identical code. The backend image contains all 13 plugin
directories (Dockerfile COPY at line 55; the header comment listing "eleven core
plugins" is stale), and the SPA compiles every plugin's routes and views via a
static glob (frontend/src/router/index.js:11) plus hardcoded imports. A site's
"chosen set" exists only as runtime enable flags in instance/plugins.json.
Disabled is not absent: a site that never wants printedparts/usb/network still
ships, and can execute, that code.
Distribution per ADR-003 is "drop a directory into plugins/ by hand". There is
no artifact format, no signing, no catalog, no validate gate, and the loader
trusts whatever it finds on disk (loader.py:61-73 discovers any folder with a
plugin.py; loader.py:185-224 loads it; migrations.py:29-62 runs its DDL with
full DB privileges). Plugins run in-process with the full shopdb.api surface
including db, so the only tenable security model on air-gapped GE networks is
curation plus cryptographic provenance, enforced everywhere code can execute,
not sandboxing.
Known defects this ADR also resolves:
- upgrade_all_plugins checks hasattr(registry, 'list_installed') which never
exists (registry has only get_all/get_enabled_plugins), so it always falls
back to migrating every folder on disk, adopted or not (__init__.py:94).
- PILOT-DEPLOY.md enables plugins that were never installed; enable refuses.
There is no declarative "apply this chosen set" operation.
- Reverse-dependency checks on uninstall/disable read only LOADED plugin
instances, so an installed-but-unloaded dependent is invisible.
- Dependency install/enable is check-only; nothing computes a closure, and
install-a-dependent can fail at its own load step because the dependency was
installed disabled (default_enabled=false on employees).
- The dependency sort has no cycle detection.
- Soft couplings (geenforce -> computers, notifications -> employees) are
invisible to the manifest graph.
Frontend reality check (this drove the design below): plugin UI is NOT one
folder per plugin. Routes live in routes/<plugin>.js (slides has none), inside
the shared core.js (computers report, printers toner report, employees detail,
slides settings), and as six hardcoded top-level imports in index.js itself
(/parts-kiosk, /tv, /print/printer-qr x2, /print/usb-labels,
/print/printedparts-labels). View dirs mismatch plugin names (computers ->
views/pcs). Plugin settings cards sit in shared views/settings/
(DellWarrantySettings, ZabbixSettings, SlideManager, EmployeeDirectory,
MeasuringToolTypesList, PrintedPartsSettings), plugin print views in shared
views/print/, and some views span plugins (AssetLabel.vue serves five asset
types; PCDetail imports WarrantyPanel). Any lean-frontend design that only
moves routes/*.js and views/<plugin>/ fails the build the moment a plugin is
pruned. This ADR scopes that work honestly instead of calling it mechanical.
## Decision
### 1. Tiering: mandatory core is the core package; all plugins are catalog-optional
- The mandatory core is the non-plugin shopdb/core/ package (auth, users,
assets, locations, vendors, models, settings, audit, dashboard, search,
reports, plugin management). It already survives every plugin being absent
via hasattr/lazy-import guards. No plugin is promoted into it.
- New optional manifest field `tier: "core" | "optional"`, default "optional".
All 13 existing manifests are unchanged and unchanged in meaning. The
lifecycle gains a guard: uninstall_plugin and disable_plugin refuse a
tier:core plugin (alongside the reverse-dependency checks at
__init__.py:269-278 and :351-360). No plugin ships tier:core initially; the
field and guard exist so a future curation decision is a manifest edit, not a
framework change.
- Per-site mandates live in the site profile (section 5): a `locked` list the
profile applier refuses to remove. This preserves ADR-004 site autonomy: a
wing site can mandate usb without the framework mandating it fleet-wide.
- New manifest field `optional_dependencies: []` (names only, loader-ignored).
Declared for the verified soft couplings: geenforce lists computers
(service.py:32-43 loses app-detection gates without it), notifications lists
employees (routes.py:151-171 loses name/photo enrichment). Catalog listing
and adopt WARN on unmet optional deps; nothing blocks.
- Hard `dependencies` gains optional PEP440 ranges ("employees>=1.1").
validate/adopt honor ranges; the runtime loader keeps name-only semantics
(specifier stripped) so no loader behavior changes. The single existing hard
edge printedparts -> employees stays as-is; whether it can relax to optional
(badges.py has an external HR fallback) is a follow-up product question, not
blocked on this ADR.
- Dependency plumbing fixes: _sort_by_dependencies gains cycle detection
(raise PluginDependencyError on a back edge); reverse-dependency checks read
manifests of ALL installed plugins from disk, not loaded instances.
### 2. Packaging: signed, versioned artifacts
Artifact: `<name>-<version>.shopdbplugin` (a zip of the plugin directory:
manifest.json, plugin.py, api/, models/, migrations/, and frontend/ once
section 6 lands) plus two members generated at pack time:
- `PROVENANCE.json`: plugin name, version, publisher id, build timestamp, and
a sorted map of every packaged file path to its SHA-256. PROVENANCE.json is
not listed in its own map, so there is no circular-hash problem and no zip
canonicalization needed; determinism comes from sorted per-file hashes.
- `PROVENANCE.sig`: detached ed25519 signature over the exact PROVENANCE.json
bytes.
New CLI:
- `flask plugin pack <name> --key <path>` (producer side): runs validate on the
directory, then emits the artifact.
- `flask plugin validate <dir|artifact>` (the missing pre-publish gate),
fail-closed pipeline: signature (artifact mode) -> per-file hashes -> manifest
against a new docs/plugin-manifest.schema.json -> name == directory ->
core_version parses as a specifier and admits the target contract version ->
static import-surface scan reusing tests/test_plugin_contract.py logic ->
alembic versions parse. The schema types the known fields (name, version,
description, dependencies, optional_dependencies, tier, core_version,
api_prefix, display_name, default_enabled, provides, settings) and PERMITS
additional properties, so all 13 existing manifests pass unmodified.
The import-surface scan is documented as a lint, not a security control; it is
trivially bypassed by dynamic import. The security control is human review
before signing (section 4).
### 3. The shelf: a read-only folder, transport-agnostic by design
- One config knob: `PLUGIN_SHELF_DIR`. The app only ever reads this folder. It
never speaks SharePoint, OneDrive, or any network protocol.
- Transport is explicitly out of scope and explicitly untrusted. On networks
that can reach corporate M365, a SharePoint document library sync populates
the folder. On strictly air-gapped floors where no sync agent can run, the
folder is populated by robocopy/USB. Both are equally supported and equally
untrusted, because every decision-bearing byte is signed: swapping transport
changes nothing about the trust model.
- Layout: `<shelf>/<name>/<name>-<version>.shopdbplugin` plus
`shelf-index.json` and `shelf-index.sig`.
- The index is SIGNED with the same publisher key and carries a monotonically
increasing `serial` plus a `revoked` list of name-version pairs. Each site
records the last-seen serial in instance state and refuses an index with a
lower serial (anti-rollback of the catalog itself). The index also carries
per-entry version/tier/core_version so `flask plugin shelf-list` can display
compatibility without unpacking, but the index is a BROWSE layer only:
adopt reads dependencies, tier, and core_version from the signed manifest
inside the verified artifact, never from the index.
- Trusted keys: `PLUGIN_TRUSTED_KEYS` is a list of pinned public keys delivered
out-of-band in the site's deployed config/image. Keys are NEVER read from the
shelf; a folder that can be written by an attacker must not also carry the
keys that authenticate it. Multiple pinned keys allow overlap rotation.
Revocation of an artifact rides the signed index `revoked` list; a
`flask plugin audit` command warns when an installed version appears there.
- Partial-sync robustness: adopt copies the artifact to a temp location,
verifies signature and every file hash there, then unpacks to
plugins/.staging/<name> and renames into place atomically. OneDrive
placeholder stubs, zero-byte files, or an index referencing not-yet-synced
artifacts all fail closed with a clear "artifact not fully synced/verified"
error.
- `flask plugin adopt <name>[==version]`: resolve version from the shelf,
verify, compute the hard-dependency closure from signed manifests, then for
each closure member in topological order: unpack, INSTALL, and ENABLE (not
install-only; the load gate at loader.py:201-206 checks is_enabled, so an
install-only closure with default_enabled=false deps would fail its own
load). Migrations run via the unchanged per-plugin chain (ADR-008). Refuses
to adopt a version lower than the installed one unless
`--force-downgrade` is given interactively. Prints the restart notice.
- Adopt/install/uninstall remain CLI-only. The admin HTTP surface stays a
read-only catalog view plus the existing enable/disable toggle; because
Flask cannot register blueprints after the first request, any adopt or
enable takes full effect only on restart, and the UI says so. There is no
"install button" that pretends otherwise.
### 4. Trust model: verify at adopt AND at every load and migrate
Signing that gates only adoption is bypassable through every other write path
into plugins/ (git clone, symlink, USB drop) and defeated by post-adoption
tampering. Therefore verification is enforced where code executes:
- Adoption leaves PROVENANCE.json and PROVENANCE.sig inside plugins/<name>/
and records publisher + artifact hash in the registry entry.
- load_plugin verifies the signature against PLUGIN_TRUSTED_KEYS and re-hashes
the plugin tree against the provenance file map BEFORE importing plugin.py
(new step ahead of loader.py:185). Missing or invalid provenance is a
fail-closed refusal in production.
- run_plugin_migrations performs the same verification before executing any
revision, so a routine `flask plugin upgrade-all` can never run DDL from an
unverified folder.
- upgrade_all_plugins iterates registry.get_all() (fixing the phantom
list_installed fallback at __init__.py:94), so unadopted on-disk folders are
never migrated as a side effect of deploys.
- Development and the ADR-003 external-repo/symlink workflow (including
scripts/test-external-plugin.sh) are preserved via `PLUGIN_DEV_TRUST_DIRS`,
honored ONLY when DEBUG or TESTING is set. Production ignores it.
- Cost: hashing 13 small plugin trees at boot is milliseconds; accepted.
What signing does NOT claim: a valid signature proves the artifact is exactly
what a curator reviewed and signed, nothing more. Plugins remain in-process
Python with full DB access. The actual safety control is the human review
before signing; the signature makes that review's verdict tamper-evident all
the way to execution.
### 5. Declarative site profiles and lean backend builds
- `site-profile.json` per site (kept in the site's deploy config): site name,
list of chosen plugins, optional `locked` list. `flask plugin apply-profile
<file>` resolves the closure, installs AND enables in dependency order, runs
migrations, reports which changes need a restart. This replaces the
imperative CLI sequences in DEPLOY.md/PILOT-DEPLOY.md and fixes the
enable-without-install bug.
- Lean backend image: `scripts/build-site.sh` reads the profile and stages
only core + chosen plugin directories into the Docker build context
(correcting the Dockerfile COPY and its stale header comment). Discovery
needs no change; it already scans whatever exists.
- Prerequisite the naive version misses: core hardcodes plugin imports.
shopdb/core/api/search.py (~15 sites), reports.py, assets.py, collector.py,
applications.py, auditlogs.py, and shopdb/cli/__init__.py import
plugins.<name>.* lazily. Some already guard ImportError; ALL must, with
graceful degradation, before any site prunes a folder. This is audited and
enforced by a new CI job that deletes one plugin directory and runs the full
test suite (repeated per plugin). Longer term these aggregators should move
to registry-driven contract hooks (get_search_providers/get_report_sources)
so a new catalog plugin can join search/reports without core edits; that is
scoped as follow-up work, not a blocker for lean builds.
- Schema-lean is DEFERRED to its own ADR. The core baseline 68b3947ae14f
unconditionally creates the 10 pre-cutover plugins' tables, and lifting them
into plugin baselines collides with cross-plugin foreign keys (the
computers-owned installedapps table FKs machines.machineid while computers
declares no dependency on machines). Reversing the cutover would either
introduce undeclared hard deps or drop FKs; neither is decided here. A lean
site therefore carries a handful of empty pre-cutover tables. Accepted.
### 6. Frontend delivery: Path C for rich UIs, Path A for simple ones, Path B rejected
Path B (runtime-loaded JS / module federation) is REJECTED: it moves executable
UI delivery from a signed, statically auditable build artifact to runtime
fetching, which is exactly the wrong direction for an air-gapped,
review-then-sign posture, for zero benefit given restarts are already required.
Path A (declarative JSON UI over generic renderers) is COMMITTED and scheduled
EARLY: the three unwired ADR-010 endpoints (pluginui.py asset-panels:62,
map-overlays:88, asset-presentation:100) get generic core renderers, joining
the already-consumed settings-cards. After this, a simple plugin ships JSON-only
UI with zero frontend build involvement. Sequencing this before the relocation
gives every plugin an escape hatch during the migration instead of after it.
Path C (self-contained plugin frontend) is the primary mechanism, scoped
against the real code, not the idealized layout:
- Canonical home: plugins/<name>/frontend/ containing routes.js (the plugin's
complete route array, INCLUDING routes currently embedded in index.js and
core.js), views/, and settings views.
- A pre-Vite staging step (scripts/stage-frontend.mjs, run by build-site.sh
and the dev script) copies the CHOSEN plugins' frontend/ into
frontend/src/.plugins-staged/<name>/ (gitignored) and generates two files
inside the Vite root: routes.gen.js (aggregated plugin routes) and
meta.gen.js (plugin-supplied icon names, title spellings, settings-standalone
flags, replacing the hardcoded iconMap/TITLE_SPELLINGS/SETTINGS_STANDALONE in
AppLayout.vue, settingsCatalog.js, and index.js). This exists because
import.meta.glob requires a static literal inside the project root and
cannot select a per-site subset by itself.
- ONE-TIME core-router surgery, done first and called what it is: the six
hardcoded plugin-view imports in index.js (PartsKiosk, TVDashboard,
PrinterQRBatch/Single, USBLabelBatch, PrintedPartsLabels) and the plugin
routes embedded in core.js move into their owning plugins' routes.js. Without
this, pruning slides/printers/usb/printedparts fails the Vite build on
unresolvable imports; no amount of glob work fixes it.
- Per-plugin relocation PRs (13), each REAL WORK, not a file move: carve routes
out of shared files, move views (handling name mismatches like computers ->
views/pcs), move the plugin's settings views out of shared views/settings/,
and rewrite relative ../../ imports of core shared code to the @/ alias
(relative paths break at the staged depth). A lint rule enforces alias-only
core imports in plugin frontend code from then on.
- Shared plugin-aware code STAYS CORE and ships to every site: AssetLabel.vue
(spans five asset types), views/print helpers (assetLabel.js, qrLogo.js),
MachineBadge.vue, and cross-plugin panels like WarrantyPanel used by
PCDetail. These already null-guard or gate via isPluginEnabled and must keep
degrading when a peer plugin is absent; over time they migrate to ADR-010
asset-panels so the data becomes plugin-supplied. Lean v1 therefore prunes
plugin-EXCLUSIVE code; a small plugin-aware core remainder is accepted and
shrinks as Path A absorbs it.
- Dual-location transition: the staging step unions legacy locations
(routes/*.js glob, views/<plugin>/) with plugins/<name>/frontend/ until each
plugin has moved. The SPA builds green at every commit; each plugin's move is
independently revertable until the legacy glob is removed at the end.
- Nav and settings cards are already server-driven (dashboardApi.navigation,
settings-cards); the remaining hardcoded plugin entries in settingsNav.js
(/settings/zabbix, /settings/dellwarranty) move to those plugins'
get_settings_cards so pruning leaves no dead links.
### 7. Effect on the 13 existing plugins
- Backend: ZERO code changes required. tier/optional_dependencies/provenance
are additive; pack zips the directory as-is; all plugins keep passing
tests/test_plugin_contract.py. Bundled plugins in a site's image get
provenance stamped at build time by pack, so verify-at-load applies to them
identically.
- Frontend: one relocation PR each, of the honest scope above. Until a
plugin's PR lands it keeps working from its legacy location.
- Operationally nothing changes for a site that does nothing: default builds
remain all-plugins, apply-profile is opt-in, and enable/disable semantics
(including the restart requirement) are unchanged.
## Consequences
### Positive
- A real catalog: sites declare their set in site-profile.json and apply it in
one idempotent command; the chosen set drives backend image, SPA bundle, and
runtime state from one source of truth.
- Curated marketplace with end-to-end provenance: review -> sign -> any
transport -> verify at adopt, at load, and at migrate. Transport (SharePoint
sync or sneakernet) is untrusted and interchangeable, which is exactly right
for air-gapped sites.
- Lean per-site builds: unchosen plugins exist in neither the image nor the
bundle, shrinking attack surface and download size.
- Fixes shipped along the way: upgrade-all migrating unadopted folders,
PILOT-DEPLOY install/enable ordering, reverse-dep checks blind to unloaded
plugins, missing cycle detection, missing dependency closure, hardcoded
frontend plugin metadata.
- Path A completion makes simple plugins UI-capable with no build glue, which
is the cheapest possible marketplace onboarding.
### Negative
- Key management is a per-site operational burden: pinned keys delivered
out-of-band, rotation is a config change everywhere. Accepted as the price of
not trusting the distribution folder.
- The frontend re-org is the long pole: one core-router surgery plus 13
non-trivial PRs. It is sequenced to be always-green and per-plugin
revertable, but it is weeks of work, not a rename.
- Schema is not lean: pre-cutover plugin tables still appear at every site
until the deferred baseline re-org ADR.
- Restarts remain required after adopt/enable (Flask blueprint constraint);
the marketplace UX is honest about it rather than working around it.
- Boot adds a signature + tree-hash check per enabled plugin (milliseconds,
but nonzero).
### Risks
- Key compromise or curation failure: a signature proves provenance, not
safety; a compromised pinned key or a rubber-stamp review signs malware that
every gate will happily pass. Mitigations: multi-key pinning with overlap
rotation, signed revocation list with monotonic index serial, and keeping the
signing key offline with the curator. The static import scan is a lint and
must never be presented as a boundary.
- Rollback/downgrade: mitigated three ways: index serial monotonicity, adopt
refusing version downgrades without interactive --force-downgrade, and the
signed revoked list. Residual risk: a site that never syncs a newer index
cannot learn of revocations; `flask plugin audit` at deploy time narrows the
window.
- Version skew across ADR-004 sites: one shelf serves sites at different
contract versions. Adopt checks core_version from the signed manifest against
the site's own __contract_version__ (authoritative); shelf-list shows an
advisory compatibility column from the index. Incompatible artifacts are
listable but not adoptable.
- Partial/placeholder sync files: fail closed on hash verification; the error
message distinguishes "not fully synced" from "tampered" only by wording,
intentionally, since the app cannot tell.
- Dev-trust misuse: PLUGIN_DEV_TRUST_DIRS silently ignored outside
DEBUG/TESTING; a prod config carrying it gets a startup warning.
- Frontend closure drift: plugin views importing cross-plugin components is a
graph the manifest does not model. The lint rule (plugin frontend may import
core @/ paths and its own tree only, never another plugin's) prevents new
edges; existing shared plugin-aware code is explicitly core-owned.
## Implementation phases
- Phase 0, groundwork (small, days): upgrade_all_plugins uses
registry.get_all(); reverse-dep checks read installed manifests from disk;
cycle detection in _sort_by_dependencies; shopdb/plugins/manifest_schema.json +
`flask plugin validate` (directory mode); `flask plugin apply-profile` with
install+enable closure ordering; fix Dockerfile stale comment. All additive,
zero risk to running sites.
- Phase 1, packaging and signing (medium, about a week): PROVENANCE format,
`flask plugin pack`, validate artifact mode, PLUGIN_TRUSTED_KEYS config,
ed25519 signing tooling and curator docs. No runtime behavior change yet.
- Phase 2, shelf and enforcement (medium-large, one to two weeks):
PLUGIN_SHELF_DIR, signed shelf-index with serial + revoked list,
`flask plugin shelf-list` / `adopt` / `audit` with atomic verified unpack;
verify-at-load in load_plugin and verify-at-migrate in
run_plugin_migrations, fail-closed in prod; PLUGIN_DEV_TRUST_DIRS for
dev/test and the external-repo harness; tier:core lifecycle guard;
provenance stamping of bundled plugins at build. This phase completes the
security model; everything after it is delivery optimization.
- Phase 3, Path A completion (medium, one to two weeks): generic renderers for
asset-panels, map-overlays, asset-presentation; migrate settingsNav.js
hardcoded plugin cards to get_settings_cards. Done BEFORE relocation so
JSON-only UI is available during the migration.
- Phase 4, frontend re-org (large, the long pole, several weeks elapsed):
stage-frontend.mjs staging + routes.gen.js/meta.gen.js codegen; ONE core PR
moving the six index.js hardcoded plugin imports and the core.js-embedded
plugin routes into plugin route files; then 13 per-plugin relocation PRs
(views, settings views, name-mismatch dirs, @/ alias rewrite) under the
dual-location union; lint rule for plugin frontend imports. Always-green,
per-plugin revertable.
- Phase 5, lean builds end to end (medium, about a week after Phase 4):
build-site.sh staging backend dirs + frontend staging from site-profile.json;
core lazy-import guard audit finished, enforced by the delete-a-plugin CI
matrix; remove the legacy glob; pilot one real lean site (a location without
printedparts/usb/network) and diff its image and bundle against a full build.
Deferred, each to its own future decision: schema-lean core-baseline re-org
(blocked on the installedapps -> machines FK question), pip/entry-point
distribution (ADR-003 v2), hook-based search/report aggregation contract, and
any revisit of Path B.
## Amendment, 2026-08-14
Status corrected from PROPOSED to ACCEPTED. The lean-build half of this decision
shipped some time ago - `scripts/build-site.sh`, `SITE_PLUGINS` staging,
`flask plugin prune-schema` and `default_enabled: false` are all live, and
ADR-014 was accepted on top of it - while the record still said the decision was
under consideration. A decision that has been implemented and depended upon is
not proposed, whatever the header says, and leaving it that way makes every
other status in the index worth less.
The catalog and signed-artifact parts of the decision remain unbuilt. That is
the ordinary state of an accepted decision: accepted means settled, not
delivered.

View File

@@ -0,0 +1,150 @@
<!-- docmeta
purpose: explanation
audience: developer, adopter
status: current
summary: Why a lean site stops creating database tables for plugins it never installed.
loadwith: none
-->
# ADR-014: Schema-lean per-site builds (retire cross-plugin FKs, lift plugin tables)
- Status: ACCEPTED
- Date: 2026-07-19
- Deciders: ShopDB maintainers
- Relates to: ADR-008 (per-plugin migration ownership), ADR-013 (plugin catalog + lean per-site builds), ADR-001 (asset model)
## Context
ADR-013 delivered lean per-site builds for plugin CODE (backend tree + frontend
bundle carry only chosen plugins). One residual was left, explicitly deferred:
the DATABASE. The core Alembic baseline (68b3947ae14f) creates EVERY table,
including ~30 plugin-owned tables (PLUGIN_TABLE_OWNERS). Each plugin's own
baseline is a stamp-only no-op (the core chain already made its tables, per
ADR-008). So a lean site that omits a plugin still creates that plugin's tables,
empty and unused.
The deferral cited a blocker: "the computers-owned installedapps table FKs
machines.machineid while computers declares no dependency on machines; reversing
the cutover would introduce undeclared hard deps or drop FKs; neither is
decided." Investigation refined this:
The cross-boundary foreign keys into the machines plugin table are ALL held by
DEAD legacy tables/columns that predate the asset model (ADR-001) and the
per-plugin cutover (ADR-008), and are queried nowhere in the codebase:
- `machinerelationships` (child/parentmachineid -> machines) - superseded by
`assetrelationships`. No model, no query.
- `printerdata` (machineid -> machines) - the pre-cutover printers table,
superseded by the printers plugin. No model, no query.
- `installedapps` (machineid -> machines) - a standalone machine-app link table;
the live relationship is `computerinstalledapps` (FK to computers only). The
standalone table has no model, no query.
- `communications.machineid` (-> machines) - a legacy column on the core
communications table (which is now assetid-based). Not read anywhere.
No LIVE plugin table hard-FKs another plugin's table. computerinstalledapps FKs
only computers.computerid (intra-plugin). So the blocker is dead cruft, not
live design.
## Decision
Two phases, both leaving existing databases correct.
### Phase 1: retire the dead cross-boundary cruft - ALREADY DONE
Investigation found this is already accomplished by existing migrations:
`7a01_adr001_position_contract` and `7c01_drop_legacy_machine` drop
`machinerelationships`, `printerdata`, `installedapps`, and
`communications.machineid` (with its FK). The current schema (verified on the
dev database) has none of them. So the cross-plugin FK blocker ADR-013 cited no
longer exists in the live schema - only in the baseline's transient
create-then-later-drop. No new migration is needed for Phase 1.
Precedent: ADR-001 dropped a cross-plugin FK the same way
(usbcheckouts.machineid -> machines became a soft sentinel).
### Enabling change (executed now): idempotent create_plugin_tables
`shopdb/plugins/alembic_template.py:create_plugin_tables` now skips any table
that already exists (inspects the bind first) instead of raising. This is the
mechanism Phase 2 needs: a plugin anchor can create its tables on a fresh lean
install AND be a safe no-op on an existing database that already has them from
the pre-cutover core baseline. Correct and inert regardless of Phase 2 (no
current caller creates against a populated schema). Verified against the
plugin-migration suite.
### Phase 2 (executed): prune not-installed plugin tables after upgrade
Two mechanisms were weighed to make a lean site's database carry only
core + chosen-plugin tables:
- **Relocate** (rejected): pull every plugin-table create/alter out of the core
chain into the plugin baselines, so the core chain never creates a
not-installed plugin's table. Measurement killed this: plugin tables are
created and altered across ~15 released core migrations (baseline plus 7c04,
7d05, 7d08, 7d13, 7d15, 7d16, 7d17, ...), not just the baseline. Because the
whole core chain runs before any plugin chain, removing a table's create from
core while a later core migration still alters it breaks FULL installs too, so
relocation means surgically rewriting ~15 released migrations - the highest
blast radius in the project - for a purely cosmetic gain (the omitted tables
are empty and the lean CODE build already never loads the plugin).
- **Prune-after-upgrade** (chosen): leave the entire core chain untouched. Add
`flask plugin prune-schema`, which drops the tables of every plugin in
PLUGIN_TABLE_OWNERS that is not installed on this site. Run once at deploy,
after `flask db upgrade` and `flask plugin upgrade-all`. Same end state
(core + chosen tables) with near-zero blast radius: no released migration is
edited, and an existing full site is unaffected because it never runs the
command.
`prune-schema` drops by table name (no plugin-code import), so it works on a
lean image where the omitted plugin's directory is absent. It is a dry-run by
default and refuses to drop a table that holds rows unless `--force`, so a
misfire on a populated site cannot silently delete data. Because the core chain
seeds a few plugin reference tables (e.g. 7d05 inserts default access
protocols), initial lean provisioning uses `--force` - at that point the tables
hold only migration-seeded defaults, before any site data exists.
The idempotent `create_plugin_tables` (enabling change above) is what lets a
lean site later ADD an omitted plugin: its anchor recreates the pruned tables.
Verified end to end on MySQL: fresh full install (86 tables) then prune is a
no-op; fresh lean install (machines + printers) then prune drops the other 19
plugin tables, leaving core + chosen; second prune is a no-op; the non-empty
guard refuses without `--force`. Four SQLite regression tests pin the behavior
(tests/test_plugin_prune_schema.py), running in the backend CI job via the real
CLI runner: drop-only-not-installed, full-site no-op, refuse-non-empty, and
force-drops-non-empty.
## Consequences
### Positive
- A lean site's database contains only core + chosen-plugin tables, with no edit
to any released migration (near-zero blast radius).
- The cross-plugin FK blocker ADR-013 cited is gone (dead cruft, dropped by
existing migrations), so plugin schemas are already FK-independent.
- Adding an omitted plugin to a lean site later just works: the idempotent
anchor recreates its tables.
### Negative / risk
- prune-schema is destructive by nature; the row-count guard + dry-run default +
required `--force` for non-empty tables contain that. It is a deploy-time
provisioning step, not something to run casually on a live populated site.
- A lean fresh install still transiently creates then drops the omitted plugins'
tables (the core chain builds them, prune removes them). Harmless and one-time
at provisioning; the trade for not touching the released baseline.
## Implementation
- Phase 1: nothing to do - the dead cross-boundary FK objects were already
dropped by existing migrations `7a01_adr001_position_contract` and
`7c01_drop_legacy_machine`; verified absent on a fresh full MySQL upgrade.
- Enabling change: `create_plugin_tables` made idempotent
(`shopdb/plugins/alembic_template.py`).
- Phase 2: `flask plugin prune-schema` (`shopdb/plugins/cli.py`), dry-run by
default, `--yes` to execute, `--force` for non-empty tables. Deploy order:
`flask db upgrade` -> `flask plugin upgrade-all` -> `flask plugin prune-schema
--yes --force`. Regression tests in `tests/test_plugin_prune_schema.py` (run in
the backend CI job).

View File

@@ -0,0 +1,156 @@
<!-- docmeta
purpose: explanation
audience: developer, adopter
status: current
summary: Where a site own hostnames, paths and defaults are allowed to live, and where they are not.
loadwith: none
-->
# ADR-015: Where a site's own data is allowed to live
- Status: ACCEPTED
- Date: 2026-08-11
- Deciders: ShopDB maintainers
- Relates to: ADR-004 (per-site instances, not multi-tenant), ADR-013 (plugin catalog + lean per-site builds), ADR-014 (schema-lean per-site builds)
## Context
ShopDB is built as a product several GE Aerospace sites can adopt (ADR-004:
each site runs its own instance). It is also developed at one site, against
that site's live fleet. Every feature therefore arrives
carrying that site's hostnames, networks, share paths and vocabulary, and the
fastest way to ship any of them is to write the value where the code needs it.
That is not a hypothetical drift. A scan of `plugins/` and `shopdb/` for a site
host, a site FQDN or a site network returns 19 hits, of which roughly 8 are a
shipped default or a hardcode rather than a comment:
- `plugins/geenforce/seed_display_scope.py` baked one site's own ShopDB URL and
`device.geaerospace.net` into an inline PowerShell payload. A sister site
could not override either without editing the source. FIXED 2026-08-14: the
kiosk base URL now comes from the `site_base_url` setting at seed time and
from HKLM at run time, and the dispatcher refuses rather than guessing when
neither is set.
- `plugins/backups/services/registry.py` defaults the backup share root to one site's own
UNC path.
- `shopdb/core/api/settings.py` ships `device.geaerospace.net` and
`Printer-{ip}.printer.geaerospace.net` as SETTING DEFAULTS, so another site
inherits that site's domain silently until someone notices.
- `shopdb/core/models/dashboarddefault.py` holds the same domain a second time
as a module constant, and `plugins/computers/api/routes.py` a third time as a
literal fallback.
The problem is not that site-specific values exist - they must. It is that the
repository already has three good mechanisms for them and no rule about which to
use, so each new value lands wherever was convenient. The result reads as drift
because it is unmanaged, not because any single choice was wrong.
Nothing here is a security matter: these are internal hostnames on an air-gapped
network, and `tests/test_docs_publishable.py` already gates what reaches the
public wiki. This is about whether a sister site can adopt the product without
reading our source.
## Decision
A site's own data lives in one of three places, in this order of preference.
**1. A setting, with a NEUTRAL default.** Anything an operator could plausibly
change: hostnames, domains, share paths, networks, base URLs, label prefixes.
The default that ships must be blank or generic, never one site's value. Blank
must be a working state - code reads the setting and degrades sensibly, the way
`display_fqdn_domain` already treats blank as "use the hostname as-is". A
site-specific default is the failure mode this ADR exists to stop: it is
invisible, it looks deliberate, and it is wrong everywhere except here.
**2. A site-namespaced directory.** Anything that is genuinely one site's and
will never be another's, such as the reference site's import loader at
`scripts/site_imports/wjf/`. Never reached by another site's build.
**3. Seed data, not code.** Vocabularies and catalogs, loaded by `flask seed`
and editable per site afterwards.
Fleet-wide convention is NOT site-specific. The `gea-shopfloor-*` imaging types
are a GE fleet taxonomy, not one site's data, and they already carry a per-site
override through the `pctypemap_*` settings. They are out of scope here.
Comments and documentation may name a site where it explains WHY - "part markers
share a machine number at the reference site" is a fact a maintainer needs. Prefer
phrasing the rule generally and the example specifically.
## Enforcement
`scripts/check-naming-and-style.sh`, which already runs in CI, greps `plugins/`
and `shopdb/` for a site host, a site FQDN or a site network, excluding tests
and `site_imports/`.
It shipped in REPORT-ONLY mode, because turning a 19-item backlog into a hard
gate on the day it lands blocks every unrelated commit until someone works
through it. See the amendment below: it now fails the build.
## Consequences
Positive:
- A sister site can adopt without reading our source for values it must change.
- The backlog is visible and counted rather than felt.
- New site-specifics are caught at commit time, in the same place the naming
rules are already enforced, so there is nothing new to remember.
Negative:
- Report-only means the rule is advisory until someone flips it, and an
advisory rule can be ignored.
- A neutral default is more code than a working one: every read needs a blank
case, which is a real cost paid at every call site.
- The pattern list is a denylist and will miss a site value it has not been
taught. It catches drift, not everything.
## Backlog
CLEARED - see the amendment below. Kept as the record of what the decision was
answering:
- `plugins/geenforce/seed_display_scope.py` - kiosk base URL, FQDN domain, and
the legacy-shortcut sweep pattern
- `plugins/backups/services/registry.py` - `DEFAULTSHAREROOT`
- `shopdb/core/api/settings.py` - `display_fqdn_domain` and the printer
hostname pattern defaults
- `shopdb/core/models/dashboarddefault.py` - `DEFAULT_DISPLAY_FQDN_DOMAIN`
- `plugins/computers/api/routes.py` - literal fallback duplicating that default
The domain appears three times in three files, which is its own defect: one
setting, one default, one read.
Cosmetic mentions in comments and UI placeholder text are acceptable under this
ADR and are not part of the backlog, with one exception worth fixing when
convenient: the GE-Enforce settings page shows one site's real CIDRs as its
placeholder, which every site sees.
## Amendment, 2026-08-14
The backlog above is cleared and the scanner now ENFORCES: it fails the build,
and `SITE_LITERALS_ENFORCE=0` is the escape hatch rather than the default. The
report-only period ended for the reason the Consequences section predicted - the
hit count did not move for weeks, because a rule that only prints is read as no
rule.
Three things changed beyond clearing the list:
- The scan covers PowerShell, the installer, the seeds, generated JSON and the
frontend, case-insensitively, across `plugins/`, `shopdb/`, `scripts/`,
`deploy/`, `frontend/src/` and `tools/`. Its original scope - three file types
under two directories - missed every literal that actually reached a second
site, because those were all in the client scripts and the installer.
- A line may declare itself deliberate with a trailing `ADR-015-OK: <reason>`
marker. That is for an organisation-wide default which is right at every site
and configurable anyway, such as the corporate device domain. It is not a
way to defer work: the marker puts the claim in the diff where a reviewer
sees it.
- Both publication gates - the export scrub and the docs publishability test -
carry the site patterns, which neither did. The ADR said this was not a
security matter because the values were internal to an air-gapped network;
that was true of the fleet and false of the public mirror, which had been
carrying a production hostname and a share path.
This amendment is appended rather than edited into the text above, because what
the decision looked like when it was taken is the part worth keeping.

View File

@@ -0,0 +1,204 @@
<!-- docmeta
purpose: explanation
audience: developer
status: historical
summary: The 2026-08 plan for ShopDB-resolved credential targeting. Never built; superseded by ADR-019.
loadwith: none
-->
# ADR-016: Credential delivery to the fleet
- Status: SUPERSEDED by [ADR-019](ADR-019-per-pc-endpoint-credentials.md)
- Date: 2026-08-11
- Deciders: ShopDB maintainers
- Relates to: ADR-006 (collector contract), ADR-012 (GE-Enforce manifest ownership), ADR-015 (site-specific configuration)
> **Do not build against this. Nothing in it was built.** The model below has a
> PC ask ShopDB for credentials chosen by server-side targeting rules. The
> `GET /api/collector/credentials` endpoint, the `credentials.fetch` and
> `credentials.manage` permissions and the rule store do not exist in the code,
> and a reader searching for them will not find them.
>
> What shipped instead is the opposite shape: the endpoint holds ONE secret of
> its own, obtained by enrolling, and there are no targeting rules to resolve.
> [ADR-019](ADR-019-per-pc-endpoint-credentials.md) records that decision.
>
> The PROBLEM stated below is still the real problem, and ADR-019 cites it.
> Kept as the record of a design that was decided and then not taken.
## Context
Shopfloor bays need credentials they cannot prompt for. A collections bay loses
the Windows Credential Manager entry for its controller subnet periodically -
suspected Defender or Intune scrub - and without it, controller-side
connections need an interactive prompt, so an unattended bay simply stops
working. A machine whose NTLARS config points at a program share needs a
credential to reach that share for the same reason.
Today this is solved by `Set-ControllerCredential.ps1` on the SFLD share:
```powershell
$Target = '192.168.1.1'
$Username = 'CHANGEME'
$Password = 'CHANGEME'
```
A person edits the values into the file, GE-Enforce runs it every cycle, and
`cmdkey /add` re-applies it. It works, and it has three problems.
The password is **cleartext in a file on a share**, readable by anything that
can read the share - a wider exposure than the machine-local secret it becomes.
Rotating it means editing that file and hoping: there is no way to ask which
bays have picked up the new value. And it is **one value for everything**,
while the requirement is per-bay variation: sites will have machines needing
different credentials from their neighbours.
Per-bay variation is what rules out the obvious alternative. GE-Enforce scopes
are per PC TYPE, so expressing per-machine credentials as manifest scopes means
a scope per bay, which is not a model. The manifest can deliver a value
(`Registry` entries with inline payloads already do this for the display
scope), but it cannot decide WHICH value a given PC should get.
## Decision
ShopDB owns credential definitions and their targeting. A PC asks for its own
credentials and receives a decided answer; it never receives rules to evaluate.
### Targeting
A credential rule carries a targeting expression using the SAME axes
GE-Enforce already filters on, plus those the asset model knows:
| Axis | Source | Notes |
|---|---|---|
| hostname | reported | exact or `-like` glob (`WJRP*`) |
| machine | PC -> machine link | the machine the PC drives |
| model | asset model | e.g. every Okuma of one model |
| controller OS | asset / reported | version-valued, like the existing CmmVersion gate |
| PC type | ComputerType | with GE-Enforce ALIAS EXPANSION |
| GE-Enforce profile | manifest scope | the imaging profile |
| DNC/NTLARS setting | stored backup projection | e.g. any machine whose `DataHost` is a given UNC |
| site default | setting | the fallback |
The DNC axis is not a special case bolted on: the NTLARS projection ShopDB
already stores contains `DataHost`, `DataPath` and `MarkMasterPath`, so "every
machine that talks to \\ntshare" is a selector over data already held. It is
also the axis that answers "which machines even need a credential", which
nothing answers today.
### Precedence
Overlapping matches are guaranteed, not exceptional - a hostname glob and a
per-model rule will both hit the same bay. Precedence is therefore EXPLICIT,
never emergent:
1. Each rule carries an integer `priority`. Higher wins. This is the only
thing an operator needs to reason about.
2. Ties break on scope specificity, in the order of the table above (hostname
most specific, site default least).
3. Remaining ties break on the lowest rule id, so the result is stable rather
than dependent on row order.
A resolve endpoint must be able to EXPLAIN itself - "this PC got rule 12,
matched on model, beating rule 4 on priority" - or nobody will trust it. The
GE-Enforce simulator (`applicable_entry_names`) already set this precedent.
### Reuse the existing matcher
`plugins/geenforce/filters.py` already mirrors the engine's targeting: alias
groups, hostname globs, the rule that a machine-number filter with no machine
number EXCLUDES rather than defaults. The resolver reuses it. It must not
become a third implementation of the same matching logic - there are already
two (the PowerShell engine and its Python mirror), kept honest by a parity
harness, and a third would drift silently.
Alias expansion in particular is not optional: if a rule says `PCTypes:
Standard`, it must mean what it means in GE-Enforce, or the two systems will
disagree about who a rule covers.
### Targeting is not detection
Who gets a credential and whether it is already applied are separate
questions, exactly as `PCTypes` and `DetectionMethod` are separate in the
manifest. The client decides the second locally - is this `cmdkey` entry
already present and correct - and rewrites only on drift. Conflating them
rewrites the credential every cycle instead of healing it, and turns the audit
trail into noise.
### Storage
- Encrypted at rest with Fernet. `cryptography` is already a dependency.
- The key lives OUTSIDE the database - instance file or environment, like
`SECRET_KEY`. A key in the database it protects is not encryption.
- Write-only through the API. Masked on read, as `smtp_password` already is. A
fleet PC receives a value; a human never reads one back.
- A dedicated `credentials.manage` permission for authoring.
### Delivery
```
GET /api/collector/credentials X-API-Key: <credentials.fetch token>
```
- A DEDICATED `credentials.fetch` scope, not `collector.ingest`. A leaked
collector key must not yield controller passwords.
- The caller is resolved by hostname; it receives only what it is entitled to,
and never the rule set.
- Every fetch is audited: which PC, which credential, when. The audit log
exists; this belongs in it.
- The client half is a shared helper alongside `ShopdbBackupClient.psm1`:
fetch, compare, `cmdkey /add` on drift, log once per state.
## Consequences
Positive:
- The cleartext credential leaves the share.
- Rotation becomes a settings change plus a report of who has picked it up,
instead of a file edit and a hope.
- Per-bay, per-model and per-share credentials become expressible, which is the
actual requirement.
- "Which machines need a credential" becomes answerable from the DNC data
already stored.
Negative, and these are real:
- **ShopDB becomes a credential store.** That is a different security posture
from an asset database, and it earns the obligations above rather than
choosing them.
- **The key is a new single point of loss.** Restore the database without it
and every stored credential is unrecoverable. `docs/BACKUP-RESTORE.md` must
say so IN THE SAME CHANGE, not later.
- A PC that cannot reach ShopDB gets no credential. The client must keep the
last known good value rather than clearing a working entry on a failed fetch.
- Targeting complexity is real complexity. Eight axes with priorities will
produce a rule set someone has to debug, which is why the explain endpoint is
a requirement and not a nicety.
## Alternatives rejected
**Keep editing the script on the share.** Cannot express per-bay values, keeps
the cleartext, no rotation story.
**A manifest scope per bay.** Per-machine credentials as GE-Enforce scopes is a
spreadsheet, not a model, and the manifest cannot resolve WHICH value applies.
**Encrypted payload in the manifest.** DPAPI is per-machine, and one manifest
serves many machines, so the payload cannot be encrypted to its readers.
**Registry-only provisioning (no ShopDB).** A worthwhile FIRST STEP - it moves
the secret off the share immediately and needs no new endpoint - but it leaves
provisioning per-bay by hand and offers no rotation. Recommended as tier one
regardless, since the client helper is the same either way.
## Implementation status
Never implemented, and now superseded. The fetch endpoint and the
`credentials.*` permissions described above do not exist in the code.
The note that stood here until 2026-09-18 said the decision still stood and a
plugin author designing against it was designing correctly. That is no longer
true: design against
[ADR-019](ADR-019-per-pc-endpoint-credentials.md). What credentials the fleet
uses today, and where they live, is in
[FLEET-ARCHITECTURE](../FLEET-ARCHITECTURE.md).

View File

@@ -0,0 +1,151 @@
<!-- docmeta
purpose: explanation
audience: developer, adopter
status: current
summary: Why the floor map became buildings and levels instead of one image with stacked floors.
loadwith: none
-->
# ADR-017: Buildings and levels as the map model
- Status: ACCEPTED
- Date: 2026-08-17
- Deciders: ShopDB maintainers
- Relates to: ADR-001 (asset as the platform contract), ADR-004 (per-site
instances), ADR-010 (frontend plugin hooks), ADR-015 (site-specific
configuration)
- Supersedes: the site-wide `map_blueprint_light` / `map_blueprint_dark` /
`map_width` / `map_height` settings
## Context
A site's floor map is one image. Four settings describe it - two blueprints
(light and dark) and the native pixel width and height - and `assets.mapx` /
`assets.mapy` are absolute pixel coordinates in that image's space.
That model has one floor in one building, which was true when it was written and
is no longer. The reference site is adding a second level, and a second building
is likely within a year or two. The immediate trigger is a new blueprint at
3308x4000 where the old one was 3300x2550.
The obvious cheap answer is to stack levels on one canvas: draw the second level
below the first and keep one image. It works, and it was seriously considered.
It was rejected because it makes the level an inference rather than a fact:
- "Which level is this asset on" becomes `mapy > 2550`. Every per-level filter,
count, report and export has to know that constant, and re-exporting the
drawing at a different height silently changes the answer everywhere. Not
broken - wrong, which is worse.
- Distance between two markers becomes computable and meaningless, so anything
doing nearest-asset or clustering quietly answers nonsense across levels.
- One canvas forces one scale. A mezzanine drawn at a different scale than the
floor below cannot be expressed at all.
- 3308x4000 is portrait for a building whose floor is landscape. At a zoom where
a marker is clickable, roughly half a level is visible, and "fit to level" is
not expressible.
- A third level makes each of these worse, and adds a second threshold.
Timing decided it. Because the level layout changed and machines moved, every
marker is going to be repositioned anyway. Introducing levels now costs one pass
over the positions; stacking now and splitting later costs two, and in between
every position placed encodes the threshold into real data - which a later
migration would then have to un-guess by comparing Y against it.
## Decision
Two tables, and assets reference the level.
```
buildings buildingid, buildingname, sortorder, isactive
maplevels levelid, buildingid, levelname, sortorder,
blueprintlight, blueprintdark, mapwidth, mapheight,
isdefault, isactive
assets levelid (nullable; the default level for existing rows)
```
**1. An asset references the level, never the building.** The building derives
from the level, so the two cannot disagree. Storing both would be a fact
recorded twice with no difference in granularity to justify it.
**2. Blueprints and native dimensions belong to the level.** This is what the
site-wide settings could not express: two levels in one building may be drawn at
different sizes and scales, and two buildings certainly are.
**3. Name and order are separate.** `levelname` is text and `sortorder` is an
integer. Levels are not reliably numbered - basement, ground, mezzanine, roof,
tunnel - and sort order gives adjacency and up/down navigation without
pretending the names are ordinal. It also lets a mezzanine be inserted between
two existing levels without renumbering anything.
**4. `mapx` / `mapy` keep their meaning, scoped to the level.** They stay
absolute pixels in the native coordinate space of the level's blueprint. No
normalisation to fractions: pixels are what the drawing tools produce, what an
operator can read off an image, and what the existing data already holds.
**5. A level is required to render a position, and absence is not a default.**
Where a payload carries a position without a level, the UI renders "level
unknown" rather than falling back to the default level. Falling back draws one
building's ground floor with a marker positioned for another building's
mezzanine: it renders perfectly and points at the wrong place, and nothing about
the result looks wrong. A visible gap is worth more than a confident wrong
answer.
**6. The level list is readable without authentication.** The printer installer
map runs optional-auth, before anyone logs in, and it needs a blueprint. This
follows the precedent already set for printer install-list and the slide feed.
Blueprint paths and level names are not secrets; the positions of assets on them
already render on public kiosk pages.
**7. Levels are not Locations.** Assets carry `locationid` already, and reusing
it is tempting. A Location answers "which operation owns this"; a level answers
"which drawing renders it, at what native size". Overloading Location with
blueprint images and pixel dimensions makes both concepts worse. They coexist:
an asset on level 2 in operation 0613.
## Migration
The existing four settings become one building and one level, marked default,
and every asset with a position points at it. Nothing renders differently on the
day it lands. The settings keys are then retired rather than left as a second
source of truth that can disagree with the rows.
## Consequences
Positive:
- A second building costs a row and a blueprint. So does a third level.
- Per-level export, per-level zoom, and per-level calibration all become
expressible. Calibrating one level against its own landmarks is more correct
than transforming a whole site at once.
- Level becomes queryable - counts and filters per level or per building are
ordinary queries rather than coordinate arithmetic.
Negative:
- Every surface that draws a marker learns there is more than one drawing. That
is two components, seven views, four asset position pickers, the printer
installer map, the PDF export, and eight API payloads that must emit `levelid`
beside `mapx`. A payload that forgets it produces the silent-wrong-map failure
described above, which is why rule 5 exists and why a build gate checks that
the two fields travel together.
- `get_map_overlays` gains level context, so this is a plugin contract change
(0.20.0) and plugins rendering overlays need to declare which level they are
for.
- One more admin surface: levels and buildings have to be managed somewhere, and
the existing floor-map settings page becomes that.
## Alternatives considered
**Stack levels on one canvas.** Rejected above. Zero cost today, and it makes
the level an inference over a magic number.
**Normalise coordinates to fractions of the image.** Would make a rescaled
blueprint self-correcting, which is genuinely attractive. Rejected for now
because it converts every existing integer position through a lossy division,
and because it does not help the actual problem: the levels changed and machines
moved, so the positions need human review regardless. Worth revisiting
independently.
**A `level` string on the asset, with one blueprint per level in settings.**
Rejected: settings keyed by level name is a table with extra steps, and it gives
no place for per-level dimensions or ordering.

View File

@@ -0,0 +1,132 @@
<!-- docmeta
purpose: explanation
audience: developer
status: current
summary: Why metrology instruments are machines and the measuringtools plugin is retired.
loadwith: none
-->
# ADR-018: Metrology instruments are machines
- Status: ACCEPTED
- Date: 2026-09-04
- Deciders: ShopDB maintainers
- Relates to: ADR-001 (asset as the platform contract), ADR-006 (collector
contract), ADR-008 (a plugin owns its tables and its migration chain)
- Supersedes: ADR-005 (equipment plugin scope vs measuringtools plugin)
## Context
ADR-005 split manufacturing machinery from metrology instruments into two
plugins on the grounds that they are two asset classes: machines make parts,
measuring tools measure them. It named the metrology population as "CMMs,
Keyence vision systems, wax-and-trace surface profilometers, GenSpec
instruments", and the `measuringtools` plugin was built against a docstring
naming a different population again: "calipers, micrometers, thread gages,
bore gages, height gages".
Those two lists are not the same thing, and the difference is the whole
decision. One is bay-mounted capital equipment with a controlling PC. The other
is hand tools in a drawer. The plugin was built for the second and populated
with neither.
Sixteen months on, the evidence is in:
- `measuringtools` holds **zero rows**, against 204 machines, 305 computers,
73 network devices and 53 printers.
- The metrology inventory actually waiting to be entered is 134 instruments:
35 CMM, 32 form tracer, 23 vision, 18 wax trace, 14 measuring, 8 Genspect,
2 eddy current, 1 spline. Every one is bay-mounted with a controlling PC.
- Not one caliper, micrometer, thread gage, bore gage or height gage. The
population the plugin's own docstring justifies it with does not exist at
the reference site and is not planned.
- `CMM` already exists as a `machinetype`, and `Part Marker` is already
classed as a machine while `Form Tracer` is classed as a measuring tool.
Two bay instruments with a controlling PC, split across two plugins, with
the line drawn somewhere no operator can predict.
The split has therefore been paying an ongoing cost - a duplicate type page, a
settings group, a nav section, a migration chain, a searcher, dashboard cards,
map colours, label printing and four collector `pctypemap` entries - to model a
distinction that the inventory does not make.
## Decision
Metrology and inspection instruments are machines. The `measuringtools` plugin
is retired and the `measuring_tool` asset type is removed.
### Calibration moves to machines, and is not lost
`machines` gains the calibration lifecycle as nullable columns:
```
machines
- calibrationintervaldays integer, null
- lastcalibrationdate date, null
- nextcalibrationdate date, null
- calibrationprovider varchar(150), null
```
Calibration status stays **derived at read time** from `nextcalibrationdate`
(`overdue` / `duesoon` / `current` / `unknown`, 30-day window), exactly as
`measuringtools` derived it and for the same reason: a stored status is stale
the day after it is written. The helper moves across unchanged.
This is a net gain rather than a port. A machine could not record calibration
at all before, and a part marker with a certified fixture, or a machine with a
calibrated probe, had nowhere to put it.
### `machinetypes.ismetrology` answers ADR-005's objection
ADR-005 made one concrete technical argument, and it deserves a concrete
answer rather than silence:
> "show me all measuring tools" requires an enumeration of measuring-instrument
> equipmenttype values, which scales badly.
That is correct about enumeration and it is why `machinetypes` gains a boolean
`ismetrology`. The metrology view stays a first-class single-column filter -
`WHERE mt.ismetrology = 1` - not a hardcoded list of type names that drifts the
first time someone adds an instrument type. The calibration report, the gage
lab view and any compliance export all hang off that flag.
### What does not change
- `assets.gaugelabreference` stays on the core asset. The gage tag was never a
`measuringtools` column, so it survives untouched and stays searchable for
every asset type.
- The collector's device identity file and the `measuringtoolid` payload alias
stay accepted. Bays staged with `measuringtool-id.txt` keep reporting; only
the asset type they resolve to changes.
- Instrument types keep their names. A Form Tracer is still a Form Tracer, now
as a `machinetype` with `ismetrology = 1`.
## Consequences
**The migration is free today and expensive later.** `measuringtools` has zero
rows, so retirement is a `DROP TABLE`, not a data migration. Once the 134
staged instruments are entered they carry relationships, audit history, map
positions and collector links, and every one of those would have to be
rewritten. This ADR is being taken now specifically because the window is open.
**A site that did populate it needs a real migration.** The reference site did
not and the sister site shows no sign of having done so - its branch contains
no deliberate work on the plugin in its entire history - but that is inference,
not a query against their database. Confirm before the removal ships to another
site. If rows exist, the retirement migration must copy them into `machines`
with `ismetrology` types rather than drop them.
**Four collector `pctypemap` entries change asset type.** `gea-shopfloor-cmm`,
`-keyence`, `-genspect` and `-waxtrace` resolve to `machine` instead of
`measuring_tool`. The `collector:measuringtool` relationship label is kept as a
recognised origin marker so links a collector already created are still
archived correctly rather than orphaned.
**One fewer of everything.** A duplicate type page, a settings group, a plugin
migration chain, a gated searcher, a set of dashboard cards, a map colour entry
and a label-printing branch all go. That is the point.
**ADR-005 is superseded, not reversed in principle.** Its reasoning was sound
for the population it described. The population never arrived. If a site ever
does track hand gages in quantity, the answer is a `handgages` plugin against
that real inventory - not the reinstatement of this one.

View File

@@ -0,0 +1,217 @@
<!-- docmeta
purpose: explanation
audience: developer
status: current
summary: Why every endpoint gets its own enrolled secret instead of one fleet-wide shared key.
loadwith: none
-->
# ADR-019: Per-PC endpoint credentials
- Status: ACCEPTED
- Date: 2026-09-18
- Deciders: ShopDB maintainers
- Relates to: ADR-006 (collector contract), ADR-012 (GE-Enforce manifest
ownership), ADR-015 (site-specific configuration)
- Supersedes: ADR-016 (credential delivery to the fleet)
## Context
The endpoints plugin authenticates every agent-facing call with ONE key. The
same `X-API-Key` value is on every PC in the fleet, and the server cannot tell
one caller from another: a request names its own hostname and is believed.
That key is not only an inventory-ingest key. It is also the key on the command
channel (`AGENT-CONTRACT.md` section 8), where the server hands an agent running
as LocalSystem an allowlisted action to execute - including `run_script`, which
fetches a reviewed PowerShell body and runs it. Anyone holding the key can claim
commands as any PC and post results as any PC.
And the key is readable. The agent installer writes it to
`HKLM\SOFTWARE\GE\ShopDB` `CollectorKey`, and the DACL that subkey inherits by
default grants `BUILTIN\Users` read, so any logged-on user on any bay could read
the fleet key (the reason is recorded in `plugins/endpoints/api/siteagent.py`
beside the code that now locks the key down). The same value also lived in
`appsettings.json` in the agent directory, in a text file on the GE-Enforce
share, and in the `Args` of the manifest entry that wrote it.
One fleet-wide secret, readable by a standard user on any bay, authenticating a
SYSTEM command channel. Rotating it meant touching every one of those places at
once, and nothing could say which PCs had picked the new value up.
ADR-016 answered a different question about credentials - which controller
password a given bay should receive, decided by server-side targeting rules - and
was never built. The question underneath it is "which PC is this", and it needs
no targeting at all.
## Decision
Each endpoint holds its OWN secret, obtained by enrolling, and the server
derives the calling asset from the secret.
### The credential
- `X-Endpoint-Credential: sdbe_` + 43 characters of base64url (32 random bytes).
- The server stores only the SHA-256 of the secret plus its first 12 characters
as a display prefix (`EndpointCredential` in `plugins/endpoints/models/credential.py`).
The full value exists once, in the enroll response.
- One row per asset, so an asset never carries two credentials. Re-issuing after
a revoke updates the same row.
- A request whose credential resolves to one asset while naming another is
refused `403 ASSET_MISMATCH`. A PC can no longer report as another PC.
### Enrollment
`POST /api/endpoints/enroll` never refuses a call for its authentication. The
shared key, if present and still allowed, only decides whether the credential is
issued automatically. Auto-issue requires all of: the shared key authenticated
the call, `plugin.endpoints.autoenroll` is on, the hostname matches exactly one
computer record, the serial on record matches, and the asset has no active
credential. Anything else records a PENDING request an admin approves in the UI.
The agent generates an enrollment nonce once and persists it. The nonce is
recorded on first sight and an approval is bound to it, so a second machine
claiming the same hostname and serial cannot collect the approved credential. A
rejected request binds to the identity instead, so a reinstall cannot argue its
way back into the queue.
### Storage on the endpoint
`%ProgramData%\ShopDB\EndpointAgent\credential\credential.bin`, DPAPI
`LocalMachine`, in a directory ACLed to SYSTEM and Administrators only. The tray
holds no secret at all: it runs as the interactive operator and asks the SYSTEM
service over a local named pipe. `HKLM\SOFTWARE\GE\ShopDB` is locked to SYSTEM
and Administrators in both registry views.
### Server pinning
The agent pins the server's public key: the lowercase hex SHA-256 of the leaf
certificate's SubjectPublicKeyInfo, in `ServerPins` (REG_MULTI_SZ, both views),
authoritative whenever the value exists even when empty. Locking the registry
key is what makes the registry safe to treat as authoritative, since a standard
user can no longer write a pin.
### The shared key stays, deliberately
`plugin.endpoints.sharedkeyallowed` keeps the shared key working for PCs that
have NOT enrolled. It is always refused for a PC that holds an active credential
(`401 SHARED_KEY_REFUSED`). A PC whose credential was REVOKED falls back to the
shared key, which is what makes a rollback to an older agent possible;
`plugin.endpoints.revokeddeniessharedkey` turns that off and is off by default,
because it is only safe once the fleet is off the shared key.
## Alternatives rejected
**Client certificates, 2026-09-14.** This was not a thought experiment: it was
built and then removed three days later. `client_cert.py`, certificate renewal,
expiry monitoring, an unauthenticated `/test/client-cert` route, nginx
client-certificate forwarding and an agent certificate script all shipped on a
branch and were all deleted.
Three reasons, and the first is fatal on its own:
1. **The header was forgeable.** Behind IIS the client certificate reaches the
app as a request header, and nothing stopped a caller from simply sending
that header itself.
2. **The private key was readable by users** on the endpoint, so the certificate
was no better protected than the key it replaced.
3. **The shared key remained underneath it** as a fallback, so the weakest path
was still open and the certificate bought nothing.
It also cost more than it looked. The attempt put five underscore-spelled
certificate columns on the CORE `Asset` model for a plugin's feature and
retracted them again, with no contract bump in either direction - an ADR-001 and
ADR-002 breach inside an unreleased branch. Its migrations
(`endpoints0021clientcerts`, `0022`) survive as retired no-ops so a database
that ran them still upgrades; `0023` drops the columns where a database has
them.
**ShopDB-resolved credential targeting (ADR-016).** Decided 2026-08-11 and never
implemented. It gave a PC a credential chosen by rules over eight targeting axes
with an explicit priority order and an explain endpoint to debug the result. The
targeting was the whole design, and the exposure that needed closing was not a
targeting problem: the fleet needed identity, not a rule engine. ADR-016 is
superseded rather than deleted because the problem it opens with - a cleartext
controller password on a share, with no rotation story - is still unsolved. This
ADR does not solve it.
**Keep one key and just lock down where it lives.** The registry ACL is part of
this decision anyway, and it is worth having on its own. It does not help: one
secret shared by every PC still means the server cannot identify a caller, a
revoked bay cannot be evicted without rotating the whole fleet, and any
administrator or any process running as SYSTEM on any bay still reads the key
that drives every other bay's command channel.
## Consequences
**The shared key remains until every consumer is migrated.** That is the main
cost of this decision and it is not short. The key lives in a GE-Enforce
manifest entry's `Args`, in `CollectorKey` in both registry views, in a text file
in the share's `common/configs`, in `appsettings.json` in the agent directory, in
every generated `install.ps1` that was kept, and on display kiosks. Each one has
to be cleared in a specific order - retire the entry that rewrites the value
before deleting the value - and the agent never deletes the shared key itself,
because other SYSTEM scripts may still read it. The runbook
(`plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md`) carries the table.
**Turning the shared key off is wider than the agent.** It is also refused on
`/api/collector/computers` and `/api/collector/printers`, so a PC with reporter
scripts but no enrolled agent goes silent at cutover.
**Enrollment is a new admin queue.** Anything that cannot be auto-issued waits
for a person: unknown or duplicate hostname, missing or mismatched serial, a
one-off PC nobody wrote a manifest entry for. A dashboard row counts PCs waiting
and PCs blocked from re-enrolling.
**Pins have to arrive before the agent does.** An agent that pins with no pin set
falls back to plain Windows chain validation, which a self-signed or internal-CA
server may fail, and a pin set after the fact is a per-bay debugging job.
Rotating the server key becomes a two-phase fleet operation: publish old and new
pins everywhere, switch the certificate, then retire the old pin. Switching
first cuts off every pinned PC, including its route to the corrected pin list.
**GE-Enforce can undo an upgrade.** Agents installed from the share are held to a
file hash by a manifest entry. A store upgrade that lands a newer agent while the
entry still carries the old hash is reverted on the next cycle, so the entries
have to be re-stamped or removed before the waves reach those PCs.
**Revoke means two different things depending on one setting.** With
`revokeddeniessharedkey` off, revoking drops a PC back to the shared key, which
is how rollback and credential rotation work. With it on, revoking is a real
eviction and the PC reports nothing until it enrolls again.
**A PC that cannot reach ShopDB keeps its credential.** Unlike ADR-016's fetch
model, there is nothing to re-fetch per cycle: the secret is local and long-lived
until an admin revokes it.
## Implementation status
**Built, and in this tree.** `plugins/endpoints/api/agentauth.py`,
`api/enrollment.py` and `models/credential.py` are all present,
`shopdb/core/api/collector.py` reads `X-Endpoint-Credential` on the generic
collector route, `AGENT-CONTRACT.md` carries section 9, and
`tests/test_plugins/test_endpoints_credentials.py` covers the auth decisions.
The C# agent enrolls and sends the header, and both shipped PowerShell reporters
send it when a credential is present and fall back to the shared key when it is
not.
Proven on a Windows test PC against a test server: self-enrollment, the shared
key refused for an enrolled PC, a request naming another asset refused, pins
enforced, the tray pipe working for a non-admin user, both SYSTEM reporters
sending with the per-PC credential, revoke followed by re-enrollment, rollback
to the previous agent, and enrolled PCs still working with the shared key
switched off.
What is NOT finished is the fleet migration, which is the consequence this ADR
opens with: every PC that has not enrolled is still on the shared key by design,
and the key still lives in the places listed above. The runbook is
`plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md`.
## References
- `plugins/endpoints/api/agentauth.py` - credential and shared-key authentication
- `plugins/endpoints/api/enrollment.py` - enroll, approve, revoke, migration waves
- `plugins/endpoints/models/credential.py` - credential, enroll request, wave
- `plugins/endpoints/AGENT-CONTRACT.md` section 9 - the wire contract
- `plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md` - the rollout runbook
- ADR-016 - the superseded targeting model

69
docs/adr/README.md Normal file
View File

@@ -0,0 +1,69 @@
<!-- docmeta
purpose: reference
audience: developer, adopter
status: current
summary: Which architectural decision governs your question, and whether it still holds.
loadwith: none
-->
# Architecture Decision Records
An ADR captures one decision: the context, the decision, the consequences and
the alternatives. ADRs are immutable once accepted. A superseded ADR stays in
this folder, marked at the top, pointing forward.
**Read the index, not the folder.** Find the one line that matches your
question, open that ADR, and skip the rest. Nothing here is a prerequisite for
anything else.
## Which decision governs your question
| ADR | The question it answers | Holds? |
|---|---|---|
| [001](ADR-001-asset-as-platform-contract.md) | What may a plugin depend on? What is the versioned platform surface? | In force |
| [002](ADR-002-plugin-versioning.md) | How is that surface versioned, and what does a plugin's `core_version` mean? | In force |
| [003](ADR-003-plugin-distribution.md) | How does a plugin reach a site - bundled, or from its own repo? | In force. ADR-013 added the catalog, artifact and signing on top |
| [004](ADR-004-deployment-topology.md) | One instance per site, or one multi-tenant instance for all sites? | In force, except its "Migration strategy" section, replaced by ADR-008 |
| [005](ADR-005-equipment-vs-measuringtools.md) | Are metrology instruments a separate asset class from machinery? | **Superseded by [018](ADR-018-metrology-instruments-are-machines.md).** Answer reversed |
| [006](ADR-006-collector-contract.md) | How does a plugin accept data pushed in from outside? | In force |
| [007](ADR-007-product-versioning-and-releases.md) | What version is the PRODUCT, as distinct from the plugin contract? | In force |
| [008](ADR-008-plugin-migration-ownership.md) | Whose Alembic chain owns a plugin's tables? | In force |
| [009](ADR-009-frontend-plugin-gating.md) | What happens when someone opens a URL belonging to a disabled plugin? | In force |
| [010](ADR-010-frontend-plugin-hooks.md) | How does a plugin add UI without editing core Vue files? | In force |
| [011](ADR-011-machines-rename.md) | Why is the code called `machines` and what happened to `machinetypes`? | In force |
| [012](ADR-012-geenforce-manifest-ownership.md) | Does ShopDB own GE-Enforce manifests, and does it own the engine? | In force |
| [013](ADR-013-plugin-catalog-and-lean-builds.md) | How does a site ship only the plugins it chose, and how is plugin code trusted? | In force |
| [014](ADR-014-schema-lean-per-site.md) | Does a lean site still create tables for plugins it never installed? | In force |
| [015](ADR-015-site-specific-configuration.md) | Where may one site's hostnames, shares and defaults live? | In force |
| [016](ADR-016-credential-delivery.md) | How does a bay get a controller credential without a cleartext file on a share? | **Superseded by [019](ADR-019-per-pc-endpoint-credentials.md), and never built.** Do not build against it |
| [017](ADR-017-buildings-and-levels.md) | How does the floor map hold more than one floor and more than one building? | In force |
| [018](ADR-018-metrology-instruments-are-machines.md) | Where do CMMs and vision systems live now that `measuringtools` is retired? | In force |
| [019](ADR-019-per-pc-endpoint-credentials.md) | How does the server know WHICH PC is calling, and what replaced the fleet-wide key? | In force. Built, not yet merged into this tree |
Related decisions that are not ADRs, kept as records of why something was built:
[docs/proposals](../proposals/README.md).
## Status vocabulary
The `Status:` line at the top of each ADR uses these, and nothing else:
- **PROPOSED** - drafted, awaiting decision.
- **ACCEPTED** - in effect.
- **SUPERSEDED by ADR-NNN** - replaced. The link forward is part of the status,
and the superseded ADR carries a note at the top saying not to build against
it and what changed.
- **DEPRECATED** - no longer in effect, with no replacement.
## Authoring
1. Copy the most recent ADR as a template and increment the number.
2. Start the file with a `docmeta` comment, before the H1. ADRs are
`purpose: explanation`.
3. Add a row to the table above: the QUESTION it answers, not its title.
4. Add it to [docs/INDEX.md](../INDEX.md) too - under "Decisions" while it is
current, under "Archive" once it is superseded. It has to be a table ROW
with its real line count, not a mention in a paragraph:
`tests/test_docs_index.py` fails on an ADR no row routes to, and on a row
whose line count is stale.
5. Never edit an accepted ADR's decision. Supersede it with a new one, and mark
the old one at the top - ADR-005 and ADR-016 are the worked examples.

View File

@@ -1,177 +1,136 @@
# Deploy ShopDB to Hyper-V VM
<!-- docmeta
purpose: howto
audience: adopter, developer
status: current
summary: Put the Docker stack on a throwaway Windows VM to try it or rehearse a deploy, and what the helper script gets wrong.
loadwith: none
-->
# Deploying the stack to a Windows VM
Quick guide to deploy the Docker stack to a Windows 11 Hyper-V VM.
> **Where this sits:** a rehearsal, not a site. For a real site follow
> [DEPLOY](../DEPLOY.md). To only LOOK at a build with no source at all,
> [DEV-BUILD-TEST](../DEV-BUILD-TEST.md) pulls a published image and is one
> script.
## Prerequisites on VM
A Windows VM with Docker Desktop is the cheapest way to prove a deploy before
doing it for real: the stack is identical to a site's, the VM can be reverted,
and nothing on it matters. This page is the VM-specific part - getting the code
there, the firewall, reaching it from the host. Everything else is
[DEPLOY](../DEPLOY.md).
1. **Docker Desktop** installed and running
2. **Git Bash** or **WSL** installed (for bash commands)
3. **PowerShell** (already on Windows 11)
## On the VM
## Step 1: Copy Files to VM
1. **Docker Desktop**, installed and running.
2. **Git Bash or WSL**, for the bash commands.
3. Enough RAM given to Docker. The stack is MySQL plus a Python container plus
nginx; 8GB to Docker is comfortable.
## Step 1: Get the code onto the VM
**Method A: Shared Folder**
```powershell
# On host PC, share the repo
# Then on VM, copy from network share
Copy-Item -Recurse \\host-pc\shopdb-flask C:\shopdb
```
# From a network share
Copy-Item -Recurse \\<host>\shopdb-flask C:\shopdb
**Method B: Git Clone**
```bash
# On VM
cd C:\
git clone git@github.com:ge-aero/shopdb-flask.git
cd shopdb-flask
git checkout wj
```
# Or clone it
git clone <your-remote>/shopdb-flask.git C:\shopdb
**Method C: Export/Import**
```powershell
# On host PC
Compress-Archive -Path C:\Users\270015376\Documents\github\flask-shopdb -DestinationPath C:\shopdb-deploy.zip
# Copy shopdb-deploy.zip to VM, then on VM:
# Or hand it over as a zip, which is what an isolated VM usually wants
Expand-Archive -Path C:\shopdb-deploy.zip -DestinationPath C:\shopdb
```
## Step 2: Run Deployment Script
## Step 2: Bring it up
The pre-built path is the one to use on a VM: npm inside the image is slow and
is the step most likely to fail behind a proxy. Build the frontend once on the
host or on the VM, then build the image from it -
[DOCKER-PREBUILT-APPROACH](DOCKER-PREBUILT-APPROACH.md).
```powershell
# On VM, open PowerShell as Administrator
cd C:\shopdb
copy .env.example .env
# Edit .env: SECRET_KEY, JWT_SECRET_KEY, MYSQL_ROOT_PASSWORD, MYSQL_PASSWORD,
# CORS_ORIGINS. Generate a secret with:
# python -c "import secrets; print(secrets.token_urlsafe(64))"
# Option 1: Quick deploy with defaults
.\deploy-to-vm.ps1
cd frontend; npm ci; npm run build; cd ..
# Option 2: Specify site name and password
.\deploy-to-vm.ps1 -SiteName "west-jefferson" -AdminPassword "YourSecurePassword123!"
# Option 3: See all options
.\deploy-to-vm.ps1 -Help
```
## What the Script Does
1. [OK] Checks Docker Desktop is running
2. [OK] Creates .env from template
3. [OK] Builds frontend (if needed)
4. [OK] Builds Docker images (2-3 minutes, offline packages)
5. [OK] Starts all services (db + api + backup)
6. [OK] Runs database migrations
7. [OK] Sets admin password
## Manual Deployment (Alternative)
If you prefer to run commands manually:
```bash
# 1. Create environment
cp .env.docker-dev .env
# 2. Build frontend (if not already built)
cd frontend
npm ci
npm run build
cd ..
# 3. Build and start Docker stack
docker compose -f docker-compose.prebuilt.yml -f docker-compose.backup.yml --profile backup build
docker compose -f docker-compose.prebuilt.yml -f docker-compose.backup.yml --profile backup up -d
# 4. Initialize database
docker exec flask-shopdb-api-1 flask db upgrade
# 5. Reset admin password (optional)
docker exec flask-shopdb-db-1 mysql -uroot -proot-dev-password-not-for-production shopdb_flask -e \
"UPDATE users SET passwordhash='scrypt:32768:8:1\$B4LhpPrM6qxijInK\$b99b47a0bb5fd45761a1823f401f70a3ceccf71a5d38dc433378aa07426ef8eaf7e679ff3dd7b0ce9772600f83368ac642e9419b9beaa49f87483318bae34547', failedlogins=0, lockeduntil=NULL WHERE username='admin';"
# (Password is: admin)
```
## Access
The `migrate` service runs the schema, the plugin bootstrap and the seeders
before `api` starts, so there is no separate migration step. Watch it with
`docker compose logs migrate`.
**URL:** https://localhost:5001 (or https://vm-ip-address:5001 from host)
**Login:**
- Username: `admin`
- Password: Whatever you set in deploy script (default: `ChangeMe123!`)
## Verify Deployment
Create the first admin at `https://localhost:5001/setup`, or headlessly:
```powershell
# Check all services are running
docker ps
# Should see:
# - flask-shopdb-db-1
# - flask-shopdb-api-1
# - flask-shopdb-backup-store-1
# - flask-shopdb-backup-watcher-1
# View logs
docker logs -f flask-shopdb-api-1
# Test backup
# Go to https://localhost:5001/settings/shopdbbackups
# Click "Back Up Now"
# Wait 30 seconds, should show as "Checked"
docker compose exec api flask seed admin --username admin --email admin@example.com
```
## Troubleshooting
The password is generated and printed once.
**Docker not running:**
```powershell
# Start Docker Desktop manually
& "C:\Program Files\Docker\Docker\Docker Desktop.exe"
Start-Sleep -Seconds 30
```
## Step 3: Reach it from the host
**Port 5001 already in use:**
```powershell
# Edit .env and change API_PORT
# API_PORT=5002
```
**Can't access from host PC:**
```powershell
# On VM, check Windows Firewall
New-NetFirewallRule -DisplayName "ShopDB" -Direction Inbound -LocalPort 5001 -Protocol TCP -Action Allow
```
**SSL certificate warnings:**
- Normal for self-signed certs
- Click "Advanced" -> "Proceed to localhost (unsafe)"
Then `https://<vm-ip>:5001` from the host. The certificate is self-signed until
you replace the pair in `certs/`, so the browser warns; that is expected on a
VM.
## Stop/Start
`CORS_ORIGINS` is an exact origin match, so it has to name the address the
browser really uses. Reaching the VM by IP while `CORS_ORIGINS` names
`localhost` is the usual cause of "the page loads and every request fails".
## About `deploy-to-vm.ps1`
The repository root carries `deploy-to-vm.ps1`, which wraps the above into eight
steps with `-SiteName` and `-AdminPassword` parameters. **Read it before you run
it.** As it stands it does not work on a clean clone, and neither problem is
subtle:
- It copies `.env.docker-dev` to create `.env`. That file is not in the
repository, so step 3 fails on a fresh checkout. Create `.env` from
`.env.example` yourself first and the script skips that branch.
- It addresses containers by the fixed names `flask-shopdb-api-1` and
`flask-shopdb-db-1`. Compose names containers after the project, which is the
directory name, so on a clone called `shopdb` those `docker exec` calls
miss. Use `docker compose exec api` instead, or `docker compose ps` to see
the real names.
It also runs only `flask db upgrade` for the database, which predates the
`migrate` service and is now redundant rather than wrong.
Its default `-AdminPassword` is a literal in the file and therefore public.
Pass your own, and never leave the default on anything reachable.
## Stop, start, update
```bash
# Stop
docker compose -f docker-compose.prebuilt.yml -f docker-compose.backup.yml down
# Start (after first deployment)
docker compose -f docker-compose.prebuilt.yml -f docker-compose.backup.yml --profile backup up -d
```
## Update After Code Changes
`down` keeps the volumes. `down -v` deletes them, which is the database and
`instance/` together, with no prompt.
To take new code:
```bash
# 1. Pull latest code
git pull origin wj
# 2. Rebuild frontend (if changed)
cd frontend && npm run build && cd ..
# 3. Rebuild and restart
git pull
cd frontend; npm run build; cd ..
docker compose -f docker-compose.prebuilt.yml -f docker-compose.backup.yml build
docker compose -f docker-compose.prebuilt.yml -f docker-compose.backup.yml up -d
```
## Offline Deployment Notes
Rebuilding the frontend is not optional on the pre-built path: the image copies
whatever is in `frontend/dist/`, so a skipped `npm run build` ships the old UI
with no warning.
**This deployment works completely offline** because:
- [OK] Frontend pre-built and checked into repo
- [OK] Restic binary pre-downloaded (8.6MB in repo)
- [OK] No npm/pip downloads during Docker build
- [OK] All dependencies baked into images
## Why this works with no network
Perfect for air-gapped or restricted network environments!
With `frontend/dist/` already built and the Python wheels vendored, the image
build downloads nothing, which is what makes the same procedure the rehearsal
for [DEPLOY-AIRGAP](../DEPLOY-AIRGAP.md).

View File

@@ -1,96 +1,87 @@
# Docker Pre-Built Frontend Approach
<!-- docmeta
purpose: howto
audience: adopter, developer
status: current
summary: Build the Docker image where npm cannot reach the registry, by compiling the frontend on the host first.
loadwith: none
-->
# Building the image with a pre-built frontend
**Problem:** Zscaler corporate proxy causes npm to fail inside Docker builds with "Exit handler never called" error.
> **Where this sits:** a variant of Step 2 of [DEPLOY](../DEPLOY.md), not a
> separate deployment. Everything else on that page still applies.
**Solution:** Build frontend on host (where npm works), copy pre-built dist/ into Docker image.
The shipped `Dockerfile` is a multi-stage build: stage 1 runs `npm ci` and
`npm run build` under Node, stage 2 copies the compiled SPA into the Python
image. Behind a proxy that re-signs HTTPS, stage 1 is where it dies - npm inside
the container does not trust the corporate root CA, and the failure reads
`npm ci failed` or the unhelpful `Exit handler never called`.
## Quick Start
The fix is to build the frontend where npm already works - the host, which has
the corporate CA in its trust store - and give the image the finished
`frontend/dist/`.
It is also much faster: two to three minutes instead of fifteen. A site with no
proxy problem at all may want it for that reason alone.
## Do it
```bash
# 1. Build frontend on host
# 1. Build the frontend on the host
cd frontend
npm ci
npm run build
# 2. Build Docker image (uses pre-built dist/)
npm run build # writes frontend/dist/
cd ..
# 2. Build and start the stack from the pre-built dist
docker compose -f docker-compose.prebuilt.yml build
# 3. Start services
docker compose -f docker-compose.prebuilt.yml up -d
# 4. Initialize (first time only)
docker compose -f docker-compose.prebuilt.yml exec api flask db upgrade
docker compose -f docker-compose.prebuilt.yml exec api bash /app/scripts/bootstrap-plugins.sh
# 5. Access
https://localhost:5001
```
## Files
The rest is [DEPLOY](../DEPLOY.md) unchanged: the `migrate` service runs the
schema, the plugin bootstrap and the seeders before `api` starts, nginx
terminates TLS on `API_PORT`, and the first admin comes from `/setup` or
`flask seed admin`.
- `Dockerfile.prebuilt` - Simplified Dockerfile without npm build
- `docker-compose.prebuilt.yml` - Uses Dockerfile.prebuilt
- `.dockerignore.prebuilt` - Allows frontend/dist to be copied
## The files this uses
## Why This Works
| File | What it is |
|---|---|
| `Dockerfile.prebuilt` | single-stage: Python dependencies plus a copy of `frontend/dist/`. No Node, no npm |
| `docker-compose.prebuilt.yml` | the same stack, building from `Dockerfile.prebuilt` |
| `.dockerignore.prebuilt` | permits `frontend/dist/` to be copied, which the normal `.dockerignore` excludes |
**Normal Dockerfile:**
- Multi-stage build
- Stage 1: Node 20 runs `npm ci` and `npm run build`
- Stage 2: Copies built frontend from stage 1
- **PROBLEM:** npm fails inside Docker with Zscaler proxy
## What it costs
**Pre-Built Approach:**
- Build frontend on Windows host (npm works here)
- Single-stage Dockerfile copies pre-built `frontend/dist/`
- Only installs Python dependencies
- **FASTER:** 2-3 minutes vs 15+ minutes
- **npm has to exist on the build host.** That is the whole trade: the trust
problem moves from the container to a machine that has already solved it.
- **It is two steps, and forgetting the first one ships a stale UI.** The image
copies whatever is in `frontend/dist/` at build time. A frontend change that
was not rebuilt is simply not in the image, and nothing warns you. If the UI
does not show your change, rebuild `dist/` before debugging anything else.
- **`frontend/dist/` has to reach the build.** Either it is committed, or the
pipeline builds it immediately before the image.
## Trade-offs
## The version of this that scales
**Pros:**
- Avoids corporate proxy issues
- Much faster builds (no npm in container)
- Frontend build errors visible immediately
- Can iterate on frontend without rebuilding container
**Cons:**
- Requires npm on host machine
- Two-step build process
- frontend/dist must be in version control OR built before deployment
## For Production (CI/CD)
The pre-built approach is a workaround for building at the deployment site. The
end state is not building there at all:
```yaml
# GitHub Actions / Jenkins example
- name: Build frontend
run: |
cd frontend
npm ci
npm run build
- name: Build Docker image
run: |
docker build -f Dockerfile.prebuilt -t shopdb:${{ github.sha }} .
- name: Push to registry
run: |
docker push shopdb:${{ github.sha }}
# CI: build the frontend, build the image, push it
- run: cd frontend && npm ci && npm run build
- run: docker build -f Dockerfile.prebuilt -t shopdb:$GIT_SHA .
- run: docker push <registry>/shopdb:$GIT_SHA
```
## Comparison
A site then pulls a tagged image and runs it - seconds, no build step, no npm
anywhere near the facility. `docker-compose.pull.yml` is the compose file for
that shape today, and `docker-compose.airgap.yml` is the same idea for a site
that loads an image tarball off removable media
([DEPLOY-AIRGAP](../DEPLOY-AIRGAP.md)). Finishing that path is the distributed
release item on [ROADMAP](../ROADMAP.md).
| Approach | Build Time | Corporate Proxy | Complexity | Production Ready |
|----------|-----------|----------------|------------|------------------|
| Multi-stage Dockerfile | 15+ min | [FAIL] Fails | High | [FAIL] No |
| Pre-built frontend | 2-3 min | [OK] Works | Medium | [WARN] Yes (with CI/CD) |
| Pre-built images from registry | 30 sec | [OK] Works | Low | [OK] Yes |
## Next Steps
For true production deployment:
1. Set up CI/CD to build frontend + Docker image
2. Push to container registry
3. Sites pull pre-built images
4. No build step at deployment site
| Approach | Build time | Behind a re-signing proxy | Where it belongs |
|---|---|---|---|
| Multi-stage `Dockerfile` | 15+ min | fails unless the CA is staged | a developer box with a clean network |
| Pre-built frontend (this page) | 2-3 min | works | a site that must build locally |
| Pull a published image | seconds | works | where every site should end up |

View File

@@ -1,398 +0,0 @@
# ShopDB Docker Setup
Complete Docker deployment for development and testing.
## Prerequisites
- Docker Desktop installed and running
- At least 8GB RAM allocated to Docker
- Zscaler corporate certificate extracted (if behind corporate proxy)
## Quick Start
```bash
# 1. Clone repository
cd C:/Users/YOUR_USERNAME/Documents/github/flask-shopdb
# 2. Extract Zscaler certificate (if needed)
powershell.exe -File C:/temp/extract-ca.ps1
# This creates corp-root-ca.pem in repo root
# 3. Build images
docker compose build
# 4. Start stack
docker compose up -d
# 5. Initialize database (first time only)
docker compose exec api flask db upgrade
# 6. Install plugins (first time only)
docker compose exec api flask plugin apply-profile deploy/site-profile-universal.json
# 7. Access ShopDB
https://localhost:5001
```
Login: `admin` / (reset password - see below)
## Architecture
The Docker stack includes:
1. **MySQL Database** (`db` service)
- Port: 3306 (internal), mapped to host based on `.env`
- Volume: `db_data` for persistence
- Health checks enabled
2. **Flask API** (`api` service)
- Port: 5001 (HTTPS with self-signed cert)
- Built frontend served from `/app/frontend/dist`
- Gunicorn with gevent workers (4 workers, 2000 connections each)
- Volumes:
- `instance_data` - Plugin state, uploads, branding
- `./plugins` - Plugin code (read-only)
- `./scripts` - Deployment scripts (read-only)
- `./certs` - TLS certificates (read-only)
3. **Backup Service** (optional, via `docker-compose.backup.yml`)
- One-shot container for backups
- Triggered manually or via scheduler
## First-Time Setup
### 1. Extract Zscaler Certificate (Corporate Proxy)
Save this as `C:/temp/extract-ca.ps1`:
```powershell
$request = [System.Net.HttpWebRequest]::Create('https://registry.npmjs.org/')
$request.Method = 'HEAD'
$request.Timeout = 10000
$response = $request.GetResponse()
$root = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($request.ServicePoint.Certificate)
$builder = [System.Text.StringBuilder]::new()
[void]$builder.AppendLine('-----BEGIN CERTIFICATE-----')
[void]$builder.AppendLine([System.Convert]::ToBase64String($root.RawData, 'InsertLineBreaks'))
[void]$builder.AppendLine('-----END CERTIFICATE-----')
$certPath = 'C:\Users\270015376\Documents\github\flask-shopdb\corp-root-ca.pem'
Set-Content -Path $certPath -Value $builder.ToString() -Encoding ASCII
Write-Host "Staged corp-root-ca.pem"
```
Run: `powershell.exe -ExecutionPolicy Bypass -File C:/temp/extract-ca.ps1`
### 2. Generate TLS Certificates
```bash
cd certs
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout server.key -out server.crt -days 365 \
-subj "//CN=localhost"
```
Or use the provided script:
```bash
./scripts/generate-dev-certs.sh
```
### 3. Configure Environment
Copy and edit `.env`:
```bash
cp .env.docker-dev .env
# Edit if needed - defaults work for local dev
```
### 4. Build Images
```bash
docker compose build
```
**Note:** First build takes 10-15 minutes due to:
- npm install (~5 min with Zscaler proxy)
- Frontend Vite build (~2 min)
- Python dependencies (~3 min)
Subsequent builds use Docker layer cache and are much faster.
### 5. Start Services
```bash
docker compose up -d
```
Check status:
```bash
docker compose ps
docker compose logs -f api
```
### 6. Initialize Database
```bash
# Run migrations
docker compose exec api flask db upgrade
# Install and enable all plugins
docker compose exec api flask plugin apply-profile deploy/site-profile-universal.json
```
The `apply-profile` command:
- Installs all 17 plugins
- Registers them in `instance/plugins.json`
- Leaves them disabled by default
- Enables them via Settings UI as needed
### 7. Reset Admin Password
```bash
# Generate hash for "admin" password
docker compose exec api python -c "from werkzeug.security import generate_password_hash; print(generate_password_hash('admin'))"
# Update database
docker compose exec db mysql -u root -p shopdb_flask -e "
UPDATE users
SET passwordhash = 'PASTE_HASH_HERE',
failedlogins = 0,
lockeduntil = NULL
WHERE username = 'admin';"
```
Or use quick method (sets password to `admin`):
```bash
docker compose exec db mysql -u root -p shopdb_flask -e "
UPDATE users SET
passwordhash = 'scrypt:32768:8:1\$B4LhpPrM6qxijInK\$b99b47a0bb5fd45761a1823f401f70a3ceccf71a5d38dc433378aa07426ef8eaf7e679ff3dd7b0ce9772600f83368ac642e9419b9beaa49f87483318bae34547',
failedlogins = 0,
lockeduntil = NULL
WHERE username = 'admin';"
```
## Import Production Database
```bash
# Copy SQL dump to container
docker cp latest.sql shopdb-api-1:/tmp/
# Import
docker compose exec db mysql -u root -p shopdb_flask < /tmp/latest.sql
# Run migrations to bring schema up to date
docker compose exec api flask db upgrade
docker compose exec api flask plugin upgrade-all
```
## Common Commands
### View Logs
```bash
docker compose logs -f api
docker compose logs -f db
```
### Restart Services
```bash
docker compose restart api
docker compose down && docker compose up -d
```
### Rebuild After Code Changes
```bash
docker compose build api
docker compose up -d api
```
### Access Container Shell
```bash
docker compose exec api bash
docker compose exec db bash
```
### Run Flask Commands
```bash
docker compose exec api flask plugin list
docker compose exec api flask db current
docker compose exec api flask routes
```
### Backup & Restore
```bash
# Trigger backup
docker compose -f docker-compose.backup.yml up backup
# View backups
docker compose exec api flask plugin list
# (Accessible via Settings > Backups in UI)
# Restore (via UI at Settings > Backups > Select backup > Restore)
```
## Troubleshooting
### "npm ci failed" during build
**Cause:** Zscaler proxy blocking npm registry.
**Fix:**
1. Extract Zscaler certificate (see above)
2. Ensure `corp-root-ca.pem` exists in repo root
3. Rebuild: `docker compose build --no-cache`
### "database is unhealthy"
**Cause:** MySQL container not ready or port conflict.
**Fix:**
```bash
# Check logs
docker compose logs db
# Check if port 3306 is already in use
netstat -an | grep 3306
# If conflict, edit .env and set MYSQL_PORT=3307
```
### "Access denied for user 'shopdb'"
**Cause:** Database credentials mismatch.
**Fix:** Check `.env` file - `MYSQL_PASSWORD` must match `DATABASE_URL`.
### "Could not connect to database"
**Cause:** Database service not started or wrong hostname.
**Fix:**
```bash
# Check db is running
docker compose ps
# API must use hostname 'db' not 'localhost'
# In docker-compose.yml: DATABASE_URL=...@db:3306/...
```
### "ERR_CERT_AUTHORITY_INVALID" in browser
**Expected:** Self-signed certificate warning.
**Fix:** Click "Advanced" > "Proceed to localhost (unsafe)" - this is normal for dev.
Or generate proper cert:
```bash
# Add your machine's hostname to cert
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout certs/server.key -out certs/server.crt -days 365 \
-subj "//CN=$(hostname)" \
-addext "subjectAltName=DNS:localhost,DNS:$(hostname),IP:127.0.0.1"
```
### "Plugin X is already installed" but not visible
**Cause:** Plugin installed but not enabled.
**Fix:**
```bash
docker compose exec api flask plugin enable X
docker compose restart api
```
### Build is very slow
**Expected:** First build with Zscaler proxy takes 10-15 minutes.
**Speed up:**
- Subsequent builds use cache (much faster)
- If rebuilding often, comment out `--no-cache` flag
- Build frontend separately on host, copy dist/ into image
## Production Deployment Notes
For production:
1. **Change all secrets** in `.env`:
- `SECRET_KEY`
- `JWT_SECRET_KEY`
- `MYSQL_ROOT_PASSWORD`
- `MYSQL_PASSWORD`
2. **Use proper TLS certificates** (not self-signed):
- Place in `./certs/server.crt` and `./certs/server.key`
- Or use reverse proxy (nginx/IIS) for TLS termination
3. **Set `FLASK_DEBUG=0`** in production
4. **Configure backups**:
- Set up cron job: `0 2 * * * cd /path/to/shopdb && docker compose -f docker-compose.backup.yml up backup`
- Configure SMB or S3 destination in Settings > Backups
5. **Set `SHOPDB_ENV_LABEL`** to site name (e.g., "WEST-JEFFERSON")
6. **Review `docker-compose.yml`**:
- Adjust worker count based on load
- Configure proper `restart` policy
- Set resource limits if needed
## Architecture Notes
### Why HTTPS in Docker?
Secure context APIs (clipboard, geolocation) require HTTPS. Running over HTTP disables these browser features.
### Why Gevent Workers?
Endpoint agents long-poll `/api/endpoints/commands/next`. With threading, 200 agents = 200 threads = resource exhaustion. Gevent uses greenlets (cheap coroutines) so 2000+ connections per worker is feasible.
### Plugin Bootstrap
The entrypoint (`scripts/docker-entrypoint.sh`) automatically:
1. Runs database migrations
2. Checks if `instance/plugins.json` exists
3. If not, installs all available plugins (disabled)
4. Starts gunicorn
This ensures imported databases with plugin tables get matching plugin registry.
### Instance Data Persistence
The `instance_data` volume persists:
- `plugins.json` - Which plugins are enabled
- Uploaded files (floor plans, branding, photos, warranties, slides)
- Dell OAuth tokens
- Any plugin-specific state
**Important:** `docker compose down -v` deletes volumes - use plain `docker compose down` to preserve data.
## File Structure
```
flask-shopdb/
|-- docker-compose.yml # Main stack definition
|-- docker-compose.backup.yml # Backup service
|-- Dockerfile # Multi-stage build
|-- .env # Environment config (gitignored)
|-- corp-root-ca.pem # Zscaler cert (gitignored)
|-- certs/ # TLS certificates (gitignored)
| |-- server.crt
| +-- server.key
|-- scripts/
| |-- docker-entrypoint.sh # Container startup
| +-- bootstrap-plugins.sh # Plugin auto-install
+-- deploy/
+-- site-profile-universal.json # Full plugin list
```
## Next Steps
After setup:
1. Browse to https://localhost:5001
2. Login as `admin`
3. Go to Settings > Plugins
4. Enable plugins your site needs
5. Configure settings (CORS, backup destination, etc.)
6. Import data or start fresh
For local development with hot-reload, see `LOCAL-DEV-SETUP.md`.

View File

@@ -1,372 +0,0 @@
# ShopDB Local Development Setup (Windows)
Complete guide for setting up ShopDB Flask development environment on Windows.
## Prerequisites
- Python 3.13+
- Docker Desktop (for MySQL and testing)
- Git
- Node.js 20+ (for frontend development)
## Quick Start
```bash
# 1. Clone and enter repo
cd C:/Users/YOUR_USERNAME/Documents/github/flask-shopdb
# 2. Create Python virtual environment
python -m venv venv
./venv/Scripts/activate
pip install -r requirements.txt
# 3. Start MySQL in Docker
docker compose up -d db
# 4. Configure environment
cp .env.docker .env
# Edit .env and set DATABASE_URL (see below)
# 5. Install and enable plugins
export DATABASE_URL="mysql+pymysql://root:root-dev-password-not-for-production@localhost:3307/shopdb_flask_dev"
./venv/Scripts/flask.exe plugin install backups computers employees endpoints geenforce guides knowledgebase machines measuringtools network notifications printedparts printers slides tools usb warranty
./venv/Scripts/flask.exe plugin enable <each-plugin-name>
# 6. Start Flask
./venv/Scripts/python.exe run_dev.py
```
Visit http://localhost:5001 and login with `admin` / `admin`.
## Detailed Setup
### 1. Database Setup
MySQL runs in Docker on **port 3307** (not 3306) to avoid conflicts with any local MySQL instance.
```bash
# Start MySQL container
docker compose up -d db
# Verify it's running
docker ps --filter name=shopdb-dev-db
# Should show: 127.0.0.1:3307->3306/tcp
# Test connection
docker exec shopdb-dev-db mysql -u root -p'root-dev-password-not-for-production' -e "SELECT 1;"
```
### 2. Environment Configuration
Create `.env` file with:
```bash
# Database (port 3307 because Docker maps it there)
DATABASE_URL=mysql+pymysql://root:root-dev-password-not-for-production@localhost:3307/shopdb_flask_dev
# Flask
FLASK_ENV=development
FLASK_DEBUG=1
# Secrets (dev only)
SECRET_KEY=docker-dev-secret-key-change-for-production
JWT_SECRET_KEY=docker-dev-jwt-secret-change-for-production
# CORS
CORS_ORIGINS=http://localhost:5173,http://localhost:5001
# API Port
API_PORT=5001
# ShopDB Label
SHOPDB_ENV_LABEL=LOCAL-DEV
# Log Level
LOG_LEVEL=DEBUG
# Backup Storage (for local dev)
SHOPDB_BACKUPS_ROOT=C:/Users/YOUR_USERNAME/Documents/github/flask-shopdb/backups
```
**Important:** Change `YOUR_USERNAME` to your actual Windows username.
### 3. Import Database (Optional)
If you have a production database dump:
```bash
# Import latest.sql
docker exec -i shopdb-dev-db mysql -u root -p'root-dev-password-not-for-production' shopdb_flask_dev < latest.sql
# Run migrations
export DATABASE_URL="mysql+pymysql://root:root-dev-password-not-for-production@localhost:3307/shopdb_flask_dev"
./venv/Scripts/flask.exe db upgrade
```
### 4. Plugin Installation
All 17 plugins must be installed before they can be enabled:
```bash
# Set DATABASE_URL for CLI commands
export DATABASE_URL="mysql+pymysql://root:root-dev-password-not-for-production@localhost:3307/shopdb_flask_dev"
# Install all plugins
for plugin in backups computers employees endpoints geenforce guides knowledgebase machines measuringtools network notifications printedparts printers slides tools usb warranty; do
./venv/Scripts/flask.exe plugin install $plugin
done
# Enable all plugins
for plugin in backups computers employees endpoints geenforce guides knowledgebase machines measuringtools network notifications printedparts printers slides tools usb warranty; do
./venv/Scripts/flask.exe plugin enable $plugin
done
```
**Why this is necessary:**
- Plugin state is tracked in `instance/plugins.json`, not the database
- When you import a prod database, it has plugin tables but no plugin registry
- Installing registers plugins in the JSON file
- Enabling makes them active (but requires app restart)
### 5. Reset Admin Password
If you imported a prod database and don't know the password:
```bash
# Generate new password hash
./venv/Scripts/python.exe -c "from werkzeug.security import generate_password_hash; print(generate_password_hash('admin'))"
# Reset via MySQL
docker exec shopdb-dev-db mysql -u root -p'root-dev-password-not-for-production' shopdb_flask_dev -e "
UPDATE users
SET passwordhash = 'PASTE_HASH_HERE',
failedlogins = 0,
lockeduntil = NULL
WHERE username = 'admin';
"
```
Or use the simpler method:
```bash
docker exec shopdb-dev-db mysql -u root -p'root-dev-password-not-for-production' shopdb_flask_dev -e "
UPDATE users
SET passwordhash = 'scrypt:32768:8:1\$B4LhpPrM6qxijInK\$b99b47a0bb5fd45761a1823f401f70a3ceccf71a5d38dc433378aa07426ef8eaf7e679ff3dd7b0ce9772600f83368ac642e9419b9beaa49f87483318bae34547',
failedlogins = 0,
lockeduntil = NULL
WHERE username = 'admin';
"
```
Password is now: `admin` / `admin`
### 6. Starting Flask
**Always use `run_dev.py`** (not `flask run` directly):
```bash
./venv/Scripts/python.exe run_dev.py
```
This script:
- Loads `.env` file with DATABASE_URL
- Sets all environment variables
- Starts Flask without auto-reloader (prevents env var loss)
- Binds to 0.0.0.0:5001
**Do NOT use:**
- `flask run` - Won't load `.env` properly
- `flask run --reload` - Loses environment variables
### 7. Frontend Development (Optional)
To develop the Vue frontend:
```bash
cd frontend
npm ci
npm run dev
```
Frontend dev server runs on http://localhost:5173 and proxies API requests to Flask on port 5001.
To point the proxy at a backend on another machine, set `VITE_DEV_API_TARGET`
in your shell instead of editing `vite.config.js`:
```bash
VITE_DEV_API_TARGET=https://10.0.0.5:5001 npm run dev
```
Certificate verification is off for an https target because those boxes
normally use a self-signed cert; set `VITE_DEV_SECURE=true` to enforce it.
For production build (served by Flask):
```bash
cd frontend
npm run build
# Outputs to frontend/dist/
```
## Troubleshooting
### "Can't connect to MySQL on 127.0.0.1:3306"
**Cause:** Flask is trying port 3306 instead of 3307.
**Fix:**
1. Make sure `.env` has `DATABASE_URL=...@localhost:3307/...`
2. Use `run_dev.py` to start Flask (NOT `flask run`)
3. If problem persists, restart Flask completely
### "Cannot import name '_backups_configured'"
**Cause:** Merge conflict between wj and wilm backup implementations.
**Fix:** This was fixed in commit `8a1fe0ada8` - pull latest wj branch.
### "Plugin X is not installed"
**Cause:** Plugin registry (`instance/plugins.json`) doesn't exist or is incomplete.
**Fix:** Run plugin installation commands (section 4 above).
### "Access is denied: '/backups'"
**Cause:** Backup system trying to use Linux path on Windows.
**Fix:**
1. Add to `.env`: `SHOPDB_BACKUPS_ROOT=C:/Users/YOUR_USERNAME/Documents/github/flask-shopdb/backups`
2. Create directory: `mkdir backups`
3. Restart Flask with `run_dev.py`
### Docker npm install fails (Zscaler proxy)
**Cause:** Corporate proxy intercepts HTTPS, npm doesn't trust certificate.
**Fix:** Extract Zscaler root CA certificate:
```powershell
# Run this PowerShell script
$request = [System.Net.HttpWebRequest]::Create('https://registry.npmjs.org/')
$request.Method = 'HEAD'
$request.Timeout = 10000
$response = $request.GetResponse()
$root = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($request.ServicePoint.Certificate)
$builder = [System.Text.StringBuilder]::new()
[void]$builder.AppendLine('-----BEGIN CERTIFICATE-----')
[void]$builder.AppendLine([System.Convert]::ToBase64String($root.RawData, 'InsertLineBreaks'))
[void]$builder.AppendLine('-----END CERTIFICATE-----')
Set-Content -Path 'C:\Users\270015376\Documents\github\flask-shopdb\corp-root-ca.pem' -Value $builder.ToString() -Encoding ASCII
```
Then rebuild Docker: `docker compose build --no-cache`
## Common Tasks
### Run Tests
```bash
pytest
pytest tests/test_specific.py
pytest -v -s # Verbose with print output
```
### Run Migrations
```bash
export DATABASE_URL="mysql+pymysql://root:root-dev-password-not-for-production@localhost:3307/shopdb_flask_dev"
./venv/Scripts/flask.exe db upgrade
```
### Create Migration
```bash
./venv/Scripts/flask.exe db migrate -m "Description of changes"
```
### Check Plugin Status
```bash
./venv/Scripts/flask.exe plugin list
```
### Enable/Disable Plugin
```bash
./venv/Scripts/flask.exe plugin enable slides
./venv/Scripts/flask.exe plugin disable slides
```
### Trigger Backup
```bash
# Via API (requires admin JWT token)
curl -X POST http://localhost:5001/api/shopdbbackups \
-H "Authorization: Bearer YOUR_TOKEN"
# Or via Docker
docker compose -f docker-compose.backup.yml up backup
```
## Architecture Notes
### Why Port 3307?
Docker MySQL maps internal port 3306 to host port 3307 to avoid conflicts with any local MySQL installation. This is configured in `docker-compose.yml`:
```yaml
ports:
- "127.0.0.1:3307:3306"
```
### Why run_dev.py?
Flask's `flask run` command uses an auto-reloader that spawns a subprocess. This subprocess loses environment variables loaded from `.env`. The `run_dev.py` wrapper:
1. Loads `.env` via python-dotenv BEFORE importing Flask
2. Uses `--no-reload` flag to prevent subprocess spawning
3. Ensures DATABASE_URL and other vars persist
### Plugin Architecture
- Plugins are Python packages in `plugins/` directory
- Plugin state tracked in `instance/plugins.json` (not database)
- Plugin tables tracked via Alembic migration chains
- Each plugin has own migrations in `plugins/<name>/migrations/`
### Backup System
- Designed for Docker compose deployment
- API queues backups via sentinel file (`.backup-requested`)
- Actual backup execution requires Docker sidecar container
- On Windows dev, API can queue but won't execute without Docker
## File Structure
```
flask-shopdb/
|-- .env # Local environment config (gitignored)
|-- run_dev.py # Local development launcher
|-- shopdb/ # Flask application
| |-- __init__.py # App factory
| |-- config.py # Configuration classes
| |-- core/ # Core models and APIs
| +-- plugins/ # Plugin manager
|-- plugins/ # Plugin packages
| |-- backups/
| |-- computers/
| +-- ...
|-- frontend/ # Vue 3 SPA
| |-- src/
| +-- dist/ # Production build (gitignored)
|-- instance/ # Runtime state (gitignored)
| |-- plugins.json # Plugin registry
| +-- ...
|-- migrations/ # Core Alembic migrations
|-- scripts/ # Deployment and backup scripts
|-- backups/ # Local backup storage (gitignored)
+-- tests/ # Test suite
```
## Git Workflow
- `main` branch - Published/public version (GitHub)
- `wj` branch - Production deployment (West Jefferson site)
- `wilm` branch - Development branch (upstream from benjamin.baker)
- `dev` branch - Ops deployment branch
**Never commit to `main` directly** - it's published to public wiki.
## Additional Resources
- [Backup Setup](scripts/backup/README.md)
- [Plugin Development](docs/PLUGIN-HOOKS.md)
- [ADRs](wiki/ADRs/)
- [API Docs](http://localhost:5001/api/docs) (when Flask is running)

View File

@@ -1,7 +1,29 @@
# ShopDB Production Readiness Assessment
<!-- docmeta
purpose: explanation
audience: developer, adopter
status: historical
summary: The 2026-09-07 assessment of why deployment was hard. Its gaps became the roadmap; its recommendation was reversed.
loadwith: none
-->
# Production readiness assessment, 2026-09-07
**Last Updated:** 2026-09-07
**Branch:** wj (post-wilm merge)
> **This is a record, not guidance.** Read it for WHY the deployment story
> needed work, not for what to do. Two things have moved since:
>
> - **Its recommendation was reversed.** It recommends Windows/IIS for the
> reference site and Docker only for new ones. The decision since is the
> other way round: Docker is the path for every site and Windows/IIS is the
> legacy topology that will be deprecated.
> - **Its "critical path" became the roadmap.** Pre-built images in a registry,
> one-command setup, automatic plugin registration and a simpler backup mode
> are now the distributed-release item on [ROADMAP](../ROADMAP.md). Several
> of its gaps are already closed: the `migrate` service runs the schema, the
> plugin bootstrap and the seeders with no manual step, and
> `docker-compose.prebuilt.yml` removes npm from the image build.
>
> Current pages: [DEPLOY](../DEPLOY.md) to stand a site up,
> [DOCKER-PREBUILT-APPROACH](DOCKER-PREBUILT-APPROACH.md) to build it behind a
> proxy, [ROADMAP](../ROADMAP.md) for what is left.
## Executive Summary
@@ -196,15 +218,17 @@ The Docker deployment requires extensive manual setup, multiple services, and as
## Honest Recommendations
### For West Jefferson (Your Site)
### For the reference site
**Recommended:** Option 1 (IIS + Windows Services)
**Why:**
- You already have Windows infrastructure
*Reversed since. See the note at the top of this page.*
**Why (as argued at the time):**
- The site already had Windows infrastructure
- Simpler to operate
- Faster to deploy
- Your team knows IIS
- The team knew IIS
**Steps:**
1. Build Windows installer
@@ -262,6 +286,9 @@ https://your-server:5001
- `PRODUCTION-READINESS.md` - This file (meta/assessment)
- `scripts/backup/README.md` - Backup system (3 containers, restic)
*Closed since: the first two were folded into `DEVELOPMENT-SETUP` and `DEPLOY`,
and `INDEX.md` is now the single entry point this section asks for.*
**Problem:** Which one do I follow?
**Solution:** Create decision tree:
@@ -279,7 +306,7 @@ https://your-server:5001
## What We Did Today (Honest Assessment)
[OK] **Merged wilm->wj** - 98 commits, wilm features now in wj
[OK] **Merged the upstream development branch down** - 98 commits
[OK] **Fixed local dev** - `run_dev.py` works for Windows development
[OK] **Documented Docker** - Comprehensive but too complex
[OK] **Added backup docs** - System works but needs expertise
@@ -296,7 +323,7 @@ https://your-server:5001
- Gets people to working system fast
2. **Test Windows Installer**
- Build installer for wj branch
- Build the installer for the deployment branch
- Deploy to test Windows Server
- Document any issues
@@ -308,7 +335,7 @@ https://your-server:5001
4. **Set up CI/CD for image builds**
- GitHub Actions or Jenkins
- Build on every push to wj
- Build on every push to the deployment branch
- Push to internal registry
5. **Create `deploy.sh` script**

View File

@@ -1,29 +0,0 @@
# ShopDB Deployment Documentation
Complete deployment guides for development, Docker, and production environments.
## Quick Start
**Local development**: `LOCAL-DEV-SETUP.md`
**Docker deployment**: `DOCKER-SETUP.md`
**VM deployment**: `DEPLOY-TO-VM.md`
## Docker Deployment
- `DOCKER-SETUP.md` - Complete Docker setup and configuration
- `DOCKER-PREBUILT-APPROACH.md` - Workaround for corporate proxy blocking npm in Docker
- `PRODUCTION-READINESS.md` - Production deployment checklist
## Development
- `LOCAL-DEV-SETUP.md` - Setting up local development environment (Flask + Vite)
## VM Deployment
- `DEPLOY-TO-VM.md` - Deploying the C# endpoint agent to test VMs
## Related Documentation
- `.claude/setup/` - MCP servers, VM configuration, and development tools
- `.claude/maintenance/` - Operational checklists and maintenance procedures
- `../shopdb-flask.wiki/ADR-004-deployment-topology.md` - Architecture decision record for deployment topology

136
docs/llms.txt Normal file
View File

@@ -0,0 +1,136 @@
# ShopDB - guide for an AI agent
ShopDB is a plugin-based asset-management system (PCs, printers, machines,
network devices, measuring tools, applications, knowledge base, USB,
warranties, backups and more) for manufacturing sites. Every site runs its
own instance.
This file is the machine entry point, served at `/api/docs/llms.txt`. It covers
the API, which is what most callers need, and routes everything else.
## Routing - read this before opening any document
`docs/INDEX.md` is the routing table. Rows are TASKS, each row gives the
smallest set of pages that answers it, and each row says how many lines that
costs. Find the row, read those pages, skip the rest. The corpus is far larger
than any one context window and almost none of it applies to a given question.
Every page opens with an HTML comment naming `purpose`
(tutorial/howto/reference/explanation), `audience`, `status` and a one-sentence
`summary`, plus `loadwith` for the pages it is not complete without. Filter on
that metadata before opening anything: `status: legacy` means true but
superseded (do not follow it for new work) and `status: historical` means a
finished migration or a proposal kept as a record, so neither is an answer to
"how do I do this today".
`docs/START-HERE.md` is the same routing for a human, and shorter.
Not an API question? Still start from the index rather than guessing at a
filename: its rows cover installing and running a site, loading its data, the
shop-floor PCs, and building on the platform. For "why is it built this way",
`docs/adr/README.md` says which decision governs the question and whether it
still holds. For writing a plugin, `docs/PLUGIN-HOOKS.md` is the contract and
everything else defers to it.
## Answering "how do I install or fix this server"
The people running these servers are often not Windows, IIS or Python
specialists. `docs/INSTALL-WINDOWS.md` (a new site, one offline installer
`.exe`) and `docs/OPERATE-WINDOWS.md` (restart, logs, backups, health) are
authoritative, and both ship in `docs/` inside the install directory on every
installed server.
Do NOT walk anyone through `docs/INSTALL-WINDOWS-IIS.md` for a new site. It is
the manual procedure kept for servers that predate the installer, and following
it produces a server the installer then refuses to upgrade.
Before diagnosing anything, ask for the output of `shopdb-admin.ps1 check -Json`
from the install directory (default `C:\shopdb-flask`). It reports version,
publishing method, IIS and pool state, HTTP reachability, database host and
reachability, Python version, installed plugins and errors, and it contains no
secrets. The other day-2 verbs are `status`, `start`, `stop`, `restart`, `logs`,
`open`, `backup`, `repair`, `catalog`, `sessions`, `plugins`, `add-plugin`,
`verify`, `uninstall`. `verify -Path <name>` answers "does this server carry
component X" from the on-box CycloneDX SBOM.
Python is 3.14 and the wheelhouse is locked to it; an upgrade against a venv
built by a different minor version is refused by design.
## Bulk-loading a site's data
Two routes, and the right one depends on what the site has:
- SPREADSHEET, no developer (the common case): `flask csv templates --out <dir>`
generates templates FROM THE LIVE SCHEMA, then `flask csv import --dir <dir>`
checks and `--commit` applies. Do NOT hand-write the templates - generate
them. `docs/CSV-IMPORT.md`.
- A SOURCE DATABASE to script against: the HTTP import API,
`docs/IMPORT-API.md`, with `docs/IMPORT-ADOPTION.md` for the loader around it.
User accounts are deliberately not CSV-importable.
## Base URL
Each site runs its own instance (ADR-004), so there is no single production URL:
it is whatever that site serves, e.g. `https://shopdb.example.net/shopdb`.
All API paths are under `/api` (e.g. `<base>/api/assets`). Dev: `http://localhost:5001`.
## Auth
Three schemes:
- **Bearer JWT** - most endpoints. Get one by logging in, or use a managed
Personal Access Token (PAT). Send `Authorization: Bearer <token>`.
- Login: `POST /api/auth/login` `{ "username": "...", "password": "..." }`
-> `data.access_token`. Refresh: `POST /api/auth/refresh`.
- PATs are minted in the UI (Settings > API Tokens); a *scoped* PAT is limited
to named permissions and suspends the admin bypass.
- **X-API-Key** - unattended/service endpoints (collector ingest, GE-Enforce
fetch). Send `X-API-Key: <managed-token>`. How a shop-floor PC gets its own
credential is `docs/COLLECTOR-AUTH.md`.
- **Public** - some read endpoints (e.g. printer install-list, employee search,
dashboards) need no auth.
Auth level per endpoint is in the OpenAPI `security` field: `bearerAuth`,
`apiKeyAuth`, or none. Admin-only and permission-gated routes both use bearer.
## Response envelope
JSON endpoints return `{ "status": "success", "data": <payload>, "meta": {...} }`.
Errors nest under `data`, NOT at the top level:
`{ "status": "error", "data": { "error": { "code": "...", "message": "...", "details": {...} } } }`
with an HTTP 4xx/5xx. Reading `message` or `code` off the root gives `undefined`.
Lists carry `meta.pagination.{page, perpage, total, pages}` - again nested, not
`meta.total`. A few feed endpoints (screensaver, some installer text formats)
return raw text/JSON without the envelope - noted per route.
## Common recipes
- Search everything: `GET /api/search?q=<term>` (multi-word = AND across words).
- List assets on the map: `GET /api/assets/map`.
- List a type: `GET /api/printers`, `/api/computers`, `/api/machines`,
`/api/network`, `/api/measuringtools` (paginated: `?page=&perpage=`). A
plugin that is disabled at that site serves none of its routes.
- Get one: `GET /api/printers/<id>` etc.
- Create (bearer): `POST /api/printers` `{assetnumber, windowsname, vendorid, ...}`.
- Reports: `GET /api/reports` (list), `GET /api/reports/pc-relationships` (PC<->machine).
- Printer installer data: `GET /api/printers/install-list` (public; add
`?format=text` for a pipe-delimited variant); `GET /api/printers/pc-default?machine=<n>`.
- Collector ingest (X-API-Key): `POST /api/collector/computers`.
- GE-Enforce: `GET /api/geenforce/manifest?pctype=<scope>`,
`GET /api/geenforce/payload/<sha256>`, `POST /api/geenforce/report`.
- Import (admin PAT, preserves timestamps with `X-Import-Mode`): `docs/IMPORT-API.md`.
## Conventions
- DB-mirrored params/fields use lowercase concatenated names (no underscores):
`locationid`, `vendorid`, `windowsname` - match them exactly.
- IDs in paths are integers.
- Plugin endpoints live under the plugin's prefix (`/api/<plugin>/...`).
## Full reference
- Machine spec: `GET /api/docs/openapi.json` (OpenAPI 3.1). Operation and
plugin counts live in `docs/PROJECT-MAP.md`, which is generated.
- Interactive: `GET /api/docs` (Redoc).
- Human reference: the same spec at `GET /api/docs`, and `docs/INDEX.md` for everything else.

View File

@@ -1,3 +1,10 @@
<!-- docmeta
purpose: explanation
audience: developer
status: historical
summary: Working notes for the phased endpoint telemetry and PC Details UI effort. Not a contract.
loadwith: none
-->
# Endpoint telemetry and PC Details UI - phased working notes
Scratch/reference notes for a multi-phase effort. Not a contract document. When

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.

View File

@@ -1,9 +1,24 @@
<!-- docmeta
purpose: explanation
audience: developer
status: historical
summary: Draft decision record for the plugin settings-routes hook, checked against the code on 2026-09-18.
loadwith: none
-->
# ADR-019: Plugin Dynamic Settings Routes
> **Read with the date on it.** This is a point-in-time audit, kept as the
> record of what was found and what it turned out to be, not as current
> guidance. One thing it says repeatedly is no longer true: it was written when
> this line carried no ADRs, and the full ADR set now lives beside it in
> [docs/adr](../adr/README.md). Where this page says a decision record is
> missing or lives elsewhere, look there first.
**Status:** Accepted (2026-09-11)
**Context:** Plugin UI extensions, lean builds
**Supersedes:** Hardcoded plugin routes in core router
**Last checked against code:** 2026-09-18, branch gh-wj at 35d4fcd41
**Last checked against code:** 2026-09-18, the deployment branch at 35d4fcd41
This draft lives only in `docs/wiki-drafts/`. There is no `docs/adr/` directory
on this branch, so this is the sole ADR-shaped record here and nothing enforces
@@ -68,7 +83,7 @@ class ComputersPlugin(BasePlugin):
### Backend: API Endpoint
In `shopdb/core/api/plugins.py:46-95`, as it stands on gh-wj at 35d4fcd41:
In `shopdb/core/api/plugins.py:46-95`, as it stands on the deployment branch at 35d4fcd41:
```python
@plugins_bp.route('/settings-routes', methods=['GET'])

View File

@@ -1,17 +1,32 @@
<!-- docmeta
purpose: explanation
audience: developer
status: historical
summary: ADR-by-ADR conformance review of one 20-commit range, as it stood on 2026-09-18.
loadwith: none
-->
# ADR Conformance Review - September 18, 2026
**Branch:** gh-wj at 35d4fcd41
**Range reviewed:** 2ac2e6d33..gh-wj, 20 commits, 149 files, +22055 lines
> **Read with the date on it.** This is a point-in-time audit, kept as the
> record of what was found and what it turned out to be, not as current
> guidance. One thing it says repeatedly is no longer true: it was written when
> this line carried no ADRs, and the full ADR set now lives beside it in
> [docs/adr](../adr/README.md). Where this page says a decision record is
> missing or lives elsewhere, look there first.
**Branch:** the deployment branch at 35d4fcd41
**Range reviewed:** 2ac2e6d33..HEAD, 20 commits, 149 files, +22055 lines
**Method:** read-only. Every statement below cites a file and line that was read.
No test suite was run except `tests/test_plugins/test_kiosk_network_allowlist.py`
(20 passed) and `bash scripts/check-naming-and-style.sh` (exit 0).
## Read this first: the ADRs are not in this repository
`ls docs/adr` fails on gh-wj. There is no ADR directory on this branch. The
`ls docs/adr` fails on the deployment branch. There is no ADR directory on this branch. The
whole set, ADR-001 through ADR-018 plus a README, lives on the other lineage at
`docs/adr/ in the product repo` and was read there for this review.
`docs/` on gh-wj holds only `ShopDB-Planner-Tasks-v2.csv`, `deployment/`,
`docs/` on the deployment branch holds only `ShopDB-Planner-Tasks-v2.csv`, `deployment/`,
`notes/`, `openapi.json` and `wiki-drafts/`.
Three consequences, and they are the reason this page exists:
@@ -36,7 +51,7 @@ Three consequences, and they are the reason this page exists:
implemented". Both corrections belong in files that are not in this
repository.
The one ADR-shaped file on gh-wj is the ADR-019 draft in this folder, and it is
The one ADR-shaped file on the deployment branch is the ADR-019 draft in this folder, and it is
a draft.
## Result by ADR
@@ -78,9 +93,10 @@ This covers the names added and kept. It does NOT cover a name added to the
Asset model and then withdrawn again without a bump, which happened in this
range; see finding 2.
**ADR-002, the bumps.** `__contract_version__` moved 0.32.0 to 0.35.0 with a
per-bump rationale naming what was added and why it is additive
(`shopdb/__init__.py:124-152`). Every name those comments claim is genuinely
**ADR-002, the bumps.** The plugin contract moved three minors over this range,
each with a rationale naming what was added and why it is additive
(`shopdb/__init__.py:124-152`; the current value is in
[PROJECT-MAP](../PROJECT-MAP.md), which is generated). Every name those comments claim is genuinely
exported. Every new `BasePlugin` hook is optional with a safe default
(`shopdb/plugins/base.py:313-332`), so minor is the right classification. The
loader still checks `core_version` against `__contract_version__`
@@ -106,12 +122,12 @@ only for its own asset (`plugins/computers/plugin.py:729-733`,
and the dispatcher maps the `PermissionError` to 403 ASSET_MISMATCH
(`collector.py:294-299`).
**ADR-007, the version lock-step.** `__version__ = '0.12.0'`
(`shopdb/__init__.py:158`) matches `frontend/package.json:3`, and `__version__`
is still absent from `shopdb.api`.
**ADR-007, the version lock-step.** The product version in
`shopdb/__init__.py:158` matches `frontend/package.json:3`, and the version
literal is still absent from `shopdb.api`.
**ADR-008 in the forward direction.**
`git diff --stat 2ac2e6d33..gh-wj -- migrations/` is empty. Not one line of
`git diff --stat 2ac2e6d33..HEAD -- migrations/` is empty. Not one line of
plugin schema went into the core chain. The endpoints chain is 24 revisions with
exactly one root and one head, no dangling `down_revision`, longest revision id
29 characters against the 32-character `alembic_version` cap.
@@ -167,7 +183,7 @@ shipped code.
`CHANGELOG.md:11`
`git diff --stat 2ac2e6d33..gh-wj -- CHANGELOG.md` returns nothing at all. The
`git diff --stat 2ac2e6d33..HEAD -- CHANGELOG.md` returns nothing at all. The
file is byte-identical across all 20 commits, which include per-PC agent
credentials, enrollment, bootstrap tokens, server pinning, kiosk network
admission and three contract bumps. Grepping the whole file for "per-PC",
@@ -215,7 +231,7 @@ it stands now. `git show 18e5fdc00:shopdb/core/models/asset.py` puts
`client_cert_fingerprint` (declared `index=True`), `cert_issued_at`,
`cert_expires_at`, `cert_revoked_at` and `cert_revocation_reason` on the CORE
`Asset` model. 18e5fdc00 (2026-09-14, "Fix critical security vulnerabilities in
client certificate authentication") is an ancestor of `gh-wj`; 7480e4fe3
client certificate authentication") is an ancestor of the deployment branch; 7480e4fe3
(2026-09-15) deleted all 28 lines again. The grep comes back empty because it
runs after the retraction, not because the contract was never touched.
@@ -323,7 +339,7 @@ re-litigated.
### 3. ADR-016: four files here describe a decision ADR-016 never took
`docs/wiki-drafts/README.md:316-323` (corrected 2026-09-18), `CLAUDE.md:119`,
`docs/wiki-drafts/README.md:316-323` (corrected 2026-09-18), the assistant-instructions file, line 119,
`the assistant quick-reference file, lines 135 and 14`,
`docs/wiki-drafts/Security-Audit-2026-09-11.md:392`
@@ -398,7 +414,7 @@ Unlike ADR-016, ADR-018 carries no implementation-status paragraph, so its text
reads as done. Low severity: the table holds zero rows, so nothing is broken
today, and ADR-018's own argument is that the window is time-boxed - the
migration is free while the 134 staged metrology instruments have not been
entered. gh-wj is the branch that deploys to the reference site, so entering them here is what
entered. The deployment branch is the branch that deploys to the reference site, so entering them here is what
closes the window.
### 6. ADR-001 / ADR-002: raw SQL over core tables from a plugin
@@ -503,7 +519,7 @@ Recorded because it is real and someone will look for it here.
vendored Redoc at `/api/docs`, specifically for the air-gapped production box.
The tracked spec holds 377 paths and `info.version` is still `0.12.0`. It
contains zero paths matching `enroll` or `bootstrap`, while
`git diff 2ac2e6d33..gh-wj -- 'plugins/*/api/*.py' 'shopdb/core/api/*.py'` adds
`git diff 2ac2e6d33..HEAD -- 'plugins/*/api/*.py' 'shopdb/core/api/*.py'` adds
24 route decorators, including the whole enrollment surface
(`plugins/endpoints/api/enrollment.py:289`, `:580`, `:657`, `:752`, `:773`,
`:827`, `:853`, `:1095`, `:1135`, `:1233`, `:1253`) and the bootstrap-token
@@ -520,14 +536,14 @@ this branch: `docs/api-inventory.json` is not tracked here, so
`tests/test_api_inventory_parity.py` skips and `scripts/gen_openapi.py` has no
input.
**`CLAUDE.md` in this repo contradicts the repo.** Line 3 and line 9 describe
**The assistant-instructions file in this repo contradicts the repo.** Line 3 and line 9 describe
the database as "PostgreSQL 15+", while `docker-compose.yml:20` runs
`mysql:8.4` and `.env.example:29` is a `mysql+pymysql://` URL. Line 31 states
"**Columns**: snake_case (e.g., `serial_number`, `last_seen`)", which is the
opposite of `CONTRIBUTING.md:17` ("Column names: lowercase, concatenated,
singular. No dashes, no underscores") and of what the gate enforces at
`scripts/check-naming-and-style.sh:236`. Line 119 carries the ADR-016 device
token misattribution described above. `CLAUDE.md` is outside this folder and was
token misattribution described above. That file is outside this folder and was
not edited.
## Scope note

View File

@@ -1,8 +1,23 @@
<!-- docmeta
purpose: explanation
audience: developer
status: historical
summary: The 2026-09-11 architecture findings with a 2026-09-18 verdict on each; most were refuted.
loadwith: none
-->
# Architectural Findings - September 11, 2026
> **Read with the date on it.** This is a point-in-time audit, kept as the
> record of what was found and what it turned out to be, not as current
> guidance. One thing it says repeatedly is no longer true: it was written when
> this line carried no ADRs, and the full ADR set now lives beside it in
> [docs/adr](../adr/README.md). Where this page says a decision record is
> missing or lives elsewhere, look there first.
**Scope:** Full codebase analysis via Gortex semantic code intelligence
**Original severity claim:** 22 findings (6 critical, 6 high, 10 medium)
**Status:** RE-CHECKED against branch gh-wj at 35d4fcd41 on 2026-09-18. Most of
**Status:** RE-CHECKED against the deployment branch at 35d4fcd41 on 2026-09-18. Most of
the original findings do not survive a read of the code. This page keeps them,
with the verdict and the evidence, so nobody acts on a claim that was never
true.
@@ -380,7 +395,7 @@ Re-checked on 2026-09-18:
## Testing Performed (2026-09-18 re-check)
```bash
# Every claim above was checked with a grep or a read on gh-wj at 35d4fcd41.
# Every claim above was checked with a grep or a read on the deployment branch at 35d4fcd41.
grep -rn "^from plugins\." plugins/ --include=*.py # 0 hits
grep -rn "from shopdb\.\(core\|utils\|extensions\|services\)" \
plugins/ --include=*.py # 0 hits

View File

@@ -1,9 +1,24 @@
<!-- docmeta
purpose: explanation
audience: developer
status: historical
summary: Point-in-time measurement of what maintaining this codebase costs, annotated where the tree moved on.
loadwith: none
-->
# Maintenance Footprint Baseline - 2026-09-11
> **Read with the date on it.** This is a point-in-time audit, kept as the
> record of what was found and what it turned out to be, not as current
> guidance. One thing it says repeatedly is no longer true: it was written when
> this line carried no ADRs, and the full ADR set now lives beside it in
> [docs/adr](../adr/README.md). Where this page says a decision record is
> missing or lives elsewhere, look there first.
**Total annual burden:** 936 hours/year (estimate, see the note below)
**Risk level:** originally stated as 8 critical + 10 high operational risks
**Optimization potential:** 147-205 hours/year savings with automation
**Last checked against code:** 2026-09-18, branch gh-wj at 35d4fcd41
**Last checked against code:** 2026-09-18, the deployment branch at 35d4fcd41
## About the numbers (added 2026-09-18)
@@ -140,7 +155,7 @@ Backup credential redaction landed in the same commit as audit retention
**Time breakdown per deploy:**
- Bundle creation: 15 minutes (build frontend, tar source, verify integrity)
- Transfer dev->work: 10 minutes (USB copy + verification)
- GitHub push: 5 minutes (pull wilm, merge to wj, resolve conflicts, push)
- GitHub push: 5 minutes (pull the upstream branch, merge down, resolve conflicts, push)
- Transfer work->VM: 10 minutes (USB or shared folder)
- VM deployment: 30-45 minutes (extract, migrate DB, restart services, smoke test)
- Rollback (if needed): +60 minutes (restore snapshot, rollback migrations)
@@ -438,7 +453,7 @@ rsync -av $BACKUP_DIR/ /mnt/offsite-backups/shopdb/
`:218`), filtering out `migrations/versions/`, `frontend/dist/`, `staticdocs/`
and `.plugins-staged/`. Setting `USE_GIT_LS_FILES=0` falls back to recursive
grep with `--exclude-dir` for non-git checkouts. The whole script exits 0 on
gh-wj at 35d4fcd41.
the deployment branch at 35d4fcd41.
**The 5x / 10s-to-2s claim is unsourced** and no timing was recorded for this
page. The savings line below it follows from it and is equally unsourced.

View File

@@ -1,9 +1,24 @@
<!-- docmeta
purpose: reference
audience: developer
status: historical
summary: Index of the 2026-09-11 audit drafts, what each one got wrong, and the 2026-09-18 corrections.
loadwith: none
-->
# Wiki Page Drafts - 2026-09-11, revised 2026-09-18
> **Read with the date on it.** This is a point-in-time audit, kept as the
> record of what was found and what it turned out to be, not as current
> guidance. One thing it says repeatedly is no longer true: it was written when
> this line carried no ADRs, and the full ADR set now lives beside it in
> [docs/adr](../adr/README.md). Where this page says a decision record is
> missing or lives elsewhere, look there first.
This directory contains drafted wiki pages based on work completed Q3 2026. Copy
these to the GitHub wiki at https://github.com/ge-aero/shopdb-flask/wiki.
**Revised 2026-09-18, branch gh-wj at 35d4fcd41.** A full review re-checked every
**Revised 2026-09-18, the deployment branch at 35d4fcd41.** A full review re-checked every
page here against the code. `Architectural-Findings-2026-09-11.md` was
substantially wrong and now carries a verdict and evidence per finding.
`Maintenance-Footprint-Baseline.md` is annotated where the tree has moved on.
@@ -116,7 +131,7 @@ than "device token auth". Docker log rotation is still genuinely missing:
**Remediation timeline:** Q4 2026 - Q4 2027
**2026-09-18: do not publish this page as it stood.** Re-checking it against
gh-wj refuted most of it. There is no module-level cross-plugin import anywhere
the deployment branch refuted most of it. There is no module-level cross-plugin import anywhere
(`grep -rn "^from plugins\." plugins/ --include=*.py` = 0 hits) and `machines`
does not import `computers` at all, so finding 1's cycle does not exist. No
plugin deep-imports `shopdb.core` (0 hits), so finding 2 is refuted outright. No
@@ -131,12 +146,12 @@ form.
### 5. ADR Conformance - September 18, 2026
**File:** `ADR-Conformance-2026-09-18.md`
**Wiki path:** `ADRs/ADR-Conformance-2026-09-18`
**Summary:** ADR-by-ADR result of the 2026-09-18 review of gh-wj at 35d4fcd41.
**Summary:** ADR-by-ADR result of the 2026-09-18 review of the deployment branch at 35d4fcd41.
Fourteen ADRs hold with cited evidence; eight items are recorded as violations
or gaps, each with a file and line.
**Reads first, before the table:** the ADR set does not exist on this branch.
`ls docs/adr` fails on gh-wj; ADR-001 through ADR-018 live only on the other
`ls docs/adr` fails on the deployment branch; ADR-001 through ADR-018 live only on the other
lineage. Nothing here states the rules and nothing here enforces them except
`scripts/check-naming-and-style.sh`, which names exactly two ADRs (ADR-017 at
line 343, ADR-015 at line 395). A handful of tests cover ADR ground without
@@ -263,7 +278,7 @@ Dev PC (the internal git server) <--USB--> Work PC (GitHub) <--USB--> Production
```bash
# On work PC
cd C:\Users\270015376\Documents\github\flask-shopdb
cd C:\Users\<you>\Documents\github\flask-shopdb
./pull-shopdb-bundle.cmd # Creates tarball for transfer
```
@@ -337,9 +352,9 @@ Gate tests validate generated documentation and API consistency:
git add docs/openapi.json
```
**Caveat for the wj branch (2026-09-18):** the parity gate cannot run here.
**Caveat for the deployment branch (2026-09-18):** the parity gate cannot run here.
`tests/test_api_inventory_parity.py` skips itself when `docs/api-inventory.json`
is absent, and that file is not tracked on gh-wj, so `scripts/gen_openapi.py`
is absent, and that file is not tracked on the deployment branch, so `scripts/gen_openapi.py`
has no input either. `docs/openapi.json` on this branch is 377 paths at
`info.version` 0.12.0 and documents none of the 24 routes added since
2ac2e6d33. Regenerating it needs the inventory file from the other lineage.
@@ -408,7 +423,7 @@ reads the collector key from `HKLM:\SOFTWARE\GE\ShopDB`, and
pins to the same key by design. Publishing the old text would have told the next
person to delete values the fleet depends on.
**What is actually built, as of gh-wj 35d4fcd41:**
**What is actually built, as of the deployment branch at 35d4fcd41:**
- **Per-PC endpoint credentials.** An enrolled PC authenticates with
`X-Endpoint-Credential` (`plugins/endpoints/api/agentauth.py`). The server
@@ -563,7 +578,7 @@ yet.
## Next Steps
1. Read the 2026-09-18 notes inside each page before pasting anything. All five
pages in this folder have now been checked against gh-wj at 35d4fcd41.
pages in this folder have now been checked against the deployment branch at 35d4fcd41.
2. Copy to GitHub wiki at https://github.com/ge-aero/shopdb-flask/wiki
3. Update wiki navigation/sidebar to include the new pages
4. Link related pages. In particular, `Security-Audit-2026-09-11.md` finding 31
@@ -574,12 +589,12 @@ yet.
**Three corrections that live outside this folder** and were therefore NOT made:
- `CLAUDE.md:3` and `:9` say PostgreSQL 15+; `docker-compose.yml:20` runs
- the assistant-instructions file, lines 3 and 9, say PostgreSQL 15+; `docker-compose.yml:20` runs
`mysql:8.4` and `.env.example:29` is a `mysql+pymysql://` URL.
- `CLAUDE.md:31` says columns are snake_case; `CONTRIBUTING.md:17` says
- the assistant-instructions file, line 31 says columns are snake_case; `CONTRIBUTING.md:17` says
lowercase concatenated with no underscores, and that is what
`scripts/check-naming-and-style.sh:236` enforces.
- `CLAUDE.md:119` and the assistant quick-reference file, lines 14 and 135 repeat the
- the assistant-instructions file, line 119 and the assistant quick-reference file, lines 14 and 135 repeat the
ADR-016 device-token misattribution.
---

View File

@@ -1,7 +1,22 @@
<!-- docmeta
purpose: explanation
audience: developer
status: historical
summary: The 2026-09-11 security audit with a 2026-09-18 verdict per finding and thirteen added ones.
loadwith: none
-->
# Security Audit - September 11, 2026, refreshed September 18, 2026
> **Read with the date on it.** This is a point-in-time audit, kept as the
> record of what was found and what it turned out to be, not as current
> guidance. One thing it says repeatedly is no longer true: it was written when
> this line carried no ADRs, and the full ADR set now lives beside it in
> [docs/adr](../adr/README.md). Where this page says a decision record is
> missing or lives elsewhere, look there first.
**Scope:** Endpoints plugin, C# agent, system tray application, and (new on 2026-09-18) the USB and printed-parts kiosks
**Branch reviewed:** `gh-wj` at `35d4fcd41` (2026-09-16). Range re-read for this refresh: `2ac2e6d33..gh-wj`, 38 commits, 2026-09-11 to 2026-09-16
**Branch reviewed:** the deployment branch at `35d4fcd41` (2026-09-16). Range re-read for this refresh: `2ac2e6d33..HEAD`, 38 commits, 2026-09-11 to 2026-09-16
**Severity, 2026-09-11 set:** 4 critical, 6 high, 8 medium
**Severity, added 2026-09-18:** 1 high, 4 medium, 9 low
**Status:** see the remediation summary. Every 2026-09-11 finding below now carries a verdict read off the code on this branch, with a file and line.
@@ -138,7 +153,7 @@ Partial compensating control added since: the app now sets `X-Content-Type-Optio
### 14-18. Additional Medium Findings [PARTLY ADDRESSED, SOURCE NOW MISSING]
**2026-09-18 verdict:** the file this section pointed at is gone. the assistant memory directory does not exist on this branch; commit `8eee6f19d` moved the 2026-09-12 audit reports and scan output out of the product tree on purpose. The detail behind findings 14 to 18 is therefore not readable from here.
**2026-09-18 verdict:** the file this section pointed at is gone. That assistant memory directory does not exist on this branch; commit `8eee6f19d` moved the 2026-09-12 audit reports and scan output out of the product tree on purpose. The detail behind findings 14 to 18 is therefore not readable from here.
Of the five items summarised in the original bullet list, two can be answered from the code:
@@ -261,13 +276,13 @@ Three operator-facing places enumerate what depends on the `X-Forwarded-For` rew
### 31. This page, and three other documents, attributed a credential policy to ADR-016 that ADR-016 does not contain
The References section of the 2026-09-11 version of this page described ADR-016 as "Credential Delivery (device token authentication)". That is wrong, and the same misattribution is repeated at `docs/wiki-drafts/README.md:316-323`, `CLAUDE.md:119` and the assistant quick-reference file line 135. Grepping the ADR set for device tokens returns nothing in any ADR.
The References section of the 2026-09-11 version of this page described ADR-016 as "Credential Delivery (device token authentication)". That is wrong, and the same misattribution is repeated at `docs/wiki-drafts/README.md:316-323`, the assistant-instructions file, line 119 and the assistant quick-reference file line 135. Grepping the ADR set for device tokens returns nothing in any ADR.
The worst form is in the SECURITY.md page that `docs/wiki-drafts/README.md:429` queues for publication. Under "ADR-016: Credential Delivery" it lists device tokens, personal access tokens and environment variables as the delivery mechanisms and then bans, at `docs/wiki-drafts/README.md:323`, "Hardcoded in source, registry keys, git history". ADR-016 decides none of that. Its Decision is a fleet credential store with Fernet at rest, a targeting and precedence resolver reusing `plugins/geenforce/filters.py`, an explain endpoint and `GET /api/collector/credentials` behind a dedicated `credentials.fetch` scope. Its own alternatives section recommends registry-only provisioning as a worthwhile first step.
The blanket ban on registry keys also contradicts shipped code on this branch: `plugins/computers/client/Report-AssetToShopDB.ps1:367-374` reads the collector key from `HKLM:\SOFTWARE\GE\ShopDB`, and `plugins/endpoints/client/Set-ShopDBServerPins.ps1:4-20` writes the server trust pins there by design.
**Status:** the References section of this page is corrected below. The other three are not, and the SECURITY.md draft should not be published as written. Note that `gh-wj` carries no `docs/adr` directory at all, so there is no ADR text on this branch to correct a reader who follows the wiki.
**Status:** the References section of this page is corrected below. The other three are not, and the SECURITY.md draft should not be published as written. Note that the deployment branch carries no `docs/adr` directory at all, so there is no ADR text on this branch to correct a reader who follows the wiki.
**Related, and unverifiable from this branch:** ADR-016's own mechanism is still unbuilt. Grepping `shopdb/` and `plugins/` for `credentials.fetch`, `collector/credentials` and `Fernet` returns nothing, and `plugins/endpoints/plugin.py:163-253` declares only `endpoints.*` permissions. The per-PC credential programme in this range is a different subject under the same word: the agent's own identity secret, not a store that delivers third-party credentials to a PC. The design is sound and consistent with ADR-016's intent, but nothing on this branch records it as a decision. That record belongs on the lineage that holds `docs/adr`.
@@ -402,5 +417,5 @@ git ls-files | xargs grep -l "shopdb_pat_" | wc -l
- ADR-006: Collector API, the single dynamic dispatch route and its response contract
- ADR-015: Site-specific configuration, and the 2026-08-14 amendment that set the scan's scope
- ADR-016: Credential Delivery. A fleet credential store with Fernet at rest and a `credentials.fetch` scope. It is **not** about device tokens, and it does not ban registry storage. Still unbuilt on this branch. See finding 31.
- The ADR set is not in this repository. It lives on the product lineage; `gh-wj` has no `docs/adr` directory.
- The 2026-09-12 detailed audit reports were removed from the product tree by `8eee6f19d` and are kept with the site's working records. the assistant memory directory does not exist here, so the 2026-09-11 page's pointers into it are dead.
- The ADR set is not in this repository. It lives on the product lineage; the deployment branch has no `docs/adr` directory.
- The 2026-09-12 detailed audit reports were removed from the product tree by `8eee6f19d` and are kept with the site's working records. That directory does not exist here, so the 2026-09-11 page's pointers into it are dead.

View File

@@ -29,7 +29,22 @@ from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
OUTPUT = REPO / 'docs' / 'PROJECT-MAP.md'
HEADER = """<!-- GENERATED by scripts/gen_project_map.py - do not edit by hand.
# The docmeta block is part of the generated output, not something a human
# re-adds afterwards. This script rewrites the page whole, so anything hand-added
# above the GENERATED comment survives exactly until the next regeneration - and
# tests/test_docs_index.py requires the block to be the FIRST thing in the file,
# so losing it turns a routine regeneration into a red docs gate. Emitting it
# here is the only arrangement where "regenerate" and "the gate is green" are
# the same action. Keep the summary under the 120-character limit that test
# enforces, and keep the field vocabulary in step with it.
HEADER = """<!-- docmeta
purpose: reference
audience: developer, adopter
status: current
summary: Generated inventory - versions, plugins, Alembic heads, ADR index and codebase size.
loadwith: none
-->
<!-- GENERATED by scripts/gen_project_map.py - do not edit by hand.
Regenerate with: venv/bin/python scripts/gen_project_map.py -->
# Project map

393
tests/test_docs_index.py Normal file
View File

@@ -0,0 +1,393 @@
"""docs/INDEX.md is the router, and a router nobody checks is a router that lies.
The corpus is larger than any reader - person or agent - can hold, so the only
thing that makes it usable is being able to find the minimum correct set of
pages for a task and skip the rest. That needs two things to stay true, and
neither survives on good intentions:
1. Every page declares what it is for and whether it is still current, in a
`docmeta` block a machine can filter on without opening the page.
2. docs/INDEX.md routes every current page, and quarantines the ones that are
superseded or are only a record.
The failure this gate exists to stop is the quiet one: a page is added, nothing
routes to it, and it rots unread - or worse, a page goes `historical` and keeps
being followed because it is still sitting in a task row.
A second pass over the whole corpus added three more, because the first three
turned out to be satisfiable by an index that was still useless:
3. Being NAMED somewhere in INDEX.md is not the same as being routed. A
current page that appears only in a paragraph, or only in the Archive
section's prose, is a page no task row sends anyone to. It must be in a
row.
4. The line counts have to be right, not roughly right. The cost column is
the reason to trust the index over opening pages at random, and a corpus
that is being cut shrinks pages constantly - so a stale number is the
normal failure, not an exotic one.
5. The two routers - INDEX.md and START-HERE.md - must not point at a file
that is not there. Pages get merged and renamed; a dead link in the router
is worse than a missing row, because the reader trusts it.
Line-count tolerance: INDEX.md prints what a row costs to read, so a reader can
see the price before paying it. That number is checked against the real files
within 3 percent or 10 lines, whichever is larger. That absorbs a reflowed
paragraph and nothing else: the tolerance used to be 10 percent or 25 lines,
which let a 52-line row claim 77 and let whole sections drift after an edit
pass. If a row is out by more than a reflow, the number is simply wrong and
recomputing it is one command.
"""
import re
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parents[1]
DOCS = REPO / 'docs'
INDEX = DOCS / 'INDEX.md'
START_HERE = DOCS / 'START-HERE.md'
# The two routers. Everything else in docs/ is a destination; these two are how
# a reader arrives, so they are exempt from "must sit in a task row" (a row
# pointing at the index the row is in would be noise) and their links are held
# to a stricter standard than anyone else's.
ROUTERS = (INDEX, START_HERE)
# Same skip rule as tests/test_docs_publishable.py: docs/ prose is stripped from
# the published mirror and lives in the wiki, so a checkout with no docs/*.md
# has nothing to route and this module is inapplicable. docs/openapi.json is
# tracked, so keying off the DIRECTORY would make this run on the mirror.
pytestmark = pytest.mark.skipif(
not any(DOCS.glob('*.md')),
reason='no docs/ prose in this checkout - it is published to the wiki instead')
PURPOSES = {'tutorial', 'howto', 'reference', 'explanation'}
AUDIENCES = {'operator', 'adopter', 'developer', 'integrator'}
STATUSES = {'current', 'legacy', 'historical'}
FIELDS = ('purpose', 'audience', 'status', 'summary', 'loadwith')
ARCHIVED = {'legacy', 'historical'}
SUMMARY_LIMIT = 120
# The block must be the FIRST thing in the file, before the H1. A GitHub wiki
# renders YAML front matter as visible junk, which is why this is an HTML
# comment, and a comment further down the page is one a reader trips over.
DOCMETA = re.compile(r'\A<!-- docmeta\n(.*?)\n-->\n', re.S)
LINK = re.compile(r'\[[^\]]*\]\(([^)#]+)(?:#[^)]*)?\)')
ARCHIVE_HEADING = re.compile(r'^##\s+Archive\b', re.M)
ARCHIVE_TOTAL = re.compile(r'they are ([\d,]+) lines')
ALL_LINKS = re.compile(r'\[[^\]]*\]\(([^)]+)\)')
def pages():
"""Every authored page in docs/, including adr/ and proposals/."""
return sorted(p for p in DOCS.rglob('*.md'))
def rel(path):
return path.relative_to(DOCS).as_posix()
def line_count(path):
return len(path.read_text(errors='replace').splitlines())
def parse_docmeta(path):
"""Return the docmeta fields, or None when the block is missing."""
match = DOCMETA.match(path.read_text(errors='replace'))
if not match:
return None
fields = {}
for line in match.group(1).splitlines():
key, sep, value = line.partition(':')
if not sep:
return None
fields[key.strip()] = value.strip()
return fields
def value(fields, key):
"""A field with the template's trailing '# comment' hint removed."""
return re.sub(r'\s+#.*$', '', fields.get(key, '')).strip()
def status_of(path):
fields = parse_docmeta(path)
return value(fields, 'status') if fields else None
def links_in(text):
"""Every in-repo .md target a page links to, as written."""
return [t for t in LINK.findall(text) if t.endswith('.md') and '://' not in t]
def all_targets_in(text):
"""Every relative link target on a page, whatever its extension.
links_in() only sees .md, which is right for routing questions and wrong
for "does this file exist": the index links llms.txt, and a router that
points at a file nobody kept is the failure this catches.
"""
out = []
for target in ALL_LINKS.findall(text):
target = target.split()[0] if target.split() else target
if '://' in target or target.startswith(('mailto:', '#')):
continue
out.append(target.split('#')[0])
return [t for t in out if t]
def resolve(page, target):
"""A link target on `page`, as a path under docs/, or None if it escapes."""
candidate = (page.parent / target).resolve()
try:
candidate.relative_to(DOCS.resolve())
except ValueError:
return None
return candidate
def index_sections():
"""INDEX.md split into (task side, archive side) at the Archive heading."""
text = INDEX.read_text()
match = ARCHIVE_HEADING.search(text)
assert match, 'docs/INDEX.md must carry a "## Archive" section heading'
return text[:match.start()], text[match.start():]
def table_rows(text):
"""(link targets, stated line total) for every table row that states one."""
rows = []
for line in text.splitlines():
line = line.strip()
if not line.startswith('|') or set(line) <= set('|- '):
continue
cells = [c.strip() for c in line.strip('|').split('|')]
if len(cells) < 2 or not cells[-1].isdigit():
continue
targets = links_in(line)
if targets:
rows.append((targets, int(cells[-1])))
return rows
def tolerance(real):
"""How far a stated line count may sit from the real one. See the docstring."""
return max(10, real * 0.03)
def test_there_is_something_to_check():
"""A path change that matched nothing would make this whole file vacuous."""
assert len(pages()) > 20, 'docs/ prose disappeared, or the glob stopped matching'
assert INDEX.is_file(), 'docs/INDEX.md is missing - there is no router'
assert len(table_rows(index_sections()[0])) > 20, 'docs/INDEX.md routes almost nothing'
@pytest.mark.parametrize('page', pages(), ids=rel)
def test_every_page_carries_a_docmeta_block(page):
fields = parse_docmeta(page)
assert fields is not None, (
'%s has no docmeta block. It must be the first thing in the file, '
'before the H1: an HTML comment opening "<!-- docmeta" and closing '
'with "-->", one "key: value" per line.' % rel(page))
missing = [f for f in FIELDS if f not in fields]
assert not missing, '%s docmeta is missing: %s' % (rel(page), ', '.join(missing))
unknown = [f for f in fields if f not in FIELDS]
assert not unknown, '%s docmeta has unknown fields: %s' % (rel(page), ', '.join(unknown))
@pytest.mark.parametrize('page', pages(), ids=rel)
def test_docmeta_uses_the_allowed_vocabularies(page):
fields = parse_docmeta(page)
if fields is None:
pytest.skip('no docmeta - reported by test_every_page_carries_a_docmeta_block')
purpose = value(fields, 'purpose')
assert purpose in PURPOSES, (
'%s: purpose "%s" is not one of %s. A page that is two of these is a '
'page that should be split.' % (rel(page), purpose, sorted(PURPOSES)))
audience = [a.strip() for a in value(fields, 'audience').split(',') if a.strip()]
assert audience, '%s: audience is empty' % rel(page)
bad = [a for a in audience if a not in AUDIENCES]
assert not bad, '%s: audience %s not in %s' % (rel(page), bad, sorted(AUDIENCES))
status = value(fields, 'status')
assert status in STATUSES, (
'%s: status "%s" is not one of %s' % (rel(page), status, sorted(STATUSES)))
@pytest.mark.parametrize('page', pages(), ids=rel)
def test_summary_answers_is_this_the_page_i_need(page):
fields = parse_docmeta(page)
if fields is None:
pytest.skip('no docmeta - reported by test_every_page_carries_a_docmeta_block')
summary = fields.get('summary', '').strip()
assert summary, '%s: summary is empty. It is what a router shows INSTEAD of the page.' % rel(page)
assert len(summary) <= SUMMARY_LIMIT, (
'%s: summary is %d chars, limit is %d. Say what question the page '
'answers, not what it contains.' % (rel(page), len(summary), SUMMARY_LIMIT))
@pytest.mark.parametrize('page', pages(), ids=rel)
def test_loadwith_targets_exist(page):
"""loadwith names the pages this one is not complete without."""
fields = parse_docmeta(page)
if fields is None:
pytest.skip('no docmeta - reported by test_every_page_carries_a_docmeta_block')
raw = value(fields, 'loadwith')
if not raw or raw.lower() == 'none':
return
for name in [n.strip() for n in raw.split(',') if n.strip()]:
target = resolve(page, name) or (DOCS / name)
if not target.is_file():
target = DOCS / name
assert target.is_file(), (
'%s: loadwith names "%s", which does not exist' % (rel(page), name))
@pytest.mark.parametrize(
'page', [p for p in pages() if p != INDEX], ids=lambda p: rel(p))
def test_every_page_is_routed_or_archived(page):
"""A page nothing routes to is a page nobody reads and nobody maintains."""
text = INDEX.read_text()
targets = {resolve(INDEX, t) for t in links_in(text)}
assert page in targets, (
'%s is in docs/ but docs/INDEX.md does not name it. Add a task row for '
'it, or - if it is superseded or only a record - mark it legacy or '
'historical in its docmeta and list it in the Archive section.' % rel(page))
@pytest.mark.parametrize('router', ROUTERS, ids=lambda p: rel(p))
def test_a_router_never_points_at_a_file_that_is_not_there(router):
"""INDEX.md and START-HERE.md are the two pages everyone is sent to first.
A dead link here is worse than a missing row: a missing row makes a reader
look further, a dead link makes them think the answer is gone. Pages get
merged, split and renamed, and both routers name files by hand, so this is
the check that catches it the same day.
Every relative target counts, not just .md - the index links llms.txt too -
and a target outside docs/ is dead by definition once these pages are
published to the wiki, which carries docs/ and nothing above it.
"""
broken = []
for target in sorted(set(all_targets_in(router.read_text()))):
page = resolve(router, target)
if page is None:
broken.append('%s (outside docs/)' % target)
elif not page.is_file():
broken.append(target)
assert not broken, (
'docs/%s links to files that do not exist: %s'
% (rel(router), ', '.join(broken)))
def test_every_current_page_is_reachable_from_a_task_row():
"""Named in the index is not the same as routed by it.
Nothing stops a page being mentioned in a paragraph, or slipped into the
Archive section's prose, and passing "INDEX.md names it". A reader works
the rows: they find the row that matches the job and read what it lists. A
current page that is in no row is a page that gets written, gets stale and
never gets opened - and the cost of keeping it is paid by everyone who
greps the corpus.
The routers themselves are exempt: they are where a reader starts.
"""
tasks, _ = index_sections()
routed = set()
for targets, _lines in table_rows(tasks):
for target in targets:
page = resolve(INDEX, target)
if page:
routed.add(page)
stranded = sorted(
rel(p) for p in pages()
if status_of(p) == 'current' and p not in routed and p not in ROUTERS)
assert not stranded, (
'these pages are current guidance but no task row in docs/INDEX.md '
'sends anyone to them: %s. Give each one a row saying what task it '
'answers, or - if it is superseded or only a record - mark it legacy '
'or historical and move it to the Archive section.' % ', '.join(stranded))
def test_task_rows_never_route_to_an_archived_page():
"""Historical and legacy pages belong in the Archive section, nowhere else."""
tasks, _ = index_sections()
offenders = []
for target in sorted(set(links_in(tasks))):
page = resolve(INDEX, target)
if page and page.is_file() and status_of(page) in ARCHIVED:
offenders.append('%s (%s)' % (target, status_of(page)))
assert not offenders, (
'docs/INDEX.md routes a task to a page that is not current guidance: %s'
% ', '.join(offenders))
def test_the_archive_section_holds_nothing_current():
_, archive = index_sections()
offenders = []
for target in sorted(set(links_in(archive))):
page = resolve(INDEX, target)
if page and page.is_file() and status_of(page) == 'current':
offenders.append(target)
assert not offenders, (
'docs/INDEX.md files these current pages under Archive, where readers '
'are told not to follow them: %s' % ', '.join(offenders))
def test_no_current_page_hides_behind_an_archived_one():
"""Walk out from INDEX.md without passing through legacy or historical pages.
A current page that can only be reached through a superseded one is a page
a careful reader will never open, because they were told to skip the page
that links it.
"""
seen = set()
queue = [INDEX]
while queue:
page = queue.pop()
if page in seen:
continue
seen.add(page)
if status_of(page) in ARCHIVED:
continue # a dead end on purpose: nothing may depend on it
for target in links_in(page.read_text(errors='replace')):
nxt = resolve(page, target)
if nxt and nxt.is_file():
queue.append(nxt)
stranded = sorted(
rel(p) for p in pages() if status_of(p) == 'current' and p not in seen)
assert not stranded, (
'these current pages are only reachable through an archived page: %s'
% ', '.join(stranded))
def test_index_line_totals_are_honest():
"""The cost column is the point of the index. A wrong one is worse than none."""
tasks, archive = index_sections()
offenders = []
for targets, stated in table_rows(tasks) + table_rows(archive):
real = 0
for target in targets:
page = resolve(INDEX, target)
if page and page.is_file():
real += line_count(page)
if abs(real - stated) > tolerance(real):
offenders.append('%s: says %d lines, is %d' % ('+'.join(targets), stated, real))
assert not offenders, (
'docs/INDEX.md misstates what a row costs to read:\n %s' % '\n '.join(offenders))
def test_the_archive_total_is_honest():
_, archive = index_sections()
match = ARCHIVE_TOTAL.search(archive)
assert match, 'the Archive section should say how many lines it adds up to'
stated = int(match.group(1).replace(',', ''))
real = sum(count for _, count in table_rows(archive))
assert abs(real - stated) <= tolerance(real), (
'the Archive section claims %d lines; its rows add up to %d' % (stated, real))

View File

@@ -34,14 +34,6 @@ DOCS = REPO / 'docs'
# published mirror - keying off the directory made this module run there against
# a prose-less tree and fail. A published checkout is one with no authored
# prose, matching the canonical definition in scripts/check-gates-ran.sh.
# KNOWN GAP, left deliberately for a decision rather than closed here: this
# keys on docs/*.md, so prose that lives only in docs/wiki-drafts/ does NOT
# turn the module on, and on gh-wj that is every page there is. Pointed at the
# drafts on 2026-09-18 the scrub fails on 27 lines - site name, internal git
# server, developer home paths - which is a question about what those drafts
# are for (internal wiki, or publication) and not a bug to fix by rewriting
# them. scripts/check-gates-ran.sh matches this definition exactly; change both
# together or the gate runner reports a skip it was told to expect.
pytestmark = pytest.mark.skipif(
not any(DOCS.glob('*.md')),
reason='no docs/ prose in this checkout - it is excluded from publication and lives in the wiki')