Files
shopdb-flask/docs/wiki-drafts/ADR-Conformance-2026-09-18.md
cproudlock 69a7997dc4 Build the plugin set a real site runs, and write down what shipped
The lean-build job proved the delete-a-plugin guarantee with two plugins and
four hand-picked marker components. It now builds a real twelve-plugin set, and
the assertions follow a rule instead of a hunch: one component from EVERY
omitted plugin must be absent, one from EVERY chosen plugin must be present, and
the backend tree is checked both ways. An omitted set that grows without its
check growing is how a leak ships.

CHANGELOG had not been touched across the whole range, so the largest
security-behaviour change in the product - per-PC endpoint credentials, the
shared-key refusal for enrolled PCs, server pinning, agent 1.20.1 and tray 1.7.1
- had no release record. An [Unreleased] section now carries it, written from
the commits rather than from memory.

CLAUDE.md and the agent quick-reference stated that ADR-016 bans storing
credentials in the registry. It does not, and this fleet depends on HKLM for its
collector key and server pins: the next person following that text removes them
and silently breaks reporting and pinning on every un-enrolled PC. Corrected,
along with two other statements that were false on this branch - the database is
MySQL, not PostgreSQL, and column names are lowercase concatenated, not
snake_case, which is the opposite of what the gate enforces.

The wiki drafts are updated against the code as it stands, each old finding
given a verdict with a file and line rather than left as an assertion from a
week ago, and a new page records the ADR conformance result - including that the
ADR set itself is not on this branch, so nothing here enforces it except the
naming and style script.

They are also scrubbed of the things that must not reach a published wiki: the
site name, the internal git server, developer home paths and the assistant
tooling. They now pass the repository's own publishability patterns, which is
what the scrub is for.
2026-09-18 13:31:25 -04:00

557 lines
31 KiB
Markdown

