pgc-assembler 3.0.0__tar.gz → 5.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. {pgc_assembler-3.0.0/pgc_assembler.egg-info → pgc_assembler-5.0.0}/PKG-INFO +20 -6
  2. {pgc_assembler-3.0.0 → pgc_assembler-5.0.0}/README.md +17 -5
  3. pgc_assembler-5.0.0/VERSION +1 -0
  4. pgc_assembler-5.0.0/assembler/VERSION +1 -0
  5. {pgc_assembler-3.0.0 → pgc_assembler-5.0.0}/assembler/__init__.py +1 -1
  6. {pgc_assembler-3.0.0 → pgc_assembler-5.0.0}/assembler/cli.py +67 -1
  7. {pgc_assembler-3.0.0 → pgc_assembler-5.0.0}/assembler/core.py +67 -18
  8. {pgc_assembler-3.0.0 → pgc_assembler-5.0.0}/assembler/indexes.py +35 -3
  9. pgc_assembler-5.0.0/assembler/signing.py +229 -0
  10. {pgc_assembler-3.0.0 → pgc_assembler-5.0.0/pgc_assembler.egg-info}/PKG-INFO +20 -6
  11. {pgc_assembler-3.0.0 → pgc_assembler-5.0.0}/pgc_assembler.egg-info/SOURCES.txt +1 -0
  12. pgc_assembler-5.0.0/pgc_assembler.egg-info/requires.txt +4 -0
  13. {pgc_assembler-3.0.0 → pgc_assembler-5.0.0}/pyproject.toml +11 -3
  14. pgc_assembler-3.0.0/VERSION +0 -1
  15. pgc_assembler-3.0.0/assembler/VERSION +0 -1
  16. pgc_assembler-3.0.0/pgc_assembler.egg-info/requires.txt +0 -1
  17. {pgc_assembler-3.0.0 → pgc_assembler-5.0.0}/LICENSE +0 -0
  18. {pgc_assembler-3.0.0 → pgc_assembler-5.0.0}/MANIFEST.in +0 -0
  19. {pgc_assembler-3.0.0 → pgc_assembler-5.0.0}/NOTICE +0 -0
  20. {pgc_assembler-3.0.0 → pgc_assembler-5.0.0}/_build_hook.py +0 -0
  21. {pgc_assembler-3.0.0 → pgc_assembler-5.0.0}/assembler/conformance.py +0 -0
  22. {pgc_assembler-3.0.0 → pgc_assembler-5.0.0}/pgc_assembler.egg-info/dependency_links.txt +0 -0
  23. {pgc_assembler-3.0.0 → pgc_assembler-5.0.0}/pgc_assembler.egg-info/entry_points.txt +0 -0
  24. {pgc_assembler-3.0.0 → pgc_assembler-5.0.0}/pgc_assembler.egg-info/top_level.txt +0 -0
  25. {pgc_assembler-3.0.0 → pgc_assembler-5.0.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pgc-assembler
3
- Version: 3.0.0
3
+ Version: 5.0.0
4
4
  Summary: PGC snapshot assembler — composes compiled projections into a manifest-pinned executable snapshot (import package: assembler)
5
5
  Author-email: Bhash Ganti <bachipeachy@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -21,6 +21,8 @@ Description-Content-Type: text/markdown
21
21
  License-File: LICENSE
22
22
  License-File: NOTICE
23
23
  Requires-Dist: pyyaml
24
+ Provides-Extra: signing
25
+ Requires-Dist: cryptography>=42; extra == "signing"
24
26
  Dynamic: license-file
25
27
 
26
28
  # snapshot_assembler
@@ -40,8 +42,10 @@ repos → protocol_compiler → each repo's compiled/ projections
40
42
  ```
41
43
 
42
44
  The platform is an ordinary member of the composition — no singleton branch. The composition
43
- currently assembles **seven domains**: `platform`, `workload`, `inspection`, `transformation`,
44
- and the business domains `ai_governance`, `blockchain` and `book_library_mgmt`.
45
+ currently assembles **eight domains**: `platform`, `workload`, `inspection`, `transformation`,
46
+ and the business domains `ai_governance`, `blockchain`, `book_library_mgmt` and
47
+ `causal_language_model`. A deliberately narrower composition is assembled by naming its roots in
48
+ `PGC_SOURCE_ROOTS`.
45
49
 
46
50
  ## Install
47
51
 
@@ -132,8 +136,18 @@ export PGC_PLATFORM_ROOT=$PWD/software_governance
132
136
  pgc # reports what is installed and whether the anchor resolves
133
137
  ```
134
138
 
135
- `PGC_BUILD_ROOT` (compiled output, keeping the governance repo read-only) and `PGC_DOMAIN_ROOTS`
136
- (additional domains contributing their own `registry/structures`) are optional.
139
+ `PGC_DOMAIN_ROOTS` names an additional domain contributing its own `registry/structures` — the
140
+ directory that *directly contains* it, not the repository above it; pointing one level too high is a
141
+ silent no-op. Where compiled output is written is declared by each build configuration
142
+ (`output_configuration.root`), not by the environment. `PGC_SNAPSHOT_PROFILES` is the directory
143
+ holding snapshot profiles, required by the assembler and the runtime alike. A snapshot's identity
144
+ covers the content of the profile it claims, so the profiles a node reads must be the ones it was
145
+ sealed against.
146
+
147
+ `PGC_BUILD_ROOT` is accepted and reported and **nothing reads it**.
148
+
149
+ The full sequence, with the repositories it needs, is in
150
+ [`pgc_install`](https://github.com/protocol-governed-computing/pgc_install).
137
151
 
138
152
  **Versioning.** Two schemes, and the published version follows the second.
139
153
 
@@ -143,7 +157,7 @@ pgc # reports what is installed and whether the anchor resolves
143
157
  cycle is tagged `release-<N>`. This is not published.
144
158
  - **Public** — `PUBLIC_VERSION`, tagged on every component repository. The platform is at **`v3`**.
145
159
 
146
- **The published version is the public one: `v3` is `3.0.0`.** The standard the packages implement is a
160
+ **The published version is the public one: `v4` is `4.0.0`.** The standard the packages implement is a
147
161
  separate artifact on its own track and is not this number.
148
162
 
149
163
  The standard these packages implement is published separately: https://doi.org/10.5281/zenodo.22150616
@@ -15,8 +15,10 @@ repos → protocol_compiler → each repo's compiled/ projections
15
15
  ```
16
16
 
17
17
  The platform is an ordinary member of the composition — no singleton branch. The composition
18
- currently assembles **seven domains**: `platform`, `workload`, `inspection`, `transformation`,
19
- and the business domains `ai_governance`, `blockchain` and `book_library_mgmt`.
18
+ currently assembles **eight domains**: `platform`, `workload`, `inspection`, `transformation`,
19
+ and the business domains `ai_governance`, `blockchain`, `book_library_mgmt` and
20
+ `causal_language_model`. A deliberately narrower composition is assembled by naming its roots in
21
+ `PGC_SOURCE_ROOTS`.
20
22
 
21
23
  ## Install
22
24
 
@@ -107,8 +109,18 @@ export PGC_PLATFORM_ROOT=$PWD/software_governance
107
109
  pgc # reports what is installed and whether the anchor resolves
108
110
  ```
109
111
 
110
- `PGC_BUILD_ROOT` (compiled output, keeping the governance repo read-only) and `PGC_DOMAIN_ROOTS`
111
- (additional domains contributing their own `registry/structures`) are optional.
112
+ `PGC_DOMAIN_ROOTS` names an additional domain contributing its own `registry/structures` — the
113
+ directory that *directly contains* it, not the repository above it; pointing one level too high is a
114
+ silent no-op. Where compiled output is written is declared by each build configuration
115
+ (`output_configuration.root`), not by the environment. `PGC_SNAPSHOT_PROFILES` is the directory
116
+ holding snapshot profiles, required by the assembler and the runtime alike. A snapshot's identity
117
+ covers the content of the profile it claims, so the profiles a node reads must be the ones it was
118
+ sealed against.
119
+
120
+ `PGC_BUILD_ROOT` is accepted and reported and **nothing reads it**.
121
+
122
+ The full sequence, with the repositories it needs, is in
123
+ [`pgc_install`](https://github.com/protocol-governed-computing/pgc_install).
112
124
 
113
125
  **Versioning.** Two schemes, and the published version follows the second.
114
126
 
@@ -118,7 +130,7 @@ pgc # reports what is installed and whether the anchor resolves
118
130
  cycle is tagged `release-<N>`. This is not published.
119
131
  - **Public** — `PUBLIC_VERSION`, tagged on every component repository. The platform is at **`v3`**.
120
132
 
121
- **The published version is the public one: `v3` is `3.0.0`.** The standard the packages implement is a
133
+ **The published version is the public one: `v4` is `4.0.0`.** The standard the packages implement is a
122
134
  separate artifact on its own track and is not this number.
123
135
 
124
136
  The standard these packages implement is published separately: https://doi.org/10.5281/zenodo.22150616
@@ -0,0 +1 @@
1
+ 17
@@ -0,0 +1 @@
1
+ 17
@@ -1,6 +1,6 @@
1
1
  """PGC Snapshot Assembler — composes compiled projections into a manifest-pinned snapshot.
2
2
 
3
- Contract: snapshot_assembler/doc/SNAPSHOT_ASSEMBLY_CONTRACT.md
3
+ Contract: snapshot_assembler/CONTRACT.md
4
4
  """
5
5
 
6
6
  from pathlib import Path
@@ -22,7 +22,7 @@ import os
22
22
  import sys
23
23
  from pathlib import Path
24
24
 
25
- from assembler import conformance, core
25
+ from assembler import conformance, core, signing
26
26
 
27
27
 
28
28
  def _build_parser() -> argparse.ArgumentParser:
@@ -44,9 +44,19 @@ def _build_parser() -> argparse.ArgumentParser:
44
44
  "none cannot have clause 4 of 3b §7 evaluated about it.",
45
45
  )
46
46
 
47
+ a.add_argument(
48
+ "--signing-key", metavar="PEM", default=None,
49
+ help="Private key authenticating the sealed snapshot's identity. The build machine "
50
+ "holds this and no node that executes a workflow does: a node able to sign can "
51
+ "mint a snapshot its peers would accept.")
52
+
47
53
  v = subs.add_parser("verify", help="Verify an assembled snapshot against its manifest")
48
54
  v.add_argument("--out", required=True, metavar="SNAPSHOT_DIR", help="Assembled snapshot dir to verify")
49
55
 
56
+ k = subs.add_parser("keygen", help="Mint a signing keypair for a snapshot trust root")
57
+ k.add_argument("--private", required=True, metavar="PEM", help="Where to write the private half")
58
+ k.add_argument("--public", required=True, metavar="PEM", help="Where to write the public half")
59
+
50
60
  c = subs.add_parser("conform", help="Run Composition Conformance against an assembled snapshot")
51
61
  c.add_argument("--out", required=True, metavar="SNAPSHOT_DIR", help="Assembled snapshot dir to check")
52
62
 
@@ -58,6 +68,37 @@ def _fatal(msg: str) -> None:
58
68
  sys.exit(1)
59
69
 
60
70
 
71
+ def _report_authentication(out_root: Path, snapshot_id: str) -> None:
72
+ """Report whether the snapshot is authenticated, and refuse where this party requires it.
73
+
74
+ A party holding a trust root is a party that requires one. Absent `PGC_TRUST_ROOT_PUBKEY`
75
+ this reports what it finds and refuses nothing: an unsigned snapshot is well-formed, and a
76
+ platform whose profile does not select signing must still verify. Holding an anchor is the
77
+ opt-in, and it lives outside the artifact being authenticated — which is also why the key
78
+ is read from here and never from the signature record.
79
+ """
80
+ anchor = os.environ.get("PGC_TRUST_ROOT_PUBKEY")
81
+ try:
82
+ record = signing.read_signature(out_root)
83
+ except signing.SignatureMalformed as exc:
84
+ _fatal(f"snapshot authentication: {exc}")
85
+
86
+ if not anchor:
87
+ state = (f"present, unchecked (signed under key {record.get('key_id', '?')})"
88
+ if record else "absent")
89
+ print(f"[signing] {state} — set PGC_TRUST_ROOT_PUBKEY to require and check one")
90
+ return
91
+
92
+ if record is None:
93
+ _fatal("snapshot authentication: this party anchors a trust root and the snapshot "
94
+ "carries no signature. An unsigned snapshot is not one this party accepts.")
95
+ try:
96
+ key = signing.verify_identity(snapshot_id, record, Path(anchor))
97
+ except (signing.SignatureError, signing.SigningUnavailable, OSError) as exc:
98
+ _fatal(f"snapshot authentication: {type(exc).__name__}: {exc}")
99
+ print(f"[signing] AUTHENTICATED under trust root {key}")
100
+
101
+
61
102
  def main() -> None:
62
103
  args = _build_parser().parse_args()
63
104
 
@@ -88,6 +129,17 @@ def main() -> None:
88
129
  print(f"[conformance] composition: {ev['status']} "
89
130
  f"({ev['rules_evaluated']} rule(s) over {ev['artifacts_examined']} artifacts)")
90
131
 
132
+ # Signing follows sealing and never precedes it: what is authenticated is the identity
133
+ # the seal produced. A snapshot is sealed whether or not anyone signs it; a signature
134
+ # says who vouches for the one that was sealed.
135
+ if args.signing_key:
136
+ try:
137
+ record = signing.sign_identity(manifest["snapshot_id"], Path(args.signing_key))
138
+ path = signing.write_signature(out_root, record)
139
+ except (signing.SigningUnavailable, OSError, ValueError) as exc:
140
+ _fatal(f"signing failed: {exc}")
141
+ print(f"[signing] signed under key {record['key_id']} -> {path}")
142
+
91
143
  elif args.command == "verify":
92
144
  out_root = Path(args.out).resolve()
93
145
  try:
@@ -96,6 +148,20 @@ def main() -> None:
96
148
  _fatal(str(exc))
97
149
  print(f"[assembler] VERIFIED snapshot_id={manifest['snapshot_id']}")
98
150
  print(f"[assembler] domains: {json.dumps([d['domain'] for d in manifest['domains']])}")
151
+ _report_authentication(out_root, manifest["snapshot_id"])
152
+
153
+ elif args.command == "keygen":
154
+ try:
155
+ private_pem, public_pem = signing.generate_keypair()
156
+ except signing.SigningUnavailable as exc:
157
+ _fatal(str(exc))
158
+ priv, pub = Path(args.private), Path(args.public)
159
+ priv.write_bytes(private_pem)
160
+ priv.chmod(0o600)
161
+ pub.write_bytes(public_pem)
162
+ print(f"[keygen] key_id: {signing.key_id_from_public_pem(public_pem)}")
163
+ print(f"[keygen] private: {priv} (build machine only — never on an executing node)")
164
+ print(f"[keygen] public: {pub} (each node holds this, from outside the snapshot)")
99
165
 
100
166
  elif args.command == "conform":
101
167
  out_root = Path(args.out).resolve()
@@ -4,7 +4,7 @@ core.py — PGC snapshot assembly.
4
4
  Composes each domain's compiled projections (from the protocol compiler) into one
5
5
  executable snapshot + a content-derived, manifest-pinned identity.
6
6
 
7
- Contract: snapshot_assembler/doc/SNAPSHOT_ASSEMBLY_CONTRACT.md
7
+ Contract: snapshot_assembler/CONTRACT.md
8
8
 
9
9
  Invariants enforced here:
10
10
  * The assembler INVENTS no per-domain identity — every hash is lifted verbatim from
@@ -182,7 +182,13 @@ def _identity_view(domains: list[dict]) -> list[dict]:
182
182
  # it is a determination ABOUT the snapshot, in the sense `3b` §7 means, and evidence rather than
183
183
  # content. That it is written inside the tree is a placement defect recorded in Task D; excluding it
184
184
  # here is what makes the tree honest about what it sealed, not a carve-out to make a check pass.
185
- POST_SEAL = ("conformance/",)
185
+ # A signature is the same shape as a conformance result and joins this set for the same reason:
186
+ # it is over `snapshot_id`, so it cannot exist before the identity does, and it records who
187
+ # vouches for what was sealed rather than forming part of it. The placement defect noted above
188
+ # applies to it equally — it is written inside the tree and is not of it. That is an existing
189
+ # defect extended to a second file, not a new class of one, and not a carve-out invented to
190
+ # admit signing: a signature that were a constituent would change the identity it signs.
191
+ POST_SEAL = ("conformance/", "signature.json")
186
192
 
187
193
 
188
194
  def _is_post_seal(rel: str) -> bool:
@@ -190,7 +196,7 @@ def _is_post_seal(rel: str) -> bool:
190
196
 
191
197
 
192
198
  # What an attestation records rather than constitutes, per
193
- # `cryptographic_trust::CONSTITUTION_CRYPTOGRAPHIC_TRUST_V0`. The projection binding and the value
199
+ # `cryptographic_trust::CONSTITUTION_CRYPTOGRAPHIC_TRUST_V1`. The projection binding and the value
194
200
  # over it are enforced by the runtime at boot and constitute the composition like any other content;
195
201
  # when the signing happened records something *about* it, is read by nothing, and changes on every
196
202
  # build. Counting it made a composition's identity a function of when it was built — two compiles of
@@ -242,16 +248,20 @@ def enumerate_constituents(out_root: Path) -> list[dict[str, str]]:
242
248
  return out
243
249
 
244
250
 
245
- def compute_snapshot_identity(domains: list[dict], constituents: list[dict], profile: str) -> str:
251
+ def compute_snapshot_identity(domains: list[dict], constituents: list[dict], profile: str,
252
+ profile_sha256: str) -> str:
246
253
  """The snapshot's identity, derived from content and covering every constituent (SN-2, SN-6).
247
254
 
248
- Three inputs, and each is there for a stated reason:
255
+ Four inputs, and each is there for a stated reason:
249
256
 
250
257
  * the domains identity view — the semantic identity of what was compiled;
251
258
  * every constituent, by path and by a hash OF ITS BYTES — so that changing any carried file
252
259
  changes the identity, which is SN-2's totality clause;
253
260
  * the claimed profile — a self-description that claims a profile and does not cover the claim
254
- would let the claim change without the identity changing (SN-5, SN-6).
261
+ would let the claim change without the identity changing (SN-5, SN-6);
262
+ * the claimed profile's content — the name alone let a profile be weakened, requirements
263
+ removed from it, without the identity of any snapshot claiming it changing. The digest is
264
+ taken over the profile's declaration (`profile_sha256`).
255
265
 
256
266
  Provenance is excluded and that exclusion is declared in the manifest's `identity_covers`.
257
267
  It is observational content (`3e` §5): `assembled_at` differs between two assemblies of identical
@@ -259,7 +269,8 @@ def compute_snapshot_identity(domains: list[dict], constituents: list[dict], pro
259
269
  SN-6. What SN-6 requires covered is the description's determinative content, which this is.
260
270
  """
261
271
  payload = json.dumps(
262
- {"domains": _identity_view(domains), "constituents": constituents, "profile": profile},
272
+ {"domains": _identity_view(domains), "constituents": constituents, "profile": profile,
273
+ "profile_sha256": profile_sha256},
263
274
  sort_keys=True, separators=(",", ":"),
264
275
  )
265
276
  return hashlib.sha256(payload.encode("utf-8")).hexdigest()
@@ -326,6 +337,19 @@ def _profile_declaration(identity: str) -> dict[str, Any]:
326
337
  f"{root} — a claim nobody can read is not a claim (3b SN-7)")
327
338
 
328
339
 
340
+ def profile_digest(profile: str) -> str:
341
+ """The digest of the claimed profile's declaration, as the identity covers it.
342
+
343
+ Taken over the `snapshot_profile` block in canonical form rather than over the file: what the
344
+ profile requires is its declaration, and a change to the prose around it changes nothing a
345
+ snapshot is checked against.
346
+ """
347
+ declared = _profile_declaration(profile)
348
+ return hashlib.sha256(
349
+ json.dumps(declared, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8")
350
+ ).hexdigest()
351
+
352
+
329
353
  def verify_profile(out_root: Path, profile: str) -> list[str]:
330
354
  """What the claimed profile requires and the composition does not carry.
331
355
 
@@ -358,12 +382,6 @@ def verify_profile(out_root: Path, profile: str) -> list[str]:
358
382
  return unmet
359
383
 
360
384
 
361
- def compute_composite_hash(domains: list[dict]) -> str:
362
- """sha256 over canonical-JSON of the identity view. Deterministic; timestamp-free."""
363
- canonical = json.dumps(_identity_view(domains), sort_keys=True, separators=(",", ":"))
364
- return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
365
-
366
-
367
385
  # ---------------------------------------------------------------------------
368
386
  # Cross-domain FQDN uniqueness (per-domain vocabularies are independent address namespaces)
369
387
  # ---------------------------------------------------------------------------
@@ -521,7 +539,8 @@ def assemble(source_roots: list[Path], out_root: Path, profile: str) -> dict[str
521
539
  # `3b` §6 requires all five, with the enumeration total. It was four per domain; six top-level
522
540
  # constituents were carried and enumerated nowhere, which under §6 is undeclared content.
523
541
  constituents = enumerate_constituents(out_root)
524
- snapshot_id = compute_snapshot_identity(domains, constituents, profile)
542
+ sealed_profile = profile_digest(profile)
543
+ snapshot_id = compute_snapshot_identity(domains, constituents, profile, sealed_profile)
525
544
  manifest = {
526
545
  "manifest_version": MANIFEST_VERSION,
527
546
  "snapshot_id": snapshot_id,
@@ -529,11 +548,14 @@ def assemble(source_roots: list[Path], out_root: Path, profile: str) -> dict[str
529
548
  # What the identity is taken over, declared so a party who did not build this snapshot can
530
549
  # recompute it without reading the assembler (AI-16). Provenance is excluded as observational
531
550
  # content (`3e` §5) — see compute_snapshot_identity.
532
- "identity_covers": ["domains", "constituents", "profile"],
551
+ "identity_covers": ["domains", "constituents", "profile", "profile_sha256"],
533
552
  # Written after sealing and therefore outside what was sealed. Declared rather than silently
534
553
  # skipped, so a party recomputing the identity knows exactly what to exclude (AI-16).
535
554
  "post_seal": list(POST_SEAL),
536
555
  "profile": profile,
556
+ # What the claimed profile declared when this snapshot was sealed. Acceptance recomputes it
557
+ # from the profile as it now reads and refuses a difference.
558
+ "profile_sha256": sealed_profile,
537
559
  "domains": domains,
538
560
  "constituents": constituents,
539
561
  "provenance": _build_provenance(inputs, domains),
@@ -553,7 +575,9 @@ def assemble(source_roots: list[Path], out_root: Path, profile: str) -> dict[str
553
575
  # Kinds a domain build instantiates. MUST match the compiler's _inject_imported_governance
554
576
  # filter (compiler/stages/s1_extract.py): a governance invariant is imported into a domain iff
555
577
  # its applies_to_kinds intersects this set and it declares no layer/surface scope.
556
- _DOMAIN_INSTANTIATED = frozenset({"WF", "CC", "CS", "CT", "RB", "AC", "IN", "EV", "TI", "TE"})
578
+ # TEST_DATA joined when transform vectors moved into the supplying domain's build
579
+ # (conformance::CONSTITUTION_TEST_DATA_V2).
580
+ _DOMAIN_INSTANTIATED = frozenset({"WF", "CC", "CS", "CT", "RB", "AC", "IN", "EV", "TI", "TE", "TEST_DATA"})
557
581
 
558
582
 
559
583
  def _recompute_governance_closure(out_root: Path, source_domain: str) -> tuple[str, int]:
@@ -681,7 +705,8 @@ def verify_snapshot(out_root: Path) -> dict[str, Any]:
681
705
  3. identity — recomputed from the RECOMPUTED constituent hashes, compared to the identity
682
706
  borne. Recomputing over recorded hashes detects a tampered manifest; only
683
707
  recomputing over recomputed hashes detects a tampered constituent
684
- 4. profile — the snapshot claims one
708
+ 4. profile — the snapshot claims one, and the profile it claims is the one it was sealed
709
+ against: its declaration digests to the value the identity covers
685
710
 
686
711
  This previously compared recorded values to recorded values — manifest against metadata.json
687
712
  against attestation — which is transitive from construction and establishes nothing to a party
@@ -747,7 +772,20 @@ def verify_snapshot(out_root: Path) -> dict[str, Any]:
747
772
  + "; ".join(unmet[:5])
748
773
  + (f"; and {len(unmet) - 5} more" if len(unmet) > 5 else ""))
749
774
 
750
- derived = compute_snapshot_identity(domains, recomputed, profile)
775
+ # The profile read now, not the digest the manifest carries: recomputing over the recorded value
776
+ # would accept a profile weakened after sealing, which is what covering its content is for.
777
+ sealed_profile = manifest.get("profile_sha256")
778
+ if not sealed_profile:
779
+ raise AssemblyError(
780
+ "manifest carries no profile_sha256 — the profile's content is not in the identity, so "
781
+ "a weakened profile could not be told from the one this snapshot was sealed against.")
782
+ current_profile = profile_digest(profile)
783
+ if current_profile != sealed_profile:
784
+ raise AssemblyError(
785
+ f"profile failure: {profile} now digests to {current_profile[:16]}…, and this snapshot "
786
+ f"was sealed against {sealed_profile[:16]}…. The profile it claims has changed since.")
787
+
788
+ derived = compute_snapshot_identity(domains, recomputed, profile, current_profile)
751
789
  borne = manifest.get("snapshot_id")
752
790
  if derived != borne:
753
791
  raise AssemblyError(
@@ -755,6 +793,17 @@ def verify_snapshot(out_root: Path) -> dict[str, Any]:
755
793
  f"{str(borne)[:16]}… (3b §7 clause 2, SN-2)."
756
794
  )
757
795
 
796
+ # The manifest states its identity twice. Both statements are the claim, so both are evaluated:
797
+ # checking one and not the other let a manifest carry two contradictory identities and be
798
+ # accepted — measured, not hypothesised. A self-description that disagrees with itself is not a
799
+ # self-description.
800
+ borne_composite = manifest.get("composite_hash")
801
+ if borne_composite != derived:
802
+ raise AssemblyError(
803
+ f"identity failure: derived {derived[:16]}… from content, snapshot's composite_hash "
804
+ f"bears {str(borne_composite)[:16]}… (3b §7 clause 2, SN-2)."
805
+ )
806
+
758
807
  # Copies of one identity across domains must agree (GC-12) — a composition obligation rather
759
808
  # than correspondence, and not subsumed by the four conditions above.
760
809
  _verify_copies_agree(out_root)
@@ -35,8 +35,36 @@ SCHEMA_VERSION = "v0"
35
35
  # Shared readers over the consolidated snapshot
36
36
  # ---------------------------------------------------------------------------
37
37
 
38
+ def _authoring_rank(doc: dict) -> int:
39
+ """How authoritative a canonical copy is for its identity. Higher wins.
40
+
41
+ One identity can be published by more than one domain. A domain that CONSUMES a platform
42
+ capability carries it as an execution binding — the compiler lifts the compiled node so the
43
+ capability is executable there, and the authoring stays platform-owned. Both copies are
44
+ canonical and both are legitimate; they are not interchangeable.
45
+
46
+ The binding copy is the authoring copy with the authored parts removed: `content_hash`,
47
+ `frontmatter` and the IR are identical, while `content` and `references` are empty and
48
+ `layer_code` names the consuming layer. So the copy that still carries authored content is the
49
+ authoring copy, and it is the one an identity must resolve to — `si.artifact.show` promises "the
50
+ published canonical artifact for an FQDN, as authored".
51
+
52
+ A binding copy also carries the `content_hash` of content it does not hold, which is the sharper
53
+ tell but not one to select on: a hash mismatch is a defect to report, not a discriminator.
54
+ """
55
+ return 1 if (doc.get("content") or "").strip() else 0
56
+
57
+
38
58
  def _load_canonical(out_root: Path) -> dict[str, dict]:
39
- """fqdn → canonical artifact doc, across all domains (canonical/<domain>/<type>/*.json)."""
59
+ """fqdn → canonical artifact doc, across all domains (canonical/<domain>/<type>/*.json).
60
+
61
+ Where an identity is published more than once, the authoring copy wins (`_authoring_rank`).
62
+ Selecting by iteration order would resolve an identity by the alphabetical accident of its
63
+ consumer's domain name: `canonical/platform/` sorts before `canonical/workload/` and after
64
+ `canonical/blockchain/`, so last-write-wins returned the authoring copy for consumers named
65
+ early and the binding copy for consumers named late. Renaming a domain would then change what
66
+ an identity resolves to.
67
+ """
40
68
  canon = out_root / "canonical"
41
69
  docs: dict[str, dict] = {}
42
70
  if not canon.is_dir():
@@ -46,8 +74,12 @@ def _load_canonical(out_root: Path) -> dict[str, dict]:
46
74
  continue
47
75
  raw = json.loads(f.read_text(encoding="utf-8"))
48
76
  fqdn = raw.get("fqdn_id")
49
- if fqdn and "::" in fqdn:
50
- raw["_canonical_path"] = f.relative_to(out_root).as_posix()
77
+ if not fqdn or "::" not in fqdn:
78
+ continue
79
+ raw["_canonical_path"] = f.relative_to(out_root).as_posix()
80
+ held = docs.get(fqdn)
81
+ # Ties keep the first seen, so a sorted walk stays the tiebreak and the result is stable.
82
+ if held is None or _authoring_rank(raw) > _authoring_rank(held):
51
83
  docs[fqdn] = raw
52
84
  return docs
53
85
 
@@ -0,0 +1,229 @@
1
+ """Snapshot authentication — a signature over a sealed snapshot's identity.
2
+
3
+ Three things are kept apart here, and conflating any two of them defeats the purpose:
4
+
5
+ snapshot identity `snapshot_id`, derived from what was sealed
6
+ snapshot authentication a signature over that identity
7
+ trust anchor a public key the verifying party holds, from outside the snapshot
8
+
9
+ Verification is `Verify(K, I, sigma)` where **K comes from the node and never from the
10
+ artifact**. A snapshot carrying the key that authenticates it is self-authenticating, which
11
+ establishes nothing: whoever replaced the snapshot would replace the key with it. The
12
+ `key_id` in a signature record exists only so a refusal can say *which* failure occurred —
13
+ signed by a key this node does not anchor, versus signed badly. It MUST NOT be used to
14
+ select a key.
15
+
16
+ **What is signed is the identity, not the manifest.** The self-description sits outside the
17
+ identity it declares, so signing the file would bind the signature to bytes the identity does
18
+ not cover. And the record is a sidecar rather than a manifest field, because a sealed
19
+ snapshot's manifest is written once and never rewritten.
20
+
21
+ **Asymmetric by requirement, not by preference.** A profile requiring that a node able to
22
+ verify is unable to sign rules out every shared-secret construction, whatever its strength.
23
+
24
+ The dependency is an extra (`snapshot_assembler[signing]`) rather than a core requirement.
25
+ Signing is something a profile selects; a platform whose profile does not select it should
26
+ not acquire a cryptography dependency because the capability exists elsewhere. Absent the
27
+ extra, this module refuses — it never degrades to an unsigned success.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import base64
33
+ import hashlib
34
+ import json
35
+ from datetime import datetime, timezone
36
+ from pathlib import Path
37
+ from typing import Any
38
+
39
+ SIGNATURE_FILENAME = "signature.json"
40
+ SIGNATURE_VERSION = "V0"
41
+ ALGORITHM = "ed25519"
42
+
43
+ # Fields recorded beside the signature that the signature does not cover. `signed_at` says
44
+ # when signing happened; it is an accompanying fact, and covering it would make an identical
45
+ # snapshot produce a different record on every run.
46
+ ACCOMPANYING = ("signed_at",)
47
+
48
+
49
+ class SigningUnavailable(RuntimeError):
50
+ """The signing extra is not installed. Never a reason to proceed unsigned."""
51
+
52
+
53
+ class SignatureError(RuntimeError):
54
+ """Base for every refusal this module issues."""
55
+
56
+
57
+ class SignatureAbsent(SignatureError):
58
+ """The snapshot carries no signature and the verifying party requires one."""
59
+
60
+
61
+ class SignatureMalformed(SignatureError):
62
+ """A signature record exists and cannot be read as one."""
63
+
64
+
65
+ class TrustRootMismatch(SignatureError):
66
+ """Signed by a key this party does not anchor. Distinct from a bad signature."""
67
+
68
+
69
+ class SignatureInvalid(SignatureError):
70
+ """Anchored by the right key and the signature does not verify."""
71
+
72
+
73
+ class IdentityMismatch(SignatureError):
74
+ """The signature authenticates an identity the snapshot does not claim."""
75
+
76
+
77
+ def _ed25519():
78
+ """The primitives, or a refusal naming what to install.
79
+
80
+ Imported here rather than at module scope so that reading this module, and importing the
81
+ assembler at all, costs nothing when signing is not in use.
82
+ """
83
+ try:
84
+ from cryptography.hazmat.primitives import serialization
85
+ from cryptography.hazmat.primitives.asymmetric import ed25519
86
+ except ImportError as exc: # pragma: no cover - exercised by absence, not by tests
87
+ raise SigningUnavailable(
88
+ "snapshot signing requires the 'signing' extra: "
89
+ "pip install 'snapshot-assembler[signing]'"
90
+ ) from exc
91
+ return serialization, ed25519
92
+
93
+
94
+ def key_id(public_key_bytes: bytes) -> str:
95
+ """A short, stable name for a public key.
96
+
97
+ Identifies which anchor a record was produced under so that a refusal can distinguish a
98
+ key this party does not hold from a signature that does not verify. It is a label, never
99
+ a credential.
100
+ """
101
+ return hashlib.sha256(public_key_bytes).hexdigest()[:16]
102
+
103
+
104
+ def key_id_from_public_pem(public_pem: bytes) -> str:
105
+ """The `key_id` of a PEM-encoded public key, for reporting at mint time."""
106
+ serialization, _ = _ed25519()
107
+ return key_id(_raw_public(serialization.load_pem_public_key(public_pem)))
108
+
109
+
110
+ def generate_keypair() -> tuple[bytes, bytes]:
111
+ """A new Ed25519 pair as (private_pem, public_pem).
112
+
113
+ The private half belongs on the machine that seals snapshots and on no machine that
114
+ executes them: a node able to sign can mint a snapshot its peers would accept.
115
+ """
116
+ serialization, ed25519 = _ed25519()
117
+ private = ed25519.Ed25519PrivateKey.generate()
118
+ private_pem = private.private_bytes(
119
+ encoding=serialization.Encoding.PEM,
120
+ format=serialization.PrivateFormat.PKCS8,
121
+ encryption_algorithm=serialization.NoEncryption(),
122
+ )
123
+ public_pem = private.public_key().public_bytes(
124
+ encoding=serialization.Encoding.PEM,
125
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
126
+ )
127
+ return private_pem, public_pem
128
+
129
+
130
+ def _raw_public(public_key) -> bytes:
131
+ serialization, _ = _ed25519()
132
+ return public_key.public_bytes(
133
+ encoding=serialization.Encoding.Raw,
134
+ format=serialization.PublicFormat.Raw,
135
+ )
136
+
137
+
138
+ def sign_identity(snapshot_id: str, private_key_path: Path) -> dict[str, Any]:
139
+ """Produce the signature record for a sealed snapshot's identity."""
140
+ serialization, _ = _ed25519()
141
+ private = serialization.load_pem_private_key(
142
+ Path(private_key_path).read_bytes(), password=None
143
+ )
144
+ signature = private.sign(snapshot_id.encode("utf-8"))
145
+ return {
146
+ "signature_version": SIGNATURE_VERSION,
147
+ "snapshot_id": snapshot_id,
148
+ "algorithm": ALGORITHM,
149
+ "key_id": key_id(_raw_public(private.public_key())),
150
+ "signature": base64.b64encode(signature).decode("ascii"),
151
+ "signed_at": datetime.now(timezone.utc).isoformat(),
152
+ }
153
+
154
+
155
+ def write_signature(out_root: Path, record: dict[str, Any]) -> Path:
156
+ """Write the sidecar beside the manifest, leaving the manifest untouched."""
157
+ path = Path(out_root) / SIGNATURE_FILENAME
158
+ path.write_text(json.dumps(record, indent=2, sort_keys=True) + "\n", encoding="utf-8")
159
+ return path
160
+
161
+
162
+ def read_signature(out_root: Path) -> dict[str, Any] | None:
163
+ """The signature record, or None where the snapshot carries none."""
164
+ path = Path(out_root) / SIGNATURE_FILENAME
165
+ if not path.exists():
166
+ return None
167
+ try:
168
+ record = json.loads(path.read_text(encoding="utf-8"))
169
+ except json.JSONDecodeError as exc:
170
+ raise SignatureMalformed(f"{path} is not readable as JSON: {exc}") from exc
171
+ if not isinstance(record, dict):
172
+ raise SignatureMalformed(f"{path} does not carry a signature record")
173
+ return record
174
+
175
+
176
+ def verify_identity(snapshot_id: str, record: dict[str, Any], public_key_path: Path) -> str:
177
+ """Verify a signature over a claimed identity against a party-supplied anchor.
178
+
179
+ Returns the `key_id` of the anchor on success. Raises one of this module's refusals
180
+ otherwise, each naming which of the four failures occurred: the record authenticates a
181
+ different identity, it was produced under a key this party does not anchor, the record is
182
+ unreadable, or it does not verify.
183
+
184
+ The anchor is the caller's. `record["key_id"]` is read only to report a mismatch.
185
+ """
186
+ serialization, ed25519 = _ed25519()
187
+
188
+ for field in ("snapshot_id", "algorithm", "signature"):
189
+ if field not in record:
190
+ raise SignatureMalformed(f"signature record carries no {field!r}")
191
+
192
+ if record["algorithm"] != ALGORITHM:
193
+ raise SignatureMalformed(
194
+ f"signature algorithm is {record['algorithm']!r}; this verifier knows {ALGORITHM!r}"
195
+ )
196
+
197
+ # Checked before the cryptography: a record that verifies over a different identity is
198
+ # valid and irrelevant, and reporting it as a bad signature would name the wrong fault.
199
+ if record["snapshot_id"] != snapshot_id:
200
+ raise IdentityMismatch(
201
+ f"signature authenticates {record['snapshot_id'][:16]}… and this snapshot claims "
202
+ f"{snapshot_id[:16]}…"
203
+ )
204
+
205
+ public = serialization.load_pem_public_key(Path(public_key_path).read_bytes())
206
+ if not isinstance(public, ed25519.Ed25519PublicKey):
207
+ raise SignatureMalformed(f"{public_key_path} is not an Ed25519 public key")
208
+ anchor = key_id(_raw_public(public))
209
+
210
+ borne = record.get("key_id")
211
+ if borne and borne != anchor:
212
+ raise TrustRootMismatch(
213
+ f"signed under key {borne} and this party anchors {anchor} — the signature may be "
214
+ f"sound and is not one this party accepts"
215
+ )
216
+
217
+ try:
218
+ signature = base64.b64decode(record["signature"], validate=True)
219
+ except (ValueError, TypeError) as exc:
220
+ raise SignatureMalformed(f"signature is not valid base64: {exc}") from exc
221
+
222
+ try:
223
+ public.verify(signature, snapshot_id.encode("utf-8"))
224
+ except Exception as exc:
225
+ raise SignatureInvalid(
226
+ f"signature over {snapshot_id[:16]}… does not verify under key {anchor}"
227
+ ) from exc
228
+
229
+ return anchor
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pgc-assembler
3
- Version: 3.0.0
3
+ Version: 5.0.0
4
4
  Summary: PGC snapshot assembler — composes compiled projections into a manifest-pinned executable snapshot (import package: assembler)
5
5
  Author-email: Bhash Ganti <bachipeachy@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -21,6 +21,8 @@ Description-Content-Type: text/markdown
21
21
  License-File: LICENSE
22
22
  License-File: NOTICE
23
23
  Requires-Dist: pyyaml
24
+ Provides-Extra: signing
25
+ Requires-Dist: cryptography>=42; extra == "signing"
24
26
  Dynamic: license-file
25
27
 
26
28
  # snapshot_assembler
@@ -40,8 +42,10 @@ repos → protocol_compiler → each repo's compiled/ projections
40
42
  ```
41
43
 
42
44
  The platform is an ordinary member of the composition — no singleton branch. The composition
43
- currently assembles **seven domains**: `platform`, `workload`, `inspection`, `transformation`,
44
- and the business domains `ai_governance`, `blockchain` and `book_library_mgmt`.
45
+ currently assembles **eight domains**: `platform`, `workload`, `inspection`, `transformation`,
46
+ and the business domains `ai_governance`, `blockchain`, `book_library_mgmt` and
47
+ `causal_language_model`. A deliberately narrower composition is assembled by naming its roots in
48
+ `PGC_SOURCE_ROOTS`.
45
49
 
46
50
  ## Install
47
51
 
@@ -132,8 +136,18 @@ export PGC_PLATFORM_ROOT=$PWD/software_governance
132
136
  pgc # reports what is installed and whether the anchor resolves
133
137
  ```
134
138
 
135
- `PGC_BUILD_ROOT` (compiled output, keeping the governance repo read-only) and `PGC_DOMAIN_ROOTS`
136
- (additional domains contributing their own `registry/structures`) are optional.
139
+ `PGC_DOMAIN_ROOTS` names an additional domain contributing its own `registry/structures` — the
140
+ directory that *directly contains* it, not the repository above it; pointing one level too high is a
141
+ silent no-op. Where compiled output is written is declared by each build configuration
142
+ (`output_configuration.root`), not by the environment. `PGC_SNAPSHOT_PROFILES` is the directory
143
+ holding snapshot profiles, required by the assembler and the runtime alike. A snapshot's identity
144
+ covers the content of the profile it claims, so the profiles a node reads must be the ones it was
145
+ sealed against.
146
+
147
+ `PGC_BUILD_ROOT` is accepted and reported and **nothing reads it**.
148
+
149
+ The full sequence, with the repositories it needs, is in
150
+ [`pgc_install`](https://github.com/protocol-governed-computing/pgc_install).
137
151
 
138
152
  **Versioning.** Two schemes, and the published version follows the second.
139
153
 
@@ -143,7 +157,7 @@ pgc # reports what is installed and whether the anchor resolves
143
157
  cycle is tagged `release-<N>`. This is not published.
144
158
  - **Public** — `PUBLIC_VERSION`, tagged on every component repository. The platform is at **`v3`**.
145
159
 
146
- **The published version is the public one: `v3` is `3.0.0`.** The standard the packages implement is a
160
+ **The published version is the public one: `v4` is `4.0.0`.** The standard the packages implement is a
147
161
  separate artifact on its own track and is not this number.
148
162
 
149
163
  The standard these packages implement is published separately: https://doi.org/10.5281/zenodo.22150616
@@ -11,6 +11,7 @@ assembler/cli.py
11
11
  assembler/conformance.py
12
12
  assembler/core.py
13
13
  assembler/indexes.py
14
+ assembler/signing.py
14
15
  pgc_assembler.egg-info/PKG-INFO
15
16
  pgc_assembler.egg-info/SOURCES.txt
16
17
  pgc_assembler.egg-info/dependency_links.txt
@@ -0,0 +1,4 @@
1
+ pyyaml
2
+
3
+ [signing]
4
+ cryptography>=42
@@ -5,7 +5,7 @@ backend-path = ["."]
5
5
 
6
6
  [project]
7
7
  name = "pgc-assembler"
8
- version = "3.0.0"
8
+ version = "5.0.0"
9
9
  description = "PGC snapshot assembler — composes compiled projections into a manifest-pinned executable snapshot (import package: assembler)"
10
10
  readme = "README.md"
11
11
  requires-python = ">=3.10"
@@ -30,6 +30,13 @@ classifiers = [
30
30
  # install without this raises ModuleNotFoundError there instead of refusing.
31
31
  dependencies = ["pyyaml"]
32
32
 
33
+ # Snapshot signing is a profile's selection, not a property of the core. A platform whose
34
+ # profile requires authenticated snapshots installs this; one that does not should not
35
+ # acquire a cryptography dependency because the capability exists elsewhere. Absent it,
36
+ # assembler.signing refuses rather than degrading to an unsigned success.
37
+ [project.optional-dependencies]
38
+ signing = ["cryptography>=42"]
39
+
33
40
  [project.scripts]
34
41
  snapshot_assembler = "assembler.cli:main"
35
42
 
@@ -46,5 +53,6 @@ where = ["."]
46
53
  include = ["assembler*"]
47
54
 
48
55
  [tool.setuptools.package-data]
49
- # VERSION has no extension, so it must be named explicitly.
50
- "*" = ["*.md", "*.yaml", "*.json", "VERSION"]
56
+ # VERSION has no extension, so it must be named explicitly. Scoped to the package
57
+ # rather than "*" — see the note in protocol_compiler.
58
+ "assembler" = ["VERSION"]
@@ -1 +0,0 @@
1
- 15
@@ -1 +0,0 @@
1
- 15
@@ -1 +0,0 @@
1
- pyyaml
File without changes
File without changes
File without changes
File without changes