pgc-assembler 2.0.0__py3-none-any.whl

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.
assembler/core.py ADDED
@@ -0,0 +1,762 @@
1
+ """
2
+ core.py — PGC snapshot assembly.
3
+
4
+ Composes each domain's compiled projections (from the protocol compiler) into one
5
+ executable snapshot + a content-derived, manifest-pinned identity.
6
+
7
+ Contract: snapshot_assembler/doc/SNAPSHOT_ASSEMBLY_CONTRACT.md
8
+
9
+ Invariants enforced here:
10
+ * The assembler INVENTS no per-domain identity — every hash is lifted verbatim from
11
+ compiler-emitted metadata (tokenized/vocabulary metadata.json, trust attestation).
12
+ * composite_hash is CONTENT-DERIVED over the identity view of domains[]; provenance and
13
+ all timestamps are EXCLUDED. Same inputs → same composite.
14
+ * {platform} is an ordinary one-member composition — no singleton branch.
15
+ * Fail hard on any hash inconsistency or address-space collision. No silent skip.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import json
22
+ import shutil
23
+ import subprocess
24
+ from dataclasses import dataclass
25
+ from datetime import datetime, timezone
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ from assembler import ASSEMBLER_VERSION, MANIFEST_VERSION
30
+
31
+ # The three projection kinds the runtime consumes, in their compiled + assembled dir names.
32
+ PROJECTIONS = ("tokenized", "trust", "vocabulary")
33
+
34
+
35
+ class AssemblyError(RuntimeError):
36
+ """Raised on any assembly integrity failure. Fail hard — no fallback."""
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class DomainInput:
41
+ """One domain located under a compiled source root."""
42
+ domain: str
43
+ source_root: Path # the compiled/ root that contains this domain
44
+ repo_root: Path | None # nearest enclosing git repo (for provenance), or None
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Discovery
49
+ # ---------------------------------------------------------------------------
50
+
51
+ def discover_domains(source_roots: list[Path]) -> list[DomainInput]:
52
+ """
53
+ Discover domains under each compiled source root via its tokenized/<domain>/ subdirs.
54
+
55
+ A source root is a compiler `compiled/` directory (e.g. platform/snapshot/compiled).
56
+ """
57
+ found: dict[str, DomainInput] = {}
58
+ for root in source_roots:
59
+ tok = root / "tokenized"
60
+ if not tok.is_dir():
61
+ raise AssemblyError(f"Source root has no tokenized/ projection: {root}")
62
+ for d in sorted(p.name for p in tok.iterdir() if p.is_dir()):
63
+ if d in found:
64
+ raise AssemblyError(
65
+ f"Domain {d!r} present in two source roots "
66
+ f"({found[d].source_root} and {root}) — ambiguous."
67
+ )
68
+ found[d] = DomainInput(domain=d, source_root=root, repo_root=_git_repo_root(root))
69
+ if not found:
70
+ raise AssemblyError(f"No domains discovered under: {[str(r) for r in source_roots]}")
71
+ return [found[d] for d in sorted(found)]
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Per-domain identity (lifted verbatim from compiler output)
76
+ # ---------------------------------------------------------------------------
77
+
78
+ def _read_json(path: Path) -> dict:
79
+ if not path.exists():
80
+ raise AssemblyError(f"Required compiled projection file missing: {path}")
81
+ with path.open(encoding="utf-8") as f:
82
+ return json.load(f)
83
+
84
+
85
+ def _canonical_meta_path(root: Path, domain: str) -> Path:
86
+ """`canonical/metadata.json`, in either source shape.
87
+
88
+ The compiler emits canonical flat (`canonical/…` by artifact type) while tokenized, vocabulary
89
+ and trust are domain-scoped; assembly re-homes canonical under `canonical/<domain>`. Both shapes
90
+ are read here so the same hash can be lifted before and verified after assembly.
91
+ """
92
+ scoped = root / "canonical" / domain / "metadata.json"
93
+ return scoped if scoped.exists() else root / "canonical" / "metadata.json"
94
+
95
+
96
+ def _domain_identity(inp: DomainInput) -> dict[str, Any]:
97
+ """
98
+ Lift the domain's identity from compiler-emitted metadata and cross-check it.
99
+
100
+ Fails hard if the compiler's own three hash statements disagree (tokenized vs trust vs
101
+ vocabulary) — the assembler must not paper over an inconsistent compiled input.
102
+ """
103
+ root = inp.source_root
104
+ tok_meta = _read_json(root / "tokenized" / inp.domain / "metadata.json")
105
+ voc_meta = _read_json(root / "vocabulary" / inp.domain / "metadata.json")
106
+ trust = _read_json(root / "trust" / inp.domain / "structure_attestation.json")
107
+ # Canonical is emitted flat by the compiler; every other projection is domain-scoped.
108
+ can_meta = _read_json(_canonical_meta_path(root, inp.domain))
109
+
110
+ tok_hash = tok_meta.get("projection_hash", "")
111
+ voc_hash = voc_meta.get("projection_hash", "")
112
+ att_hash = trust.get("attestation_hash", "")
113
+ can_hash = can_meta.get("projection_hash", "")
114
+ graph_hash = tok_meta.get("graph_address_hash", "")
115
+
116
+ # --- cross-check compiler's own statements (no invention, just verification) ---
117
+ if not (tok_hash and voc_hash and att_hash and can_hash):
118
+ raise AssemblyError(f"[{inp.domain}] empty projection/attestation hash in compiled input.")
119
+ if trust.get("tokenized_projection_hash") != tok_hash:
120
+ raise AssemblyError(
121
+ f"[{inp.domain}] trust.tokenized_projection_hash "
122
+ f"{trust.get('tokenized_projection_hash')!r} != tokenized.projection_hash {tok_hash!r}"
123
+ )
124
+ if tok_meta.get("vocabulary_hash") != voc_hash:
125
+ raise AssemblyError(
126
+ f"[{inp.domain}] tokenized.vocabulary_hash {tok_meta.get('vocabulary_hash')!r} "
127
+ f"!= vocabulary.projection_hash {voc_hash!r}"
128
+ )
129
+
130
+ return {
131
+ "domain": inp.domain,
132
+ "compiler_version": tok_meta.get("compiler_version", "unknown"),
133
+ "graph_address_hash": graph_hash,
134
+ "projections": {
135
+ "tokenized": {"path": f"tokenized/{inp.domain}", "projection_hash": tok_hash},
136
+ "vocabulary": {"path": f"vocabulary/{inp.domain}", "projection_hash": voc_hash},
137
+ "canonical": {"path": f"canonical/{inp.domain}", "projection_hash": can_hash},
138
+ "trust": {"path": f"trust/{inp.domain}",
139
+ "attestation_hash": att_hash,
140
+ "tokenized_projection_hash": tok_hash},
141
+ },
142
+ }
143
+
144
+
145
+ # ---------------------------------------------------------------------------
146
+ # Composite hash — content-derived, deterministic (identity view only)
147
+ # ---------------------------------------------------------------------------
148
+
149
+ def _identity_view(domains: list[dict]) -> list[dict]:
150
+ """
151
+ The identity view of domains[] — the ONLY input to the composite hash.
152
+
153
+ Per contract: (domain, tokenized.projection_hash, vocabulary.projection_hash,
154
+ canonical.projection_hash, trust.attestation_hash, graph_address_hash), domains sorted
155
+ by name. Excludes provenance, timestamps, and file paths.
156
+
157
+ `canonical` is here because the other four are all graph-derived, and STRUCTURE artifacts
158
+ never enter the semantic graph — they are read as build configuration and materialized. Without
159
+ canonical, a STRUCTURE artifact could change inside a sealed snapshot while the identity stayed
160
+ byte-identical and every integrity check still passed. STRUCTURE is the configuration authority
161
+ for the whole system, so that is the one class of artifact the identity could least afford to
162
+ miss. The hash is the compiler's own statement, lifted verbatim like the rest.
163
+ """
164
+ view = [
165
+ {
166
+ "domain": d["domain"],
167
+ "tokenized_projection_hash": d["projections"]["tokenized"]["projection_hash"],
168
+ "vocabulary_projection_hash": d["projections"]["vocabulary"]["projection_hash"],
169
+ "canonical_projection_hash": d["projections"]["canonical"]["projection_hash"],
170
+ "attestation_hash": d["projections"]["trust"]["attestation_hash"],
171
+ "graph_address_hash": d["graph_address_hash"],
172
+ }
173
+ for d in domains
174
+ ]
175
+ return sorted(view, key=lambda e: e["domain"])
176
+
177
+
178
+ # Written into the snapshot tree AFTER sealing, and therefore not constituents of what was sealed.
179
+ #
180
+ # `3b` §3: sealing constitutes the snapshot, and a representation that changed after sealing was not
181
+ # sealed. A conformance result names the snapshot_id, so it cannot exist before the identity does —
182
+ # it is a determination ABOUT the snapshot, in the sense `3b` §7 means, and evidence rather than
183
+ # content. That it is written inside the tree is a placement defect recorded in Task D; excluding it
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/",)
186
+
187
+
188
+ def _is_post_seal(rel: str) -> bool:
189
+ return rel == "manifest.json" or rel.startswith(POST_SEAL)
190
+
191
+
192
+ # What an attestation records rather than constitutes, per
193
+ # `cryptographic_trust::CONSTITUTION_CRYPTOGRAPHIC_TRUST_V0`. The projection binding and the value
194
+ # over it are enforced by the runtime at boot and constitute the composition like any other content;
195
+ # when the signing happened records something *about* it, is read by nothing, and changes on every
196
+ # build. Counting it made a composition's identity a function of when it was built — two compiles of
197
+ # unchanged source differing in one microsecond timestamp, and every pin in the workspace expiring on
198
+ # the next rebuild.
199
+ #
200
+ # Excluding the file instead was refused: that would drop the enforced binding from the identity,
201
+ # which weakens it in the direction opposite to the fix. So the exclusion is of a field, and it is
202
+ # named here rather than inferred, exactly as the two whole-file exclusions above are.
203
+ ATTESTATION_ACCOMPANIES = ("signed_at",)
204
+
205
+
206
+ def _constituent_bytes(path: Path, rel: str) -> bytes:
207
+ """A file's bytes as the identity takes them, with what merely accompanies removed.
208
+
209
+ Only the attestation carries an accompanying field today. Every other file enters the identity
210
+ exactly as it sits on disk, and the re-serialization below is confined to the one file that
211
+ needs it so that nothing else acquires a canonical form it did not have.
212
+ """
213
+ raw = path.read_bytes()
214
+ if not rel.endswith("structure_attestation.json"):
215
+ return raw
216
+ record = json.loads(raw)
217
+ for field in ATTESTATION_ACCOMPANIES:
218
+ record.pop(field, None)
219
+ return json.dumps(record, indent=2, sort_keys=True).encode("utf-8")
220
+
221
+
222
+ def enumerate_constituents(out_root: Path) -> list[dict[str, str]]:
223
+ """Every file the snapshot carries as itself, with an integrity value over its bytes.
224
+
225
+ Total by construction: the tree is walked, not a list maintained. Two files are excluded, both
226
+ stated: `manifest.json` is the self-description doing the enumerating (SN-6 covers it by the
227
+ identity being taken over this list plus the description's determinative fields), and POST_SEAL
228
+ material was written after the snapshot was constituted.
229
+
230
+ One *field* is excluded, and the ground is the same one at a finer grain: an attestation's record
231
+ of when it was signed accompanies the composition rather than constituting it.
232
+ """
233
+ out: list[dict[str, str]] = []
234
+ for path in sorted(out_root.rglob("*")):
235
+ if not path.is_file():
236
+ continue
237
+ rel = path.relative_to(out_root).as_posix()
238
+ if _is_post_seal(rel):
239
+ continue
240
+ payload = _constituent_bytes(path, rel)
241
+ out.append({"path": rel, "sha256": hashlib.sha256(payload).hexdigest()})
242
+ return out
243
+
244
+
245
+ def compute_snapshot_identity(domains: list[dict], constituents: list[dict], profile: str) -> str:
246
+ """The snapshot's identity, derived from content and covering every constituent (SN-2, SN-6).
247
+
248
+ Three inputs, and each is there for a stated reason:
249
+
250
+ * the domains identity view — the semantic identity of what was compiled;
251
+ * every constituent, by path and by a hash OF ITS BYTES — so that changing any carried file
252
+ changes the identity, which is SN-2's totality clause;
253
+ * 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).
255
+
256
+ Provenance is excluded and that exclusion is declared in the manifest's `identity_covers`.
257
+ It is observational content (`3e` §5): `assembled_at` differs between two assemblies of identical
258
+ material, and including it would put GC-9 (same declarations, same identity) in conflict with
259
+ SN-6. What SN-6 requires covered is the description's determinative content, which this is.
260
+ """
261
+ payload = json.dumps(
262
+ {"domains": _identity_view(domains), "constituents": constituents, "profile": profile},
263
+ sort_keys=True, separators=(",", ":"),
264
+ )
265
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
266
+
267
+
268
+ # --- the claimed profile, evaluated -------------------------------------------------------------
269
+ #
270
+ # SN-5 requires a snapshot to claim a profile and SN-7 requires the claim to be evaluable. The
271
+ # realization satisfied the first and not the second: assembly recorded the identity and nothing
272
+ # ever read the profile it named. Two profiles sat side by side, one of which resolved twelve of its
273
+ # thirty-five required artifacts because a namespace migration had moved them, and **nothing
274
+ # distinguished them, because nothing read either.**
275
+ #
276
+ # Three axes are verifiable against an assembled snapshot and the profile says which: the governance
277
+ # artifacts it requires, the artifact kinds it admits, and the workload entry points it names. The
278
+ # component-capability axes are declared and unverifiable until each component emits a capability
279
+ # declaration, which the profile also says. Checking the three that can be checked is the difference
280
+ # between a claim and a conformance contract.
281
+
282
+ PROFILE_ROOT_ENV = "PGC_SNAPSHOT_PROFILES"
283
+ _DEFAULT_PROFILE_ROOT = ".github/snapshot_profiles"
284
+
285
+
286
+ def _profile_root() -> Path:
287
+ """Where profiles are read from.
288
+
289
+ Declared rather than derived. A profile is external to what it constrains (NP-7), and while that
290
+ externality is authorship rather than storage today, a path guessed from the assembler's own
291
+ location would make it neither.
292
+ """
293
+ import os
294
+ declared = os.environ.get(PROFILE_ROOT_ENV)
295
+ if declared:
296
+ return Path(declared)
297
+ here = Path(__file__).resolve()
298
+ for parent in here.parents:
299
+ candidate = parent / _DEFAULT_PROFILE_ROOT
300
+ if candidate.is_dir():
301
+ return candidate
302
+ raise RuntimeError(
303
+ f"no profile root found; set {PROFILE_ROOT_ENV} to the directory holding snapshot profiles")
304
+
305
+
306
+ def _profile_declaration(identity: str) -> dict[str, Any]:
307
+ """The `snapshot_profile` block of the named profile.
308
+
309
+ Read by identity, not by filename: a profile is named by what it declares itself to be, and a
310
+ file that happens to carry the name is not the same fact.
311
+ """
312
+ import re
313
+ import yaml
314
+ root = _profile_root()
315
+ for path in sorted(root.glob("*.md")):
316
+ for block in re.findall(r"```yaml\n(.*?)```", path.read_text(encoding="utf-8"), re.S):
317
+ try:
318
+ parsed = yaml.safe_load(block) or {}
319
+ except yaml.YAMLError:
320
+ continue
321
+ declared = parsed.get("snapshot_profile")
322
+ if isinstance(declared, dict) and declared.get("identity") == identity:
323
+ return declared
324
+ raise RuntimeError(
325
+ f"snapshot claims profile {identity!r} and no profile of that identity was found under "
326
+ f"{root} — a claim nobody can read is not a claim (3b SN-7)")
327
+
328
+
329
+ def verify_profile(out_root: Path, profile: str) -> list[str]:
330
+ """What the claimed profile requires and the composition does not carry.
331
+
332
+ Returns the unmet requirements. Empty is conformance on the three axes a profile states are
333
+ verifiable; it is not conformance on the four it states are not.
334
+ """
335
+ declared = _profile_declaration(profile)
336
+ present: set[str] = set()
337
+ kinds: set[str] = set()
338
+ for path in (out_root / "canonical").rglob("*.json"):
339
+ record = _read_json(path)
340
+ fqdn = record.get("fqdn") or record.get("fqdn_id")
341
+ if fqdn:
342
+ present.add(fqdn)
343
+ kind = (record.get("frontmatter") or {}).get("artifact_kind") or record.get("artifact_type")
344
+ if kind:
345
+ kinds.add(kind)
346
+
347
+ unmet: list[str] = []
348
+ governance = declared.get("required_governance") or {}
349
+ for fqdn in governance.get("artifacts") or ():
350
+ if fqdn not in present:
351
+ unmet.append(f"required governance artifact absent: {fqdn}")
352
+ for kind in governance.get("artifact_kinds") or ():
353
+ if kind not in kinds:
354
+ unmet.append(f"required artifact kind carried by nothing: {kind}")
355
+ for fqdn in (declared.get("required_workloads") or {}).get("entry_workflows") or ():
356
+ if fqdn not in present:
357
+ unmet.append(f"required workload entry point absent: {fqdn}")
358
+ return unmet
359
+
360
+
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
+ # ---------------------------------------------------------------------------
368
+ # Cross-domain FQDN uniqueness (per-domain vocabularies are independent address namespaces)
369
+ # ---------------------------------------------------------------------------
370
+
371
+ def _check_address_space(domains: list[DomainInput]) -> None:
372
+ """
373
+ Composition safety check across domains.
374
+
375
+ Integer addresses are DOMAIN-LOCAL: the runtime loads each domain's own
376
+ `vocabulary/<domain>/{forward,reverse}.json` and resolves within it, so the same integer
377
+ address legitimately recurs across independent domains (platform 0x0000 ≠ workload 0x0000).
378
+ Cross-domain address reuse is therefore NOT a collision — it is the expected, isolated model.
379
+
380
+ What WOULD break composition is the same FQDN owned by two domains (ambiguous ownership). That
381
+ is what we guard here. True cross-domain *shared addressing* (reconciled composite forward/reverse
382
+ maps) only becomes necessary when a domain references another domain's artifacts by shared
383
+ address — deferred until the first cross-domain reference (see the assembly contract).
384
+ """
385
+ # Compiler-internal graph vocabulary is shared infrastructure present in every domain — not
386
+ # domain-owned artifacts. Only artifact FQDNs carry ownership.
387
+ system_ns = {"edge_kind", "node_kind", "outcome", "transition"}
388
+ # Platform-provided capabilities are legitimately CONSUMED cross-domain: a domain that invokes a
389
+ # platform CS/CT carries its execution binding (Option A "static link"), so the same
390
+ # capability_* FQDN appears in the owner (platform) AND every consumer. That is expected sharing,
391
+ # not redeclaration — the domain's OWN artifacts live in its own namespace and stay single-owned.
392
+ shared_ns = system_ns | {"capability_side_effects", "capability_transforms"}
393
+ owner: dict[str, str] = {} # fqdn -> domain
394
+ for inp in domains:
395
+ forward = _read_json(inp.source_root / "vocabulary" / inp.domain / "forward.json")
396
+ for fqdn in forward.values():
397
+ if "::" not in fqdn or fqdn.split("::", 1)[0] in shared_ns:
398
+ continue # shared infrastructure / platform-provided capability — not domain-owned
399
+ if fqdn in owner and owner[fqdn] != inp.domain:
400
+ raise AssemblyError(
401
+ f"FQDN ownership conflict: {fqdn!r} present in both "
402
+ f"'{owner[fqdn]}' and '{inp.domain}'. A domain must not redeclare another's artifact."
403
+ )
404
+ owner.setdefault(fqdn, inp.domain)
405
+
406
+
407
+ # ---------------------------------------------------------------------------
408
+ # Provenance (metadata only — never enters the composite hash)
409
+ # ---------------------------------------------------------------------------
410
+
411
+ def _git_repo_root(path: Path) -> Path | None:
412
+ try:
413
+ out = subprocess.run(
414
+ ["git", "-C", str(path), "rev-parse", "--show-toplevel"],
415
+ capture_output=True, text=True, check=True,
416
+ )
417
+ return Path(out.stdout.strip())
418
+ except (subprocess.CalledProcessError, FileNotFoundError):
419
+ return None
420
+
421
+
422
+ def _git_commit(repo_root: Path | None) -> str:
423
+ if repo_root is None:
424
+ return "unknown"
425
+ try:
426
+ out = subprocess.run(
427
+ ["git", "-C", str(repo_root), "rev-parse", "HEAD"],
428
+ capture_output=True, text=True, check=True,
429
+ )
430
+ return out.stdout.strip()
431
+ except (subprocess.CalledProcessError, FileNotFoundError):
432
+ return "unknown"
433
+
434
+
435
+ def _build_provenance(inputs: list[DomainInput], domains: list[dict]) -> dict[str, Any]:
436
+ source_commits: dict[str, str] = {}
437
+ for inp in inputs:
438
+ if inp.repo_root is not None:
439
+ source_commits[inp.repo_root.name] = _git_commit(inp.repo_root)
440
+ compiler_versions = {d["domain"]: d["compiler_version"] for d in domains}
441
+ return {
442
+ "assembler_version": ASSEMBLER_VERSION,
443
+ "assembled_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
444
+ "source_commits": source_commits,
445
+ "compiler_versions": compiler_versions,
446
+ }
447
+
448
+
449
+ # ---------------------------------------------------------------------------
450
+ # Assemble
451
+ # ---------------------------------------------------------------------------
452
+
453
+ def assemble(source_roots: list[Path], out_root: Path, profile: str) -> dict[str, Any]:
454
+ """
455
+ Assemble the domains under source_roots into out_root; write manifest.json.
456
+
457
+ `profile` is the profile identity this snapshot claims (SN-5). It is required: a snapshot that
458
+ claims none cannot have clause 4 of `3b` §7 evaluated about it, and acceptance would be
459
+ establishing three of four conditions while reporting success.
460
+
461
+ Claiming a profile is not the same as the profile being external to what claims it (SN-7, NP-7).
462
+ That is authorship rather than declaration and is not settled here.
463
+
464
+ Returns the manifest dict. Regenerates the projection tree (build product); manifest.json
465
+ is the committed identity.
466
+ """
467
+ if not profile:
468
+ raise AssemblyError(
469
+ "no profile claimed — a snapshot must claim the profile it asks to be evaluated "
470
+ "against (3b SN-5). Pass --profile or set PGC_SNAPSHOT_PROFILE."
471
+ )
472
+ inputs = discover_domains(source_roots)
473
+ _check_address_space(inputs)
474
+
475
+ # --- lift per-domain identity (with compiler cross-checks) ---
476
+ domains = [_domain_identity(inp) for inp in inputs]
477
+
478
+ # --- regenerate the assembled projection tree ---
479
+ # Compose EVERY projection kind the compiler emitted (tokenized / trust / vocabulary / evidence /
480
+ # canonical / behavior_logic / …), each domain-scoped, so the consolidated snapshot is the single
481
+ # central inspection location. Two source shapes are handled:
482
+ # * domain-scoped <kind>/<domain>/… (tokenized, trust, vocabulary, evidence) → <kind>/<domain>
483
+ # * flat <kind>/… (canonical by type, behavior_logic by WF) → <kind>/<domain>
484
+ # Only tokenized/vocabulary/trust feed the composite identity; the rest are supplementary.
485
+ kinds: set[str] = set()
486
+ for inp in inputs:
487
+ for child in inp.source_root.iterdir():
488
+ if child.is_dir():
489
+ kinds.add(child.name)
490
+
491
+ for kind in kinds:
492
+ dst_kind = out_root / kind
493
+ if dst_kind.exists():
494
+ shutil.rmtree(dst_kind)
495
+
496
+ for inp in inputs:
497
+ for kind in kinds:
498
+ src_scoped = inp.source_root / kind / inp.domain # domain-scoped in source
499
+ src_flat = inp.source_root / kind # flat in source
500
+ _ignore = shutil.ignore_patterns(".DS_Store")
501
+ if src_scoped.is_dir():
502
+ shutil.copytree(src_scoped, out_root / kind / inp.domain, ignore=_ignore)
503
+ elif src_flat.is_dir():
504
+ shutil.copytree(src_flat, out_root / kind / inp.domain, ignore=_ignore)
505
+
506
+ # --- cross-domain query indexes over the composed snapshot (inspection; not identity) ---
507
+ from assembler.indexes import (
508
+ build_artifact_index,
509
+ build_kind_index,
510
+ build_store_index,
511
+ write_index,
512
+ )
513
+ write_index(out_root, "artifact_index/index.json", build_artifact_index(out_root))
514
+ write_index(out_root, "kind_index/index.json", build_kind_index(out_root))
515
+ write_index(out_root, "store_index/index.json", build_store_index(out_root))
516
+
517
+ # --- governance provenance: a domain must have been compiled against the governance it claims ---
518
+ _verify_governance_provenance(out_root)
519
+
520
+ # --- self-description: identity, constituents, integrity, provenance, claimed profile ---
521
+ # `3b` §6 requires all five, with the enumeration total. It was four per domain; six top-level
522
+ # constituents were carried and enumerated nowhere, which under §6 is undeclared content.
523
+ constituents = enumerate_constituents(out_root)
524
+ snapshot_id = compute_snapshot_identity(domains, constituents, profile)
525
+ manifest = {
526
+ "manifest_version": MANIFEST_VERSION,
527
+ "snapshot_id": snapshot_id,
528
+ "composite_hash": snapshot_id,
529
+ # What the identity is taken over, declared so a party who did not build this snapshot can
530
+ # recompute it without reading the assembler (AI-16). Provenance is excluded as observational
531
+ # content (`3e` §5) — see compute_snapshot_identity.
532
+ "identity_covers": ["domains", "constituents", "profile"],
533
+ # Written after sealing and therefore outside what was sealed. Declared rather than silently
534
+ # skipped, so a party recomputing the identity knows exactly what to exclude (AI-16).
535
+ "post_seal": list(POST_SEAL),
536
+ "profile": profile,
537
+ "domains": domains,
538
+ "constituents": constituents,
539
+ "provenance": _build_provenance(inputs, domains),
540
+ }
541
+
542
+ out_root.mkdir(parents=True, exist_ok=True)
543
+ (out_root / "manifest.json").write_text(
544
+ json.dumps(manifest, indent=2, sort_keys=False) + "\n", encoding="utf-8"
545
+ )
546
+ return manifest
547
+
548
+
549
+ # ---------------------------------------------------------------------------
550
+ # Governance provenance — a domain was checked against the governance it records
551
+ # ---------------------------------------------------------------------------
552
+
553
+ # Kinds a domain build instantiates. MUST match the compiler's _inject_imported_governance
554
+ # filter (compiler/stages/s1_extract.py): a governance invariant is imported into a domain iff
555
+ # 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"})
557
+
558
+
559
+ def _recompute_governance_closure(out_root: Path, source_domain: str) -> tuple[str, int]:
560
+ """Recompute the normative-closure hash from an assembled domain's canonical governance.
561
+
562
+ Mirrors the compiler's closure exactly — see `s1_extract._inject_imported_governance`, whose
563
+ `closure_sources` this must track member-for-member:
564
+
565
+ * invariants — domain-applicable (applies_to_kinds intersects the instantiated set) and not
566
+ surface-scoped
567
+ * vocabulary — all of it, unfiltered: a vocabulary has no subject to intersect, and it is
568
+ the language the domain is written in
569
+
570
+ Drift between this and the compiler is caught by the stage-5 mutation test, which asserts the
571
+ two agree on an unchanged closure — and by assembly itself, which fails closed on a count or
572
+ hash mismatch rather than assembling a domain checked against different governance.
573
+ """
574
+ canonical = out_root / "canonical" / source_domain
575
+ members: list[tuple[str, str]] = []
576
+
577
+ inv_dir = canonical / "invariants"
578
+ if inv_dir.is_dir():
579
+ for path in inv_dir.glob("*.json"):
580
+ raw = _read_json(path)
581
+ proj = (raw.get("frontmatter", {}) or {}).get("assert_projection", {}) or {}
582
+ kinds = set(proj.get("applies_to_kinds", []) or [])
583
+ if not (kinds & _DOMAIN_INSTANTIATED):
584
+ continue
585
+ if (proj.get("scope", {}) or {}).get("applies_to"):
586
+ continue # surface-specific — not generically imported
587
+ members.append((raw.get("fqdn_id", ""), raw.get("content_hash", "")))
588
+
589
+ vocab_dir = canonical / "vocabulary"
590
+ if vocab_dir.is_dir():
591
+ for path in vocab_dir.glob("*.json"):
592
+ raw = _read_json(path)
593
+ members.append((raw.get("fqdn_id", ""), raw.get("content_hash", "")))
594
+
595
+ members.sort()
596
+ h = hashlib.sha256()
597
+ for fqdn, content_hash in members:
598
+ h.update(fqdn.encode("utf-8")); h.update(b"\x00")
599
+ h.update((content_hash or "").encode("utf-8")); h.update(b"\x00")
600
+ return h.hexdigest(), len(members)
601
+
602
+
603
+ def _verify_governance_provenance(out_root: Path) -> None:
604
+ """Fail if any domain's recorded governance closure disagrees with the assembled source.
605
+
606
+ A domain attestation may carry `imported_governance` (the governance it was compiled against).
607
+ Recompute that closure from the source domain present in this assembly; a mismatch means the
608
+ domain was compiled against different governance than is being assembled — stale, fail closed.
609
+ """
610
+ trust_root = out_root / "trust"
611
+ if not trust_root.is_dir():
612
+ return
613
+ for att_path in sorted(trust_root.glob("*/structure_attestation.json")):
614
+ att = _read_json(att_path)
615
+ recorded = att.get("imported_governance")
616
+ if not recorded:
617
+ continue
618
+ source = recorded.get("import_domain", "")
619
+ expected = recorded.get("governance_closure_hash", "")
620
+ actual, count = _recompute_governance_closure(out_root, source)
621
+ if actual != expected:
622
+ raise AssemblyError(
623
+ f"[{att.get('structure_id')}] governance provenance mismatch: attestation records "
624
+ f"closure {expected[:16]}… over '{source}' ({recorded.get('closure_member_count')} members), "
625
+ f"but the assembled '{source}' surface yields {actual[:16]}… ({count}). "
626
+ f"The domain was compiled against different governance than is being assembled — recompile it."
627
+ )
628
+
629
+
630
+ # ---------------------------------------------------------------------------
631
+ # Verify — the boot-time root-of-trust check, reusable by the runtime
632
+ # ---------------------------------------------------------------------------
633
+
634
+ def _verify_copies_agree(out_root: Path) -> None:
635
+ """Every copy of one artifact identity in the composition must be identical.
636
+
637
+ A platform artifact is compiled into each domain's own output and the assembler collects them
638
+ all, so one identity exists in the snapshot N times — `capability_side_effects::CS_MUTABLE_JSON_V0`
639
+ exists five times today. Nothing checked that the copies agreed, and they can disagree easily:
640
+ editing a governance artifact and recompiling one domain leaves every other domain carrying the
641
+ previous version. That composition assembled, reported conformance PASSED over 376 artifacts and
642
+ round-trip verified OK, while the published capability surface answered from a stale copy.
643
+
644
+ Compared by content_hash, which the compiler already writes per artifact.
645
+ """
646
+ seen: dict[str, dict[str, list[str]]] = {}
647
+ for path in sorted((out_root / "canonical").glob("*/*/*.json")):
648
+ if path.name == "metadata.json":
649
+ continue
650
+ doc = _read_json(path)
651
+ fqdn = doc.get("fqdn_id")
652
+ digest = doc.get("content_hash")
653
+ if not fqdn or not digest:
654
+ continue
655
+ seen.setdefault(fqdn, {}).setdefault(digest, []).append(str(path.relative_to(out_root)))
656
+
657
+ disagreeing = {fqdn: copies for fqdn, copies in seen.items() if len(copies) > 1}
658
+ if disagreeing:
659
+ detail = "; ".join(
660
+ f"{fqdn} differs across {sum(len(v) for v in copies.values())} copies "
661
+ f"({len(copies)} distinct versions: "
662
+ + ", ".join(sorted(paths[0] for paths in copies.values())) + ")"
663
+ for fqdn, copies in sorted(disagreeing.items())
664
+ )
665
+ raise AssemblyError(
666
+ f"composition holds disagreeing copies of {len(disagreeing)} artifact identity(ies) — "
667
+ f"recompile every domain after a governance edit: {detail}"
668
+ )
669
+
670
+
671
+ def verify_snapshot(out_root: Path) -> dict[str, Any]:
672
+ """Acceptance. Establish that what is in hand is what was determined.
673
+
674
+ Construction establishes admissibility; acceptance establishes correspondence, and correspondence
675
+ is a property of the carried bytes rather than of the act that produced them. `3b` §7 names four
676
+ conditions and every one is established here FROM CONTENT:
677
+
678
+ 1. integrity — each constituent's hash recomputed from its bytes, compared to the
679
+ self-description's value for it
680
+ 2. totality — every file present is enumerated, every file enumerated is present
681
+ 3. identity — recomputed from the RECOMPUTED constituent hashes, compared to the identity
682
+ borne. Recomputing over recorded hashes detects a tampered manifest; only
683
+ recomputing over recomputed hashes detects a tampered constituent
684
+ 4. profile — the snapshot claims one
685
+
686
+ This previously compared recorded values to recorded values — manifest against metadata.json
687
+ against attestation — which is transitive from construction and establishes nothing to a party
688
+ who did not build the snapshot (AI-16).
689
+
690
+ On any failure the snapshot is refused whole (SN-9). There is no partial acceptance.
691
+ """
692
+ manifest = _read_json(out_root / "manifest.json")
693
+ domains = manifest.get("domains", [])
694
+
695
+ profile = manifest.get("profile") or ""
696
+ if not profile:
697
+ raise AssemblyError(
698
+ "snapshot claims no profile — clause 4 of 3b §7 cannot be evaluated about it (SN-5)."
699
+ )
700
+
701
+ declared = manifest.get("constituents")
702
+ if declared is None:
703
+ raise AssemblyError(
704
+ "manifest enumerates no constituents — it is a provenance record rather than a "
705
+ "self-description, and integrity cannot be established per constituent (3b §6, SN-5)."
706
+ )
707
+
708
+ on_disk = {
709
+ rel for rel in (
710
+ p.relative_to(out_root).as_posix() for p in out_root.rglob("*") if p.is_file()
711
+ ) if not _is_post_seal(rel)
712
+ }
713
+ enumerated = {c["path"] for c in declared}
714
+ undeclared = sorted(on_disk - enumerated)
715
+ missing = sorted(enumerated - on_disk)
716
+ if undeclared:
717
+ raise AssemblyError(
718
+ f"undeclared content: {len(undeclared)} file(s) present and enumerated nowhere — "
719
+ f"e.g. {undeclared[:3]}. A constituent absent from the self-description must be "
720
+ f"refused at acceptance (3b §6)."
721
+ )
722
+ if missing:
723
+ raise AssemblyError(
724
+ f"missing content: {len(missing)} file(s) enumerated and absent — e.g. {missing[:3]}."
725
+ )
726
+
727
+ recomputed: list[dict[str, str]] = []
728
+ for entry in declared:
729
+ # The same bytes the identity was taken over, not the bytes on disk — an attestation's
730
+ # record of when it was signed accompanies the composition and is excluded from both.
731
+ actual = hashlib.sha256(
732
+ _constituent_bytes(out_root / entry["path"], entry["path"])).hexdigest()
733
+ if actual != entry["sha256"]:
734
+ raise AssemblyError(
735
+ f"integrity failure at {entry['path']}: content hashes to {actual[:16]}… but the "
736
+ f"self-description carries {entry['sha256'][:16]}… for it (3b §7 clause 1)."
737
+ )
738
+ recomputed.append({"path": entry["path"], "sha256": actual})
739
+
740
+ # The claim, evaluated. SN-5 is satisfied by claiming a profile; SN-7 asks whether the claim
741
+ # holds, and until now nothing asked. A snapshot claiming a profile whose required artifacts it
742
+ # does not carry is a snapshot asserting a conformance it does not have.
743
+ unmet = verify_profile(out_root, profile)
744
+ if unmet:
745
+ raise AssemblyError(
746
+ f"snapshot claims {profile} and does not satisfy it — "
747
+ + "; ".join(unmet[:5])
748
+ + (f"; and {len(unmet) - 5} more" if len(unmet) > 5 else ""))
749
+
750
+ derived = compute_snapshot_identity(domains, recomputed, profile)
751
+ borne = manifest.get("snapshot_id")
752
+ if derived != borne:
753
+ raise AssemblyError(
754
+ f"identity failure: derived {derived[:16]}… from content, snapshot bears "
755
+ f"{str(borne)[:16]}… (3b §7 clause 2, SN-2)."
756
+ )
757
+
758
+ # Copies of one identity across domains must agree (GC-12) — a composition obligation rather
759
+ # than correspondence, and not subsumed by the four conditions above.
760
+ _verify_copies_agree(out_root)
761
+
762
+ return manifest