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.