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