# ADR Conformance Review - September 18, 2026
**Branch:** gh-wj at 35d4fcd41
**Range reviewed:** 2ac2e6d33..gh-wj, 20 commits, 149 files, +22055 lines
**Method:** read-only. Every statement below cites a file and line that was read.
No test suite was run except `tests/test_plugins/test_kiosk_network_allowlist.py`
(20 passed) and `bash scripts/check-naming-and-style.sh` (exit 0).
## Read this first: the ADRs are not in this repository
`ls docs/adr` fails on gh-wj. There is no ADR directory on this branch. The
whole set, ADR-001 through ADR-018 plus a README, lives on the other lineage at
`docs/adr/ in the product repo` and was read there for this review.
`docs/` on gh-wj holds only `ShopDB-Planner-Tasks-v2.csv`, `deployment/`,
`notes/`, `openapi.json` and `wiki-drafts/`.
Three consequences, and they are the reason this page exists:
1. **Nothing on this branch states the rules.** A person working here has no
ADR to read, so a decision can be contradicted without anyone noticing that
a decision existed. That is exactly what happened to ADR-016 (see below).
2. **Nothing on this branch enforces them except one script.**
`scripts/check-naming-and-style.sh` is the only ADR-aware gate in the repo,
and it names two ADRs: ADR-017 at line 343 and ADR-015 at line 395. Its other
eight checks enforce CONTRIBUTING.md house rules, not ADRs. A handful of
tests cover ADR ground without being described that way:
`tests/test_plugin_migrations.py` (ADR-008 chain shape and ownership),
`tests/test_core/test_plugin_settings_hubs.py` (ADR-019),
`tests/test_plugin_contract.py` (manifest fields),
`tests/test_core/test_public_read_surface.py` (the read gate).
`tests/test_api_inventory_parity.py` skips itself entirely on this branch:
`docs/api-inventory.json` is not tracked here, and its own skip reason says
"docs/ is excluded from publication".
3. **Some violations cannot be fixed from here.** ADR-018 has no
implementation-status note; ADR-016 is still marked "decided, NOT yet
implemented". Both corrections belong in files that are not in this
repository.
The one ADR-shaped file on gh-wj is the ADR-019 draft in this folder, and it is
a draft.
## Result by ADR
| ADR | Subject | Result |
|-----|---------|--------|
| ADR-001 | Asset as platform contract | Two exceptions, both below: the unbumped Asset cert columns (2) and raw SQL over core tables (6) |
| ADR-002 | Plugin versioning | Holds for the bumps. Two stale manifests, and one unbumped core-model change (2) |
| ADR-003 | Plugin distribution | Holds |
| ADR-004 | Deployment topology | Holds |
| ADR-005 | Equipment vs measuringtools | Superseded by ADR-018 |
| ADR-006 | Collector contract | Holds on shape. One gap in a new boundary |
| ADR-007 | Product versioning and releases | VIOLATED (CHANGELOG) |
| ADR-008 | Plugin migration ownership | VIOLATED (plugin chain, core table) |
| ADR-009 | Frontend plugin gating | Holds |
| ADR-010 | Frontend plugin hooks | Holds |
| ADR-011 | Machines rename | Holds |
| ADR-012 | GE-Enforce manifest ownership | Holds |
| ADR-013 | Plugin catalog and lean builds | Holds |
| ADR-014 | Schema-lean per site | Holds |
| ADR-015 | Site-specific configuration | Holds in scope. Scope has a hole |
| ADR-016 | Credential delivery | Unbuilt by its own status, and MISDESCRIBED in four files here |
| ADR-017 | Buildings and levels | Holds |
| ADR-018 | Metrology instruments are machines | Accepted, not implemented |
| ADR-019 | Plugin dynamic settings routes (draft) | Implementation matches the draft |
## What holds, and the evidence
**ADR-001, the import surface.**
`grep -rn "from shopdb\.\(core\|utils\|extensions\|services\)" plugins/
--include=*.py` returns zero hits. The only `shopdb.*` import in `plugins/`
outside `shopdb.api` is `shopdb.plugins.alembic_template` from migration
`env.py` files, which is the shared Alembic runner ADR-008 sanctions. The three
names added to the contract this range - `BusinessUnit`, `ip_in_cidrs`,
`request_ip_allowlisted` - are both defined and listed in `__all__`
(`shopdb/api/__init__.py:190-194`, `:471-486`). The new endpoints tables key off
`assets.assetid` by foreign key rather than introducing a parallel identity.
This covers the names added and kept. It does NOT cover a name added to the
Asset model and then withdrawn again without a bump, which happened in this
range; see finding 2.
**ADR-002, the bumps.** `__contract_version__` moved 0.32.0 to 0.35.0 with a
per-bump rationale naming what was added and why it is additive
(`shopdb/__init__.py:124-152`). Every name those comments claim is genuinely
exported. Every new `BasePlugin` hook is optional with a safe default
(`shopdb/plugins/base.py:313-332`), so minor is the right classification. The
loader still checks `core_version` against `__contract_version__`
(`shopdb/plugins/loader.py:255-258`).
**ADR-004 and ADR-014, per-site instances.** No `siteid` column anywhere in the
new schema. utf8mb4 still pinned in `.env.example`. The two-command deploy
(`flask db upgrade` then `flask plugin upgrade-all`) is unchanged. All four new
endpoints tables are registered in `PLUGIN_TABLE_OWNERS`
(`shopdb/plugins/alembic_template.py:57-63`) in the same change that creates
them, so `flask plugin prune-schema` still leaves a lean site lean.
**ADR-006, the collector shape.** `/api/collector/<pluginname>` is still the
single dynamic dispatch route (`shopdb/core/api/collector.py:229`), the response
envelope is byte-for-byte the ADR's `{status, action, assetid, identityvalue,
warnings}` (`:324-331`), every submission still writes an AuditLog row
(`:313-320`), `/api/collector/_schemas` is still JWT-protected (`:337-341`), and
per-plugin `COLLECTOR_API_KEY_<PLUGIN>` with shared fallback resolves as the ADR
specifies (`:194-203`). Identity integrity under the new credential mode is
enforced in all three schema-declaring plugins - a per-PC credential reports
only for its own asset (`plugins/computers/plugin.py:729-733`,
`plugins/printers/plugin.py:556-559`, `plugins/endpoints/plugin.py:1422-1427`) -
and the dispatcher maps the `PermissionError` to 403 ASSET_MISMATCH
(`collector.py:294-299`).
**ADR-007, the version lock-step.** `__version__ = '0.12.0'`
(`shopdb/__init__.py:158`) matches `frontend/package.json:3`, and `__version__`
is still absent from `shopdb.api`.
**ADR-008 in the forward direction.**
`git diff --stat 2ac2e6d33..gh-wj -- migrations/` is empty. Not one line of
plugin schema went into the core chain. The endpoints chain is 24 revisions with
exactly one root and one head, no dangling `down_revision`, longest revision id
29 characters against the 32-character `alembic_version` cap.
**ADR-009, route gating.** Every plugin-owned route added or touched this range
carries `meta.plugin`, including the two new endpoints settings pages, the USB
kiosk and the printed-parts kiosk. `GET /api/plugins/enabled` stayed
`jwt_required(optional=True)` (`shopdb/core/api/plugins.py:31-42`) through the
read-permission sweep that gated 165 other GETs, so the unauthenticated kiosk
gate still works.
**ADR-010 and ADR-013, frontend boundaries.** The new
`frontend/src/components/NetworkAllowlistPicker.vue` is a CORE component
consumed by plugin views through the `@/` alias
(`plugins/usb/frontend/views/USBSettings.vue:108`,
`plugins/printedparts/frontend/views/PrintedPartsSettings.vue:164`), which is
the established pattern; 97 plugin frontend files already import that way. No
core file gained a hardcoded plugin import.
**ADR-012, GE-Enforce ownership.** The per-PC rollout keeps ShopDB as author of
record: the runbook's JSON snippets are entries to author and publish through
the plugin, not hand edits to the share, and `Set-ShopDBServerPins.ps1` is
staged as a normal PS1 entry with `TargetHostnames`/`TargetMachineNumbers`
gates. The engine is still referenced, not vendored.
**ADR-015 within its enforced scope.** `scripts/check-naming-and-style.sh` exits
0 and the pipeline is not vacuous: a control pattern over the same six
directories returns 1995 lines. The new kiosk network admission is a setting
with a blank default and a documented-neutral example
(`plugins/usb/plugin.py:153-168`), blank means off
(`shopdb/utils/ipallowlist.py:44-46`), and `frontend/vite.config.js:5-16`
replaced a hardcoded dev-proxy address with `VITE_DEV_API_TARGET`.
**ADR-017.** The per-occurrence `mapx`/`levelid` gate and the
`LocationMapTooltip :levelid` gate both pass. No payload in this range emits a
position without its level.
**ADR-019 against the implementation.** `SETTINGS_HUBS_WITH_PLUGIN_TABS =
('model-catalog',)` at `shopdb/plugins/base.py:29`; the loud drop at `:32-54`;
the `settings.view` gate at `shopdb/core/api/plugins.py:47`; the still
unrewritable `` import(`../../${tab.component}`) `` at
`frontend/src/views/settings/ModelCatalogHub.vue:84` with no `vite.config.js`
change to fix it; and a non-vacuous test suite in
`tests/test_core/test_plugin_settings_hubs.py`. The draft in this folder was
corrected on 2026-09-18: its code block still showed
`@jwt_required(optional=True)` and its Future Extensions section still called
the endpoint anonymous, both contradicting its own Auth paragraph and the
shipped code.
## Violations
### 1. ADR-007: no release record for the largest change in the range
`CHANGELOG.md:11`
`git diff --stat 2ac2e6d33..gh-wj -- CHANGELOG.md` returns nothing at all. The
file is byte-identical across all 20 commits, which include per-PC agent
credentials, enrollment, bootstrap tokens, server pinning, kiosk network
admission and three contract bumps. Grepping the whole file for "per-PC",
"X-Endpoint-Credential", "Endpoint credential" and "enrol" finds nothing
relevant. ADR-007 decision point 3 makes `CHANGELOG.md` the canonical
human-readable record and says work in flight accumulates under
`## [Unreleased]`; that block still describes only the pre-range share-link and
read-gate work.
Low severity: no release has been cut in the range, so the obligation point
(ADR-007 release procedure step 3) has not been reached and the debt is fully
repairable before the tag. Operator-facing content does exist, at
`plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md`, but that is plugin-local
and not where ADR-007 says an adopter looks.
### 2. ADR-008: a plugin migration chain does DDL on the core `assets` table
`plugins/endpoints/migrations/versions/0023_endpoint_credentials.py:73` and `:76`
0023 declares `plugin_name = 'endpoints'` (line 33) and then runs
`op.drop_index(index['name'], table_name='assets')` and
`op.drop_column('assets', column)`. It is cleaning up after itself:
`git show 5cd533239:plugins/endpoints/migrations/versions/0021_client_certificates.py`
shows the shipped 0021 adding three columns to `assets` plus
`idx_assets_cert_fingerprint` from the same plugin chain. `assets` is not, and
cannot be, in `PLUGIN_TABLE_OWNERS['endpoints']`
(`shopdb/plugins/alembic_template.py:57-63`), so the metadata ownership filter
never saw it, and `tests/test_plugin_migrations.py` has no test over which
tables a chain touches.
Low severity: every step is guarded on the live schema (0023:64-76), 0021 and
0022 are now no-ops, the forward direction is clean, and
`tests/test_plugins/test_endpoints_credentials_migration.py:96-140` runs the
chain twice from both a legacy and a fresh schema. The lane crossing is real;
the fleet blast radius is essentially zero.
**The ADR-001/ADR-002 half of this was written up wrong, and that conclusion is
withdrawn here (2026-09-18).** The original text read: "`git show 5cd533239
--stat` touched no file under `shopdb/`, and `grep -rn cert
shopdb/core/models/asset.py` is empty: the Asset *model* never carried those
columns. Only the database did. No contract bump was owed." Both halves of the
evidence are real and the conclusion drawn from them is still false, because
5cd533239 is not the only commit in the range and `grep` only sees the tree as
it stands now. `git show 18e5fdc00:shopdb/core/models/asset.py` puts
`client_cert_fingerprint` (declared `index=True`), `cert_issued_at`,
`cert_expires_at`, `cert_revoked_at` and `cert_revocation_reason` on the CORE
`Asset` model. 18e5fdc00 (2026-09-14, "Fix critical security vulnerabilities in
client certificate authentication") is an ancestor of `gh-wj`; 7480e4fe3
(2026-09-15) deleted all 28 lines again. The grep comes back empty because it
runs after the retraction, not because the contract was never touched.
The ADR-001/ADR-002 finding therefore holds, in a worse form than first stated:
the core Asset contract surface was extended to carry a plugin's feature and
then retracted, with no contract bump in either direction, and for the length of
that window five underscore-spelled columns sat on a core model in a repo whose
column convention forbids underscores. Severity stays low - the window is inside
an unreleased branch, and nothing outside the endpoints plugin ever read the
columns - but the finding is a real ADR-001/ADR-002 breach and not, as written,
a non-finding.
**Now guarded, 2026-09-18.** The finding above ended with "no test over which
tables a chain touches". There is one:
`tests/test_plugin_migrations.py::test_no_plugin_migration_touches_another_lanes_table`.
It AST-scans every `plugins/*/migrations/versions/*.py` for `create_table`,
`drop_table`, `add_column`, `drop_column`, `alter_column`, `create_index`,
`drop_index`, `rename_table`, the five constraint ops (`create_foreign_key`,
`drop_constraint`, `create_unique_constraint`, `create_check_constraint`,
`create_primary_key`) and the `batch_alter_table` forms, resolves each target
table through module-level constants and literal `for` sequences, and fails when
the table belongs to `core` or to a different plugin. Ownership comes from
`PLUGIN_TABLE_OWNERS` and a static scan of `shopdb/core/models/` (including the
three bare `db.Table` association tables), so the map stays the single source of
truth rather than a table list copied into a test.
Raw SQL is scanned too, because `op.execute('ALTER TABLE assets DROP COLUMN
assetnumber')` is DDL that no `op.*` scan can see. `op.execute` and
`exec_driver_sql` have their statement text flattened - f-string holes and
unresolvable names become a sentinel character rather than being dropped - and
`ALTER TABLE x`, `DROP TABLE x`, `CREATE TABLE x`, `RENAME TABLE x TO y`,
`CREATE/DROP INDEX ... ON x` and `TRUNCATE x` name the op's target table.
`UPDATE`, `DELETE`, `INSERT` and `SELECT` are data, not schema, and are not
flagged: three shipped revisions already run cross-checked DML
(`plugins/backups/.../0002_backups_lastseenat.py:37`,
`plugins/backups/.../0003_backups_clear_backfilled_lastseen.py:58`,
`plugins/computers/.../0006_policyactionhold_indefinite.py:78`) and all three
stay green. Keeping the hole in place is what separates the two cases that
matter: `machines 0002` interpolates a CONSTRAINT name into
`f"ALTER TABLE equipment DROP FOREIGN KEY {name}"`, where the table is literal
text and resolves; `f'ALTER TABLE {t} DROP COLUMN x'` puts the hole inside the
table name, resolves to nothing, and fails as UNRESOLVED, which is the obvious
dodge closed.
Four supporting tests keep it honest: an op whose table name cannot be resolved
statically is a failure, not a pass, so a computed name does not slip through; a
non-vacuity test asserts the scan still finds more than 100 ops and still
detects the 0023 crossing; a second non-vacuity test pins two specific raw-SQL
ops out of `machines 0002` and asserts the three DML statements are absent; and
a dead-entry test fails if an allowlist line stops matching anything, so an
excuse cannot outlive what it excused.
The allowlist keys on `(plugin, filename, op, table, column)`, not on the first
four. Keyed on the first four alone, the one shipped `drop_column on assets`
entry would also excuse a brand new `op.drop_column('assets', 'assetnumber')`
appended to that same shipped file - the exact breach the guard exists to stop -
so the six entries name their columns: one `drop_index` (column `None`, the
index name is computed) and one `drop_column` per certificate column. 0021 and
0022 are not listed, because they are stamp-only no-ops and present no DDL to
scan.
Verified non-vacuous against the pre-fix version of the same test module. Three
violations were planted in a scratch copy of `plugins/` (the shipped 0023 is
applied on production and was not edited in place): the five constraint ops on
`assets` from a plugin chain, `op.execute` with both a literal `ALTER TABLE
assets` and an f-string table name, and a new `op.drop_column('assets',
'assetnumber')` inside 0023. The pre-fix guard passed all four of its tests on
all three plants. The current guard fails, naming all seven ops with file, line,
op, table, column and owner, and goes green again once the plants are removed.
**The 0023 index-drop loop was examined and needs no repair.** 0023:71-73 drops
any index on `assets` whose column set intersects the certificate columns and
recreates nothing, which would matter if such an index could carry columns that
still exist. It cannot. The complete set of things that create an index on
`assets` is:
* the core baseline `68b3947ae14f`: `idx_asset_active`, `idx_asset_location`,
`idx_asset_status`, `idx_asset_type_bu`, `ix_assets_assetnumber`,
`ix_assets_serialnumber`
* core `7b02`: `ix_assets_gaugelabreference`, `ix_assets_maintenancereference`
* core `7d33`: `idx_assets_levelid`
* the shipped endpoints `0021`: `idx_assets_cert_fingerprint` over
`client_cert_fingerprint` alone (the shipped `0022` added two columns and no
index)
* `db.create_all` off `shopdb/core/models/asset.py`. The earlier version of this
inventory said that builds "that same set" as the core baseline, on the
strength of the Asset model never carrying a cert column. As corrected above,
between 18e5fdc00 and 7480e4fe3 it did, with `client_cert_fingerprint`
declared `index=True`, so `create_all` in that window ALSO built
`ix_assets_client_cert_fingerprint`. That index is missing from every earlier
telling and is the one real correction to this list.
Re-checked rather than assumed, the conclusion is unchanged.
`ix_assets_client_cert_fingerprint` is single-column over
`client_cert_fingerprint`, which `_drop_certificate_columns` removes in the
statement immediately after the loop, so the index carries no column that
outlives it and there is nothing to recreate. The filter is also unreachable by
accident: every name in `_CERT_COLUMNS` is underscore-spelled, which the column
convention forbids, so no real `assets` column can collide with it. The only two
indexes the loop can ever drop are `idx_assets_cert_fingerprint` and
`ix_assets_client_cert_fingerprint`, both dead by construction. No index was
lost on any database at the reference site and no repair migration is owed; the reasoning is
recorded in the test file beside the allowlist entries so it is not
re-litigated.
### 3. ADR-016: four files here describe a decision ADR-016 never took
`docs/wiki-drafts/README.md:316-323` (corrected 2026-09-18), `CLAUDE.md:119`,
`the assistant quick-reference file, lines 135 and 14`,
`docs/wiki-drafts/Security-Audit-2026-09-11.md:392`
All four attribute "device tokens" to ADR-016. `grep -rni device
docs/adr/ in the product repo*.md` returns no hit for device tokens
in ANY ADR. ADR-016's Decision is a fleet credential store with Fernet at rest,
a targeting and precedence resolver reusing `plugins/geenforce/filters.py`, an
explain endpoint, `credentials.manage`, and `GET /api/collector/credentials`
behind a dedicated `credentials.fetch` scope.
The `docs/wiki-drafts/README.md` copy went further and, under a heading
"ADR-016: Credential Delivery", listed registry keys among the places
credentials must **never** live. ADR-016 says the opposite: its
"Alternatives rejected" section calls registry-only provisioning "a worthwhile
FIRST STEP" and "Recommended as tier one regardless". The ban also contradicted
shipped code on this branch:
`plugins/computers/client/Report-AssetToShopDB.ps1:367-374` reads `CollectorKey`
out of `HKLM:\SOFTWARE\GE\ShopDB`, and
`plugins/endpoints/client/Set-ShopDBServerPins.ps1:4-20` writes the server trust
pins into the same key by design, with
`plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md:106-111` ("The registry
wins") making that the intended precedence. That section was rewritten on
2026-09-18; the three files outside `docs/wiki-drafts/` still carry the
misattribution and are out of scope for this edit.
Two qualifications, so nobody overcorrects: `PER-PC-CREDENTIALS-ROLLOUT.md:351-361`
deliberately plans to clear `CollectorKey` from both registry views at cutover,
so the shared key in HKLM is being retired anyway; and `ServerPins` are trust
anchors, not credentials.
### 4. ADR-016: the per-PC programme redefines "credential" with no design record
`plugins/endpoints/models/credential.py:1`
ADR-016's mechanism is unbuilt. Grepping `shopdb/` and `plugins/` for
`credentials.fetch`, `collector/credentials` and `Fernet` returns zero hits, and
`plugins/endpoints/plugin.py:154-265` declares only `endpoints.*` permissions -
no `credentials.manage`, no `credentials.fetch`. That matches ADR-016's own
"Implementation status, 2026-08-14". What the 20-commit programme built is a
different subject under the same word: `EndpointCredential` /
`EndpointEnrollRequest` / `EndpointMigrationWave` as the agent's own identity
secret (`models/credential.py:1-22`), `X-Endpoint-Credential` as the agent auth
header (`api/agentauth.py:1-18`), and an unauthenticated `/enroll`
(`api/enrollment.py:289`).
The design itself is sound and consistent with ADR-016's intent: the rollout
removes the cleartext key from the share, and rotation is a settings change plus
a pickup report, which is what ADR-016 wanted. The gap is that nobody wrote it
down as a decision. `grep -ril 'enroll|endpoint credential|X-Endpoint-Credential'`
over the ADR directory on the other lineage matches only ADR-012, which is
unrelated. Low severity and not fixable from here: the missing record belongs on
the lineage that carries `docs/adr/`.
### 5. ADR-018: accepted, unimplemented, with nothing saying so
`shopdb/plugins/alembic_template.py:74`
ADR-018 (ACCEPTED, 2026-09-04) decides that the `measuringtools` plugin is
retired and the `measuring_tool` asset type removed, that machines gains four
calibration columns and machinetypes gains `ismetrology`. None of it exists
here: `plugins/measuringtools/` still ships with its manifest,
`alembic_template.py:74` still registers
`'measuringtools': ('measuringtooltypes', 'measuringtools')`,
`shopdb/core/api/dashboard.py:19`, `:55` and `:93` still count `measuring_tool`
into the asset totals, `shopdb/cli/__init__.py:1021` still seeds it,
`deploy/site-profile-universal.json:12` still lists it in the universal profile,
and `grep -rn ismetrology shopdb/ plugins/` and
`grep -n calibration plugins/machines/models/*.py` both return nothing.
`git log -3 -- plugins/measuringtools/` shows no retirement work.
Unlike ADR-016, ADR-018 carries no implementation-status paragraph, so its text
reads as done. Low severity: the table holds zero rows, so nothing is broken
today, and ADR-018's own argument is that the window is time-boxed - the
migration is free while the 134 staged metrology instruments have not been
entered. gh-wj is the branch that deploys to the reference site, so entering them here is what
closes the window.
### 6. ADR-001 / ADR-002: raw SQL over core tables from a plugin
`plugins/computers/plugin.py:2080`
`db.session.execute(db.text(...))` joining core `assets`, `assetrelationships`
and `assettypes`, inside the `retype` CLI command. `Asset`, `AssetType`,
`AssetRelationship` and `RelationshipType` are all exported and in `__all__`
(`shopdb/api/__init__.py:47-48`, `:424`, `:438-439`), so unlike the
`BusinessUnit` case that the 0.34.0 bump note closed, there is no missing export
to blame, and `plugins/endpoints/api/routes.py:3981-3986` is the same pattern
already corrected. The 0.34.0 note names the reason in the code:
"a raw query is hidden coupling to the core schema that no contract bump can
catch, and it is the kind of thing that gets copied"
(`shopdb/__init__.py:135-142`).
Low severity and arguably generous: ADR-001's stated rule is an import rule,
which raw SQL does not literally breach, and the blast radius is one admin CLI
command with no serving route affected. Predates this range (331d27e07). The
other plugin raw query, `plugins/computers/plugin.py:2128`, touches only
plugin-owned tables and is fine.
### 7. ADR-015: the site-literal scan does not cover the C# agent
`scripts/check-naming-and-style.sh:440`
Lines 436-440 scope the grep to `plugins/ shopdb/ scripts/ deploy/
frontend/src/ tools/` with `--include` on
`.py/.vue/.js/.ps1/.psm1/.sh/.iss/.json/.html`. `agent/` is absent and `.cs` is
not an included extension. This range added roughly 2,500 lines of C# under
`agent/`, all of which ships to a sister site. Re-running the assembled site
patterns by hand over `agent/`, excluding bin and obj, finds one hit: prose in
`agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj:108`, which ADR-015
explicitly permits ("Comments and documentation may name a site where it
explains WHY"). So the rule holds today; the gate would not notice if it stopped
holding.
**This is a coverage gap in the rule, not a breach of it.** ADR-015's 2026-08-14
amendment names those six directories as the scan's scope, so the script matches
the ADR precisely. The amendment widened the scan on exactly this reasoning -
the original scope missed every literal that reached a second site, because
those were in the client scripts and the installer - and the C# agent is now
that client channel.
### 8. ADR-002: two manifests declare a floor below the contract name they import
`plugins/usb/manifest.json:8` and `plugins/printedparts/manifest.json:8`
`da91a2915` added `shopdb/utils/ipallowlist.py`, bumped `__contract_version__`
to 0.35.0 with `ip_in_cidrs` and `request_ip_allowlisted` named as the new
surface (`shopdb/__init__.py:143-152`), and made
`plugins/usb/api/routes.py:42` and `plugins/printedparts/api/routes.py:521`
import `request_ip_allowlisted`. Neither manifest was touched: usb still says
`">=0.1.0,<1.0.0"` and printedparts `">=0.16.0,<1.0.0"`. The team does maintain
this field - `066a19052` raised `plugins/endpoints/manifest.json:7` to
`">=0.34.0,<1.0.0"` one commit earlier.
On a 0.34.0-or-older core both plugins would pass the loader's version gate
(`shopdb/plugins/loader.py:141-166`, called at `:258`) and then die with an
ImportError at blueprint registration. Impact at the reference site is nil, because core and
plugins always ship from this one tree.
The ADR-002 framing is weak and should not be quoted as a clean breach: ADR-002
itself says pre-1.0 plugins should declare `">=0.1.0,<1.0.0"`, which is
literally what `plugins/usb/manifest.json:8` declares, while its Neutral section
says bundled plugins' `core_version` is "always the current
`__contract_version__`". The ADR contradicts itself here. This is staleness
against team practice.
## Checked and not a violation
These were examined and did NOT hold up as ADR breaches. They are listed so the
next review does not re-open them.
- **`get_settings_routes` narrowed to a one-hub allowlist without a contract
bump.** `shopdb/core/api/plugins.py:92` now filters through
`renderable_settings_routes`. Nothing observable changed: the only frontend
caller, `frontend/src/views/settings/ModelCatalogHub.vue:73`, already filtered
to its own hub at `:76`, and `frontend/src/router/routes/core.js:62-63` states
in-tree that no other hub page fetches or registers plugin routes. A route
naming another hub was drawn nowhere before and is drawn nowhere now. No
`BasePlugin` ABC signature or `PluginMeta` schema changed, so under ADR-002
this is patch-level and no bump was owed. It is also not silent:
`shopdb/plugins/base.py:46-52` logs an error naming the plugin, the hub and
the supported set, and the same commit added the "Constraints as implemented"
section to the ADR-019 draft plus
`tests/test_core/test_plugin_settings_hubs.py`.
- **`computers` and `printers` manifests declaring `>=0.20.0` against the
contract-0.33.0 key `acceptsagentcredential`.** ADR-002 requires no such
tracking, nothing in `loader.py` implements it, and the claimed fail-open
cannot occur: on a core pinned at 0.32.x the endpoints plugin is refused at
load by its own `>=0.34.0` floor, so no credential is ever issued and there is
no enrolled PC whose shared key an older dispatcher could wrongly accept.
## Drift that no ADR covers
Recorded because it is real and someone will look for it here.
**`docs/openapi.json` is a release behind the code.**
`shopdb/core/api/docs.py:18` pins `_SPEC` to that file and serves it through a
vendored Redoc at `/api/docs`, specifically for the air-gapped production box.
The tracked spec holds 377 paths and `info.version` is still `0.12.0`. It
contains zero paths matching `enroll` or `bootstrap`, while
`git diff 2ac2e6d33..gh-wj -- 'plugins/*/api/*.py' 'shopdb/core/api/*.py'` adds
24 route decorators, including the whole enrollment surface
(`plugins/endpoints/api/enrollment.py:289`, `:580`, `:657`, `:752`, `:773`,
`:827`, `:853`, `:1095`, `:1135`, `:1233`, `:1253`) and the bootstrap-token
surface (`plugins/endpoints/api/bootstrap.py:419`, `:474`, `:485`, `:506`,
`:530`). Where the spec does describe a route that changed, it describes the old
behaviour: `/api/plugins/settings-routes` is documented as
"**Auth:** none (jwt-optional decorator, deliberately anonymous...)" with
`"security": [{}, {"bearerAuth": []}]`, while the shipped handler is
`@require_permission('settings.view')`.
Neither ADR-001 nor ADR-002 mentions `openapi.json` or `api-inventory.json`, so
no stated rule is broken. The drift also cannot be detected or repaired from
this branch: `docs/api-inventory.json` is not tracked here, so
`tests/test_api_inventory_parity.py` skips and `scripts/gen_openapi.py` has no
input.
**`CLAUDE.md` in this repo contradicts the repo.** Line 3 and line 9 describe
the database as "PostgreSQL 15+", while `docker-compose.yml:20` runs
`mysql:8.4` and `.env.example:29` is a `mysql+pymysql://` URL. Line 31 states
"**Columns**: snake_case (e.g., `serial_number`, `last_seen`)", which is the
opposite of `CONTRIBUTING.md:17` ("Column names: lowercase, concatenated,
singular. No dashes, no underscores") and of what the gate enforces at
`scripts/check-naming-and-style.sh:236`. Line 119 carries the ADR-016 device
token misattribution described above. `CLAUDE.md` is outside this folder and was
not edited.
## Scope note
This page records ADR conformance only. The same review produced security
findings against the per-PC credential surface, the kiosk network admission and
the one-line bootstrap; those are not ADR matters and are recorded in
`Security-Audit-2026-09-11.md`, refreshed on the same date and the same commit.
Violation 3 on this page and finding 31 on that one are the same ADR-016
misattribution seen from two angles. The highest of the security findings, for
cross-reference, is that `install.ps1` writes the fleet shared key to
`HKLM\SOFTWARE\GE\ShopDB` with no ACL
(`plugins/endpoints/api/siteagent.py:328`, helper generated at `:394-411`, no
`SetAccessControl` or `RegistrySecurity` anywhere in the generated installer),
where any logged-on user can read it - a consequence the repo states itself at
`.github/GE-ENFORCE-COLLECTOR-FIX.html:377-380`.
## References
- ADR set: `docs/adr/ in the product repo` (NOT on this branch)
- `scripts/check-naming-and-style.sh` - the only ADR-aware gate in this repo
- `plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md` - the rollout runbook
- ADR-019-Plugin-Dynamic-Settings-Routes.md - the one ADR-shaped file here
- Security-Audit-2026-09-11.md - the security half of the same review
- Architectural-Findings-2026-09-11.md - re-checked 2026-09-18
- Maintenance-Footprint-Baseline.md - re-checked 2026-09-18