From 5bd0ee5ff96a9d67e8c8bb2fc9bdc1a79090afcc Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 21 Aug 2026 14:14:08 +0200 Subject: [PATCH] feat(ops-run): carry harness_profile_ref and attribution refs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ACTIVITY-WP-0032-T02 / ACT-ADR-006. ops_runs grows harness_profile_ref (text, indexed) and execution_refs (jsonb), migration 0008, threaded through the emission path, queue projection, and run artefacts. The important part is the enforcement, not the columns. ACT-ADR-006 says approach_hint must never override, synthesize, or fall back from an absent or invalid harness_profile_ref — a silent fallback would reintroduce the claim-time routing failure of 2026-08-17. resolve_execution_selector never consults the hint: a malformed ref raises even when a hint is present, and ACTIVITY_CORE_REQUIRE_HARNESS_PROFILE turns an absent ref into an error once definitions have migrated. Validation is structural only, since the glas-harness catalogue is authoritative and must not be mirrored. Requiring the @ pin is worth doing locally: GlasProfiles.resolve matches an unpinned ref against every version and refuses it as ambiguous, so the pin converts a late failure into an emission-time error without knowing any profile id. Migration verified on real PostgreSQL 16: upgrade, downgrade, re-upgrade, and a legacy-shaped row still inserts and stays claimable. Co-Authored-By: Claude Opus 5 --- WORK-RECORDS.md | 8 +- docs/ops-run-queue.md | 33 +++- .../0008_add_harness_profile_to_ops_runs.py | 52 ++++++ src/activity_core/activities.py | 2 + src/activity_core/glas_profile.py | 131 ++++++++++++++ src/activity_core/ops_run_queue.py | 16 +- src/activity_core/orm.py | 6 + src/activity_core/run_artifacts.py | 2 + tests/test_glas_profile_selection.py | 166 ++++++++++++++++++ ...WP-0032-glas-profile-execution-contract.md | 19 +- 10 files changed, 428 insertions(+), 7 deletions(-) create mode 100644 migrations/versions/0008_add_harness_profile_to_ops_runs.py create mode 100644 src/activity_core/glas_profile.py create mode 100644 tests/test_glas_profile_selection.py diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 5805b64..23ea521 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -35,7 +35,7 @@ | workplan | ACTIVITY-WP-0029 | active | — | workplans/ACTIVITY-WP-0029-hub-port-alignment.md | | workplan | ACTIVITY-WP-0030 | active | — | workplans/ACTIVITY-WP-0030-daily-sbom-catchup.md | | workplan | ACTIVITY-WP-0031 | active | — | workplans/ACTIVITY-WP-0031-production-execution-reliability-cleanup.md | -| workplan | ACTIVITY-WP-0032 | proposed | — | workplans/ACTIVITY-WP-0032-glas-profile-execution-contract.md | +| workplan | ACTIVITY-WP-0032 | active | — | workplans/ACTIVITY-WP-0032-glas-profile-execution-contract.md | | workplan | ADHOC-2026-06-01 | finished | — | workplans/ADHOC-2026-06-01.md | | workplan | ADHOC-2026-08-20 | finished | — | workplans/ADHOC-2026-08-20.md | | workplan | custodian-WP-0001 | done | — | workplans/custodian-WP-0001-temporal-backbone.md | @@ -194,9 +194,9 @@ | task | ACTIVITY-WP-0031-T03 | done | — | workplans/ACTIVITY-WP-0031-production-execution-reliability-cleanup.md | | task | ACTIVITY-WP-0031-T04 | done | — | workplans/ACTIVITY-WP-0031-production-execution-reliability-cleanup.md | | task | ACTIVITY-WP-0031-T05 | wait | — | workplans/ACTIVITY-WP-0031-production-execution-reliability-cleanup.md | -| task | ACTIVITY-WP-0032-T01 | wait | — | workplans/ACTIVITY-WP-0032-glas-profile-execution-contract.md | -| task | ACTIVITY-WP-0032-T02 | wait | — | workplans/ACTIVITY-WP-0032-glas-profile-execution-contract.md | -| task | ACTIVITY-WP-0032-T03 | wait | — | workplans/ACTIVITY-WP-0032-glas-profile-execution-contract.md | +| task | ACTIVITY-WP-0032-T01 | done | — | workplans/ACTIVITY-WP-0032-glas-profile-execution-contract.md | +| task | ACTIVITY-WP-0032-T02 | todo | — | workplans/ACTIVITY-WP-0032-glas-profile-execution-contract.md | +| task | ACTIVITY-WP-0032-T03 | todo | — | workplans/ACTIVITY-WP-0032-glas-profile-execution-contract.md | | task | ACTIVITY-WP-0032-T04 | wait | — | workplans/ACTIVITY-WP-0032-glas-profile-execution-contract.md | | task | ACTIVITY-WP-0032-T05 | wait | — | workplans/ACTIVITY-WP-0032-glas-profile-execution-contract.md | | task | ADHOC-2026-06-01-T01 | done | — | workplans/ADHOC-2026-06-01.md | diff --git a/docs/ops-run-queue.md b/docs/ops-run-queue.md index 492af0c..54cf4b9 100644 --- a/docs/ops-run-queue.md +++ b/docs/ops-run-queue.md @@ -26,7 +26,9 @@ workplan task file. Not an issue-core or Forgejo ticket. | `source_type` | text | rule \| instruction | | `source_id` | text | Rule id | | `triggering_event_id` | text | Event or workflow key | -| `approach_hint` | text nullable | Optional from rule | +| `approach_hint` | text nullable | **Legacy** definition-matching hint (ACT-ADR-006) | +| `harness_profile_ref` | text nullable | Authoritative execution selector, pinned `@` | +| `execution_refs` | jsonb | Attribution refs carried through, not authored here | | `result` | JSONB | Completion metadata | | `created_at` / `updated_at` | timestamptz | | @@ -134,3 +136,32 @@ completion event (`fi_daily_brief`, etc.). | Multi-day engineering tasks | Workplan files + State Hub | | External tracker tickets | issue-core → **Forgejo** (optional projection) | | Schedule truth | Temporal + activity definitions | + + +## Execution selection (ACT-ADR-006) + +`harness_profile_ref` names an approved, **version-pinned** glas-harness profile +(e.g. `harness.agent-dev-local@1.0.0`). The claiming executor passes the queued +request into Glas, which resolves or refuses it **before sandbox creation**. + +`approach_hint` and `harness_profile_ref` coexist with **distinct semantics**: + +- `harness_profile_ref` is the authoritative execution-constellation selector. +- `approach_hint` is a legacy definition-matching hint only. It must **never** + override, synthesize, or fall back from an absent or invalid profile ref. A + malformed ref is an error at emission, not an invitation to route on the hint. + +activity-core does **not** mirror the glas-harness profile catalogue — it is +authoritative there, and Glas exposes no network validation service. So we +validate structure only (present, no whitespace, `@` pinned). The +pin matters: `GlasProfiles.resolve` treats an unpinned ref as matching every +version and refuses it as ambiguous, so requiring the pin locally converts a +late failure into an emission-time error without knowing any profile id. + +A well-formed but *unknown* profile is still caught by the execution-side Glas +resolver rather than at emission. That residual gap is accepted and recorded in +ACT-ADR-006; closing it needs a scoped glas-harness API, not a local catalogue. + +`ACTIVITY_CORE_REQUIRE_HARNESS_PROFILE=true` makes a missing profile ref an +error. It stays off during coexistence while definitions adopt refs one at a +time; turn it on once no caller depends on `approach_hint` for routing. diff --git a/migrations/versions/0008_add_harness_profile_to_ops_runs.py b/migrations/versions/0008_add_harness_profile_to_ops_runs.py new file mode 100644 index 0000000..f6042e0 --- /dev/null +++ b/migrations/versions/0008_add_harness_profile_to_ops_runs.py @@ -0,0 +1,52 @@ +"""add_harness_profile_to_ops_runs + +Revision ID: 0008 +Revises: 0007 +Create Date: 2026-08-21 + +ACTIVITY-WP-0032 / ACT-ADR-006 — profile-driven execution selection. + +`harness_profile_ref` is the authoritative execution-constellation selector; +`execution_refs` carries the contract's attribution references. Both are +additive and nullable/defaulted, so existing open ops_runs stay claimable and +`approach_hint` keeps working as the legacy hint during coexistence. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "0008" +down_revision: Union[str, Sequence[str], None] = "0007" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "ops_runs", + sa.Column("harness_profile_ref", sa.Text(), nullable=True), + ) + op.add_column( + "ops_runs", + sa.Column( + "execution_refs", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + server_default=sa.text("'{}'::jsonb"), + ), + ) + # Operators need to answer "what is still on the legacy hint?" during the + # coexistence window, and executors filter claimable work by profile. + op.create_index( + "idx_ops_runs_harness_profile", + "ops_runs", + ["harness_profile_ref"], + ) + + +def downgrade() -> None: + op.drop_index("idx_ops_runs_harness_profile", table_name="ops_runs") + op.drop_column("ops_runs", "execution_refs") + op.drop_column("ops_runs", "harness_profile_ref") diff --git a/src/activity_core/activities.py b/src/activity_core/activities.py index 6ada182..69d50d6 100644 --- a/src/activity_core/activities.py +++ b/src/activity_core/activities.py @@ -495,6 +495,8 @@ async def emit_tasks(payload: dict) -> list[str]: session, spec, approach_hint=spec_dict.get("approach_hint"), + harness_profile_ref=spec_dict.get("harness_profile_ref"), + execution_refs=spec_dict.get("execution_refs"), ) if ops_id is not None: activity.logger.info( diff --git a/src/activity_core/glas_profile.py b/src/activity_core/glas_profile.py new file mode 100644 index 0000000..1122c0f --- /dev/null +++ b/src/activity_core/glas_profile.py @@ -0,0 +1,131 @@ +"""Harness profile selection for queued ops runs (ACT-ADR-006). + +activity-core names an **approved, versioned harness profile** on a governed +ops_run and passes the attribution references through. It does not resolve the +profile, select a rein, or mirror the glas-harness catalogue — that catalogue is +authoritative and Glas exposes no network validation service, so a local copy +would drift and produce a second, disagreeing opinion about what is executable. + +What we can check locally is *structure*, and one structural property matters a +great deal: ``GlasProfiles.resolve`` (glas_harness/profiles.py) treats a ref +without ``@version`` as matching every version of that id and raises +``AmbiguousProfileError`` when more than one exists. Requiring the pin here +turns a late ambiguity failure into an emission-time error, without knowing a +single profile id. + +Everything else — whether a well-formed ref names a profile that exists, is +enabled, and matches the contract version — is the execution-side Glas +resolver's mandatory fail-closed check, by design. +""" + +from __future__ import annotations + +import os +import re +from typing import Any + +# `harness.agent-dev-local@1.0.0` — id, then a pinned version. +_PROFILE_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") +_PROFILE_VERSION_PATTERN = re.compile(r"^[0-9]+(\.[0-9]+)*([A-Za-z0-9.\-+]*)$") + +# Passed through from workforce/leadership vocabulary (info-tech-canon). We +# carry these; we do not author or interpret them. +EXECUTION_REF_KEYS: tuple[str, ...] = ( + "correlation_id", + "assignment_ref", + "role_ref", + "duty_ref", + "goal_refs", + "resource_envelope_refs", +) +_LIST_REF_KEYS = frozenset({"goal_refs", "resource_envelope_refs"}) + + +class ProfileRefError(ValueError): + """A harness profile reference is absent or structurally unusable.""" + + +def require_harness_profile() -> bool: + """Whether a governed ops_run must carry a profile ref. + + Off during migration (ACT-ADR-006 coexistence): definitions adopt the ref + one at a time. Turn on once inventory shows no caller depends on + ``approach_hint`` for runtime selection. + """ + return os.getenv("ACTIVITY_CORE_REQUIRE_HARNESS_PROFILE", "").strip().lower() in { + "1", + "true", + "yes", + "on", + } + + +def validate_profile_ref(raw: Any) -> str: + """Return a normalised, pinned profile ref or raise ProfileRefError.""" + if raw is None or not isinstance(raw, str) or not raw.strip(): + raise ProfileRefError("harness_profile_ref is empty") + + ref = raw.strip() + if any(character.isspace() for character in ref): + raise ProfileRefError(f"harness_profile_ref contains whitespace: {ref!r}") + + profile_id, separator, version = ref.partition("@") + if not separator: + # Glas would match every version of this id and refuse as ambiguous. + raise ProfileRefError( + f"harness_profile_ref must pin a version as '@': {ref!r}" + ) + if not _PROFILE_ID_PATTERN.match(profile_id): + raise ProfileRefError(f"harness_profile_ref has an invalid id: {ref!r}") + if not _PROFILE_VERSION_PATTERN.match(version): + raise ProfileRefError(f"harness_profile_ref has an invalid version: {ref!r}") + return ref + + +def resolve_execution_selector( + harness_profile_ref: Any, + approach_hint: Any = None, +) -> tuple[str | None, str | None]: + """Return ``(harness_profile_ref, approach_hint)`` for a queued ops_run. + + ACT-ADR-006 §2: the two coexist with **distinct semantics**. The profile ref + is the authoritative execution-constellation selector; ``approach_hint`` is + a legacy definition-matching hint only. It must never override, synthesize, + or fall back from an absent or invalid profile ref — a silent fallback would + reintroduce exactly the claim-time routing failure this replaces. + + So the hint is carried through untouched and is never consulted here. + """ + hint = approach_hint if isinstance(approach_hint, str) and approach_hint.strip() else None + + if harness_profile_ref is None or ( + isinstance(harness_profile_ref, str) and not harness_profile_ref.strip() + ): + if require_harness_profile(): + raise ProfileRefError( + "governed ops_run has no harness_profile_ref " + "(approach_hint cannot substitute for one)" + ) + return None, hint + + # Malformed refs always raise, migration flag or not: a ref that was meant + # to route this run and cannot must not silently degrade to the legacy hint. + return validate_profile_ref(harness_profile_ref), hint + + +def normalise_execution_refs(raw: Any) -> dict[str, Any]: + """Keep only the contract's attribution refs, as strings / lists of strings.""" + if not isinstance(raw, dict): + return {} + + refs: dict[str, Any] = {} + for key in EXECUTION_REF_KEYS: + value = raw.get(key) + if key in _LIST_REF_KEYS: + if isinstance(value, (list, tuple)): + items = [str(item).strip() for item in value if str(item).strip()] + if items: + refs[key] = items + elif isinstance(value, str) and value.strip(): + refs[key] = value.strip() + return refs diff --git a/src/activity_core/ops_run_queue.py b/src/activity_core/ops_run_queue.py index 55651cd..97b0428 100644 --- a/src/activity_core/ops_run_queue.py +++ b/src/activity_core/ops_run_queue.py @@ -11,6 +11,10 @@ from sqlalchemy import Select, and_, func, select, update from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession +from activity_core.glas_profile import ( + normalise_execution_refs, + resolve_execution_selector, +) from activity_core.orm import OpsRun from activity_core.rules.models import TaskSpec @@ -92,6 +96,8 @@ def ops_run_to_dict(row: OpsRun) -> dict[str, Any]: "source_id": row.source_id, "triggering_event_id": row.triggering_event_id, "approach_hint": row.approach_hint, + "harness_profile_ref": row.harness_profile_ref, + "execution_refs": dict(row.execution_refs or {}), "result": dict(row.result or {}), "created_at": row.created_at.isoformat() if row.created_at else None, "updated_at": row.updated_at.isoformat() if row.updated_at else None, @@ -103,6 +109,8 @@ async def create_ops_run_from_spec( spec: TaskSpec, *, approach_hint: str | None = None, + harness_profile_ref: str | None = None, + execution_refs: dict | None = None, ) -> uuid.UUID | None: """Insert ops_run if queue enabled; return id or None if disabled/duplicate.""" if not ops_run_queue_enabled(): @@ -115,6 +123,10 @@ async def create_ops_run_from_spec( except ValueError: return None + # ACT-ADR-006: raises on a malformed ref, and on an absent one once + # ACTIVITY_CORE_REQUIRE_HARNESS_PROFILE is enabled. Never falls back to the hint. + profile_ref, hint = resolve_execution_selector(harness_profile_ref, approach_hint) + key = build_idempotency_key(spec) now = datetime.now(timezone.utc) stmt = ( @@ -133,7 +145,9 @@ async def create_ops_run_from_spec( source_type=spec.source_type or "rule", source_id=spec.source_id or "", triggering_event_id=spec.triggering_event_id or "", - approach_hint=approach_hint, + approach_hint=hint, + harness_profile_ref=profile_ref, + execution_refs=normalise_execution_refs(execution_refs), result={}, created_at=now, updated_at=now, diff --git a/src/activity_core/orm.py b/src/activity_core/orm.py index 5293468..66ffd57 100644 --- a/src/activity_core/orm.py +++ b/src/activity_core/orm.py @@ -170,6 +170,12 @@ class OpsRun(Base): source_id: Mapped[str] = mapped_column(Text, nullable=False) triggering_event_id: Mapped[str] = mapped_column(Text, nullable=False, index=True) approach_hint: Mapped[str | None] = mapped_column(Text, nullable=True) + # ACT-ADR-006: authoritative execution-constellation selector. Legacy + # `approach_hint` above must never substitute for an absent/invalid one. + harness_profile_ref: Mapped[str | None] = mapped_column(Text, nullable=True) + # Attribution refs carried through from workforce/leadership vocabulary; + # activity-core does not author or interpret them. + execution_refs: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) result: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() diff --git a/src/activity_core/run_artifacts.py b/src/activity_core/run_artifacts.py index 6663487..ac83237 100644 --- a/src/activity_core/run_artifacts.py +++ b/src/activity_core/run_artifacts.py @@ -153,6 +153,8 @@ def _ops_summary(row: OpsRun) -> dict[str, Any]: "triggering_event_id": row.triggering_event_id, "source_id": row.source_id, "approach_hint": row.approach_hint, + "harness_profile_ref": row.harness_profile_ref, + "execution_refs": dict(row.execution_refs or {}), "created_at": row.created_at.isoformat() if row.created_at else None, "updated_at": row.updated_at.isoformat() if row.updated_at else None, "result": { diff --git a/tests/test_glas_profile_selection.py b/tests/test_glas_profile_selection.py new file mode 100644 index 0000000..6128433 --- /dev/null +++ b/tests/test_glas_profile_selection.py @@ -0,0 +1,166 @@ +"""Profile-driven execution selection (ACT-ADR-006 / ACTIVITY-WP-0032-T02).""" + +from __future__ import annotations + +import pytest + +from activity_core.glas_profile import ( + EXECUTION_REF_KEYS, + ProfileRefError, + normalise_execution_refs, + resolve_execution_selector, + validate_profile_ref, +) + + +class TestProfileRefStructure: + def test_accepts_a_pinned_ref(self) -> None: + assert ( + validate_profile_ref("harness.agent-dev-local@1.0.0") + == "harness.agent-dev-local@1.0.0" + ) + + def test_strips_surrounding_whitespace(self) -> None: + assert validate_profile_ref(" harness.agent-dev@1.0.0\n") == "harness.agent-dev@1.0.0" + + def test_requires_a_version_pin(self) -> None: + """Glas resolves an unpinned ref against every version and refuses it + as ambiguous — catch that here instead, without knowing any profile.""" + with pytest.raises(ProfileRefError, match="must pin a version"): + validate_profile_ref("harness.agent-dev-local") + + @pytest.mark.parametrize("bad", ["", " ", None, 42, ["harness@1.0.0"]]) + def test_rejects_empty_or_non_string(self, bad) -> None: + with pytest.raises(ProfileRefError): + validate_profile_ref(bad) + + @pytest.mark.parametrize( + "bad", + ["harness agent@1.0.0", "harness.agent@1.0 .0", "harness\tagent@1.0.0"], + ) + def test_rejects_internal_whitespace(self, bad: str) -> None: + with pytest.raises(ProfileRefError, match="whitespace"): + validate_profile_ref(bad) + + @pytest.mark.parametrize("bad", ["@1.0.0", "-harness@1.0.0", "har/ness@1.0.0"]) + def test_rejects_invalid_ids(self, bad: str) -> None: + with pytest.raises(ProfileRefError, match="invalid id"): + validate_profile_ref(bad) + + @pytest.mark.parametrize("bad", ["harness@", "harness@v1.0.0", "harness@latest"]) + def test_rejects_invalid_versions(self, bad: str) -> None: + with pytest.raises(ProfileRefError, match="invalid version"): + validate_profile_ref(bad) + + +class TestNoFallbackToApproachHint: + """ACT-ADR-006 §2 — the single most important property of this change. + + approach_hint must never override, synthesize, or fall back from an absent + or invalid harness_profile_ref. A silent fallback would reintroduce the + claim-time routing failure of 2026-08-17. + """ + + def test_hint_does_not_synthesize_a_missing_profile(self) -> None: + profile, hint = resolve_execution_selector(None, "sbom-rescan") + + assert profile is None, "approach_hint must not become a profile ref" + assert hint == "sbom-rescan" + + def test_hint_does_not_rescue_a_malformed_profile(self) -> None: + with pytest.raises(ProfileRefError, match="must pin a version"): + resolve_execution_selector("harness.agent-dev-local", "sbom-rescan") + + def test_malformed_profile_raises_even_with_no_hint(self) -> None: + with pytest.raises(ProfileRefError): + resolve_execution_selector("not a ref", None) + + def test_profile_does_not_overwrite_the_legacy_hint(self) -> None: + """Coexistence, not replacement: both are carried.""" + profile, hint = resolve_execution_selector( + "harness.agent-dev-local@1.0.0", "sbom-rescan" + ) + + assert profile == "harness.agent-dev-local@1.0.0" + assert hint == "sbom-rescan" + + def test_absent_profile_is_allowed_during_coexistence(self) -> None: + assert resolve_execution_selector(None, None) == (None, None) + + def test_absent_profile_is_an_error_once_required(self, monkeypatch) -> None: + monkeypatch.setenv("ACTIVITY_CORE_REQUIRE_HARNESS_PROFILE", "true") + + with pytest.raises(ProfileRefError, match="cannot substitute"): + resolve_execution_selector(None, "sbom-rescan") + + def test_required_mode_still_accepts_a_valid_profile(self, monkeypatch) -> None: + monkeypatch.setenv("ACTIVITY_CORE_REQUIRE_HARNESS_PROFILE", "1") + + profile, _ = resolve_execution_selector("harness.agent-dev@2.1.0", None) + + assert profile == "harness.agent-dev@2.1.0" + + def test_blank_hint_is_normalised_away(self) -> None: + assert resolve_execution_selector(None, " ") == (None, None) + + +class TestExecutionRefs: + def test_keeps_the_contract_refs(self) -> None: + refs = normalise_execution_refs( + { + "correlation_id": "activity-42", + "assignment_ref": "role-assignment:agent-7:42", + "role_ref": "role:maintainer@3", + "duty_ref": "duty:bounded-change@2", + "goal_refs": ["goal:reliable-delivery@5"], + "resource_envelope_refs": ["resource-envelope:standard-coding@1"], + } + ) + + assert set(refs) == set(EXECUTION_REF_KEYS) + assert refs["goal_refs"] == ["goal:reliable-delivery@5"] + + def test_drops_unknown_keys(self) -> None: + """We carry the contract's refs; we do not become a bag for anything.""" + refs = normalise_execution_refs( + {"role_ref": "role:maintainer@3", "api_key": "sk-nope", "extra": {"a": 1}} + ) + + assert refs == {"role_ref": "role:maintainer@3"} + + def test_drops_empty_and_wrongly_typed_values(self) -> None: + refs = normalise_execution_refs( + { + "correlation_id": " ", + "role_ref": 17, + "goal_refs": "not-a-list", + "resource_envelope_refs": ["ok", "", " "], + } + ) + + assert refs == {"resource_envelope_refs": ["ok"]} + + @pytest.mark.parametrize("bad", [None, "refs", ["a"], 5]) + def test_non_dict_becomes_empty(self, bad) -> None: + assert normalise_execution_refs(bad) == {} + + +def test_ops_run_model_carries_the_new_columns() -> None: + from activity_core.orm import OpsRun + + columns = OpsRun.__table__.columns + assert "harness_profile_ref" in columns + assert columns["harness_profile_ref"].nullable is True + assert "execution_refs" in columns + assert columns["execution_refs"].nullable is False + + +def test_queue_projection_exposes_selection_fields() -> None: + """Executors claim from this projection; the profile must reach them.""" + import inspect + + from activity_core import ops_run_queue + + source = inspect.getsource(ops_run_queue) + assert '"harness_profile_ref": row.harness_profile_ref' in source + assert '"execution_refs"' in source diff --git a/workplans/ACTIVITY-WP-0032-glas-profile-execution-contract.md b/workplans/ACTIVITY-WP-0032-glas-profile-execution-contract.md index 3e936f3..371b08a 100644 --- a/workplans/ACTIVITY-WP-0032-glas-profile-execution-contract.md +++ b/workplans/ACTIVITY-WP-0032-glas-profile-execution-contract.md @@ -117,10 +117,27 @@ Written up as `docs/adr/adr-006-glas-profile-execution.md`. ```task id: ACTIVITY-WP-0032-T02 -status: todo +status: done priority: high ``` +Done 2026-08-21. `ops_runs` grows `harness_profile_ref` (text, indexed) and +`execution_refs` (jsonb, default `{}`), migration `0008`, threaded through +`ops_run_queue.py`, `activities.py`, and `run_artifacts.py`. +`src/activity_core/glas_profile.py` holds the selection rules; +`docs/ops-run-queue.md` documents the contract. + +The no-fallback rule is enforced in `resolve_execution_selector`, not merely +documented: a malformed ref raises even when a hint is present, and +`ACTIVITY_CORE_REQUIRE_HARNESS_PROFILE` makes an absent one an error once +definitions have migrated. 34 tests, with the `TestNoFallbackToApproachHint` +class covering the property directly. + +Migration verified against a real PostgreSQL 16: upgrade → columns and index +present → downgrade 0007 → clean re-upgrade. A legacy-shaped row (approach_hint +only, no profile) still inserts, defaults `execution_refs` to `{}`, and stays +`open`/claimable — existing production runs are unaffected. + Add `harness_profile_ref` and the correlation/attribution refs to the ops_run model, emission path (`activities.py`), queue projection (`ops_run_queue.py`), and run artefacts (`run_artifacts.py`), with a migration.