Phase 0 Research — expected-artifacts-loader-unification

Consolidated from two research scouts + a two-lens post-spec adversarial squad. Every decision below is anchored to file:line evidence.

D1 — Home for the relocated authority: charter/activation/manifest_loader.py

ManifestSchemaError into charter/offering/missions/repository.py beside the already-charter-resident MalformedManifestError (repository.py:38).

(charter/activation/org_expected_artifacts.py:54), resolve_existing_org_roots (charter/offering/drg/org_pack_config.py:571), MissionTemplateRepository (charter/offering/missions/repository.py:145), and the model (.../expected_artifact_manifest.py:87). Activation may import offering — proven by mission_type_profiles.py:975,1120,1130. Offering must NOT import activation (only a TYPE_CHECKING mention exists, mission_step_repository.py:44), so the loader cannot live in offering/missions. Activation is the correct layer.

offering→activation for org resolution. (b) A dedicated top-level charter/manifest/ — rejected as premature; activation already owns the org-precedence collaborators.

  • Decision: Put the cached loader function in charter/activation/. Move
  • Rationale: The loader needs resolve_org_expected_artifacts
  • Alternatives: (a) offering/missions home — rejected, would need

D2 — Sibling error model (the load-bearing post-spec correction)

fail-loud channel for present-but-unparseable manifests (YAML-syntax, non-mapping, and present-but-unreadable) on BOTH tiers. ManifestSchemaError is the sibling for schema/extra="forbid" violations. Both are distinct from None="not found". The malformation channel is NOT canonicalized on ManifestSchemaError.

1. C-001 — a charter-resident loader cannot raise a specify_cli-resident ManifestSchemaError (specify_cli/dossier/manifest.py:104) without charter importing specify_cli. MalformedManifestError is already in charter. 2. Symmetry — the built-in tier already raises MalformedManifestError for YAML-syntax (repository.py:411-412, 1763bf2ae3); the org tier must match. 3. Message correctnessManifestSchemaError.__str__ says "schema-invalid" (manifest.py:149-155), actively misleading for a parse fault; its own docstring (manifest.py:106-108) excludes YAML-syntax.

it for the schema case), re-exported via the shim.

ManifestSchemaError (FR-008 v1); corrected to the sibling model.

  • Decision: MalformedManifestError (charter, repository.py:38) is the
  • Rationale: Three independent forces converge (both squad lenses found this):
  • Consequence: ManifestSchemaError still moves to charter (the loader raises
  • Adversarial disposition: changed — the spec originally canonicalized on

D3 — ManifestRegistry stays; charter owns a loader function

as a thin delegate; the charter authority is a function carrying its own _cache. ManifestRegistry's sibling completeness methods (get_required/blocking/optional_artifacts, validate_manifest, clear_cache) do NOT move.

specify_cli (dossier_pipeline.py:361, reconcile.py:158, rebaseline.py:346, + doc) as Indexer(ManifestRegistry()); moving it wholesale would drag specify_cli-owned completeness logic into charter. Only the load+cache concern relocates.

dossier-completeness logic; larger blast radius).

  • Decision: ManifestRegistry remains in specify_cli/dossier/manifest.py
  • Rationale: ManifestRegistry is a stateful class instantiated 4× in
  • Alternative: move the whole class — rejected (charter would host
  • Adversarial disposition: accepted (scope lens SEV-3).

D4 — Shim re-export surface

(kept), load_manifest (delegate), ManifestSchemaError, and MalformedManifestError.

(sync/namespace.py:102, sync/dossier_pipeline.py:363, 6 tests). Omitting it from the shim breaks every catch site on import.

  • Decision: specify_cli/dossier/manifest.py re-exports ManifestRegistry
  • Rationale: ManifestSchemaError is imported/caught at 8+ specify_cli sites
  • Adversarial disposition: accepted (scope lens SEV-4).

D5 — Close the launder seam by construction (not the model_validate gate)

UnregisteredMissionFamilyError only, plus a positive regression asserting a malformed org manifest through the composed guard propagates (never []).

broadened except (UnregisteredMissionFamilyError, MalformedManifestError) reopens #3412 while the string-gate stays green. The malformed raise fires at gather time (composition.py:486, outside the :502-504 try) and, as a distinct type, is not caught there — failing BEFORE the None-vs-frozenset decision (cores.py:724), so C-002 (tri-state) and C-003 (guard-table) are untouched. Verified non-contradictory by the fail-loud lens.

  • Decision: Pin runtime_bridge_composition.py:504's except to
  • Rationale: The model_validate arch-gate cannot police the guard seam; a
  • Adversarial disposition: changed — added FR-010 durability gate.

D6 — Delete from_yaml_file (do not "route")

(expected_artifact_manifest.py:130) and migrate its 3 tests (test_manifest.py:458/472/486) to the canonical loader.

model_validate( string-gate cannot police. The arch-gate is therefore broadened to also forbid bare ExpectedArtifactManifest( construction outside the model's own tests. No production callers exist.

  • Decision: Delete ExpectedArtifactManifest.from_yaml_file
  • Rationale: It constructs via cls(**data) (:152), which the
  • Adversarial disposition: accepted (scope lens SEV-2).

D7 — Widen present-but-unreadable on BOTH tiers (costed, not hidden)

MalformedManifestError on both tiers. This re-touches the built-in reader (repository.py:413-414, which today swallows both to None) — the shipped fix widened only YAMLError.

the unreadable case is NOT covered by the shipped built-in fix, so honoring symmetry forces this — captured as FR-012 rather than an unstated edge case.

  • Decision: OSError/UnicodeDecodeError on a manifest that EXISTS →
  • Rationale: Symmetry (D2) demands both tiers agree; the fail-loud lens showed
  • Adversarial disposition: changed — promoted an edge case to FR-012.

D8 — Red-first tagging hygiene

@pytest.mark.regression: US1-AC1 (org YAML broken), US1-AC4 (launder through composed guard), US1-AC5 (non-mapping org), the org-unreadable case. US1-AC2 (absent degrades), US1-AC3 (built-in YAML — already shipped), and all US2 (consolidation/cache) are characterization (green-stays-green), NOT regression.

034/041; the green-regression-test lesson). Transitional repros do not remain marked regression.

  • Decision: Only genuinely-RED-on-upstream/main scenarios carry
  • Rationale: A green @regression test is a landing defect (charter DIRECTIVE
  • Adversarial disposition: accepted (fail-loud lens SEV-2).

D9 — Non-goal fences confirmed

delegate to charter.drg.resolve_existing_org_roots (#3525) — 1-line wrappers. No WP targets them (removed from scope to prevent a manufactured dedup WP).

guard-table short-circuit (cores.py:721-723, #3386/#3397/#3407) are untouched; malformation fails before both.

  • Org-root resolver "triplication" is NOT dedup work: the three wrappers already
  • blocking_artifact_names None-vs-frozenset() tri-state (#3729) and the

Supply-chain / dependencies

No dependency added, upgraded, or removed. 051-supply-chain-install-safety is N/A for this mission. Silence here is compliance because there is no dependency decision to examine.