The theme foundation DESIGN.md requires (ADR 0060):
- A design token may carry a `dark` value and a `reduced` value. The
token stylesheet selects them in CSS alone: dark under
prefers-color-scheme unless the host chose light, or when it chose
dark; motion durations zero under prefers-reduced-motion unless it
allowed motion, or when it chose reduce. `color-scheme` travels with
the tokens. System is live, an explicit choice wins, and every module
root follows through custom-property inheritance, with no module
contract change.
- @ekklesia/design-tokens adds a small appearance API: system/light/dark
and system/reduce/allow, parsers for untrusted stored values,
resolvers, and a controller that applies the attributes immediately.
The shell applies it before React renders. That is the hook the
account preferences work will feed; nothing is persisted. The
Dashboard page declares `color-scheme: light dark` so a dark system
never flashes white. Displays stay light.
- A designed dark theme: teal-tinted charcoal, never pure black or
white, the same teal accent.
- WCAG AA contrast is tested for 41 element pairings in both schemes.
Two light pairings failed and are fixed visibly: text.muted darkens,
and form controls get a new border.control (3:1).
background.placeholder keeps skeletons visible in dark.
- Motion tokens (duration-short, duration-ambient) replace literal
durations and every per-element reduced-motion rule. radius.scale
multiplies sm/md/lg, unchanged at its default of 1.
- The sign-in card and module loading/error text move from literal
colours to tokens. Guards keep raw colours and literal radii out of
Dashboard product source and @ekklesia/ui.
- Fixes ConfirmationDialog's phrase input, which used a class that
does not exist (`ek-input`) and so was unstyled.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Stage 1's later screens and modules need the interactions DESIGN.md
fixes once for the product (ADR 0059). @ekklesia/ui gains them,
additively:
- ConfirmationDialog: simple, destructive, or typed-phrase
confirmation, with severity chosen by the caller. Destructive is
marked by symbol and frame, not colour alone, and starts on Cancel.
A required phrase must match exactly (forgiving only surrounding
whitespace and NFC). While busy, Cancel and Escape are disabled.
- ToastProvider / useToasts: a top-center toast system per React tree,
never a package singleton (ADR 0040). Live regions are mounted
before any toast arrives; errors are assertive and persist until
dismissed; routine toasts last at least three seconds and longer for
longer text. Timers pause on hover and focus; duplicates refresh;
errors are never displaced; every timer is cleared on unmount.
- ErrorState: recovery actions, plus collapsed technical details and a
reference, rendered as bounded inert text.
- Skeleton / SkeletonGroup: an aria-busy region labelled once, with
every animation stopped under prefers-reduced-motion.
- PageHeader `back` (link or button) above the page's one h1.
- Dialog: the focus trap ADR 0039 deferred, plus focus restoration and
an accessible description. Escape is now handled on the dialog
itself, so a drawer it was opened from stays open.
The shell validates Back targets (parseReturnTarget, dashboardBack):
only a canonical Dashboard page on this origin is accepted, otherwise
Back goes to the page's parent. It never uses history.back().
Biome's scope-aware suspicious/noAlert is now an error in the root
biome.json. tests/native-dialogs.test.ts proves it catches real
dialogs and not lookalikes, and scans for the forms the rule misses.
It supersedes the shell-only regex test.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
CI failed in `identity-verification-boundaries.test.ts`: its guard that a
reset token never reaches browser storage was a blanket ban on the word
`localStorage` anywhere in the shell. The sidebar preference (ADR 0058)
legitimately reads it in the entry point.
The guarantee is unchanged: the reset token stays in React state for
the life of one form. The guard is now precise:
- `localStorage` appears only in `main.tsx`, only to hand it to the
sidebar preference;
- the shell's only storage write is the sidebar's own key and a
validated mode;
- the files that ever hold the token (`auth-protocol.ts`,
`ResetPasswordForm.tsx`, `HostApp.tsx`) touch no storage API at all;
- `sessionStorage` and `document.cookie` stay banned everywhere.
A planted `localStorage.setItem("t", token)` in `auth-protocol.ts` fails
it. The runtime reset-token test in `auth-recovery.test.tsx` now runs
with the production localStorage-backed preference and asserts storage
holds nothing but the sidebar key.
It passed locally only because Turbo restored `@ekklesia/core:test` from
cache: that test reads shell source that Core's task hash does not
include.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The Dashboard's shell now follows DESIGN.md §4 (ADR 0058):
- A collapsible left sidebar replaces the header-and-column layout. It
is icons and labels when expanded and icons only when collapsed; each
collapsed item keeps its name, visually hidden, and shows a tooltip
on hover and on keyboard focus (WCAG 2.2 §1.4.13). The Overview comes
first, then the server's groups and modules. There is no top bar.
- The expanded/collapsed choice is remembered in this browser's
localStorage, behind a SidebarPreference interface that a synced
store can replace later.
- An account menu at the bottom of the sidebar follows the WAI-ARIA menu
button pattern and offers only Sign out, which now lives there rather
than in the header.
- On phones (below 48em) the same navigation opens in a modal drawer:
the page behind it is inert and focus is trapped inside it. Escape,
its close button, and the backdrop close it and return focus to the
menu button. Choosing a page closes it and focuses the page. Changing
layout keeps the route, the desktop mode, and the mounted module.
- Lucide (lucide-react 1.48.0, shell only) is Ekklesia's icon
vocabulary. Every module shows one generic icon until manifest v9
lets modules declare their own; there is no per-module icon map.
- The identity slot is a neutral placeholder with no inputs, waiting
for branding (#101).
The tooltip, menu, and drawer stay shell-local until a second consumer
needs them (ADR 0039). The deploy smoke now signs out through the
account menu, and drives the drawer at phone size in a real browser.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Module pages move from `/modules/<id>` to `/<module-id>` (`/signage`),
beside `/settings` and `/account`, so the implementation concept
"module" never appears in a user-facing address. ADR 0057 is rewritten
to match.
- Core reserves the top-level names the platform actually serves today
(`account`, `api`, `assets`, `display`, `settings`), and discovery
refuses a module that declares one with `MODULE_ID_RESERVED`. That
covers startup, `modules:validate`, and package verification. Tests in
`apps/api` hold the set to every top-level route the API registers and
every page the shell interprets.
- The API serves the Dashboard page at `/<module-id>` and below only for
modules discovered with a Dashboard UI, enabled or not: a disabled
module's bookmark still opens the Dashboard, which says it is
unavailable. An arbitrary root path such as
`/definitely-not-a-real-module` stays the sanitized 404.
- Both reference proxies forward every top-level path shaped like a
module id and let the API decide; the split Dashboard hostname still
refuses `/api`, `/display`, and `/assets`. Installing a module needs no
proxy change.
- `/<module-id>/<page>` is already served and forwarded, so a later
module sub-page contract needs no routing or deployment change; the
shell shows "Page not found" there until it exists.
- The deploy smoke checks a module's address before install, while
installed, while disabled, after removal, and after reinstall, plus
unknown names and the old `/modules/…` form through the real proxy.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The Dashboard had one address. The API served the page only at `/`, both
reference proxies forwarded only `/`, and the open module lived in React
state, so a refresh went back to the first module, nothing could be
bookmarked, and Back left the Dashboard (DESIGN.md §2.7).
ADR 0057 makes the address bar the source of truth:
- `/` is a host-owned Overview, now the landing page; `/modules/<id>`
mounts that module. An id the server's navigation does not list shows
"This page isn't available" and loads nothing; any other Dashboard
address is a host-owned "Page not found".
- `/modules`, `/settings`, and `/account` are reserved whole, from one
list (`@ekklesia/shell/dashboard-routes`) that the shell's router and
the API's page routes both read. The API serves the same frozen page,
ETag, and security headers anywhere inside them; look-alikes and other
methods stay the sanitized 404.
- A small host-owned history replaces the selected-module state, built in
the entry point so it stays the only file touching `window`. Navigation
items are real links; modified clicks are left to the browser. Focus
moves to the page after an in-place change, and the tab is named after
the page.
- Sign-in still happens in place with no return value (ADR 0034), so a
deep link opens its own page after sign-in.
- Both reference Caddyfiles forward the new paths; the deploy smoke now
reloads a module's address through the real proxy and requires it to
mount again.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Four integration tests shut the API down and immediately count the
database's rows in pg_stat_activity, expecting zero. PostgreSQL ends a
backend asynchronously: pool.end() resolves once the client side has
closed, and the server can still list that backend for a moment. On a
loaded runner an immediate count saw one, failing migration-smoke on a
docs-only PR (Ekklesia CI run #238, module-database.integration.ts:764,
"expected 1 to be +0").
Reproduced against postgres:18.4-alpine3.24 limited to 0.05 CPU: after
pool.end() resolved, the backend was still listed in 7 of 300 trials,
and gone about a millisecond later.
The checks now wait up to 3 s for the count to reach zero. A connection
that is genuinely left open is still caught: an idle pooled client
stays listed for pg-pool's 10 s idle timeout, far past the deadline.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Findings that only live in a chat reply get lost. Agents now open a
descriptive issue in the repository a problem belongs to (after
searching for an existing one), mark it found-by-claude, leave it
unassigned, and list what they filed when they report back.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
scripts/test-ci-sweep.sh proves the stale sweep by running
`ci-sweep.sh --stale --max-age-seconds 0` on the runner's shared Docker
daemon. With a zero threshold that removes every `ekklesia-ci-` stack on
the daemon, not just the test's own: any job running at that moment
loses its containers, networks, and volumes. It reported "CI sweep
checks passed" while doing it. Core's infrastructure-smoke job runs the
self-test on every run, and twice tore down Signage's integration stack
mid-scenario (Zayin/Signage run 288: its first attempt, and the second
one during connectivity-blip).
`ci-sweep.sh --stale` now takes `--prefix P`, which narrows the sweep to
stacks whose names begin with P and must itself begin with the CI
prefix, so it can only narrow a sweep, never widen one. The self-test
confines every stale sweep to its own fake run and proves that another
run's stack survives even the zero threshold. The workflow's own sweeps
are unchanged.
Regression: a bystander stack from another run in the self-test, and a
Docker-free guard that every stale sweep in the self-test is confined
and the workflow's keep the default threshold.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
ADR 0056 (Zayin/Ekklesia#94) replaces the fixed 180 days from pairing with
180 days from last use, renewed while the screen is in use; the guide no
longer tells operators to track pairing dates.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A device credential was accepted for a fixed 180 days from pairing, so every
working screen dropped to a pairing code on the anniversary of its pairing,
and screens paired together failed together. Measure the 180 days from when
the device was last seen instead, and have the display descriptor - which
every screen requests on start, on reconnect, and periodically - re-issue the
cookie that authenticated it with a fresh lifetime. The value is not rotated,
so a renewal lost in transit cannot strand a screen. Revoked, expired, and
unknown credentials are never renewed, a person never receives a device
cookie, and an abandoned screen still stops being an identity 180 days after
it was last seen. No migration: lastSeenAt already slides with use.
ADR 0056 supersedes the session-lifetime section of ADR 0036.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The package registry moved with the repositories to
git.zayin.net/api/packages/Zayin/npm/; the old River_of_Glory_Church address
now answers 502, so the next manual Publish packages run would have failed.
Point the workflow, the release and module-development docs, and the
disposable-Gitea publication proof's owner at Zayin. The CI Docker label keys
keep their names: they are identifiers the sweep matches on, not hosts.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The revoke scenario left the kiosk revoked, so any display check after it -
the post-recreation restart check in particular - failed by construction.
That is why Signage could never run it. Finish the scenario the way the
roadmap states the requirement: a revoked screen shows a fresh code, is paired
again, resumes without a reload, and for Signage keeps its existing roster
entry rather than appearing as a new screen.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A kiosk that booted before its server, as after a power cut, stopped on 'This
display could not be reached' until someone pressed Retry: with the network
up, no online event ever came. It now retries on its own, from two seconds
doubling to a minute. A running display also re-resolves its descriptor every
five minutes in the background, so a module upgraded on the server is picked
up without a reload, and the same answer disturbs nothing.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Stage 0 requires that kiosk setup can be reproduced from documentation, and
there was none. Cover the address, a single-launcher Chromium command with a
persistent profile, a systemd user unit, pairing and the fixed 180-day device
credential, what the screen does through each kind of outage, the known offline
cold-start limit, and why a second launcher produces duplicate windows.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Revoke acted on a single click, yet it strands the screen until someone
re-pairs it in person. Explain the consequence in the shared in-product
Dialog first (DESIGN 21.4), with an obvious Cancel.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Every browser 'online' event reset the display host to its loading state and
mounted the module again: a fresh viewer session, a fresh stage, and every
cached object URL released, on each Wi-Fi blip. And a device revoked while its
display was open never noticed; it kept playing cached content and polling
403s until someone reloaded the page.
The host now re-resolves its descriptor in the background (ADR 0043) and only
replaces the frame when the answer actually changed: a new version or
principal remounts, a refusal returns the screen to pairing (ADR 0036), and
any other failure keeps the running display. A module request refused with
401/403 prompts the same revalidation, throttled, so revocation is noticed
within one heartbeat. The offline indicator waits ten seconds before
appearing, so a momentary blip shows nothing, and no longer names the
platform on a lobby wall.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Core's agent guidance still described modules living in this monorepo and
manifest v7, and said nothing about the product rules that now govern every
change. Record that first-party modules live in their own Zayin repositories
and install as Module Packages, that the parent ROADMAP.md and DESIGN.md are
authoritative for product direction, the ownership, branding, token, dark-mode
and no-native-dialog rules, that display pairing is transitional until Stage 1,
and the branch-per-PR, no-push-to-main, and no-release-tag workflow.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Every decision in ADR 0052 is implemented and tested in @ekklesia/module-pack:
- package material first, and a supplement only adds to it;
- reviewed, generic supplement data, checked in with the packager;
- an exact name, version, and content-digest match, failing closed on any mismatch;
- the documented, host-independent digest;
- a deterministic, labelled merge;
- no licence override, with a consistency check;
- no network during packaging;
- packageFormat 1.
The libvips 1.3.3 and 1.3.4 supplements are curated and shipped. The source-obligation determination
it deferred is now decided by ADR 0055. Only the status line and the index entry change; the decision
text is untouched.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Ekklesia may publicly distribute a native Module Package containing LGPL or MPL components only beside
a version-specific corresponding-source artifact. The artifact carries the exact source, patches,
build definitions, and rebuild and relink instructions for those components. ADR 0055 (Accepted)
records the policy: project compliance policy, not legal certification. It decides the item ADR 0052
deferred, and extends ADR 0049's distributable-release gate. Module Package v1, packageFormat 1, and
both libvips supplements are unchanged.
The mechanism is generic and lives in @ekklesia/module-pack:
- Source recipes: reviewed data under corresponding-sources/<package>/<version>/. recipe.json names
the exact package (name, version, content digest) and the exact supplement it was prepared with,
and lists every component with its role, licence, licence material, and relationship to the
binary. Every source file and patch is recorded by origin, size, and SHA-256; documents are hashed
too, and every exclusion has a reason. Loading is strict and refuses anything unlisted.
- Acquisition (the only network step) fetches each recorded file once into a cache keyed by
SHA-256. It uses node:https with exact headers and no decoding, retries transient failures, and
stores only bytes that match.
- Construction is offline. From a built package it finds the covered native dependencies (by
declared licence, or by a supplement component's licence), verifies every byte, and writes four
sibling artifacts: the deterministic <package>-corresponding-source.tar.gz and its .sha256;
<package>.redistribution.json, linking package, dependencies, supplement, recipe, and source by
digest; and <package>.source-notice.txt, the release notice.
- The public-distribution gate (verifyRedistribution, `ekklesia-corresponding-source verify`)
approves only when the recipe, supplement, canonical archive, manifest, and metadata identify
exactly these files. It refuses bundled copyleft code, and approves packages with no covered
native code without an artifact. packModule is untouched.
First complete proof: @img/[email protected] (sharp 0.35.5), from the reviewed supplement
and provenance.
- 383 components and 387 source files, about 208 MB: the sharp-libvips v1.3.4 build definitions,
the 27 library archives, the five build-time patches, the 353 crates librsvg's resolved Cargo.lock
records, and rust-src for nightly-2026-09-27 (rustc 75a75c3e0), which vendors std's dependencies.
- BUILDING.md and two helpers point sharp-libvips's unchanged build logic at those files.
- Followed in a Rocky Linux 8 container with networking disabled, they rebuilt
libvips-cpp.so.8.18.7, byte-identically across two builds. Swapped into a verified, extracted
native package, the rebuilt library was the one loaded, and sharp ran.
- The native proof's source artifact, native-proof-0.1.0-linux-x64-glibc-corresponding-source.tar.gz,
is 207242785 bytes, sha256 cc405492032b04a5391ab84e64f0e5e50f78e571ef7818204782c25a03eb50b7. It is
byte-identical from unrelated directories and from a cache acquired fresh from upstream.
Tests:
- corresponding-source.test.ts runs in CI: synthetic inputs, every negative case, acquisition with
retries and refusal, and determinism.
- corresponding-source-native.test.ts checks the real recipe everywhere. With a verified cache
(`pnpm test:corresponding-source`, which acquires once) it builds the real artifact twice, gates
it, fails it closed, and runs sharp from the package.
Docs: the module guide's distribution section, the README and architecture notes, and an ADR 0052
cross-reference.
No Signage change, version bump, publication, or tag.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Every platform workspace package and every public @ekklesia/* package declares 0.2.1, and the deploy
README's image build example names it. templates/module and the fixture modules keep their own
versions.
0.2.1, a patch: since v0.2.0 (1788d17), main has one change, #84. It adds the reviewed supplement for
@img/[email protected] and moves the packager's native proof to sharp 0.35.5. That is new
packager data and test-only dependencies. No SDK, manifest, database ABI, packageFormat, or package
API changed.
The standalone fixture consumes exactly this release: its @ekklesia ranges become ^0.2.1 and its
module-pack pin 0.2.1 (docs/development/releases.md, step 1). Module compatibility ranges stay ^0.2.0,
as the release guide says a patch version requires. The template, the standalone fixture, the native
proof module, and the test fixtures modelling the current platform are therefore unchanged. A module
built for 0.2.0 remains compatible with 0.2.1.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
sharp 0.35.4 carries a production-relevant librsvg advisory (GHSA-wq5f-xc86-pv6w). Its fix, sharp
0.35.5, loads @img/sharp-libvips-linux-x64 1.3.4, which ships no licence material either. This adds
the reviewed supplement for that exact package (ADR 0052) and moves the packager's native proof to
sharp 0.35.5, so a later 0.2.1 release can package it on linux-x64-glibc. Nothing in module-pack's
code changes: supplement selection is already by exact name, version, and content digest.
- Supplement: license-supplements/@img/sharp-libvips-linux-x64/1.3.4/
- content digest ekklesia-content-v1:sha256:2927fe11493461fa34847191d0b9b1c6441007c3e99f306e5708cb07fb0a5e59;
- 190 components and 146 hashed texts;
- assembled with the same method as 1.3.3's, from sharp-libvips v1.3.4's own inputs. That method
reproduces the 1.3.3 supplement byte for byte.
- PROVENANCE.md records:
- the 27 upstream archives and five build-time patches, by sha256;
- the librsvg 2.63.2 crate reproduction (cargo 1.99.0; Cargo.lock 5723e14c…);
- rustc 75a75c3e…;
- every change from 1.3.3.
- Notable: sharp-libvips v1.3.4 now states cairo is used under MPL 1.1, not MPL 2.0. cairo ships
that text itself, so the standard MPL 2.0 text is no longer added for cairo; selectors still
carries it.
- No legal conclusion: the LGPL and MPL source-availability determination stays open, as for 1.3.3.
- The 1.3.3 supplement is unchanged, and still proven: module-pack keeps sharp 0.35.4 as a test-only
devDependency alias (`sharp-0.35.4`). Each supplement is pinned against its real installed
libvips, and each matches only its own.
- Native proof fixture: sharp 0.35.4 → 0.35.5. The closure has the same shape: sharp, @img/colour,
detect-libc, semver, and @img/sharp-linux-x64 0.35.5 with @img/sharp-libvips-linux-x64 1.3.4.
The runtime read from the binaries is unchanged: Node-API 9, glibc >= 2.28, x86-64.
- Docs: the module guide's note on available supplements.
No version bump, no publication, no tag.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The first real 0.2.0 publish failed on its first package with ENOENT:
"Publishing ekklesia-contracts-0.2.0.tgz failed: npm error code ENOENT".
The publish workflow passes a relative release path,
--release "$(find package-train …)", which is
package-train/ekklesia-packages-0.2.0. The tool validated the artifacts
relative to the job's working directory, but ran npm in its own
temporary home (cwd /tmp/ekklesia-publish-XXXXXX) with the same relative
path, package-train/ekklesia-packages-0.2.0/ekklesia-contracts-0.2.0.tgz,
which there names nothing. The disposable-registry suite always passed
an absolute path, so it never saw this.
- loadReleaseTrain resolves the release directory once; every artifact
path is absolute from validation on, and npm is given only that exact
file's absolute path (anything else is refused at the npm boundary).
- Just before each upload the file is checked against the validated
bytes once more; the tool logs the absolute path it publishes, and a
failure names it with npm's path/syscall lines (npm's temporary home
masked, the token never shown).
- Regression tests: unit tests with a recording npm (relative nested
release from another cwd; npm's argv, cwd, and the file's existence; a
changed artifact refused before npm runs), which fail on the previous
tool; and a disposable-Gitea test that runs the CLI exactly as the
workflow does, from the Actions artifact layout, round-tripping all
nine packages byte for byte.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Every platform workspace package and every public @ekklesia/* package
declares 0.2.0, and the deploy README's image build example names it.
templates/module keeps its own version.
0.2.0, not 0.1.3: before 1.0 a minor version is the breaking one
(docs/development/releases.md, ADR 0054), and since 0.1.2 main has
broken modules, module authors, and operators:
- #64 (ADR 0048): one generic image; installed modules live outside it;
- #66, #72 (ADR 0049, 0050): modules install as Module Package v1, and
move to the Module Database ABI;
- #71: the manifest v8 cutover; v7 manifests are refused;
- #79 (ADR 0053): the curated SDK drops defineModuleSchema and the
legacy ModuleDatabase type;
plus #67 (AGPL), #74, #77 (licensing, native targets), and the public
package train (#80, #81). It is also the first package train to publish:
0.1.2 never will be.
Because 0.2.0 is outside ^0.1.0, the same change moves to ^0.2.0:
- the standalone fixture's @ekklesia ranges and its exact module-pack pin;
- the compatibility ranges of the modules built for this release: the
template, the standalone fixture, and the native proof module;
- the test fixtures that model a module of the current release, or the
current platform's versions;
- the module-development guide's examples, and the release guide, which
now names what a minor bump moves.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
ADR 0053 was accepted assuming a private package phase before launch. The
project owner has decided otherwise: Ekklesia is not secret, its source is
already public, and only publication must be restricted.
ADR 0054 supersedes ADR 0053 only where it describes that phase as
private or requires authenticated package reads. Packages may be read
anonymously, which is neither a launch nor an API stability promise
beyond pre-1.0 SemVer; publication stays authenticated and restricted;
published versions stay immutable; the River_of_Glory_Church Gitea
namespace is an acceptable host; a public launch remains a separate
decision. Every other ADR 0053 decision is unchanged.
ADR 0053 gets only a cross-reference line. The ADR index, the release and
module-development guides, the README status, and the publish workflow's
comment now point at ADR 0054.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The CI package-release gate passed, but the upload found no files: the
artifact actions leave hidden files out, and .package-release starts
with a dot. Keep the release in package-train instead, in both
workflows, and test that no artifact path is hidden.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Publishes one package release train to the Gitea npm registry under
River_of_Glory_Church, whose packages are publicly readable on purpose:
exactly the .tgz files the package-release gate proved, and nothing else.
Nothing is published by this change.
- scripts/publish-package-train.ts validates the whole local train before
any request (gate manifest hash, version, revision, unmodified source,
the nine packages, every artifact's size, SHA-256, integrity,
SHA256SUMS line, and inner package.json), then publishes in dependency
order with `npm publish <file.tgz>` under a staging tag. Each package
is reconciled first (absent: publish; same bytes: accept; other bytes:
hard stop, version burned), then read back: metadata (name, version,
integrity, dependency maps, normalized bin, tarball on the registry)
and the tarball itself, byte for byte, also anonymously. `latest` moves
only after all nine verify; reruns converge and never delete anything.
It writes a publication record. The token comes from
EKKLESIA_PACKAGES_PUBLISH_TOKEN only, through a temporary npm config
that refers to the variable.
- scripts/test-package-publication.sh proves it against a disposable
Gitea 1.27.2 with public owners, a publisher team and an outsider:
anonymous reads, refused unauthorized publishing, byte-identical round
trips, the registry's own 409, partial-train recovery, the burned
version hard stop, and dist-tag retry convergence.
- CI: package-release keeps its proven release and hands it on as an
artifact with its manifest hash; the new package-publication job
publishes exactly that artifact to the disposable Gitea. Artifact
actions v3 (node20): v4 refuses any server other than github.com.
- .github/workflows/publish-packages.yml publishes to the real registry
only by manual dispatch on main, after its own gate run, with the
secret in the publish step alone.
- docs: the public-read model, the workflow, the record, credentials,
immutability policy, provider moves, and the first-publication steps.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The package-release job failed its clean-tree assertion in CI. Reproduced
as root in a container with a bind-mounted checkout, as the Gitea runner
does: after checkout the tree is clean; `pnpm install --frozen-lockfile`
then creates `.pnpm-store/` (17,272 untracked files) at the checkout's
root, because pnpm keeps its store on the node_modules file system and
the checkout is its own mount; `pnpm build` changes nothing. No tracked
file is ever modified.
- `.pnpm-store/` is ignored, like node_modules: a package cache, never
source. The clean-commit requirement is unchanged for every tracked
file and every untracked file Git does not ignore.
- The clean-tree failure now lists `git status --short` and
`git diff --stat`.
- Nothing is built before the clean-source assertion: the gate no longer
runs a global `pnpm build` first. The builder asserts a clean tree,
builds, then fails if its build changed the tree, before packing. Core's
verifier is built after both releases, for the consumer proof only.
- Every secondary offline install (the second worktree, the consumer
module) uses the workspace install's own store explicitly, since pnpm
picks a store per file system and those directories are on another.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Produces the exact public package tarballs of a platform release, validates
them, and proves a standalone module consumes those artifacts end to end.
Nothing is published and no credential is configured.
- scripts/package-release.ts (pnpm packages:release) is the canonical
release builder: from a clean commit it rebuilds the nine public
packages from clean output, packs each, inspects every tarball, checks
the release set and coordinated versions, and only then writes
ekklesia-packages-<version>/ with the tarballs, SHA256SUMS, and a
provider-neutral manifest (ekklesia-package-train.json: version,
revision, and each package's file, size, SHA-256, npm integrity).
- Packing uses a pack-only pnpm beforePacking hook (passed with
--config.pnpmfile, never the workspace's install settings). pnpm inserts
rewritten workspace: dependencies in async completion order, so two
checkouts of one commit packed module-pack's package.json with keys in a
different order; the hook restores the source key order (and the
inspection enforces it). It also drops devDependencies, which named
private packages outside the train.
- scripts/test-package-release.sh (pnpm test:package-release, the CI
package-release gate) builds the train twice, the second time from a
separate worktree, requires byte-identical release directories, then
runs the standalone consumer proof on the release: manifest checked,
offline install from the tarballs only, lockfile integrity equal to the
manifest's, frozen-lockfile reinstall, no Drizzle, one React, contracts
only transitive, every @ekklesia package and module-pack at the release
version, skipLibCheck: false, UI build, pack, and Core verification.
- docs/development/releases.md documents the release order, the train,
the artifacts, and the reproducibility findings; registry publication
remains unimplemented.
- ADR 0053 is Accepted: everything it decides that does not need a
registry is now proven, and registry provisioning is on its own
Deferred list.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
@ekklesia/contracts still emitted a type-only import of
drizzle-orm/node-postgres for the legacy ModuleDatabase facade, so a
standalone module type-checked only because skipLibCheck hid it.
- ModuleDatabase, LegacyModuleRuntimeServices and
AnyModuleRuntimeServices move to core/src/modules/database.ts, beside
the facade Core builds; the facade itself is unchanged.
- contracts' runtime, route, and services generics no longer name an
ORM: the database type is a plain parameter defaulting to the ABI v1
executor, and the legacy definition's database is opaque until Core
specializes it. Core and apps/api name the legacy types from Core.
- contracts drops its drizzle-orm optional peer and devDependency;
Drizzle stays public only through @ekklesia/module-database/drizzle.
- The facade's type-level test moves from contracts to Core.
- Regression proof: the standalone fixture type-checks with
skipLibCheck: false against the packed tarballs, with no drizzle-orm
installed (the smoke asserts both). Against the previous contracts
tarball the same check fails with TS2307 on drizzle-orm/node-postgres.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Makes the public module-development package set usable from a module
repository outside the monorepo. Nothing is published.
- Curated @ekklesia/module-sdk: an explicit author API, no contracts
barrel, no identity/authorization internals, no Drizzle and no legacy
ModuleDatabase type. defineModuleRuntime returns the SDK's own named
interfaces so a module's emitted declarations never reach into
@ekklesia/contracts. defineModuleSchema is gone; createModuleTable and
defineModuleSchemaMetadata live in @ekklesia/module-database.
- Drizzle: no longer a dependency of module-sdk or contracts. Core keeps
the legacy Drizzle schema validator (moved to core/src/modules) and
its legacy runtime support; drizzle-orm is an optional peer only.
- React is a peer (plus devDependency) of @ekklesia/ui; the boundary
checker allows exactly that consumer-selected peer.
- @ekklesia/typescript-config is publishable; module-ui-host is private.
Every public package carries "ekklesia.distribution":
"module-development" and no monorepo-root prepack.
- module-pack reads the Node.js range from the module's own engines.node
and recognizes Ekklesia packages by name plus that marker, so
registry-installed packages are incorporated, SDK-compatibility
checked, and never credited as third-party.
- scripts/pack-public-packages.ts (pnpm packages:pack) rebuilds, packs
and inspects the nine public tarballs; scripts/test-standalone-module.sh
(pnpm test:standalone-module, new CI job) installs them into a fixture
module outside the repository and proves it typechecks, builds,
packages, and verifies.
- Public-package boundary tests and module-development docs.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Defines the external module-development and package-distribution contract
that ADR 0049 deferred.
- Protocol, not provider: packages are distributed over the standard npm
registry protocol. Gitea may host the private phase. Host-neutral
lockfiles and an Ekklesia proxy are not required; a registry move may need
one configuration change and a lockfile regeneration, while integrity hashes
keep verifying content.
- Public set:
- Author API: module-sdk, module-database, module-ui-sdk, design-tokens.
- Tooling: module-ui-build, module-pack, typescript-config.
- ui as a pre-1.0 component API.
- contracts only as a transitive implementation dependency.
- Core, apps, module-ui-host, and module-template stay unpublished.
- Curated SDK before first publication: no full contracts barrel, no forced
Drizzle at the SDK root, and no legacy Drizzle database contract in the
standalone surface. Drizzle support lives in module-database's adapter.
- Dependency rules: React/ReactDOM are peers of the public UI packages
(bundling per ADR 0023 is unchanged); Drizzle is an optional peer only
where the adapter needs it; modules put runtime packages in dependencies
and tooling/config in devDependencies.
- Versioning: one coordinated 0.x release version equal to the platform
version, with no mixed trains. Ranges go in source and exact artifacts in
lockfiles; module-pack is pinned exactly; independent versions deferred.
- Standalone packaging: no monorepo checkout. The module declares its own
Node range (engines.node). The packager recognizes Ekklesia packages by
identity and metadata, not path. No monorepo-root commands at install time.
- Supply chain: immutable versions, frozen-lockfile integrity, and a
pre-publication smoke that installs the packed tarballs into a clean
standalone fixture and proves build, package, and verify. Signing deferred.
- Private phase: no committed secrets; credentials come from the
environment; verify actual read access before the first publication.
Status Proposed. ADR 0047 (still proposed) must be revised against this. No
code, registry, credential, or Signage changes.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Implements ADR 0049's deferred native target and ADR 0052's reviewed
license supplements. The proof case is a real module using sharp.
packageFormat stays 1, and `any` is unchanged.
Packager (@ekklesia/module-pack):
- --target linux-x64-glibc. Native or platform-specific packages, found by
the same generic rule that refuses them in `any`, stay outside the bundle.
- Each native package's production closure is walked through its own
dependencies, installed optionalDependencies that match the target, and
required peers. It is copied as real files flat under node_modules/<name>,
with no links, dev dependencies, declarations, source maps, or other
platforms' packages. Missing or foreign-platform required dependencies,
version conflicts, links, foreign machines, and Node.js range conflicts
fail closed.
- runtime.nodeApi, nodeAbi, and libcVersion are read from the binaries by a
small ELF reader: the addon's node_api_module_get_api_version_v1 and the
newest GLIBC_ symbol version needed. No shelling out. A native target with
no addon is refused.
- ADR 0052 supplements: package-provided material stays primary. An exact
match on name, version, and ekklesia-content-v1 digest (sorted normalized
paths with each file's size and SHA-256; no metadata) adds labelled
material after the package's own. Any mismatch, a declared-licence
conflict, or a malformed or unreviewed supplement file fails. Missing
material with no supplement still fails.
Supplement for @img/[email protected]:
- 184 components and 143 unique texts, assembled from the exact upstream
sources sharp-libvips v1.3.3 builds linux-x64 from: 27 C/C++ libraries,
glib's gvdb, librsvg's Rust crates (reproduced with cargo tree), the Rust
standard library at the rustc commit recorded in the binary, plus the
LGPL-3.0, GPL-3.0, and MPL-2.0 texts. Each source is recorded with its
SHA-256 in PROVENANCE.md.
- It pins digest ekklesia-content-v1:sha256:50647b0b…, which is identical
from the installed copy and from the registry tarball.
- LGPL/MPL source-availability determinations stay open, as ADR 0052
states. Nothing claims they are met.
Verifier (Core):
- linux-x64-glibc is accepted only on a host whose platform, arch, glibc
family and version, and Node-API version (from Node itself) match.
- Every ELF file must be x86-64; every .node must be an ELF object.
- `any` packages now also refuse node_modules/ and ELF files.
- Contract: native metadata must state nodeApi or nodeAbi, and Linux targets
libcVersion (ADR 0049).
Proof:
- A checked-in AGPL native fixture packs byte-identically from unrelated
copies (8.86 MB) and verifies with Core's verifier.
- Extracted elsewhere, it runs a sharp resize and PNG round trip in an
empty-environment process. Every resolution stays in the package, and
only the package's addon and libvips plus host system libraries load.
Signage is untouched.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Two wording clarifications; no decision changes.
- Repository review of a supplement is project and code review of a build
input. It is not legal review, and not proof that redistribution
obligations are satisfied.
- The exact-match digest is deterministic over normalized redistributed
relative paths and file bytes. It excludes host filesystem metadata,
timestamps, ownership, permissions, and extraction or listing order.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
When a redistributed dependency's own package omits licence material that
redistribution requires, the canonical packager may use a checked-in,
reviewed supplement. The motivating case is
@img/[email protected]: LGPL-3.0-or-later, and its shared
library contains about 29 libraries, but it ships no licence text or
notice for any of them.
ADR 0052 decides:
- package-provided material stays the preferred source and is never
replaced;
- a supplement matches exact package name, version, and a packager-computed
content integrity; any mismatch fails closed, with no fallback;
- supplements are immutable, reviewed build inputs with no network access
during packaging;
- they may carry licence texts, copyright and attribution notices,
provenance, corresponding-source information, and other accompanying
material;
- merging is deterministic, and supplement material is labelled;
- a supplement never overrides a dependency's declared licence; a
disagreement fails packaging;
- the mechanism is generic, with no package-specific code;
- missing material with no exact supplement still fails packaging;
- packageFormat stays 1.
It extends ADR 0049 only in the collection rule that ADR delegated to the
implementation. Legal and source-obligation determinations for LGPL, MPL,
and similar binaries are explicitly deferred: no written offer, URL, or
mirror is asserted to satisfy any licence.
Status Proposed. No implementation, no libvips supplement, and no Signage
change.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Four Core tests prove that importing a module has no side effects. Each one
does a cold dynamic import() of a large Core source graph inside its test
body:
- doctor-import
- module-http-import
- cli-bootstrap-platform-owner
- cli-create-initial-owner (its first test)
Vitest transforms and evaluates that graph on demand, so the CPU-bound cost
counts against the default 5 s per-test timeout. Ordinary test files import
at the top level, outside any test timeout. There is no subprocess, shared
resource, or test bug.
Measured on six cores:
- alone: 0.7–1.2 s;
- alongside five other packages' suites, as in a full pnpm verify:
3.0–5.4 s (doctor-import reached 5,400 ms).
A shared IMPORT_SAFETY_TEST_TIMEOUT_MS of 30 s (core/tests/support/
import-safety.ts) now applies to exactly these four tests. That is about five
times the slowest time measured under load, the same as the repository's
integration-test timeout, and still bounded, so an import that genuinely
hangs fails. Assertions, coverage, the global Vitest timeout, parallelism,
and production code are unchanged; there are no retries.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Every Module Package v1 now declares the module's license and carries its
text. packageFormat stays 1; pre-ADR-0051 metadata fails closed.
- Contracts: ekklesia.package.json requires an SPDX `license` expression,
validated with spdx-expression-parse, with UNLICENSED and non-expressions
refused. The `files` list must hold exactly one non-empty root LICENSE.
The package contracts move to the @ekklesia/contracts/module-package
subpath, so the SPDX parser and its data (spdx-exceptions and
spdx-license-ids ship no licence files) never reach a module bundle
through the SDK's re-export of the contracts root.
- Packager: reads the source package.json's string SPDX declaration and
never invents one. It requires an explicit --license-file (a non-empty
regular file, no symlink, no parent search, no download) and copies its
bytes unchanged to LICENSE, listed with size and SHA-256 and covered by
the archive digest. THIRD_PARTY_NOTICES stays third-party only, and
bundled Ekklesia code stays in `incorporates`.
- Verifier: rejects missing or invalid license metadata, and a missing,
unlisted, duplicated, or corrupted LICENSE, through the schema and the
existing strict inventory.
- Template: packaged as AGPL-3.0-or-later with the repository's canonical
LICENSE, passed explicitly (Dockerfile, smoke, docs). Packing it twice
gives byte-identical archives.
Started by Codex; reviewed and completed in Claude Code (empty license-text
refusal, schema non-empty LICENSE, symlink and empty-file tests).
ADR 0051 is Accepted.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Narrow contract and implementation adjustments from review. No SDK, manifest,
packaging, or Signage work.
Callback-error precedence:
- A transaction or savepoint callback that rejects keeps its own error, even
while sibling work it started is still settling (one failed half of a
Promise.all). Core drains that work, rolls back, and never commits or
releases.
- A callback that resolves with work outstanding (a forgotten await) still
fails with MODULE_DATABASE_SCOPE_WORK_OUTSTANDING and rolls back.
Transaction-control SQL:
- ADR 0050 forbids it through query() as a conformance rule for trusted code,
not a security guarantee. Core does not parse SQL, so PostgreSQL has run
the statement before Core sees it. Core detects the violation from the
command tag or the transaction status, and claims nothing it cannot do: a
module's own COMMIT has committed.
- After a violation, the transaction's scopes refuse every later statement
without sending it (it would otherwise autocommit). Core never commits the
transaction itself, and the connection is destroyed rather than reused,
for top-level violations too.
- The commit and rollback guarantees apply to conforming use.
Duplicate object-mode columns: documented as found only after execution, so a
mutation can have taken effect; atomicity needs an ABI transaction.
Tests prove each of these against PostgreSQL: Promise.all failures in a
transaction and a savepoint keep 23505; a forgotten await still rolls back; a
module COMMIT's prior work is committed while nothing after it runs; a backend
PID changes after every violation; duplicate RETURNING columns persist at the
top level and roll back in a transaction.
ADR 0050 stays Accepted: the executor conforms to the revised text.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Implements the first ADR 0050 slice: Core's ORM-neutral executor and its
PostgreSQL evidence. Nothing is wired to modules yet; manifest v8, SDK
adapters, schema metadata, packaging, and Signage are later slices. The
legacy ModuleDatabase facade is unchanged.
Contracts (@ekklesia/contracts, no imports at all):
- ABI version 1; query { text, values, rowMode } returning
{ command, rowCount, rows, fields: [{ name, typeOid }] };
- executor, transaction, and savepoint types with exactly the ABI's
members; parameter and result value types;
- ModuleDatabaseError { code, sqlState? }: eight stable codes, and a
SQLSTATE only when PostgreSQL supplied one; static messages, no cause,
frozen; isModuleDatabaseError recognises it by shape.
Core (createModuleDatabaseExecutor over the API's existing pool):
- one statement per query, enforced by forcing the extended query
protocol so PostgreSQL itself refuses a second statement (42601);
no SQL parsing;
- parameters: null, boolean, number, bigint, string, Uint8Array, and
rectangular arrays of them; everything else refused before sending;
- a Core-owned decoding table that never consults pg's global parser
registry: int8, numeric, temporal, and JSON as exact text; bytea as a
plain Uint8Array; built-in arrays element by element;
- every operation pins TimeZone, DateStyle, IntervalStyle, bytea_output,
and extra_float_digits, then resets with DISCARD ALL before returning
the connection, or destroys it when it cannot be reset cleanly;
- callback-scoped transactions with explicit isolation and access mode;
a COMMIT that PostgreSQL answers with ROLLBACK is reported as failed;
- savepoints named by Core; only the innermost scope is active; scopes
are revoked when their callback settles;
- a callback that settles with work outstanding is rolled back, never
committed or released;
- transaction-control statements sent through query are refused by
command tag and transaction status, and fail the transaction;
- the module ID is carried for Core's attribution only.
Tests:
- 83 integration tests on a database with hostile defaults: queries,
the one-statement rule, parameters, decoding, errors, transactions,
savepoints, forgotten awaits, pool safety after every failure class,
and session state;
- unit tests for encoding, decoding, options, sanitization, and scope
bookkeeping;
- type-level and source checks that no ORM or driver object crosses
the boundary.
ADR 0050 is Accepted.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Three narrow clarifications to the proposed contract. No other decision
changes.
- Outstanding work: module code awaits every operation before its
transaction or savepoint callback returns. A callback that settles
with work outstanding is a scope-usage error and never commits. Core
may let started operations settle before rolling back and releasing
the connection, but waiting never turns into a commit. Executors stay
revoked once their scope has finished.
- One statement per query() call: a correctness and API rule, not a
security boundary. It is upheld by a reliable driver or protocol
mechanism, never a home-grown SQL parser. An implementation that
cannot uphold it stops and reports instead of weakening the ADR.
- Errors: `code` is a stable Ekklesia-owned category and always present.
`sqlState` is optional, present only when PostgreSQL supplied one.
ABI-detected failures (closed scope, invalid parameter, invalid
option) carry a code and no SQLSTATE.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Core and modules exchange PostgreSQL SQL text, bound parameters, result
rows with field metadata, and Core-owned transaction scopes. Never ORM
objects. A module may use Drizzle, Kysely, raw SQL, or another
module-side builder. Core's own ORM becomes an implementation detail.
ADR 0050 decides:
- an explicit correctness/compatibility boundary, not a security
sandbox: no claim for SQL parsing, execution-time prefix checks,
SET ROLE, or PostgreSQL roles;
- opt-in through manifest version 8 `database.abi: 1`, a hard cutover
like every manifest version; a version 8 module without `database`
keeps the legacy ModuleDatabase facade during the deprecation window;
- the executor: query({ text, values, rowMode }) returning
{ command, rowCount, rows, fields }, one executor per module;
- a small parameter set (no Date, no implicit JSON, no identifiers);
- a Core-owned decoding table that loses no precision and does not
depend on time zones (int8, numeric, temporal, and JSON as text;
bytea as Uint8Array);
- a sanitized ModuleDatabaseError { code, sqlState? }, recognised
structurally;
- callback-scoped transactions and Core-named savepoints, revoked when
their callback settles;
- module-side adapters (Drizzle via pg-proxy, raw SQL, later Kysely);
- plain schema metadata for ABI v1 modules instead of Drizzle objects;
- migrations unchanged; packageFormat stays 1; ABI v1 packages omit
transitional.databaseRuntime.
It partly supersedes ADR 0016 and amends ADR 0007 for ABI v1 modules,
and extends ADR 0049. Status Proposed. Accepted ADRs are unchanged. No
runtime, SDK, manifest, packaging, migration, or Signage changes.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The project decision is the GNU Affero General Public License, version 3 or
(at your option) any later version.
- "license": "AGPL-3.0-or-later" in all 14 Ekklesia-owned package.json files.
- README: the License section states AGPL v3 or any later version (SPDX
AGPL-3.0-or-later) and carries the FSF's standard grant: "either version 3 of
the License, or (at your option) any later version", with its warranty
disclaimer and pointer to the license.
- Module template README and module development guide: AGPL-3.0-or-later for
Ekklesia and official modules.
LICENSE is unchanged: it remains the canonical AGPLv3 text.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Ekklesia is open source under the GNU Affero General Public License, version 3
only. Module Package v1 (#66) bundles Ekklesia's SDK and contracts into
distributable module packages, so the platform needs an explicit license
before such artifacts are distributed.
- LICENSE: the GNU AGPL v3 text, verbatim from
https://www.gnu.org/licenses/agpl-3.0.txt
(sha256 0d96a4ff68ad6d4b6f1f30f713b18d5184912ba8dd389f86aa7710db079abcb0).
- "license": "AGPL-3.0-only" in the root package.json and every Ekklesia
workspace package (13 packages), the module template included. Nothing else in
any manifest changed.
- README: a License section replacing the "no license selected yet" placeholder:
AGPL v3 only, source available under its terms, section 13's network-use
obligation, and the policy that official Ekklesia modules use AGPL-3.0-only.
- Module template README and module development guide: the template declares
the license; Module Package v1 does not yet record a module's own license,
which needs a format change under ADR 0049.
AGPL-3.0-only, not -or-later: the decision is AGPL-3.0, and nothing in the
repository grants later versions. No runtime, ADR, package-format, Signage, or
instance change. The packaged template is byte-identical before and after.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The split deploy smoke failed in CI with "The SHA-256 file could not be read."
BuildKit's local exporter creates its output directory (`--output
type=local,dest=$work/module-package`) with mode 0700, owned by whoever ran the
build. That directory was mounted into the verifier, which runs as the image's
unprivileged `node` user (uid 1000). In CI the build runs as root, so the
directory was root:root 0700 and the verifier could read neither file; the
digest file is simply read first. On a workstation the build runs as uid 1000,
the same uid as `node`, so it was readable by coincidence.
scripts/lib/module-package-release.sh turns one build's output into the release
pair: it requires exactly one archive and its .sha256 and nothing else, checks
that the digest line names that archive, then creates a fresh directory that
must not exist yet (0755) and copies both files into it (0644). The smoke stages
and mounts only that directory; BuildKit's output directory is never mounted.
Archive-digest verification stays mandatory: the verifier still reads the
.sha256 file. Nothing in the verifier changed.
Tests: the helper is driven in bash against a 0700 build output, and refuses a
missing, malformed, or mismatched digest, stray output, a second archive, and
an existing release directory, creating nothing. A static test pins the smoke
to the release pair and the verifier's single mount with --sha256-file.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A separately versioned module is now distributed as one deterministic
.tar.gz and its SHA-256, produced by an Ekklesia-owned packager and checked by
Core before anything is installed. This slice implements the portable `any`
target, with the module template as the end-to-end proof. ADR 0049 is
accepted.
@ekklesia/contracts: the Module Package v1 contract. The strict
ekklesia.package.json schema (packageFormat 1, identity, target, runtime,
provenance, incorporated Ekklesia packages, strict transitional facts, an
exact byte-wise-sorted file list with size and SHA-256), target names, and the
generated runtime package.json.
@ekklesia/module-pack (new, tooling): `ekklesia-pack-module --module <dir>
--target any --out <dir> --source-revision <commit>`.
- Bundles server, schema, and jobs with esbuild, SDK, contracts, and Drizzle
included; only Node built-ins remain imports. Any warning, unresolved
import, external dependency, or native or platform-specific package fails.
- Copies browser artifacts as built, after proving them current, and the
migrations; generates package.json and ekklesia.package.json; never ships
src, tests, declarations, source maps, or workspace ranges.
- THIRD_PARTY_NOTICES: every third-party package traced from the bundles'
own inputs, credited once with its declared licence and every licence,
copying, copyright, and notice file it ships. A missing licence fails; a
missing text fails unless its standard text is complete without copyright
lines (Apache-2.0, vendored, needed by drizzle-orm).
- Deterministic ustar and gzip: sorted paths, uid/gid 0, empty names, mtime
0, 0755/0644, no links. Byte-identical across runs and machines.
- Writes only the archive and its .sha256; never deploys, links, or installs,
so the workspace's install state is untouched.
Core: verifyModulePackage and `verify-module-package.js <archive>
[--sha256 | --sha256-file] [--extract-to <new dir>]`. Reads the archive in
memory and refuses links, special entries, setuid/setgid/sticky modes, unsafe
or escaping paths, and a second top-level directory; checks metadata, every
file, identity, target, Node.js range, SDK, and the transitional Drizzle
agreement; runs the platform's module and migration discovery; loads the
backend once in a forked process with an empty environment; only then
extracts, with normalized modes, into a new directory. Errors are sanitized.
@ekklesia/module-ui-build: analyzeModuleUi reports each artifact's inputs
and whether the built file is current. The nested stylesheet build now works
from the module directory, so artifacts no longer depend on the caller's
working directory.
The split deploy smoke builds the package with the canonical packager
(scripts/lib/build-module-package.Dockerfile replaces the test-only pnpm
deploy packaging), verifies it with the same generic image, installs the
verified tree, removes it, and reinstalls the exact same archive.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A separately versioned module is distributed as one immutable, self-contained,
deterministic .tar.gz with a separately distributed SHA-256. The package
contract is independent of hosting, registry, transport, and marketplace.
ADR 0049 extends ADR 0048 with:
- the package layout: one top-level module directory with a generated runtime
package.json, the unchanged manifest, ekklesia.package.json, bundled backend
entrypoints, built browser artifacts, migrations, THIRD_PARTY_NOTICES, and
node_modules only for native dependencies;
- backend bundling of all pure-JavaScript dependencies, with externals decided
and validated by the canonical packager, never by module authors;
- targets: `any`, or <platform>-<arch>[-<libc>] (linux-x64-glibc for Signage);
- ekklesia.package.json (packageFormat 1): id, version, target, runtime needs,
provenance, incorporated SDK versions, a `transitional` Drizzle requirement
explicitly not the Module Database ABI, and per-file size and SHA-256;
- archive safety: no links of any kind, no absolute or `..` paths, no special
files or setuid/setgid; extraction normalizes ownership and modes;
- the intended installation lifecycle and its rollback limit: code-only
rollback only before new migrations apply, a backup restore after;
- integrity, not authenticity; signing and trust roots deferred;
- pnpm deploy explicitly not the packaging mechanism.
Status Proposed, following the repository's usual lifecycle. ADR 0047 gains a
cross-reference to consume canonical packages. The module development guide no
longer recommends running pnpm deploy in a workspace, which corrupts that
workspace's install state. No runtime, tooling, Docker, CI, Signage, or
instance changes.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The test proving that migrations of a module installed outside the repository
are discovered, while Core's come from the platform, copied templates/module
including dist/. dist/ is gitignored and never committed. CI runs `pnpm test`
before any build of the template, where only the template's own UI test may
have written dist/ui, dist/display, and dist/contributions, so discovery
failed with MODULE_ENTRYPOINT_MISSING for ./dist/server/index.js and
./dist/schema/index.js (and, had the UI test not run yet, the copy itself
would have failed). Locally a previous `pnpm build` had left dist/ complete.
The test now generates a minimal valid installed module in a temporary
directory with createMigrationModule, moved unchanged from the migration
discovery test into core/tests/support so both share it. It depends on no
build output, ignored file, or node_modules. It also asserts more precisely:
the module directory is outside the repository, Core's migrations match the
platform's own core/drizzle inventory by identity and checksum, and the
module's migration matches the SQL that was installed.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The production image now contains zero feature modules and is the same for every
installation. Installed modules live in module storage that one Core resolver
selects from EKKLESIA_MODULES_DIR: the checkout's modules/ when unset, an
absolute path that must exist when set (/var/lib/ekklesia/modules in the image).
A relative, blank, missing, or unreadable configured directory fails closed with
a sanitized error that never contains the value.
- Host configuration reads EKKLESIA_MODULES_DIR. The API, migration plan, apply
and validate, module validation, module enablement, and Doctor all resolve it
the same way; the test-only API override still takes precedence.
- Doctor receives Core's migrations directory explicitly instead of deriving it
from the modules directory's parent.
- The migration runner retains and reports the applied history of a module that
is not installed, and plans and applies Core's and present modules' migrations
around it. Core's history and every installed module's history stay strict: a
missing or changed applied migration, or a downgrade, still fails. No down
migrations, no data deletion.
- deploy/Dockerfile copies no module and installs nothing for the module
template; .dockerignore excludes modules/ and the build refuses a context that
contains it; the runtime has an empty root-owned /var/lib/ekklesia/modules and
sets EKKLESIA_MODULES_DIR.
- deploy/compose.yaml bind-mounts EKKLESIA_MODULES_HOST_DIR read-only into api
and migrate, with create_host_path disabled.
- scripts/check-image.sh proves the image holds no modules and that a populated
modules/ directory leaves its runtime file system identical; CI runs it.
- The split deploy smoke now uses the same generic image: it boots with no
modules, installs a self-contained test module packaged from the template
(test-only), migrates and restarts, removes it (Core runs, planning reports the
retained history, data survives), and restores it with its data.
Docs: deploy README (module storage, install/update/remove/restore, the runtime
modules target), .env.example, API configuration, module development guide,
testing, workflow, security, and the architecture overview.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Ekklesia produces one platform image with zero feature modules. Installed
modules live in installation-owned storage selected by EKKLESIA_MODULES_DIR
(/var/lib/ekklesia/modules in the image, <repo>/modules in development),
mounted read-only and discovered only at a controlled restart. An installed
module directory is self-contained; the platform lends it no dependencies.
Uninstall is distinct from disable and purge: removing module code keeps its
data and stored state, and applied migration history of an absent module is
retained and reported instead of blocking planning. Present modules and Core
stay strictly validated.
Supersedes in part ADR 0044 (composition from pinned sources) and ADR 0009
(rejection of history absent from the inventory); amends ADR 0006; extends
ADR 0028 and ADR 0030. ADR 0047 gains a cross-reference saying it must be
revised before acceptance, and deploy/README.md notes that its composition
model is being replaced. No code, Dockerfile, Compose, CI, or test changes.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Every workspace package declares 0.1.2, as the 0.1.1 release did for
itself, and the deploy README's build example names it. The tag
v0.1.2 created on 12dca3e carries package.json 0.1.1, which the
instance's assemble.sh refuses ("its tag and package.json disagree"),
and the running platform would report 0.1.1.
0.1.2 carries what the first production deployment found:
- #57: Doctor checks the client-address configuration the API runs;
- #58: storage over HTTP/1.1 (repeat image loads no longer fail with
ERR_HTTP2_PROTOCOL_ERROR), and storage CORS owned by the proxy;
- #60: CI stacks never outlive their job;
- #56: display-device smoke scenarios;
- #59, #61: ADR 0047 proposed, and the CI documentation corrected for
Gitea 1.27.2.
templates/module keeps its own version.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The CI documentation, the workflow header, the README, ADR 0047 and the
sweep's comment all said the server was Gitea 1.24.2, which ignores
`permissions`, `concurrency` and `timeout-minutes`. The server reports
1.27.2 (GET /api/v1/version).
Each claim is now stated from what is verified on this server:
- `concurrency` is honoured, observed: Ekklesia runs 133 and 135 were
cancelled when a newer run started for the same pull request;
- `permissions` is supported by 1.27 with Gitea's own scopes, but the
token's actual authority is not visible through the API and not yet
proven, so nothing claims the job token is read-only;
- `timeout-minutes` is no longer listed as unsupported, but is not
verified, and teardown still relies on the stack sweep and a reaper.
Requirement 1 for executing untrusted contributions (upgrade to the
1.26 line or later) is marked done; items 2 and 3, configuring and
proving token authority, remain. ADR 0047 (still Proposed) restates
its "docker run, not a reusable workflow" reasons for 1.27, where
private sources are supported. The reasons still hold without the 1.24
argument.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A migration-smoke stack (PostgreSQL and SeaweedFS) ran on the shared
runner for two days. Every CI script removes its stack in an EXIT trap,
but a trap never runs when the runner kills the job container: a
cancelled job, or the runner's own time limit (Gitea 1.24 ignores
timeout-minutes). Reproduced: SIGKILL of the infrastructure smoke
mid-run leaves exactly that stack running.
Cleanup now has three layers:
- the script's own trap, unchanged;
- a final `if: always()` step in every job that starts stacks
(infrastructure-smoke, migration-smoke, image-build, deploy-smoke)
runs `scripts/ci-sweep.sh --job`, which removes everything that job
started at any age;
- a first step in each of those jobs runs `scripts/ci-sweep.sh --stale`,
which removes CI stacks older than three hours: what a killed job left
before it could clean up.
How stacks are found is defined once, in scripts/lib/ci-project.sh:
- every CI Compose project is named `ekklesia-ci-<run>-<attempt>-<job
key>-<kind>-<unique>`, and Compose labels its containers, networks and
volumes with that name;
- the deploy smoke labels everything it starts outside Compose:
browser, kiosk and driver containers, the kiosk volume, built images;
- images also record when CI built them. An image's own creation time
can be its base's (BuildKit, metadata-only builds), and the sweep
removed a brand-new image as "old" until it stopped trusting that.
The job key is per matrix leg, so one topology's cleanup cannot remove
the other's running stack. The sweep never touches anything outside the
ekklesia-ci- prefix, disconnects rather than removes a job container
attached to a stack's network, and refuses --job outside CI. Network
creation times are read as RFC 3339; Docker's default form was
unparseable.
scripts/test-ci-sweep.sh proves all of that against real Docker, and
CI runs it before relying on it. security.md and testing.md document
the rule, and that it complements, not replaces, a runner-side reaper.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A migration-smoke stack (PostgreSQL and SeaweedFS) leaked on the shared
runner and ran for two days, until it was removed by hand. Every CI
script already tears its stack down in an EXIT/INT/TERM trap, but a
trap never runs when the runner kills the job container: a cancelled
job, or the runner's own time limit, since Gitea 1.24 ignores
timeout-minutes.
Static checks (tests/ci-scripts.test.ts):
- every stack script names its project through one helper: the reserved
ekklesia-ci- prefix, run, attempt, job key, and a unique suffix;
- everything the deploy smoke creates outside Compose carries the CI
label;
- every workflow job that starts stacks sweeps stale CI stacks first,
and removes its own in a final if: always() step. Each matrix leg has
its own key, so one cannot remove the other's running stack.
Behaviour against real Docker (scripts/test-ci-sweep.sh):
- --job removes this job's containers, network, volume, image and
staging directory, and leaves other jobs' and non-CI stacks alone;
- --job disconnects, never removes, a job container on the stack's
network, and refuses to run outside CI;
- --stale spares young stacks and removes old ones, never anything
outside the prefix.
Fails today: there is no helper, no sweep, no label and no step.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A job the runner kills never runs its own cleanup trap, and the
platform's CI leaked a whole migration stack that way. The harness
follows the platform's three-layer rule:
- everything it starts is named and labeled as a CI stack;
- it cleans up in a trap;
- the template workflow sweeps stale CI stacks before running it, and
removes its own in a final if: always() step.
The sweep ships in the harness image.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signage reached production with three defects that appear only inside
Ekklesia in a real browser, and no CI that ran it there. The ADR
proposes:
- a versioned harness image, ekklesia-module-harness:<platform version>,
built and pushed by the platform's release CI;
- what the harness does: assemble a module into that platform, validate
and build it, migrate a disposable database, start the split reference
deployment, and mount every declared UI surface in headless Chromium;
- what fails it: console errors, unexpected 4xx/5xx, unhandled
rejections, a display soak, and generic no-dead-ends injections;
- testing at the lowest and newest platform versions of a module's
range;
- extra smoke steps in ekklesia.harness.json, beside the manifest
rather than in it;
- a later data-access and egress check against declarations;
- a short template workflow that calls the harness by digest with one
`docker run`. Not a reusable workflow: thinly supported on Gitea
1.24, and private sources only arrive in 1.26.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Every reload of a cached files. image failed in the browser with
ERR_HTTP2_PROTOCOL_ERROR. A browser revalidates a cached signed URL
with If-None-Match. Caddy reached SeaweedFS over HTTP/2, and there
SeaweedFS answers with a 304 declaring content-type: application/xml
and content-length: 327 while sending no body. Caddy then logged
"aborting with incomplete response ... unexpected EOF" and reset the
stream. Over HTTP/1.1 SeaweedFS's 304 is clean. Measured layer by layer:
DevTools, curl through Caddy, and curl direct over HTTP/1.1 and HTTP/2.
The storage upstream is now HTTP/1.1 only (`versions 1.1`). Browsers
still reach the proxy over HTTP/2 and HTTP/3.
Delivery-layer CORS is the deployment's, so it no longer depends on
which S3 backend is behind it. SeaweedFS's default reflected any origin
and advertised PUT, POST and DELETE. Now the proxy:
- grants CORS only to the origins that read objects with fetch(): the
Ekklesia origin, or, when split, the API origin (displays) and the
Dashboard origin (a module's preview caches images through fetch());
- allows GET and HEAD only, with Vary: Origin;
- answers preflights itself, so they never reach storage;
- strips the object store's own CORS headers.
The deploy README documents the policy, its reasons, and what a
self-run proxy must do.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Two checks on storage delivery through the proxy, in both topologies.
The same signed image, twice, over a real HTTP/2 connection (node:http2;
Node's fetch speaks HTTP/1.1, where the failure does not exist). The
second load is conditional, as a browser's revalidation is, and must be
a clean 304. Today Caddy reaches SeaweedFS over HTTP/2, SeaweedFS's 304
declares Content-Length: 327 and sends no body, and Caddy resets the
stream. A browser reports that as ERR_HTTP2_PROTOCOL_ERROR on every
reload of a cached files. image.
CORS on delivery is the deployment's own:
- exactly the API origin (and the Dashboard origin when split) is
allowed, with Vary: Origin and nothing else;
- any other origin gets no CORS header;
- a preflight is answered by the proxy (204, GET and HEAD only) and
never reaches the object store.
Today SeaweedFS reflects every origin with PUT, POST and DELETE, and
answers the preflight itself.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Doctor checks the client-address configuration of the process it runs
in, and operators run it as `migrate`. The proxy overlay set
trusted-proxy mode on `api` only, so Doctor reported "Direct
client-address mode" for an installation that trusts its proxy.
- compose.proxy.yaml defines the three variables once, as a YAML
anchor, and applies it to both `api` and `migrate`. Rendered with
`docker compose config`, `api` is unchanged and `migrate` now
matches it, keeping NODE_EXTRA_CA_CERTS from the base.
- The example deploy/.env sets
COMPOSE_FILE=compose.yaml:compose.proxy.yaml, as the instance already
does. Plain `docker compose run --rm migrate ...` in the README then
runs with the overlay; it used to read compose.yaml alone.
- Every client-address check in Doctor now says it checked the
environment Doctor runs with, and how to read the running API's own
value (`docker compose exec api printenv
EKKLESIA_CLIENT_ADDRESS_MODE`, confirmed to work in the image).
- The deploy README explains why operator commands need the same files
as the stack.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Behind the reference proxy the API runs in trusted-proxy mode, but
compose.proxy.yaml gives those variables to `api` only. The documented
Doctor command runs as `migrate`, so it validated an empty
configuration and reported "Direct client-address mode" for an
installation that trusts its proxy. That is what the first production
installation saw.
Fails today:
- deploy.test.ts: the proxy overlay gives `migrate` no client-address
environment;
- doctor.test.ts: the check does not say whose configuration it read;
- smoke-deploy.sh: Doctor through the proxy overlay does not report
trusted-proxy mode (Docker).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The recycle step's checks spoke of "the realtime recycle" even when
run with a watch shorter than the five-minute connection age, as module
CI does. They now say what they check: live for the whole watch (plus
"across a realtime recycle" when the watch is long enough), and never
remounted.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Reproduction harness for what the first production Signage deployment
found. With EKKLESIA_SMOKE_DISPLAY_DEVICE=1, the split smoke starts a
second headless Chromium (the "kiosk") whose profile directory is a
Docker volume. It pairs that kiosk through the real flow, closes it
cleanly, restarts it on the same profile, and then runs injected-failure
scenarios: two pages on one credential, a connectivity blip, one refused
viewer-session request, one refused realtime connection, a cross-origin
image-caching probe, a full realtime recycle, and revocation.
Every DevTools command and page-side request has its own deadline, and
each step has a budget. A step that overruns prints the exact call it
was waiting for. The proxy's "aborting with incomplete response" lines
are listed afterwards by UTC time and path only.
Off by default: several scenarios expect Signage and fail on today's
v0.1.1, by design.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
An installation can now serve the Dashboard and the API on sibling
hostnames (dash./api.) beside the default same-origin topology, from one
image and with configuration alone.
- The Dashboard learns its API origin at run time: the built page carries
one empty <meta name="ekklesia-api-origin">, which the API fills with its
identity base URL's origin at startup, before computing the page's ETag.
The shell accepts only a canonical http(s) origin and otherwise fails
closed without making a request. The build-time EKKLESIA_API_BASE_URL is
gone from the bundle; the development server injects the same element.
- The display host stays on the API's origin, so its device cookies,
requests, and offline cache are unchanged.
- The API trusts its own origin (trusted origins plus the identity base
URL), as Better Auth already does, so the display page's mutations pass
the origin check without operators listing it.
- Doctor validates EKKLESIA_DASHBOARD_ORIGIN exactly as the API's startup
does (it previously skipped it), reports the topology without printing
hostnames, and warns when split hostnames are not siblings.
- The module UI contract states the CORS header limit (safelisted headers
plus Content-Type, safelisted response headers only).
- Reference deployment: Caddyfile.split.example (Dashboard hostname serves
/ and its script; API hostname serves /api/*, /display/*, and the
display script; each refuses the other's paths) and
EKKLESIA_DASHBOARD_HOSTNAME in the proxy overlay and env examples.
- The deploy smoke test runs either topology (CI runs both). The split run
installs the module template as a test-only module, checks per-hostname
routing and exact CORS, then drives a digest-pinned headless Chromium
over the DevTools protocol, with no added dependency: sign-in on the
Dashboard hostname, the session cookie host-only on the API hostname,
the module UI imported cross-origin and its credentialed request
succeeding, the display and its service worker on the API hostname, and
sign-out.
ADR 0045 is marked Accepted.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Records a core principle: Ekklesia's branding is never forced on an
installation. Anything a member, visitor, or volunteer can see (titles,
sign-in and auth screens, emails, display and offline screens, error
messages, app names and icons, attribution) is configurable and removable
entirely. With no name configured, wording is neutral, so the default
names nothing and the rule is testable. Internal identifiers, operator
tooling, and developer diagnostics are exempt. Modules follow the same
rule.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Records how an installation runs the Dashboard and the API on sibling
hostnames (dash./api.) beside the default same-origin topology, from one
image: the API origin reaches the Dashboard at run time in a meta element,
the display host stays on the API's origin (device cookies and the offline
code cache depend on it), the API trusts its own origin as Better Auth
already does, cookies stay host-only, CORS stays narrow and becomes part of
the module UI contract, the reference proxy gains per-hostname routing, and
the deploy smoke test proves both topologies, the split one in a real
browser.
Amends ADR 0034 and extends ADR 0044.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
PR #51's migration-smoke failed 0.7 s after the build: the suite pinned
PostgreSQL to an ephemeral port but inherited S3_PORT=8333 from
.env.example, so concurrent runs on one runner (Gitea ignores workflow
concurrency) contended for one host port. Compose's own error was
discarded, so the failure said only that the services "did not become
ready".
Isolation, from an audit of every script CI runs:
- test-migrations-integration.sh: S3_PORT=0, like POSTGRES_PORT=0.
- test-migrations-integration.sh and smoke-infrastructure.sh: project names
gain a random suffix; run id plus PID alone is not unique when job
containers share low PIDs.
- smoke-deploy.sh: the edge /24 is chosen from those no network on the
daemon uses, and the first `up` chooses again when a concurrent run wins
a race for the same subnet (Docker's address-pool overlap), up to five
times.
- CI image-build: the image tag includes the run id and attempt, so a pull
request and a push of the same commit cannot remove each other's image.
- Already isolated: every other published port (ephemeral), the TLS
suite's container and network (random), smoke staging directories and
image tags (random).
Diagnostics: scripts/lib/compose-diagnostics.sh prints the failed `up`
command's output, `docker compose ps`, and the last 40 log lines of each
service, all redacted (supplied literals, credential-looking NAME=value
pairs, URL user information, Authorization headers) with terminal control
sequences stripped. All three scripts use it at every readiness check; the
infrastructure smoke's unredacted full-log dump is replaced by it.
tests/ci-scripts.test.ts pins ephemeral ports, unique project names, the
subnet choice, the CI image tag, the diagnostics, and the redaction itself
(run through bash).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
it("shuts down cleanly, then restarting with the module absent rejects new grants for its former permission, returns NOT_FOUND for its former route, while the historical grant remains listable",async()=>{
it("shuts down cleanly, then restarting with the module absent rejects new grants for its former permission, returns NOT_FOUND for its former route, while the historical grant remains listable",async()=>{