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.
31 KiB
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:
- 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).
- Nothing on this branch enforces them except one script.
scripts/check-naming-and-style.shis 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.pyskips itself entirely on this branch:docs/api-inventory.jsonis not tracked here, and its own skip reason says "docs/ is excluded from publication". - 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_fingerprintoverclient_cert_fingerprintalone (the shipped0022added two columns and no index) db.create_alloffshopdb/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, between18e5fdc00and7480e4fe3it did, withclient_cert_fingerprintdeclaredindex=True, socreate_allin that window ALSO builtix_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_routesnarrowed to a one-hub allowlist without a contract bump.shopdb/core/api/plugins.py:92now filters throughrenderable_settings_routes. Nothing observable changed: the only frontend caller,frontend/src/views/settings/ModelCatalogHub.vue:73, already filtered to its own hub at:76, andfrontend/src/router/routes/core.js:62-63states 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. NoBasePluginABC signature orPluginMetaschema changed, so under ADR-002 this is patch-level and no bump was owed. It is also not silent:shopdb/plugins/base.py:46-52logs 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 plustests/test_core/test_plugin_settings_hubs.py.computersandprintersmanifests declaring>=0.20.0against the contract-0.33.0 keyacceptsagentcredential. ADR-002 requires no such tracking, nothing inloader.pyimplements 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.0floor, 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 repoplugins/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