diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 3bd634e..18f872c 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -67,6 +67,7 @@ | workplan | STATE-WP-0091 | finished | — | workplans/STATE-WP-0091-release-headroom.md | | workplan | STATE-WP-0092 | finished | — | workplans/STATE-WP-0092-workplan-flavor-and-depends-on-policy.md | | workplan | STATE-WP-0093 | finished | — | workplans/STATE-WP-0093-broadcast-receipts-and-standing-notices.md | +| workplan | STATE-WP-0094 | active | — | workplans/STATE-WP-0094-routed-gates-and-owner-returns.md | | task | STATE-WP-ADHOC-2026-06-04-T01 | done | — | workplans/ADHOC-2026-06-04.md | | task | STATE-WP-ADHOC-2026-07-01-T01 | done | — | workplans/ADHOC-2026-07-01.md | | task | STATE-WP-ADHOC-2026-07-01-T02 | done | — | workplans/ADHOC-2026-07-01.md | @@ -374,3 +375,8 @@ | task | STATE-WP-0093-T06 | done | — | workplans/STATE-WP-0093-broadcast-receipts-and-standing-notices.md | | task | STATE-WP-0093-T07 | done | — | workplans/STATE-WP-0093-broadcast-receipts-and-standing-notices.md | | task | STATE-WP-0093-T08 | done | — | workplans/STATE-WP-0093-broadcast-receipts-and-standing-notices.md | +| task | STATE-WP-0094-T01 | done | — | workplans/STATE-WP-0094-routed-gates-and-owner-returns.md | +| task | STATE-WP-0094-T02 | done | — | workplans/STATE-WP-0094-routed-gates-and-owner-returns.md | +| task | STATE-WP-0094-T03 | todo | — | workplans/STATE-WP-0094-routed-gates-and-owner-returns.md | +| task | STATE-WP-0094-T04 | done | — | workplans/STATE-WP-0094-routed-gates-and-owner-returns.md | +| task | STATE-WP-0094-T05 | todo | — | workplans/STATE-WP-0094-routed-gates-and-owner-returns.md | diff --git a/api/services/forge_projection.py b/api/services/forge_projection.py index 2071da1..032f41d 100644 --- a/api/services/forge_projection.py +++ b/api/services/forge_projection.py @@ -19,6 +19,7 @@ import subprocess import tempfile import uuid from dataclasses import dataclass, field +from datetime import date from pathlib import Path from typing import Any @@ -27,6 +28,10 @@ import yaml # Same derivation as ADR-007 / repo-manager, so a forge-derived projection and a # preliminary overlay compute identical identities for the same record. from api.services.task_record_id_backfill import qualify_task_id +from api.work_record_flavor import ( + FLAVOR_PROMOTION_REASONS, WORK_RECORD_FLAVORS, + normalize_flavor, normalize_promotion_reason, +) _WORK_RECORD_NAMESPACE = uuid.UUID("a4058507-5c4a-5a00-ab06-fffa4fb46009") _TASK_BLOCK_RE = re.compile(r"```task\s*\n(.*?)\n```", re.DOTALL) @@ -129,6 +134,10 @@ class DerivedTask: needs_human: bool = False intervention_note: str | None = None blocking_reason: str | None = None + flavor: str | None = None + decision_id: str | None = None + blocked_on: str | None = None + wait_until: date | None = None @dataclass @@ -142,6 +151,9 @@ class DerivedWorkplan: owner: str | None = None description: str | None = None tasks: list[DerivedTask] = field(default_factory=list) + flavor: str | None = None + flavor_promotion_reason: str | None = None + flavor_promoted_from: str | None = None @dataclass @@ -188,6 +200,9 @@ class DerivedProjection: "status": w.status, "owner": w.owner, "description": w.description, + "flavor": w.flavor, + "flavor_promotion_reason": w.flavor_promotion_reason, + "flavor_promoted_from": w.flavor_promoted_from, "relative_path": w.relative_path, "archived": w.archived, "tasks": [ @@ -201,6 +216,10 @@ class DerivedProjection: "needs_human": t.needs_human, "intervention_note": t.intervention_note, "blocking_reason": t.blocking_reason, + "flavor": t.flavor, + "decision_id": t.decision_id, + "blocked_on": t.blocked_on, + "wait_until": t.wait_until.isoformat() if t.wait_until else None, } for t in w.tasks ], @@ -282,6 +301,56 @@ def _clean_task_description(raw: str) -> str | None: return "\n".join(lines).strip() or None + +def _source_flavor(value: Any, field_name: str = "flavor") -> str | None: + flavor = normalize_flavor(value) + if flavor is not None and flavor not in WORK_RECORD_FLAVORS: + raise ForgeDeriveError(f"Unknown source {field_name}; no projection was applied") + return flavor + + +def _source_promotion_reason(value: Any) -> str | None: + reason = normalize_promotion_reason(value) + if reason is not None and reason not in FLAVOR_PROMOTION_REASONS: + raise ForgeDeriveError("Unknown source flavor_promotion_reason; no projection was applied") + return reason + + +def _source_wait_until(value: Any) -> date | None: + if value is None or value == "": + return None + if type(value) is date: + return value + text = str(value).strip() + if re.fullmatch(r"\d{4}-\d{2}-\d{2}", text): + try: + return date.fromisoformat(text) + except ValueError: + pass + raise ForgeDeriveError("Source wait_until must be a valid YYYY-MM-DD date; no projection was applied") + + +def _source_wait_text(value: Any) -> str | None: + if value is None: + return None + if not isinstance(value, str): + raise ForgeDeriveError("Source wait qualifier must be text; no projection was applied") + return value.strip() or None + + +def _source_needs_human(value: Any) -> bool: + if isinstance(value, bool): + return value + if value is None: + return False + text = str(value).strip().lower() + if text in {"true", "yes", "1", "on"}: + return True + if text in {"false", "no", "0", "off", ""}: + return False + raise ForgeDeriveError("Source needs_human must be boolean; no projection was applied") + + def _parse_tasks(body: str, workplan_id: str) -> list[DerivedTask]: headings = [ (m.start(), len(m.group(1)), m.group(2).strip()) for m in _HEADING_RE.finditer(body) @@ -291,8 +360,10 @@ def _parse_tasks(body: str, workplan_id: str) -> list[DerivedTask]: for index, m in enumerate(task_matches): try: block = yaml.safe_load(m.group(1).strip()) or {} - except yaml.YAMLError: - continue + except (yaml.YAMLError, ValueError) as exc: + # PyYAML raises ValueError for impossible bare ISO dates. + # Refuse the entire derive instead of dropping a stored gate. + raise ForgeDeriveError("Malformed source task metadata; no projection was applied") from exc if not isinstance(block, dict): continue rid = str(block.get("id") or "").strip() @@ -333,17 +404,21 @@ def _parse_tasks(body: str, workplan_id: str) -> list[DerivedTask]: status=(str(block["status"]).strip() if block.get("status") else None), priority=(str(block["priority"]).strip() if block.get("priority") else None), description=description, - needs_human=bool(block.get("needs_human", False)), + needs_human=_source_needs_human(block.get("needs_human", False)), intervention_note=( str(block["intervention_note"]) if block.get("intervention_note") is not None - else None + else (str(block["blocking_reason"]) if _source_needs_human(block.get("needs_human")) and block.get("blocking_reason") is not None else None) ), blocking_reason=( str(block["blocking_reason"]) if block.get("blocking_reason") is not None else None ), + flavor=_source_flavor(block.get("flavor")), + decision_id=_source_wait_text(block.get("decision_id")), + blocked_on=_source_wait_text(block.get("blocked_on")), + wait_until=_source_wait_until(block.get("wait_until")), ) ) return out @@ -397,6 +472,9 @@ def derive_from_checkout(repo_root: Path, repo_slug: str, commit: str) -> Derive else _workplan_description(body) ), tasks=_parse_tasks(body, rid), + flavor=_source_flavor(meta.get("flavor")), + flavor_promotion_reason=_source_promotion_reason(meta.get("flavor_promotion_reason")), + flavor_promoted_from=_source_flavor(meta.get("flavor_promoted_from"), "flavor_promoted_from"), ) ) proj.workplans.sort(key=lambda w: w.record_id) @@ -611,6 +689,10 @@ def diff_against_hub( changed = {} if (cur.get("status") or None) != (w.status or None): changed["status"] = {"hub": cur.get("status"), "forge": w.status} + for field_name in ("flavor", "flavor_promotion_reason", "flavor_promoted_from"): + expected = getattr(w, field_name) + if cur.get(field_name) != expected: + changed[field_name] = {"hub": cur.get(field_name), "forge": expected} cur_path = cur.get("backing_relative_path") or None if cur_path != w.relative_path: changed["backing_relative_path"] = {"hub": cur_path, "forge": w.relative_path} @@ -674,11 +756,25 @@ def diff_against_hub( ) for key, t in want.items(): cur = have.get(key) - if cur and (cur.get("status") or None) != (t.status or None): + if not cur: + continue + changed = {} + if (cur.get("status") or None) != (t.status or None): + changed["status"] = {"hub": cur.get("status"), "forge": t.status} + for field_name in ("needs_human", "intervention_note", "blocking_reason", + "flavor", "decision_id", "blocked_on", "wait_until"): + expected = getattr(t, field_name) + actual = cur.get(field_name, False if field_name == "needs_human" else None) + if isinstance(actual, date): + actual = actual.isoformat() + if isinstance(expected, date): + expected = expected.isoformat() + if actual != expected: + changed[field_name] = {"hub": actual, "forge": expected} + if changed: d.differing.append( {"kind": "task", "record_id": t.record_id, "uuid": t.uuid, - "workplan": w.record_id, - "changed": {"status": {"hub": cur.get("status"), "forge": t.status}}} + "workplan": w.record_id, "changed": changed} ) return d @@ -822,6 +918,10 @@ def _sync_existing_workplan_tasks( "needs_human": dt.needs_human, "intervention_note": dt.intervention_note, "blocking_reason": dt.blocking_reason, + "flavor": dt.flavor, + "decision_id": dt.decision_id, + "blocked_on": dt.blocked_on, + "wait_until": dt.wait_until, } st = _coerce_task_status(dt.status) if st is not None: @@ -857,6 +957,11 @@ def _sync_existing_workplan_tasks( if getattr(ht, "blocking_reason", None) != dt.blocking_reason: ht.blocking_reason = dt.blocking_reason changed = True + for field_name in ("flavor", "decision_id", "blocked_on", "wait_until"): + expected = getattr(dt, field_name) + if getattr(ht, field_name, None) != expected: + setattr(ht, field_name, expected) + changed = True if dt.priority: try: task_priority = TaskPriority(dt.priority.strip().lower()) @@ -1324,6 +1429,9 @@ async def reset_repository_projection( description=w.description, status=w.status or "proposed", owner=w.owner, + flavor=w.flavor, + flavor_promotion_reason=w.flavor_promotion_reason, + flavor_promoted_from=w.flavor_promoted_from, backing_filename=w.relative_path.rsplit("/", 1)[-1], backing_relative_path=w.relative_path, backing_archived=w.archived, @@ -1346,6 +1454,10 @@ async def reset_repository_projection( needs_human=t.needs_human, intervention_note=t.intervention_note, blocking_reason=t.blocking_reason, + flavor=t.flavor, + decision_id=t.decision_id, + blocked_on=t.blocked_on, + wait_until=t.wait_until, ) ) outcome.created.append(w.record_id) @@ -1372,6 +1484,11 @@ async def reset_repository_projection( if w.status and row.status != w.status: row.status = w.status changed = True + for field_name in ("flavor", "flavor_promotion_reason", "flavor_promoted_from"): + expected = getattr(w, field_name) + if getattr(row, field_name, None) != expected: + setattr(row, field_name, expected) + changed = True if row.backing_relative_path != w.relative_path: row.backing_relative_path = w.relative_path row.backing_filename = w.relative_path.rsplit("/", 1)[-1] diff --git a/docs/evidence/2026-10-04-forge-qualifier-receiving.md b/docs/evidence/2026-10-04-forge-qualifier-receiving.md new file mode 100644 index 0000000..d6749d8 --- /dev/null +++ b/docs/evidence/2026-10-04-forge-qualifier-receiving.md @@ -0,0 +1,63 @@ +# STATE-WP-0094-T04 — Forge qualifier receiving source validation + +The committed source model already supports task decision_id, blocked_on, +wait_until and flavor, and workplan flavor, flavor_promotion_reason and +flavor_promoted_from. Forge receiving omitted them. The correction carries them +through source derivation, projection JSON, dry-run diff, creation of a new +workplan/task, creation of a task on an existing workplan, and updates of existing +rows. It reuses the established closed flavor vocabulary and normalization. + +Source authority is explicit: absent or YAML-null qualifiers/flavors clear stored +values; quoted false clears needs_human. Existing identities are retained. +Date-only values remain datetime.date in persistence and ISO dates in JSON. +Bare/quoted timestamps, impossible dates, malformed qualifier YAML, invalid +boolean/text qualifiers and unknown flavor values refuse derivation before +receiving can update or cancel stored gates. The existing ForgeDeriveError API +path reports the refusal. Origin and origin_ref are retained only in source; +no schema or migration was added. Activity Core historical shared identities +and frontmatter task parsing are outside this correction. + +## Validation and retained failed trial + +Actual source validation command, run alone because the fixture database has a +session-level schema lifecycle: + +```sh +PYTHONDONTWRITEBYTECODE=1 .venv/bin/python -m pytest -p no:cacheprovider tests/test_forge_projection.py tests/test_forge_projection_qualifiers.py tests/test_work_record_projection_api.py tests/test_work_record_flavor.py tests/test_wait_kind.py -q +``` + +Final result: **130 passed in 41.68s**. `git diff --check` passes. Ruff is unavailable +in the repository venv and PATH; no lint result is claimed. + +Earlier trial: **127 passed, two failed**. Both failures exposed PyYAML's plain +ValueError for the impossible bare date 2099-02-31. Catching YAML/ValueError as a +whole-derivation refusal fixed the regression. Review before that run also found +datetime accepted through isinstance(date); exact date acceptance and timestamp +regressions corrected it. These are refinements of one trial, not extra successes. + +The real API/PostgreSQL regression proves residual creation and exclusion from +open views, promotion metadata, task creation on existing parents, qualifier +updates, source null/absent clearing, unchanged UUIDs, normalized quoted false, +independent repeat noop, and no stored-row changes after invalid source. + +Tested SHA256 values: + +- api/services/forge_projection.py: d935fc6c9cb34c5d340ac09f02c929c4244a5fb2b2cf44796972083e98c98516 +- tests/test_forge_projection_qualifiers.py: a9c83c8b312f61e540d9f131c47c2adacabe219ae89644e480a9354668f27491 + +## Boundaries and remaining live work + +This is local source correctness, not a production receiving receipt. T05 owns +current deployed revision, reviewed image/rollout/rollback preparation, separate +production authorization and primary parity through normal immutable Forge +reconciliation; direct C-41 repairs do not satisfy it. T05 also retains actual +graph edge/freshness coverage. T03 retains supported runner selection, pickup +deduplication and receipts; the execution launch-request consumer is retired. +No deployment, schedule or human decision resolution occurred. + +The historical CLI symptom remains distinguished. Today's CLI uses the same +checker and deliberately turns warnings-only exit 2 into shell success. C-16 +can skip writes, so successful process exit alone is not field parity. The prior +Forge receiving omitted new fields on creation and ignored them on existing +rows, including source removal; that does not prove it actively erased earlier +correct qualifiers or explain every historical CLI/direct-run discrepancy. diff --git a/tests/test_forge_projection_qualifiers.py b/tests/test_forge_projection_qualifiers.py new file mode 100644 index 0000000..219c301 --- /dev/null +++ b/tests/test_forge_projection_qualifiers.py @@ -0,0 +1,172 @@ +"""File-derived qualifiers survive normal receiving, including source clears.""" +from datetime import date +from pathlib import Path + +import pytest + +from api.services import forge_projection as fp +from tests.conftest import create_test_domain, create_test_repo + + +def _source(tmp_path: Path, *, workplan_fields='', task_fields='', extra_fields=None): + root = tmp_path / 'projection-test' + path = root / 'workplans/TEST-WP-0001.md' + path.parent.mkdir(parents=True, exist_ok=True) + body = ( + '---\nid: TEST-WP-0001\ntype: workplan\ntitle: Receiving test\nstatus: active\n' + + workplan_fields + '---\n\n## Gate\n\n```task\nid: TEST-WP-0001-T01\n' + 'status: wait\npriority: high\n' + task_fields + '```\n\nRetain the owner gate.\n' + ) + if extra_fields is not None: + body += '\n## Second gate\n\n```task\nid: TEST-WP-0001-T02\nstatus: wait\n' + extra_fields + '```\n' + path.write_text(body) + return root, path + + +@pytest.mark.parametrize('value', ['2099-01-31', '"2099-01-31"']) +def test_derivation_and_json_retain_normalized_metadata(tmp_path, value): + root, _ = _source(tmp_path, + workplan_fields='flavor: " RESIDUAL "\nflavor_promotion_reason: " Risk "\nflavor_promoted_from: " Planning "\n', + task_fields='flavor: " IMPLEMENTATION "\nneeds_human: "false"\ndecision_id: " decision-1 "\n' + 'blocked_on: " message-from:provider "\nwait_until: ' + value + '\n') + derived = fp.derive_from_checkout(root, 'projection-test', 'a' * 40) + workplan, task = derived.workplans[0], derived.workplans[0].tasks[0] + assert (workplan.flavor, workplan.flavor_promotion_reason, workplan.flavor_promoted_from) == ('residual', 'risk', 'planning') + assert task.needs_human is False + assert task.flavor == 'implementation' + assert task.decision_id == 'decision-1' + assert task.blocked_on == 'message-from:provider' + assert task.wait_until == date(2099, 1, 31) + payload = derived.to_dict()['workplans'][0] + assert payload['flavor'] == 'residual' + assert payload['flavor_promotion_reason'] == 'risk' + assert payload['flavor_promoted_from'] == 'planning' + assert payload['tasks'][0]['wait_until'] == '2099-01-31' + + +@pytest.mark.parametrize('field', [ + 'wait_until: not-a-date\n', 'wait_until: 2099-02-31\n', 'wait_until: [2099-01-31]\n', + 'wait_until: 2099-01-31T12:30:00Z\n', 'wait_until: "2099-01-31T00:00:00Z"\n', + 'needs_human: "perhaps"\n', 'decision_id: [decision-1]\n', + 'blocked_on: {owner: provider}\n', 'flavor: invented\n', + 'decision_id: [unclosed\n', +]) +def test_invalid_task_metadata_refuses_derivation(tmp_path, field): + root, _ = _source(tmp_path, task_fields=field) + with pytest.raises(fp.ForgeDeriveError): + fp.derive_from_checkout(root, 'projection-test', 'a' * 40) + + +@pytest.mark.parametrize('field', ['flavor: invented\n', 'flavor_promoted_from: invented\n', 'flavor_promotion_reason: invented\n']) +def test_invalid_workplan_flavor_metadata_refuses_derivation(tmp_path, field): + root, _ = _source(tmp_path, workplan_fields=field) + with pytest.raises(fp.ForgeDeriveError): + fp.derive_from_checkout(root, 'projection-test', 'a' * 40) + + +def test_dry_run_diff_exposes_qualifier_and_flavor_drift(tmp_path): + root, _ = _source(tmp_path, + workplan_fields='flavor: residual\nflavor_promotion_reason: demand\nflavor_promoted_from: planning\n', + task_fields='flavor: implementation\nneeds_human: true\nblocking_reason: Owner must review.\n' + 'decision_id: decision-1\nblocked_on: message-from:provider\nwait_until: 2099-01-31\n') + derived = fp.derive_from_checkout(root, 'projection-test', 'a' * 40) + workplan, task = derived.workplans[0], derived.workplans[0].tasks[0] + hub_workplan = {'id': workplan.uuid, 'slug': workplan.record_id.lower(), 'status': 'active', + 'backing_relative_path': workplan.relative_path} + hub_task = {'id': task.uuid, 'record_id': task.record_id, 'status': 'wait', 'needs_human': False} + diff = fp.diff_against_hub(derived, [hub_workplan], {workplan.uuid: [hub_task]}) + changes = {item['kind']: item['changed'] for item in diff.differing} + assert set(changes['workplan']) == {'flavor', 'flavor_promotion_reason', 'flavor_promoted_from'} + assert set(changes['task']) == {'flavor', 'needs_human', 'blocking_reason', 'intervention_note', 'decision_id', 'blocked_on', 'wait_until'} + assert changes['task']['wait_until']['forge'] == '2099-01-31' + # Diff is read-only, including the synthetic snapshot inputs. + assert hub_task['needs_human'] is False + assert 'flavor' not in hub_workplan + + +@pytest.mark.asyncio +async def test_real_receiving_creates_updates_and_clears_source_metadata(client, monkeypatch, tmp_path): + await create_test_domain(client) + repo = await create_test_repo(client, slug='projection-test') + from api.config import settings + from api.routers import work_record_projection as route + monkeypatch.setattr(settings, 'state_hub_instance_role', 'primary') + monkeypatch.setattr(settings, 'state_hub_instance_label', 'railiance01') + root, _ = _source(tmp_path, + workplan_fields='flavor: residual\n', + task_fields='flavor: residual\nneeds_human: true\nblocking_reason: Founder must review.\n' + 'decision_id: decision-1\nblocked_on: message-from:provider\nwait_until: 2099-01-31\n') + revision = ['a' * 40] + monkeypatch.setattr(route, 'derive_from_forge', lambda slug: fp.derive_from_checkout(root, slug, revision[0])) + endpoint = '/repos/projection-test/work-record-projection/reconcile' + + async def reconcile(key): + response = await client.post(endpoint, json={'expected_commit': revision[0]}, headers={'Idempotency-Key': key}) + assert response.status_code == 200, response.text + return response.json()['outcome'] + + async def snapshot(): + response = await client.get('/repos/projection-test/work-record-projection/snapshot') + assert response.status_code == 200 + return response.json() + + first = await reconcile('qualifiers:create') + assert first['status'] == 'applied' + data = await snapshot() + workplan = data['workplans'][0] + task = data['tasks'][0] + assert workplan['flavor'] == task['flavor'] == 'residual' + assert task['needs_human'] is True + assert task['intervention_note'] == 'Founder must review.' + assert task['decision_id'] == 'decision-1' + assert task['blocked_on'] == 'message-from:provider' + assert task['wait_until'] == '2099-01-31' + hidden = (await client.get('/workplans/', params={'repo_id': repo['id'], 'include_residuals': 'false'})).json() + assert hidden == [] + summary = (await client.get('/state/summary')).json() + assert workplan['id'] not in {row['id'] for row in summary['open_workplans']} + + # Existing workplan, existing task, and a new task on that existing parent. + _source(tmp_path, + workplan_fields='flavor: implementation\nflavor_promotion_reason: risk\nflavor_promoted_from: residual\n', + task_fields='flavor: extension\nneeds_human: true\nblocking_reason: Founder must review.\n' + 'decision_id: decision-2\nblocked_on: message-from:other\nwait_until: 2099-02-01\n', + extra_fields='flavor: residual\ndecision_id: decision-3\nblocked_on: message-from:new-owner\nwait_until: 2099-03-01\n') + revision[0] = 'b' * 40 + assert (await reconcile('qualifiers:update'))['status'] == 'applied' + updated = await snapshot() + workplan = updated['workplans'][0] + tasks = {row['record_id']: row for row in updated['tasks']} + assert (workplan['flavor'], workplan['flavor_promotion_reason'], workplan['flavor_promoted_from']) == ('implementation', 'risk', 'residual') + task = tasks['TEST-WP-0001-T01'] + assert (task['flavor'], task['decision_id'], task['blocked_on'], task['wait_until']) == ('extension', 'decision-2', 'message-from:other', '2099-02-01') + second = tasks['TEST-WP-0001-T02'] + assert (second['flavor'], second['decision_id'], second['blocked_on'], second['wait_until']) == ('residual', 'decision-3', 'message-from:new-owner', '2099-03-01') + groups = {workplan['id']: list(tasks.values())} + diff = fp.diff_against_hub(fp.derive_from_checkout(root, 'projection-test', revision[0]), [workplan], groups) + assert diff.missing == diff.stale == diff.differing == [] + + # Omitting qualifiers clears them; explicit null also clears. Quoted false + # clears the human flag. Source identities survive every update. + _source(tmp_path, task_fields='needs_human: "false"\ndecision_id: null\n', extra_fields='wait_until: null\n') + revision[0] = 'c' * 40 + assert (await reconcile('qualifiers:clear'))['status'] == 'applied' + cleared = await snapshot() + assert cleared['workplans'][0]['id'] == workplan['id'] + for name in ('flavor', 'flavor_promotion_reason', 'flavor_promoted_from'): + assert cleared['workplans'][0][name] is None + assert {row['id'] for row in cleared['tasks']} == {row['id'] for row in updated['tasks']} + for row in cleared['tasks']: + assert row['needs_human'] is False + for name in ('flavor', 'decision_id', 'blocked_on', 'wait_until', 'blocking_reason', 'intervention_note'): + assert row[name] is None + assert (await reconcile('qualifiers:independent-rederivation'))['status'] == 'noop' + + # Invalid source dates fail before receiving can replace/cancel stored gates. + _source(tmp_path, task_fields='wait_until: 2099-02-31\n', extra_fields='') + revision[0] = 'd' * 40 + invalid = await client.post(endpoint, json={'expected_commit': revision[0]}, headers={'Idempotency-Key': 'qualifiers:invalid'}) + assert invalid.status_code == 502 + unchanged = await snapshot() + assert unchanged['workplans'] == cleared['workplans'] + assert unchanged['tasks'] == cleared['tasks'] diff --git a/workplans/STATE-WP-0094-routed-gates-and-owner-returns.md b/workplans/STATE-WP-0094-routed-gates-and-owner-returns.md index 545075c..d6557e4 100644 --- a/workplans/STATE-WP-0094-routed-gates-and-owner-returns.md +++ b/workplans/STATE-WP-0094-routed-gates-and-owner-returns.md @@ -58,21 +58,25 @@ record receipts and provide an opt-in activation plan. Reuse the Custodian dependency monitor and established runner; do not introduce another schedule. Production activation remains a separately reviewed founder action. -## Diagnose qualifier projection parity (P6a) +## Correct Forge qualifier receiving in source (P6a) ```task id: STATE-WP-0094-T04 -status: todo +status: done priority: high state_hub_task_id: "cd03399c-a905-5804-9700-e5e763d2500b" ``` -Investigate report 70d08f35-a008-4b2c-878d-d9f32db892ce with the current editable -CLI and direct checker from alternate CWDs. Separate missing canonical record_id -from real UUID-addressed qualifier drift. Retain the historical report even if -current source does not reproduce it; correct only an evidenced defect. +Correct the evidenced immutable Forge receiving omission: task decision_id, +blocked_on, wait_until and flavor, and workplan flavor/promotion fields were +absent from derivation, serialization, diff and create/update receiving paths. +Verify source normalization, refusal of malformed qualifiers before receiving, +creation, updates, deliberate null/absent clears and repeat noop against the real +API and PostgreSQL test database. This task completes source correctness only; +historical CLI report 70d08f35-a008-4b2c-878d-d9f32db892ce remains separate from +that evidenced omission. Primary deployment and normal-sync parity remain T05. -## Assess nightly graph maintenance against the Forge boundary (P6) +## Prepare approved receiving promotion and assess graph freshness (P6) ```task id: STATE-WP-0094-T05 @@ -110,3 +114,44 @@ this form. Production Helm source explicitly disables it pending RMGR-WP-0005-T1 the supported alternative is immutable Forge reconciliation (STATE-WP-0086) and a separately reviewed freshness/wakeup runner under T03/T05. No scheduler, deployment, credential or human decision was changed. + + +## Source validation receipt + +2026-10-04 T04 source correction verified: 130 receiving, flavor and wait-kind +tests pass in the serial real API/PostgreSQL suite; whitespace check passes. +The earlier trial had 127 passing and two failing tests: PyYAML raised ValueError +for an impossible bare date before the date validator. The refinement converts +both invalid-date and malformed-task-YAML errors into a derivation refusal, +preserving existing gates. A review also identified datetime being a date +subclass; exact date acceptance plus bare/quoted timestamp regressions now +enforce YYYY-MM-DD. These refinements remain one correction trial. + +Evidence: [Forge qualifier receiving](../docs/evidence/2026-10-04-forge-qualifier-receiving.md). +Origin/origin_ref remain in source because the workplan model has no such fields. +No migration, production deployment or schedule was performed. Ruff was absent +from the venv and PATH; no lint success is claimed. + +T05 stays live: identify the actual deployed receiving revision, prepare a +concrete reviewed image/rollout/rollback package, obtain the separate per-change +production authorization, then prove primary qualifier/flavor/null-clear parity +through normal immutable Forge reconciliation without follow-up field PATCHes. +Point-in-time direct C-41 repairs do not supply that receiving proof. Confirm +task-level graph edge support and actual supported freshness coverage as well. + +T03 stays live: api/services/execution_queue.py rejects new launch requests and +has no launch-request pickup consumer. Select the supported owner-agent runner, +stable task/message deduplication and receipts through owner interfaces; do not +wire C-25 to the retired queue. Current chart sweep is disabled and legacy +hostPath fields are rollback-only. Activity Core owns schedules/ops runs, while +its runbook routes the C-rule engine to Repo Manager. Older live-sweep prose in +docs/activity-core-delegation.md does not establish current runtime coverage. + +Historical CLI causality distinction: cmd_fix_consistency forwards the same +checker, --fix/path/API/no-writeback arguments and intentionally maps warnings +exit 2 to shell success. C-16 can skip writes despite that successful shell exit. +This does not isolate every historical CLI/direct-checker discrepancy. The old +Forge path omitted new fields on creation and left them untouched on existing +rows; it did not explicitly erase already-correct new qualifiers. Source removal +now deliberately clears them. Do not call the original CLI report or deployed +receiving fixed merely because this bounded source task is done.