Verify offline scaffolds and preserve Forge qualifier receiving
Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a10391-6355-7b41-b0fa-3e14e874f091
This commit is contained in:
parent
cd9dad05e8
commit
3791e608ba
5 changed files with 417 additions and 14 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
63
docs/evidence/2026-10-04-forge-qualifier-receiving.md
Normal file
63
docs/evidence/2026-10-04-forge-qualifier-receiving.md
Normal file
|
|
@ -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.
|
||||
172
tests/test_forge_projection_qualifiers.py
Normal file
172
tests/test_forge_projection_qualifiers.py
Normal file
|
|
@ -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']
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue