From 2a7e5953da68d77e3081ff8a47f86dcd022d5b4d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 9 Oct 2026 23:14:43 -0400 Subject: [PATCH 1/2] Validate transport activation before preparation and decouple functional timing Share resource-selection checks between activation preflight and parameter resolution, and reuse the receipt kernel's data-independent contract validator. Name refused resources and paths before registry, CREATE or source reads. Add refusal spies and verify every packaged activation gap. Record the toy A1/A2 cold-run duration without a host-dependent wall-clock assertion. Update the guide, changelog and docstrings to describe the precise checks. Co-Authored-By: GPT-6.1 Sol --- changelog.d/transport-compose-cli.added.md | 15 ++- docs/agent-guide.md | 15 ++- .../src/microcosm/build/transport/cli.py | 6 +- .../build/transport/column_kernels.py | 95 ++++++++++++------ .../src/microcosm/build/transport/compose.py | 82 ++++++++++------ .../shared/test_transport_column_kernels.py | 45 +++++++++ .../shared/test_transport_composed_graph.py | 98 ++++++++++++++++++- 7 files changed, 285 insertions(+), 71 deletions(-) diff --git a/changelog.d/transport-compose-cli.added.md b/changelog.d/transport-compose-cli.added.md index 81b902e149..3f78eb3878 100644 --- a/changelog.d/transport-compose-cli.added.md +++ b/changelog.d/transport-compose-cli.added.md @@ -6,9 +6,18 @@ router, and later packages append branches through a typed extension point that cannot re-key the skeleton. The driver runs ancestor-closed checkpoints on `/.graph-store` and writes every output under `--out`; it refuses an existing store tree that contains a link, which could redirect store writes. -Activation checks every selected resource and reference set and every null -scenario knob before donor preparation. Calibration ancestry refuses hold-out -selections and sources, benefit-unit engine references, and graph digests. +Activation checks run before registry preparation, CREATE or any graph source +read. Declaration selections must name present resources; value and JSON +selections must resolve existing paths to non-null selected values. Parameter +resolution shares these checks: JSON selections require objects, while value +selections reject objects. Optional null fields within selected JSON remain +allowed. Preflight also checks the dependent-child age limit, mandatory and +selected reference activation rows, required engine commit and wheel pin +presence, and every null scenario knob. The receipt kernel's shared validator +checks contract fields, required text, program and exclusion structure, and +declared rates; checks needing person data, weights or target surfaces run during +receipt assignment. Calibration ancestry refuses hold-out selections and sources, +benefit-unit engine references, and graph digests. Selected resource lists remain literal data. The driver writes deterministic HDF5 container bytes while readback continues to hash the complete file. CREATE inventories are cached by implementation, parameters and source bytes; diff --git a/docs/agent-guide.md b/docs/agent-guide.md index 8bcd7fa1cd..0328d055e1 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -358,9 +358,18 @@ writes, including its `.graph-store`, lies under `--out`, and it has no upload or staging path. Before it opens the store it refuses any link in an existing store tree (the root, `objects`, `tmp` or a shard), because the store creates those directories by following links; it does not guard -against links planted while a build is running. It refuses before reading any graph source while the -country spec still has unresolved evidence: every selected resource/reference -set and every null scenario knob is checked before donor preparation. +against links planted while a build is running. Activation checks run before +registry preparation, CREATE or any graph source read. Every declaration +selection must name a present resource; value and JSON selections must have an +existing path and a non-null selected value. JSON selections require objects, +and value selections reject objects, using the same checks as parameter +resolution. Selected JSON may retain optional null fields. The preflight also +checks the dependent-child age limit, mandatory and selected reference activation +rows, required engine commit and wheel pin presence, and every null scenario +knob. The receipt kernel's shared validator checks the executable receipt +contract's fields, required text, program and exclusion structure, and declared +rates before preparation; checks needing person data, weights or target surfaces +run during receipt assignment. Tests for it run on the engine-free toy package in `test_support/microcosm_build/transport_composed.py`. The rules registry checks module hashes and binds the RuleSpec tree digest. Engine commit and wheel diff --git a/packages/microcosm-build/src/microcosm/build/transport/cli.py b/packages/microcosm-build/src/microcosm/build/transport/cli.py index fac10c0cc1..6c1a29f7cf 100644 --- a/packages/microcosm-build/src/microcosm/build/transport/cli.py +++ b/packages/microcosm-build/src/microcosm/build/transport/cli.py @@ -225,7 +225,11 @@ def prepare_transport_build( ) -> PreparedTransportBuild: """Check activation, build the registry and CREATE inventory, then compose. - Activation is checked before any engine is built or donor read. + Before registry preparation, CREATE or graph source reads, activation + checks declaration resource paths, non-null selected roots and encoding + shapes, the receipt kernel's data-independent contract constraints, + reference activation rows, required age/pin presence and null scenario + knobs. Optional null JSON fields remain allowed. ``sources`` maps every declared source name except the exported dataset (which the run writes) to a local path. ``engines_by_binding`` is the registry's seam for pure-Python adapters; without it, Axiom adapters are diff --git a/packages/microcosm-build/src/microcosm/build/transport/column_kernels.py b/packages/microcosm-build/src/microcosm/build/transport/column_kernels.py index 088b829aea..c385bb0a8c 100644 --- a/packages/microcosm-build/src/microcosm/build/transport/column_kernels.py +++ b/packages/microcosm-build/src/microcosm/build/transport/column_kernels.py @@ -97,6 +97,7 @@ "UnitAttributesKernel", "assign_receipts", "register_column_kernels", + "validate_receipt_contract", ] @@ -494,18 +495,18 @@ def _finite_nonnegative(value: object, name: str) -> float: return value -def assign_receipts( - person: pd.DataFrame, +def validate_receipt_contract( contract: Mapping[str, object], - *, - weights: np.ndarray | None = None, - targets: Mapping[str, float] | None = None, -) -> tuple[pd.DataFrame, dict[str, object]]: - """Apply explicit receipt priorities to determined eligible program draws. - - This shared pure operation contains receipt orchestration only. G4's - judgment decoder and the content layer's stable program draws supply its - semantics. Existing exclusions are read as booleans and never rewritten. +) -> tuple[str, list[dict[str, object]], list | tuple]: + """Validate all receipt contract checks independent of person/target data. + + Return the seed column, parsed programs and exclusion groups without reading + person data, weights or a target surface. Check the exact contract fields, + non-empty seed and required program text, an ordered non-empty program list, + program fields and rate-or-target declarations, distinct program/output + identities, exclusion groups naming distinct declared outputs, optional + column/reference text, distinct exclusion columns, and finite rates in + [0, 1]. A null optional payment column remains allowed. """ if set(contract) != {"seed_column", "programs", "exclusion_groups"}: raise ValueError( @@ -536,7 +537,30 @@ def assign_receipts( raise ValueError("A receipt program declares exactly one rate or target.") if "target_measure" in row and "target" not in row: raise ValueError("A target_measure requires a target reference.") - parsed.append({**row, **{key: _text(row[key], key) for key in required}}) + parsed_row = {**row, **{key: _text(row[key], key) for key in required}} + payment_column = row.get("payment_column") + if payment_column is not None: + parsed_row["payment_column"] = _text(payment_column, "payment_column") + excludes = row.get("exclude_columns", []) + if not isinstance(excludes, list | tuple) or len(set(excludes)) != len( + excludes + ): + raise ValueError("Receipt exclude_columns must be distinct columns.") + parsed_row["exclude_columns"] = [ + _text(column, "exclude column") for column in excludes + ] + if "target" in row: + parsed_row["target"] = _text(row["target"], "target") + if "target_measure" in row: + parsed_row["target_measure"] = _text( + row["target_measure"], "target_measure" + ) + else: + rate = _finite_nonnegative(row["rate"], "rate") + if rate > 1: + raise ValueError("Receipt rate must lie on [0, 1].") + parsed_row["rate"] = rate + parsed.append(parsed_row) outputs = [row["output"] for row in parsed] keys = [row["program"] for row in parsed] if len(set(outputs)) != len(outputs) or len(set(keys)) != len(keys): @@ -549,6 +573,24 @@ def assign_receipts( or not set(group) <= set(outputs) ): raise ValueError("Each receipt exclusion group names distinct outputs.") + return seed_column, parsed, groups + + +def assign_receipts( + person: pd.DataFrame, + contract: Mapping[str, object], + *, + weights: np.ndarray | None = None, + targets: Mapping[str, float] | None = None, +) -> tuple[pd.DataFrame, dict[str, object]]: + """Apply explicit receipt priorities to determined eligible program draws. + + This shared pure operation contains receipt orchestration only. G4's + judgment decoder and the content layer's stable program draws supply its + semantics. Existing exclusions are read as booleans and never rewritten. + The data-independent contract checks use ``validate_receipt_contract``. + """ + seed_column, parsed, groups = validate_receipt_contract(contract) out = person.loc[:, ["person_id"]].copy() audit = {} if weights is not None: @@ -568,19 +610,12 @@ def assign_receipts( ) payment_column = row.get("payment_column") if payment_column is not None: - payment = person[_text(payment_column, "payment_column")].to_numpy( - dtype=np.float64 - ) + payment = person[payment_column].to_numpy(dtype=np.float64) if not np.isfinite(payment).all(): raise ValueError("Receipt payment values must be finite.") eligible &= payment > 0 - excludes = row.get("exclude_columns", []) - if not isinstance(excludes, list | tuple) or len(set(excludes)) != len( - excludes - ): - raise ValueError("Receipt exclude_columns must be distinct columns.") - for column in excludes: - values = person[_text(column, "exclude column")] + for column in row["exclude_columns"]: + values = person[column] if str(values.dtype) not in {"bool", "boolean"} or values.isna().any(): raise ValueError( "Existing receipt exclusions must be non-null booleans." @@ -594,7 +629,7 @@ def assign_receipts( target = None mass = None if weights is None else math.fsum(weights[eligible].tolist()) if "target" in row: - name = _text(row["target"], "target") + name = row["target"] if targets is None or name not in targets or weights is None: raise ValueError( f"Receipt target {name!r} needs a surface and weights." @@ -607,9 +642,7 @@ def assign_receipts( # target <= mass, so a zero eligible mass has a zero target. rate = target / mass if mass else target else: - rate = _finite_nonnegative(row["rate"], "rate") - if rate > 1: - raise ValueError("Receipt rate must lie on [0, 1].") + rate = row["rate"] draws = derive_take_up_draws(person[seed_column].to_numpy(), row["program"]) assigned = eligible & (draws < rate) out[output] = assigned @@ -662,6 +695,7 @@ def run(self, context: KernelContext) -> KernelResult: context, self.ref, ("person",), artifact_aliases=frozenset({"surface"}) ) contract = _document(context, self.ref, "receipt_contract") + _, programs, _ = validate_receipt_contract(contract) targets = None if context.artifacts: if ( @@ -671,14 +705,11 @@ def run(self, context: KernelContext) -> KernelResult: raise ValueError(f"{self.ref} reads only the typed surface artifact.") surface = decode_target_surface(context.artifacts["surface"].payload) targets = {} - programs = contract.get("programs", ()) for row in programs: - if not isinstance(row, Mapping) or "target" not in row: + if "target" not in row: continue - name = _text(row["target"], "target") - expected_measure = _text( - row.get("target_measure", row.get("output")), "target_measure" - ) + name = row["target"] + expected_measure = row.get("target_measure", row["output"]) matches = [ target for target in surface.registry.specs if target.name == name ] diff --git a/packages/microcosm-build/src/microcosm/build/transport/compose.py b/packages/microcosm-build/src/microcosm/build/transport/compose.py index 31d48bd445..d96cde478a 100644 --- a/packages/microcosm-build/src/microcosm/build/transport/compose.py +++ b/packages/microcosm-build/src/microcosm/build/transport/compose.py @@ -42,8 +42,11 @@ version), so an extension can never re-key the skeleton. Composition reads no donor, no facts and no engine, and writes nothing. -Unresolved country evidence is refused up front with every missing item -named (:func:`validate_transport_activation`); no proposed value is chosen. +Activation checks selected resource paths, non-null selected roots and encoding +shapes, the receipt kernel's data-independent contract constraints, reference +activation rows, required age/pin presence and null scenario knobs. Refusals +name every enumerated gap (:func:`validate_transport_activation`). Optional +null JSON fields remain allowed; no proposed value is chosen. """ from __future__ import annotations @@ -80,6 +83,7 @@ from microcosm.graph.canonical import canonical_json from .artifact_types import KERNEL_OUTPUTS +from .column_kernels import validate_receipt_contract __all__ = [ "GRAPH_RESOURCE", @@ -375,8 +379,9 @@ def _selections(value: object) -> Iterator[Mapping]: yield from _selections(item) -def _selected_resources(document: Mapping) -> set[str]: - names = set() +def _declared_resource_selections(document: Mapping) -> Iterator[Mapping]: + """Resource selections in declaration parameters, including nested lists.""" + for row in document.get("nodes", ()): if not isinstance(row, Mapping): continue @@ -386,8 +391,7 @@ def _selected_resources(document: Mapping) -> set[str]: for value in params.values(): for selection in _selections(value): if isinstance(selection.get("resource"), str): - names.add(_stem(selection["resource"])) - return names + yield selection def _null_paths(value: object, path: str = "") -> Iterator[str]: @@ -402,20 +406,31 @@ def _null_paths(value: object, path: str = "") -> Iterator[str]: def validate_transport_activation(spec: Mapping[str, object]) -> None: - """Refuse unresolved declaration resources and country evidence before I/O. - - Check every selected reference resource, the mandatory pre-calibration, - target and hold-out reference sets, and every null scenario knob. Missing - resources are the declaration's explicit selections, including selections - inside parameter lists; selected resource data remains literal data. + """Refuse enumerated activation gaps before registry/CREATE/source reads. + + Resolve declaration resource selections with the same path, non-null root + and encoding checks as parameter resolution. Optional JSON nulls remain + allowed. Reuse the receipt kernel's data-independent contract validator. + Also check dependent-child age and required engine pin presence, mandatory + and selected reference activation rows, and every null scenario knob. + Selections inside parameter lists count; selected resource data is literal. """ missing = [] - selected = ( - _selected_resources(transport_resource(spec, GRAPH_RESOURCE)) + selections = ( + tuple(_declared_resource_selections(transport_resource(spec, GRAPH_RESOURCE))) if _has_resource(spec, GRAPH_RESOURCE) - else set() + else () ) + selected = {_stem(selection["resource"]) for selection in selections} + for selection in selections: + try: + _resource_param(spec, selection) + except (ValueError, TypeError) as error: + missing.append( + f"Resource {selection['resource']!r} path {selection.get('path', [])!r}: " + f"{error}" + ) for name in ("benefit_unit_rule", "axiom_rules_bindings", "scenarios"): if not _has_resource(spec, name): missing.append(f"{name}.json") @@ -458,15 +473,15 @@ def validate_transport_activation(spec: Mapping[str, object]) -> None: contracts = None if receipt is None else receipt.get("receipts") if not isinstance(contracts, Mapping): missing.append( - "receipt_contract.json (the input closure's receipt_requirements " + "receipt_contract.json path ['receipts'] " + "(the input closure's receipt_requirements " "are prose, not an executable contract)" ) - elif any( - not isinstance(row.get("programs"), list) or not row["programs"] - for row in ((contracts,) if "programs" in contracts else contracts.values()) - if isinstance(row, Mapping) - ): - missing.append("receipt_contract.json receipts.programs") + else: + try: + validate_receipt_contract(contracts) + except (ValueError, TypeError) as error: + missing.append(f"receipt_contract.json path ['receipts']: {error}") if _has_resource(spec, "axiom_rules_bindings"): bindings = transport_resource(spec, "axiom_rules_bindings") pins = _mapping(bindings.get("engine", {}), "axiom_rules_bindings.engine") @@ -482,12 +497,6 @@ def validate_transport_activation(spec: Mapping[str, object]) -> None: missing.append(f"scenario {scenario.get('id', '?')} {path}") if not _has_resource(spec, GRAPH_RESOURCE): missing.append(f"{GRAPH_RESOURCE}.json (the skeleton declaration)") - else: - missing.extend( - f"{name}.json (selected by {GRAPH_RESOURCE}.json)" - for name in sorted(selected) - if not _has_resource(spec, name) - ) if missing: raise ValueError("Transport spec is not activated: " + "; ".join(missing) + ".") @@ -818,6 +827,16 @@ def _param( return canonical_json( {"engine_refs": dict(sorted(engine_refs.items()))} ).decode("utf-8") + return _resource_param(spec, value) + + +def _resource_param(spec: Mapping[str, object], value: Mapping) -> object: + """Resolve and validate one literal resource selection for preflight/params. + + Selected roots cannot be null; JSON roots must be objects, and value roots + must not be objects. Optional JSON fields remain unchanged. + """ + if set(value) - {"resource", "path", "encoding"} or "resource" not in value: raise ValueError( "An object parameter must be a resource or prepared selection." @@ -844,11 +863,14 @@ def _param( raise ValueError(f"Resource {name!r} path {list(path)!r} is not activated.") if encoding == "json": if not isinstance(document, Mapping): - raise ValueError(f"Resource {name!r} json selection must be an object.") + raise ValueError( + f"Resource {name!r} path {list(path)!r} json selection must be an object." + ) return canonical_json(document).decode("utf-8") if isinstance(document, Mapping): raise ValueError( - f"Resource {name!r} value selection is an object; use json encoding." + f"Resource {name!r} path {list(path)!r} value selection is an object; " + "use json encoding." ) return _literal_data(document) diff --git a/packages/microcosm-build/tests/engine_free/shared/test_transport_column_kernels.py b/packages/microcosm-build/tests/engine_free/shared/test_transport_column_kernels.py index aacd6a368a..a0bdfa86cd 100644 --- a/packages/microcosm-build/tests/engine_free/shared/test_transport_column_kernels.py +++ b/packages/microcosm-build/tests/engine_free/shared/test_transport_column_kernels.py @@ -26,6 +26,7 @@ TRANSPORT_UNIT_ATTRIBUTES, assign_receipts, register_column_kernels, + validate_receipt_contract, ) from microcosm.build.transport.target_kernels import compile_target_surface from microcosm.frame import Frame, WeightKind, Weights @@ -335,6 +336,50 @@ def _contract(rate=0.6): } +@pytest.mark.parametrize( + ("field", "value", "message"), + [ + ("rate", None, "rate must be a number"), + ("rate", True, "rate must be a number"), + ("rate", -1, "rate must be finite and non-negative"), + ("rate", float("nan"), "rate must be finite and non-negative"), + ("rate", 2, "rate must lie on"), + ("payment_column", "", "payment_column must be non-empty text"), + ("exclude_columns", None, "exclude_columns must be distinct columns"), + ("exclude_columns", ["same", "same"], "exclude_columns must be distinct"), + ("exclude_columns", [None], "exclude column must be non-empty text"), + ("target", None, "target must be non-empty text"), + ("target_measure", None, "target_measure must be non-empty text"), + ], +) +def test_receipt_contract_refuses_invalid_program_fields_without_person_data( + field, value, message +): + contract = _contract(1) + program = contract["programs"][0] + if field in {"target", "target_measure"}: + del program["rate"] + program["target"] = "toy_target" + program[field] = value + with pytest.raises(ValueError, match=message): + validate_receipt_contract(contract) + with pytest.raises(ValueError, match=message): + assign_receipts(pd.DataFrame(), contract) + + +def test_receipt_contract_allows_optional_null_payment_without_person_data(): + contract = _contract(1) + program = contract["programs"][0] + program["payment_column"] = None + del program["rate"] + program["target"] = "toy_target" + seed_column, programs, groups = validate_receipt_contract(contract) + assert seed_column == contract["seed_column"] + assert programs[0]["payment_column"] is None + assert programs[0]["target"] == "toy_target" + assert groups == contract["exclusion_groups"] + + def _context(kernel, frame, params, outputs, *, artifacts=None): node = Node("toy", kernel.ref, population="open", params=params, outputs=outputs) return KernelContext( diff --git a/packages/microcosm-build/tests/engine_free/shared/test_transport_composed_graph.py b/packages/microcosm-build/tests/engine_free/shared/test_transport_composed_graph.py index e2b6272043..511bb63c79 100644 --- a/packages/microcosm-build/tests/engine_free/shared/test_transport_composed_graph.py +++ b/packages/microcosm-build/tests/engine_free/shared/test_transport_composed_graph.py @@ -44,6 +44,7 @@ import time from dataclasses import replace from pathlib import Path +from unittest.mock import Mock import numpy as np import pytest @@ -65,6 +66,7 @@ load_transport_spec, validate_transport_activation, ) +from microcosm.build.transport.population_kernels import TRANSPORT_CURRENCY from microcosm.build.transport.terminal_kernels import materialize_export from microcosm.frame.kernels import SimulateRulesKernel from microcosm.graph import ( @@ -379,9 +381,9 @@ def test_rules_nodes_take_engine_ref_and_period_only_from_their_binding(composed # --------------------------------------------------------------------------- -def test_a1_a2_three_engines_in_one_run_under_sixty_seconds(composed, cold, tmp_path): +def test_a1_a2_three_engines_in_one_run(composed, cold, tmp_path, record_property): graph, first, store, exported, seconds = cold - assert seconds < 60 + record_property("cold_run_seconds", seconds) routed = [node for node in graph.nodes if node.kernel == RULES] assert len({node.params["engine_ref"] for node in routed}) == 3 assert all(not first.nodes[node.id].hit for node in routed) @@ -628,6 +630,92 @@ def unread(*args, **kwargs): ) +def _assert_activation_refuses_before_preparation( + spec, composed, tmp_path, monkeypatch, match +): + spies = [] + for owner, name in ( + (cli_module, "build_transport_registry"), + (cli_module, "prepare_create_outputs"), + (cli_module, "source_content_key"), + (cli_module.TRANSPORT_CREATE, "run"), + ): + spy = Mock(side_effect=AssertionError(f"Activation entered {name}.")) + monkeypatch.setattr(owner, name, spy) + spies.append(spy) + with pytest.raises(ValueError, match=match): + validate_transport_activation(spec) + inventory = tmp_path / "inventory" + with pytest.raises(ValueError, match=match): + prepare_transport_build( + spec, + sources=composed.sources, + engines_by_binding=composed.engines_by_binding, + inventory_store=inventory, + ) + for spy in spies: + spy.assert_not_called() + assert not inventory.exists() + + +@pytest.mark.parametrize( + ("encoding", "resource", "reason"), + [ + ("value", {"factor": None}, "not activated"), + ("json", {"factor": None}, "not activated"), + ("value", {}, "no selected path"), + ("json", {"factor": 1}, "json selection must be an object"), + ("value", {"factor": {}}, "value selection is an object"), + ], +) +def test_activation_refuses_invalid_selected_values_before_preparation( + composed, tmp_path, monkeypatch, encoding, resource, reason +): + spec = copy.deepcopy(composed.spec) + spec["resources"]["currency"] = resource + currency = next( + row + for row in spec["resources"]["transport_graph"]["nodes"] + if row["kernel"] == TRANSPORT_CURRENCY.ref + ) + currency["params"]["probe"] = [ + {"resource": "currency", "path": ["factor"], "encoding": encoding} + ] + _assert_activation_refuses_before_preparation( + spec, composed, tmp_path, monkeypatch, reason + ) + with pytest.raises(ValueError) as error: + validate_transport_activation(spec) + assert "currency" in str(error.value) + assert "['factor']" in str(error.value) + + +@pytest.mark.parametrize("gap", ["empty", "seed", "judgment", "rate"]) +def test_activation_refuses_invalid_receipt_contract_before_preparation( + composed, tmp_path, monkeypatch, gap +): + spec = copy.deepcopy(composed.spec) + receipt = spec["resources"]["receipt_contract"] + if gap == "empty": + receipt["receipts"] = {} + elif gap == "seed": + receipt["receipts"]["seed_column"] = None + else: + key = "judgment_column" if gap == "judgment" else "rate" + receipt["receipts"]["programs"][0][key] = None + _assert_activation_refuses_before_preparation( + spec, composed, tmp_path, monkeypatch, "receipt_contract.*receipts" + ) + + +def test_activation_accepts_optional_json_nulls(composed): + spec = copy.deepcopy(composed.spec) + spec["resources"]["receipt_contract"]["receipts"]["programs"][0][ + "payment_column" + ] = None + validate_transport_activation(spec) + + class _IllegalOwner(KernelBase): ref = "test.illegal_owner@1" capabilities = Capabilities(Determinism.DETERMINISTIC) @@ -953,6 +1041,12 @@ def test_packaged_spec_refuses_with_every_activation_gap_named(): validate_transport_activation(spec) for gap in _GAPS: assert gap in str(error.value), gap + for resource in ("precal_references", "target_references", "holdout_references"): + for reference in spec["resources"][resource]["target_references"]: + if reference["metadata"].get("activation_status", "active") != "active": + assert f"placeholder reference {resource}.{reference['name']}" in str( + error.value + ) config = TransportGraphConfig( engine_refs={"x": "y"}, create_outputs=(Owned("person", "age", "int64"),) ) From a781ae95cb0d3585263c81b8a9dfc60ddede460a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 9 Oct 2026 23:46:17 -0400 Subject: [PATCH 2/2] Refuse a malformed resource name in the activation preflight The #1180 review found that a declaration selection whose `resource` is not a string (for example `{"resource": null, "path": ["factor"]}`) skipped the preflight: `_declared_resource_selections` yielded only string-named selections, so activation passed and registry preparation started before `_param` refused it through `_stem`. That made the guide's "every declaration selection must name a present resource" broader than the code. Yield every mapping that names a `resource`, whatever the name's type, so the shared `_resource_param` check refuses it with its path named before any store creation, registry preparation, CREATE run or source read. `selected` keeps only nonempty string names. Test: names None, "", 7 and ["currency"] are refused before preparation (spies on registry, CREATE inventory, source keys and CREATE.run). With the old string-only enumerator restored in memory, None, 7 and the list fail (DID NOT RAISE); "" already failed closed. test_transport_composed_graph.py 137 passed; test_transport_graph.py 15 passed; ruff check and format pass. Co-Authored-By: Claude Opus 5.5 --- .../src/microcosm/build/transport/compose.py | 10 ++++++++-- .../shared/test_transport_composed_graph.py | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/microcosm-build/src/microcosm/build/transport/compose.py b/packages/microcosm-build/src/microcosm/build/transport/compose.py index d96cde478a..97296c6c69 100644 --- a/packages/microcosm-build/src/microcosm/build/transport/compose.py +++ b/packages/microcosm-build/src/microcosm/build/transport/compose.py @@ -390,7 +390,9 @@ def _declared_resource_selections(document: Mapping) -> Iterator[Mapping]: continue for value in params.values(): for selection in _selections(value): - if isinstance(selection.get("resource"), str): + # Any mapping naming a resource is a selection, whatever the + # name's type: a malformed name is refused here, not later. + if "resource" in selection: yield selection @@ -422,7 +424,11 @@ def validate_transport_activation(spec: Mapping[str, object]) -> None: if _has_resource(spec, GRAPH_RESOURCE) else () ) - selected = {_stem(selection["resource"]) for selection in selections} + selected = { + selection["resource"].removesuffix(".json") + for selection in selections + if isinstance(selection["resource"], str) and selection["resource"] + } for selection in selections: try: _resource_param(spec, selection) diff --git a/packages/microcosm-build/tests/engine_free/shared/test_transport_composed_graph.py b/packages/microcosm-build/tests/engine_free/shared/test_transport_composed_graph.py index 511bb63c79..4aae6811f4 100644 --- a/packages/microcosm-build/tests/engine_free/shared/test_transport_composed_graph.py +++ b/packages/microcosm-build/tests/engine_free/shared/test_transport_composed_graph.py @@ -690,6 +690,24 @@ def test_activation_refuses_invalid_selected_values_before_preparation( assert "['factor']" in str(error.value) +@pytest.mark.parametrize("name", [None, "", 7, ["currency"]]) +def test_activation_refuses_a_malformed_resource_name_before_preparation( + composed, tmp_path, monkeypatch, name +): + spec = copy.deepcopy(composed.spec) + currency = next( + row + for row in spec["resources"]["transport_graph"]["nodes"] + if row["kernel"] == TRANSPORT_CURRENCY.ref + ) + currency["params"]["probe"] = [ + {"resource": name, "path": ["factor"], "encoding": "value"} + ] + _assert_activation_refuses_before_preparation( + spec, composed, tmp_path, monkeypatch, "must name a nonempty string" + ) + + @pytest.mark.parametrize("gap", ["empty", "seed", "judgment", "rate"]) def test_activation_refuses_invalid_receipt_contract_before_preparation( composed, tmp_path, monkeypatch, gap