feat: Module Package v1 carries the module's own license (ADR 0051)
CI / verify (pull_request) Successful in 58s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / image-build (pull_request) Successful in 30s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m32s

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]>
This commit is contained in:
ekklesia
2026-10-07 18:51:03 -05:00
co-authored by Claude Opus 5.5
parent 30f61094e6
commit 85fae9e07e
22 changed files with 400 additions and 40 deletions
@@ -68,6 +68,7 @@ async function treeListing(directory: string): Promise<readonly string[]> {
async function packTemplate() {
const outDirectory = await scratchDirectory("ekklesia-template-package-");
const result = await packModule({
licenseFile: path.join(repositoryRoot, "LICENSE"),
moduleDirectory: await templateDirectory(),
outDirectory,
sourceRevision: SOURCE_REVISION,
@@ -104,8 +105,19 @@ describe("the module template as a Module Package v1", () => {
const files = fileEntries(archive);
const manifest = JSON.parse(files.get("example/ekklesia.module.json")?.toString("utf8") ?? "");
const metadata = JSON.parse(files.get("example/ekklesia.package.json")?.toString("utf8") ?? "");
const canonicalLicense = await readFile(path.join(repositoryRoot, "LICENSE"));
const packagedLicense = files.get("example/LICENSE");
expect(manifest.database).toEqual({ abi: 1 });
expect(metadata.license).toBe("AGPL-3.0-or-later");
expect(packagedLicense).toEqual(canonicalLicense);
expect(metadata.files.filter((file: { path: string }) => file.path === "LICENSE")).toEqual([
{
path: "LICENSE",
size: canonicalLicense.length,
sha256: createHash("sha256").update(canonicalLicense).digest("hex"),
},
]);
expect(metadata.transitional).toBeUndefined();
expect(metadata.incorporates).toMatchObject({
"@ekklesia/module-database": expect.any(String),
@@ -209,6 +221,12 @@ describe("the module template as a Module Package v1", () => {
new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\/]/gu, "\\$&")} \\S+$`, "mu"),
);
}
expect(notices).not.toContain("@ekklesia/module-template");
expect(notices).not.toMatch(/^@ekklesia\//mu);
expect(bundled).not.toContain("spdx-expression-parse");
expect(bundled).not.toContain("spdx-exceptions");
expect(notices).not.toContain("spdx-expression-parse");
expect(notices).not.toContain("spdx-exceptions");
const licence = await readFile(path.join(packageRoot, "licenses", "Apache-2.0.txt"));
expect(createHash("sha256").update(licence).digest("hex")).toBe(
"cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30",