Files
shopdb-flask/docs/wiki-drafts/ADR-Conformance-2026-09-18.md
cproudlock f9d6b57f0f 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.
2026-09-19 09:10:15 -04:00

32 KiB

ADR Conformance Review - September 18, 2026

Read with the date on it. This is a point-in-time audit, kept as the record of what was found and what it turned out to be, not as current guidance. One thing it says repeatedly is no longer true: it was written when this line carried no ADRs, and the full ADR set now lives beside it in docs/adr. Where this page says a decision record is missing or lives elsewhere, look there first.

Branch: the deployment branch at 35d4fcd41 Range reviewed: 2ac2e6d33..HEAD, 20 commits, 149 files, +22055 lines Method: read-only. Every statement below cites a file and line that was read. No test suite was run except tests/test_plugins/test_kiosk_network_allowlist.py (20 passed) and bash scripts/check-naming-and-style.sh (exit 0).

Read this first: the ADRs are not in this repository

ls docs/adr fails on the deployment branch. There is no ADR directory on this branch. The whole set, ADR-001 through ADR-018 plus a README, lives on the other lineage at docs/adr/ in the product repo and was read there for this review. docs/ on the deployment branch holds only ShopDB-Planner-Tasks-v2.csv, deployment/, notes/, openapi.json and wiki-drafts/.

Three consequences, and they are the reason this page exists:

  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 the deployment branch 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. The plugin contract moved three minors over this range, each with a rationale naming what was added and why it is additive (shopdb/__init__.py:124-152; the current value is in PROJECT-MAP, which is generated). Every name those comments claim is genuinely exported. Every new BasePlugin hook is optional with a safe default (shopdb/plugins/base.py:313-332), so minor is the right classification. The loader still checks core_version against __contract_version__ (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. The product version in shopdb/__init__.py:158 matches frontend/package.json:3, and the version literal is still absent from shopdb.api.

ADR-008 in the forward direction. git diff --stat 2ac2e6d33..HEAD -- migrations/ is empty. Not one line of plugin schema went into the core chain. The endpoints chain is 24 revisions with exactly one root and one head, no dangling down_revision, longest revision id 29 characters against the 32-character alembic_version cap.

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..HEAD -- CHANGELOG.md returns nothing at all. The file is byte-identical across all 20 commits, which include per-PC agent credentials, enrollment, bootstrap tokens, server pinning, kiosk network admission and three contract bumps. Grepping the whole file for "per-PC", "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 the deployment branch; 7480e4fe3 (2026-09-15) deleted all 28 lines again. The grep comes back empty because it runs after the retraction, not because the contract was never touched.

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), the assistant-instructions file, line 119, the assistant quick-reference file, lines 135 and 14, docs/wiki-drafts/Security-Audit-2026-09-11.md:392

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. The deployment branch is the branch that deploys to the reference site, so entering them here is what closes the window.

6. ADR-001 / ADR-002: raw SQL over core tables from a plugin

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..HEAD -- 'plugins/*/api/*.py' 'shopdb/core/api/*.py' adds 24 route decorators, including the whole enrollment surface (plugins/endpoints/api/enrollment.py:289, :580, :657, :752, :773, :827, :853, :1095, :1135, :1233, :1253) and the bootstrap-token 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.

The assistant-instructions file in this repo contradicts the repo. Line 3 and line 9 describe the database as "PostgreSQL 15+", while docker-compose.yml:20 runs mysql:8.4 and .env.example:29 is a mysql+pymysql:// URL. Line 31 states "Columns: snake_case (e.g., serial_number, last_seen)", which is the opposite of CONTRIBUTING.md:17 ("Column names: lowercase, concatenated, singular. No dashes, no underscores") and of what the gate enforces at scripts/check-naming-and-style.sh:236. Line 119 carries the ADR-016 device token misattribution described above. That file 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