diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r3/state-hub/tests/test_frozen_binding_guard_isolated.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r3/state-hub/tests/test_frozen_binding_guard_isolated.py new file mode 100644 index 00000000..f6dac2ce --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r3/state-hub/tests/test_frozen_binding_guard_isolated.py @@ -0,0 +1,143 @@ +from pathlib import Path +import ast, asyncio, importlib.util, types, uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +import pytest + +ROOT = Path(__file__).resolve().parents[1] +spec=importlib.util.spec_from_file_location('historical',ROOT/'api/services/historical_projection.py') +h=importlib.util.module_from_spec(spec);spec.loader.exec_module(h) +B=h.FROZEN_CLUSTER_BINDINGS[0] +def binding(**changes): + d=dict(workplan_id=uuid.UUID(B['uuid']),repo_slug=B['repo'],filename=Path(B['path']).name,relative_path=B['path'],archived=True,status='finished');d.update(changes);return d +class Obj: + def __init__(self,**kw):self.__dict__.update(kw);self.writes=[] + def __setattr__(self,k,v): + if 'writes' in self.__dict__:self.writes.append(k) + object.__setattr__(self,k,v) +class Entry(Obj): + def model_dump(self):return {k:v for k,v in self.__dict__.items() if k!='writes'} +class Col: + def in_(self,x):return self + def __eq__(self,x):return self +class Query: + def __init__(self):self.locked=False;self.refreshed=False;self.ordered=False + def where(self,*a):return self + def order_by(self,*a):self.ordered=True;return self + def with_for_update(self):self.locked=True;return self + def execution_options(self,**kw):self.refreshed=kw.get("populate_existing") is True;return self +class Model: id=Col();slug=Col();workplan_id=Col() +class Result: + def __init__(self,values):self.values=values + def scalars(self):return self + def all(self):return self.values + def scalar_one_or_none(self):return self.values[0] if self.values else None +class Session: + def __init__(self,rows):self.results=iter(rows);self.commits=0;self.queries=[] + async def execute(self,q):self.queries.append(q);return Result(next(self.results)) + async def commit(self):self.commits+=1 +class HTTPException(Exception): + def __init__(self,status_code,detail):self.status_code=status_code;super().__init__(detail) +def extracted(path,name,extra): + tree=ast.parse((ROOT/path).read_text());node=next(n for n in tree.body if isinstance(n,(ast.FunctionDef,ast.AsyncFunctionDef)) and n.name==name) + node.decorator_list=[];node.returns=None + for a in node.args.args + node.args.kwonlyargs:a.annotation=None + node.args.defaults=[] + ns=dict(Path=Path,datetime=datetime,timezone=timezone,**extra) + exec(compile(ast.Module(body=[node],type_ignores=[]),str(path),'exec'),ns);return ns[name] +def actual_report(): + tree=ast.parse((ROOT/'scripts/consistency_check.py').read_text()) + nodes=[n for n in tree.body if (isinstance(n,ast.ClassDef) and n.name in {'Issue','ConsistencyReport'}) or (isinstance(n,ast.AnnAssign) and isinstance(n.target,ast.Name) and n.target.id=='_AUTOMATION_ERROR_CHECKS')] + ns=dict(dataclass=dataclass,field=field,_AUTOMATION_ERROR_CHECKS=frozenset({'C-00','C-44'})) + exec(compile(ast.Module(body=nodes,type_ignores=[]),'actual_report','exec'),ns) + return ns['ConsistencyReport']('railiance-cluster','/unused') + +def closure(): + repo=Obj(id=uuid.uuid4());parent=Obj(id=uuid.UUID(B['uuid']),repo_id=repo.id,status='finished') + tasks=[Obj(id=uuid.UUID(t['uuid']),workplan_id=parent.id,status='done') for t in B['tasks']] + return repo,parent,tasks +async def run_api(entries,repo,parent,tasks,ordinary=None): + rows=([parent] if parent else [])+([ordinary] if ordinary else []) + session=Session([rows,[repo] if repo else [],tasks]) + fn=extracted('api/routers/workstreams.py','sync_workplan_bindings',dict(frozen_cluster_binding=h.frozen_cluster_binding,validate_frozen_cluster_rows=h.validate_frozen_cluster_rows,HTTPException=HTTPException,select=lambda *_:Query(),Workplan=Model,Task=Model,ManagedRepo=Model,_invalidate_workplan_index_cache=lambda:None)) + result=await fn(Obj(bindings=[Entry(**e) for e in entries]),session) + return result,session +@pytest.mark.parametrize('change',[dict(repo_slug='foreign'),dict(workplan_id=uuid.uuid4()),dict(relative_path='workplans/other.md'),dict(filename='other.md'),dict(archived=False),dict(status='active')]) +def test_metadata_refuses(change): + with pytest.raises(ValueError):h.frozen_cluster_binding(binding(**change)) +def test_unrelated_binding_unchanged():assert h.frozen_cluster_binding(binding(workplan_id=uuid.uuid4(),filename='ordinary.md',relative_path='workplans/ordinary.md',repo_slug='other')) is None +@pytest.mark.parametrize('fault',['parent_missing','foreign_repo','parent_active','task_missing','task_active','task_foreign','repo_missing']) +def test_mixed_batch_refusal_precedes_all_writes(fault): + repo,parent,tasks=closure();ordinary=Obj(id=uuid.uuid4()) + if fault=='repo_missing':repo=None + if fault=='parent_missing':parent=None + if fault=='foreign_repo':parent.repo_id=uuid.uuid4() + if fault=='parent_active':parent.status='active' + if fault=='task_missing':tasks.pop() + if fault=='task_active':tasks[0].status='progress' + if fault=='task_foreign':tasks[0].workplan_id=uuid.uuid4() + if parent:parent.writes.clear() + for t in tasks:t.writes.clear() + normal=binding(workplan_id=ordinary.id,repo_slug='ordinary',filename='ordinary.md',relative_path='workplans/ordinary.md') + with pytest.raises(HTTPException):asyncio.run(run_api([normal,binding()],repo,parent,tasks,ordinary)) + assert ordinary.writes==[] + if parent:assert parent.writes==[] + assert all(t.writes==[] for t in tasks) +def test_exact_frozen_no_setters_or_commit(): + repo,parent,tasks=closure();result,session=asyncio.run(run_api([binding()],repo,parent,tasks)) + assert result==dict(updated=0,received=1,preserved=1);assert session.commits==0;assert parent.writes==[] + assert all(q.locked and q.refreshed and q.ordered for q in (session.queries[0],session.queries[2])) +def test_mixed_valid_preserves_frozen_and_updates_ordinary(): + repo,parent,tasks=closure();ordinary=Obj(id=uuid.uuid4()) + normal=binding(workplan_id=ordinary.id,repo_slug='ordinary',filename='ordinary.md',relative_path='workplans/ordinary.md') + result,session=asyncio.run(run_api([normal,binding()],repo,parent,tasks,ordinary)) + assert result==dict(updated=1,received=2,preserved=1);assert session.commits==1;assert parent.writes==[] + assert ordinary.writes==['backing_filename','backing_relative_path','backing_archived','backing_synced_at'] +@pytest.mark.parametrize('tamper',['metadata','bytes','symlink','missing_scan','missing_uuid','valid']) +@pytest.mark.parametrize("bound_index", range(3)) +def test_cli_exact_source_omission_or_whole_batch_refusal(tmp_path,tamper,bound_index): + bound=h.FROZEN_CLUSTER_BINDINGS[bound_index] + import shutil + infos=[] + for entry in h.FROZEN_CLUSTER_BINDINGS: + target=tmp_path/entry['path'];target.parent.mkdir(parents=True,exist_ok=True) + shutil.copyfile(Path('/home/worsch/railiance-cluster')/entry['path'],target) + infos.append((target,dict(state_hub_workstream_id=entry['uuid'],status='finished'),'')) + path=tmp_path/bound['path'] + meta=dict(state_hub_workstream_id=bound['uuid'],status='finished') + if tamper=='metadata':meta['status']='active' + if tamper=='missing_uuid':meta.pop('state_hub_workstream_id') + if tamper=='bytes':path.write_text(path.read_text()+'\n') + if tamper=='symlink':path.unlink();path.symlink_to(Path('/home/worsch/railiance-cluster')/bound['path']) + ordinary=tmp_path/'workplans/ordinary.md';ordinary.write_text('ordinary') + if tamper=='missing_scan':infos.pop(bound_index) + else:infos[bound_index]=(path,meta,'') + calls=[];report=actual_report() + fn=extracted('scripts/consistency_check.py','_sync_workplan_bindings',dict(FROZEN_CLUSTER_BINDINGS=h.FROZEN_CLUSTER_BINDINGS,frozen_cluster_binding=h.frozen_cluster_binding,normalise_workstream_status=lambda s:s,workplan_display_path=lambda root,p:p.relative_to(root).as_posix(),_api_put=lambda *a:calls.append(a) or dict(updated=1,received=1))) + fn('http://unused','railiance-cluster',[(ordinary,dict(state_hub_workstream_id=str(uuid.uuid4()),status='active'),''),*infos],tmp_path,report) + if tamper=='valid':assert len(calls)==1 and len(calls[0][2]['bindings'])==1 + else:assert calls==[] and report.failures[0].check_id=='C-44' and report.automation_errors + + +def test_api_tampered_mixed_batch_refuses_before_queries_or_assignments(): + repo,parent,tasks=closure();ordinary=Obj(id=uuid.uuid4()) + with pytest.raises(HTTPException): + asyncio.run(run_api([binding(workplan_id=ordinary.id,repo_slug='ordinary',filename='ordinary.md',relative_path='workplans/ordinary.md'),binding(archived=False)],repo,parent,tasks,ordinary)) + assert ordinary.writes==[] and parent.writes==[] + + +def test_foreign_repo_same_filename_path_is_ordinary(): + assert h.frozen_cluster_binding(binding(workplan_id=uuid.uuid4(),repo_slug='foreign')) is None + + +@pytest.mark.parametrize('entry',h.FROZEN_CLUSTER_BINDINGS) +def test_all_three_registry_bindings_are_guarded(entry): + good=dict(workplan_id=entry['uuid'],repo_slug=entry['repo'],filename=Path(entry['path']).name,relative_path=entry['path'],archived=True,status='finished') + assert h.frozen_cluster_binding(good) is entry + with pytest.raises(ValueError):h.frozen_cluster_binding(dict(good,status='active')) + + +def test_real_cli_exit_criterion_is_nonzero_including_remote_all(): + report=actual_report();report.add(severity='FAIL',check_id='C-44',message='refused') + fn=extracted('scripts/consistency_check.py','consistency_exit_code',{}) + assert fn([report])==1 and fn([report],remote_all=True)==1 diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r3/state-hub/workplans/STATE-WP-0095-historical-source-receiving.md b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r3/state-hub/workplans/STATE-WP-0095-historical-source-receiving.md new file mode 100644 index 00000000..ac44ba08 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r3/state-hub/workplans/STATE-WP-0095-historical-source-receiving.md @@ -0,0 +1,335 @@ +--- +id: STATE-WP-0095 +type: workplan +title: "Preserve exact closed historical source bindings in Forge receiving" +domain: infotech +repo: state-hub +status: active +owner: codex +created: "2026-10-04" +updated: "2026-10-17" +flavor: implementation +depends_on: [] +state_hub_workstream_id: "79579572-8120-5d3b-aca5-dd4aff60d058" +--- + +## Goal + +Normal Forge receiving must preserve the seven bound Activity legacy source +views and the Custodian retired ADHOC source without new identities, source +rewrites, task movement, deletion, relabeling or unretirement. This implements +receiving support before RMGR-WP-0019 registrar admission. CUST-WP-0077 owns +cross-owner coordination; deployment and replay remain explicit owner returns. + +## Verify source bindings and shared views + +```task +id: STATE-WP-0095-T01 +status: done +priority: high +state_hub_task_id: "6a5f87bb-e63d-5e20-b9fc-c1b5cd403ff7" +``` + +Reviewed seven exact Activity files: four existing workplans and 64 UUID-bound +closed tasks. The archived custodian-WP-0003 parent and three split_from views +share one existing parent UUID; every repeated task binding/content agrees. +Custodian ADHOC-2026-08-25-retired retains parent +1c53d3db-6633-5e2d-987f-10706d73be9e and task +d8af3e6b-26ae-580c-bbeb-caa8ad419bec. No identity migration is authorized. + +## Implement preservation receiving and global collision refusal + +```task +id: STATE-WP-0095-T02 +status: done +priority: high +state_hub_task_id: "ebb24194-e4cc-5130-aaa6-663f2ea2c822" +``` + +Prepared exact source digest manifest, existing-parent/task verification, +immutable receiving and UUID-based historical diff. Ordinary canonical sources +continue deriving their identities. Missing/changed/symlinked source, absent +bound rows or nonterminal hub histories refuse. Check task UUID holders globally, +including retired and acknowledged-stale parent workplans, before INSERT. + +Offline preview tests and snapshot proof are evidence of source behavior only; +finish after applying reviewed source and its focused tests to the owning repo. + +## Deliver receiving through normal deployment and verify owner return + +```task +id: STATE-WP-0095-T03 +status: done +priority: high +depends_on: [STATE-WP-0095-T02] +state_hub_task_id: "e8566341-cf6e-5b9c-ad80-8564d14cd52a" +decision_id: "36772026-f43d-4331-b466-5f4b1afe4eca" +``` + +No direct live code patch. This task is a deployment return, not policy authority +or permission to resolve identity decisions. Exact receiving fixture acceptance +and global collision rejection must hold in the delivered version. + +## Verify normal historical replay preserves existing identities + +```task +id: STATE-WP-0095-T04 +status: done +priority: high +depends_on: [STATE-WP-0095-T03, RMGR-WP-0019-T03] +state_hub_task_id: "3bbb311f-194e-5862-8aed-358a580f22b4" +``` + +Verify Activity four historical parent UUIDs/64 task UUIDs and Custodian retired +parent/task unchanged; no historical row creation, movement, deletion, +relabeling or unretirement. Unrelated stale projections remain separate decisions. + +2026-10-05: Source exact-set retirement support now exists. Repository projection +reconcile accepts `expected_retirement_uuids`; that set must equal the stale +projections, and any task or dependency edge on a stale row refuses the call. +Live apply still needs a reviewed rollout. On 2026-10-05 the founder accepted +review `review-1ece4f3e-d0e3-4fd6-abc8-272f1b1876f4`, which names only the three +empty projections and preserves parent `b4eb45a9-69e3-4ab0-b00c-67a53c3117c5`. +That acceptance is not a live retirement and does not resolve +`91b7c633-a66f-5d43-9f4b-6565aefb9356`. + +## Offline evidence, 2026-10-04 + +Prepared receiver suite: Forge projection regression plus historical preservation, +missing/changed source, terminality, immutable UUID diff, and global holder query. +Real-source offline snapshot proof preserves four Activity parents/64 tasks and +one Custodian parent/task without mutations; no API writes or deployment occurred. +Exact original bindings are maintained in api/services/historical_projection.py. + +2026-10-04 actual owning-source validation: 155 focused receiver/qualifier/planner +cases passed; the independent offline current-source/current-hub-snapshot proof +retained five existing parents and 65 task UUIDs, no mutations, clean historical +diff. T02 is source-complete; T03/T04 remain wait for reviewed deployment and +normal registrar/replay receiving, respectively. Evidence: docs/evidence/2026-10-04-historical-receiving-source.json. + + +## Verified receiving rollout and remaining replay gate, 2026-10-04 + +T03 is done on actual founder approval of proposal +STATE-WP-0095-T03-historical-receiving revision 1, SHA256 +`d6789359b3fe7988a6201fff6f367cb46948aa197e1cc0ca66c31f45e1ea9981`, +approval receipt reference `user_reply:call_o87uEfFs3AtXfxO5YqUnSpcg:0`. +The normal owner Helm path delivered `main-5dd3df9`, commit +`5dd3df9c69eb564af10ea5ea01e349786212501a`, revision 72; API and MCP are Ready, +primary/railiance01 health and schema a0b1c2d3e4f5 verified, running source +hashes match. Rollback remains revision 71; no receipt-table downgrade is needed. +Actual historical preservation and global task-holder refusal were verified in +the delivered version. Durable receipt: +[verified historical receiving rollout](../docs/evidence/2026-10-04-historical-receiving-rollout.json). + +Custodian immutable replay applied while preserving the retired historical +parent/task UUIDs. Activity immutable replay retained all four historical parent +and 64 task UUIDs and historical row contents, but refused transactionally on +three unrelated stale projections: custodian-wp-0003 (archived), +custodian-wp-0003a (done), custodian-wp-0003b (done). No retirement acknowledgement +was issued and that refusal is not represented as normal replay success. + +Registrar source `c41d36c2` has 38 actual passing tests and Repo Manager's own +normal sync succeeded; registrar admission does not authorize retiring Activity +projections. T04 therefore remains wait, retaining RMGR-WP-0019-T03 and awaiting +Activity Core's precise reviewed disposition of those three stale rows. No +rename, identity migration, new paid native trial or further live action is +included in this source status update. + +## Activity Core return, 2026-10-06 + +Activity Core returned the exact disposition (message +`cf2d5847-8b3f-4653-9d9f-d5fcfbe3f1e4`, ACTIVITY-WP-0045, activity-core commit +`941fac0`). `expected_retirement_uuids`: +`683724d9-4249-5e0e-88e0-7baff4330d58` (custodian-wp-0003), +`644017c7-c5f2-5a64-81c3-d8ee806f8251` (custodian-wp-0003a), +`a5806df9-5b9a-50da-81c1-7e606076bed9` (custodian-wp-0003b). Each has zero +tasks and zero dependency edges. None is preserved parent +`b4eb45a9-69e3-4ab0-b00c-67a53c3117c5`. Repo Manager independently verified the +set (message `916841f6`, repo-manager commit `498682c`). Nothing was retired. +T04 now waits on decision `91b7c633` and a reviewed live rollout. After that, +Repo Manager re-runs the Activity owner sync and closes RMGR-WP-0019-T03. + +## Retirement rollout and T04 verification, 2026-10-06 + +The founder resolved decision `91b7c633` (retire exactly the three projections) +and approved the rollout. The founder ran the normal Helm upgrade to +`main-968a811` (revision 74; rollback revision 73, `main-fad9b3f`). API and MCP +are Ready, health is ok on primary/railiance01, and schema `b3c4d5e6f7a8` is +unchanged; no migrations. The activity-core reconcile at commit +`67a92d2ff8e033baf2c36998d24187fe83edec84` with `expected_retirement_uuids` set +to the three UUIDs applied: 0 created, 3 retired (tombstoned with an +`@retired-20261006` slug; UUIDs and statuses kept), 0 refused, 0 tasks +cancelled. All 7 bound Activity source files still match their digests. The four +historical parents and 64 task UUIDs are present under their bound parents with +unchanged fields. The Custodian retired parent and task are unchanged. Activity +had 54 workplans and 288 tasks before and after. Evidence: +[activity stale retirement rollout](../docs/evidence/2026-10-06-activity-stale-retirement-rollout.json). +Repo Manager's re-run of the Activity owner sync (RMGR-WP-0019-T03) and +ACTIVITY-WP-0045-T02 remain with their owners. + + +## Cluster exact historical receiving successor, 2026-10-17 + +This scoped extension retains the completed Activity/Custodian outcomes above. +Three frozen cluster archives contain three existing parent UUIDs and 15 existing +closed task UUIDs. Extend the existing exact historical receiving path before +registrar admission; never derive replacement identities or modify those +sources. Cross-owner observation attaches to existing SHR-WP-0001, with +RMGR-WP-0019 as obligor/beneficiary and NK-WP-0049-T35 benefiting indirectly. +The local derived case is waiting; no live worker injection is authorized. + +## Prepare exact cluster source preservation and refusal cases + +```task +id: STATE-WP-0095-T05 +status: done +priority: high +state_hub_task_id: "339b9a37-181c-5599-9a89-0393e199bd4b" +``` + +Prepared the same full-file SHA256 and 15 task binding manifest as Repo Manager. +Candidate receiving tests pass 26: existing rows remain entirely unchanged; +missing/foreign/nonterminal rows and unknown, changed, missing or symlinked +source refuse. Read-only public metadata confirms all 18 existing rows have the +expected ownership and terminal status. These are source and metadata checks, +not delivered receiving or a committed native trial. + +## Deliver reviewed cluster receiving successor before registrar admission + +```task +id: STATE-WP-0095-T06 +status: done +priority: high +depends_on: [STATE-WP-0095-T05] +needs_human: false +intervention_note: "Exact revision 4 approved and verified; no pending rollout intervention." +state_hub_task_id: "6af973b4-1964-5d4d-a2f8-8dbd4c01fda4" +``` + +Completed 2026-10-17 on the founder's approval of exact revision 4 of stable +proposal STATE-WP-0095-T06-cluster-frozen-receiving-r1 (decision + e3798419-8aa9-5425-8c08-20df6bd82a19; approved digest + sha256:63a02e8296d126c0158b7eb2c4ce79c7eeb6f8d6f61298d473c1065c7557ba32). +The ordinary owner Helm path delivered revision 76, runtime source 51bc70d and +exact image manifest sha256:c802690ee1811b3d77df4077936f9cdced1385514e8673fcbc9232b84d645015. +API/MCP Ready and primary/railiance01 health report expected schema +c4d5e6f7a8b9; delivered historical module sha256:e3c4bf965764b32a6df5d61c71272ee22569df9a9ddae2a3fc3df97ec554e0b2 matches. +All three parent and 15 task full DB row hashes/columns/UUIDs remained unchanged. +Five actual native probes passed: preserve, actual foreign-parent holder move, +nonterminal task, nonterminal parent and missing task. Every probe rolled back; +a fresh transaction confirmed complete row restoration. The foreign-holder +case precisely verified historical binding refusal, not the separate global +UUID-holder creation branch. No committed probe writes were made. + +Evidence: [verified receiving rollout](../docs/evidence/2026-10-17-cluster-historical-receiving-rollout.json). +This actual receiving return satisfies the prerequisite of RMGR-WP-0019-T05. +Normal cluster publication and its immutable-source/row return remain T07; +source readiness and this deployment do not count that later acceptance twice. +The additive inbox migration is retained; Helm rollback does not downgrade +schema or stop an active migration Job. Original failures and exact-UID recovery +limits remain in the referenced approved packet. No TLS recovery/native private +admission or new autonomy/cost grant is inferred. + +## Verify ordinary cluster receiving and immutable historical rows + +```task +id: STATE-WP-0095-T07 +status: wait +priority: high +depends_on: [STATE-WP-0095-T06, RMGR-WP-0019-T06] +state_hub_task_id: "cfd1ea99-e75b-542e-a434-acefb62f3c7d" +``` + +Capture normal cluster owner publication/receiving receipts and compare entire +existing row snapshots and exact source bytes. No creation, movement, relabel, +unretirement or retirement is authorized for these histories. A rollback first +fences cluster sync and restores registrar refusal before removing receiving +support; it never rewrites archived IDs or UUIDs. This work does not establish +original TLS recovery or owner-native admission. + +## Fix policy-compatible Secret-template server dry-run preparation + +```task +id: STATE-WP-0095-T08 +status: done +priority: high +state_hub_task_id: "2dd9b04f-2250-5a8a-b5bc-c8a009872858" +``` + +Prepared the narrow Makefile successor after the existing readiness command +refused its first Secret-template phase. Ordinary client-side apply adds the +last-applied annotation, correctly denied by the existing Platform +reject-secret-last-applied policy. Only the two Secret-template server +dry-run requests now use server-side apply with dry-run; no force-conflicts, +Secret value change, policy relaxation or actual Secret write is introduced. +Command-fixture tests verify both requests and preserve other dry-run phases. +Evidence and original refusal remain in the Custodian cluster historical +publication packet, revisions/r2 and r3-readiness. This is completed source +preparation; T06 separately records the verified actual rollout. The runtime image +continues to be main-51bc70d; Makefile/workplan changes do not create a new +application image or native acceptance. + + +T08 source repair refinement, 2026-10-17: the required normal Make render +correctly refused a pre-existing generated chart classification copy at v1.0 +after authoritative canon reached v1.1. Regenerated only through the existing +scripts/sync_classification_allowed.py, preserving canon and strict check. +The exact source diff is version 1.0→1.1 plus publication/history/participation. +Normal Make rendering and the focused classification check passed in an +isolated owner-layout copy. The original refusal is retained in the Custodian +owner-followthrough-r1 validation receipt. This is source repair only: no live +ConfigMap apply, new runtime image, policy relaxation or T07 completion. + + +T07 actual ordinary-publication return, 2026-10-17: cluster owner deep consistency +and normal fast projection completed, exact archived source bytes and all 15 +historical task full-row hashes remained unchanged. The later full-row check +FAILED for exactly the three parent rows. The earlier approved T06 rollout had +preserved all 18 rows at its verification stage; that success is not the later +whole-pipeline acceptance. The original snapshots retain hashes only, so exact +changed columns and original field preimages are not established. No guessed +DB repair or claim that all 18 original row hashes are restored is authorized. +T07 and RMGR-WP-0019-T06 remain waiting pending T09/T10 and a reviewed return. + +## Guard immutable historical index bindings in CLI and receiving API + +```task +id: STATE-WP-0095-T09 +status: progress +priority: high +depends_on: [STATE-WP-0095-T06] +``` + +Prepare and publish the narrowly scoped ordinary-binding repair for the three +existing task-bound Cluster registry entries. The CLI verifies exact frozen +source metadata/bytes and omits those bindings, including on old receivers. +The API independently validates the complete batch before any mutation, checks +actual repo ownership and the existing terminal parent/task closure, and +preserves valid frozen rows without field or timestamp assignments. Invalid +metadata, missing/foreign/nonterminal rows refuse the batch. Unknown ordinary +bindings retain existing behavior; historical IDs, UUIDs and source bytes stay +unchanged. Isolated regression tests and normal owner rendering are required. +Mark done only after reviewed source publication and normal owner projection; +source preparation does not establish delivered runtime or T07 acceptance. + +## Deliver and verify the ordinary-binding guard successor + +```task +id: STATE-WP-0095-T10 +status: wait +priority: high +depends_on: [STATE-WP-0095-T09] +needs_human: true +intervention_note: "New API runtime requires its own exact image, reviewed preflight and scoped rollout approval; prior revision-4 approval does not cover it." +``` + +After source publication, resolve normal CI status and immutable image identity, +verify delivered guard source closure, prepare exact chart/reuse-values dry-run, +full closure/headroom/schema baseline and conditional recovery. Retain additive +migration and active-Job cancellation limitations; no Helm schema downgrade or +bounded-outage claim. Obtain scoped approval before live rollout. Then verify +new full-row baseline preservation and actual rollback-only refusal probes, +and only then return to T07 ordinary-pipeline acceptance. This prevents future +binding mutation; it does not reconstruct or restore original parent hashes. +No production changes, new autonomy or credential authority are granted here. diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r4/state-hub/Makefile b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r4/state-hub/Makefile new file mode 100644 index 00000000..cec684ec --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r4/state-hub/Makefile @@ -0,0 +1,517 @@ +.PHONY: start install install-cli dashboard-install dashboard-check db db-tools migrate seed api dashboard dashboard-local sync check-primary primary-port-clear check check-local test test-python clean register-project register-codex-project register-mcp configure-codex bootstrap-env dev-hub edge-relay mcp-profile validate-adr add-domain rename-domain add-repo list-repos register-path register-from-classification register-from-classification-all cleanup-stale tunnels-up tunnels-status tunnels-check bridges install-hooks install-hooks-all gitea-inventory token-reconcile railiance-state-hub-render railiance-state-hub-client-dry-run railiance-state-hub-server-dry-run railiance-state-hub-headroom + +COMPOSE = docker compose -f infra/docker-compose.yml --env-file .env +PYTHON ?= python3 +HELM ?= $(shell command -v helm 2>/dev/null || if [ -x "$$HOME/.local/bin/helm" ]; then printf "%s" "$$HOME/.local/bin/helm"; else printf "%s" "helm"; fi) +KUBECTL ?= $(shell command -v kubectl 2>/dev/null || if [ -x "$$HOME/.local/bin/kubectl" ]; then printf "%s" "$$HOME/.local/bin/kubectl"; else printf "%s" "kubectl"; fi) + +RAILIANCE_STATE_HUB_RELEASE ?= state-hub +RAILIANCE_STATE_HUB_NAMESPACE ?= state-hub +RAILIANCE_STATE_HUB_CHART ?= deploy/railiance/apps/charts/state-hub +RAILIANCE_STATE_HUB_VALUES ?= deploy/railiance/apps/helm/state-hub-values.yaml +RAILIANCE_STATE_HUB_IMAGE_TAG ?= main-51bc70d@sha256:c802690ee1811b3d77df4077936f9cdced1385514e8673fcbc9232b84d645015 +RAILIANCE_STATE_HUB_PLATFORM_DIR ?= deploy/railiance/platform +RAILIANCE_STATE_HUB_APP_MANIFESTS ?= deploy/railiance/apps/manifests +# Codex/WSL non-login shells may not source ~/.profile; keep uv discoverable. +UV ?= $(shell command -v uv 2>/dev/null || if [ -x "$$HOME/.local/bin/uv" ]; then printf "%s" "$$HOME/.local/bin/uv"; else printf "%s" "uv"; fi) +RMGR ?= $(shell command -v rmgr 2>/dev/null || if [ -x "$$HOME/repo-manager/.venv/bin/rmgr" ]; then printf "%s" "$$HOME/repo-manager/.venv/bin/rmgr"; else printf "%s" "rmgr"; fi) +STATE_HUB_API_BASE ?= http://127.0.0.1:8000 +SYNC_PATH ?= . +SYNC_PUSH ?= 1 + +start: + @echo "# Normal production UI access (run in order)" + @echo "make bridges # connect local :8000 to the railiance01 primary" + @echo "make dashboard # verify the primary, then serve the UI on :3000" + @echo "make sync # push file-backed records and reconcile the exact forge commit" + @echo "" + @echo "# Optional local MCP adapter" + @echo "make mcp-http # local SSE adapter on :8001; not needed by the UI" + @echo "" + @echo "# Local fallback/development only" + @echo "make api # local Postgres + API; conflicts with the production :8000 tunnel" + @echo "make dashboard-local # dashboard against the deliberate local API" + +install: + $(UV) sync + +dashboard/node_modules/.bin/observable: dashboard/package.json dashboard/package-lock.json + cd dashboard && npm ci + +dashboard-install: dashboard/node_modules/.bin/observable + +dashboard-check: dashboard-install + cd dashboard && npm run build + +## Symlink the custodian CLI into ~/.local/bin so it's on PATH system-wide +install-cli: install + mkdir -p ~/.local/bin + ln -sf "$(shell pwd)/.venv/bin/custodian" ~/.local/bin/custodian + ln -sf "$(shell pwd)/.venv/bin/statehub" ~/.local/bin/statehub + @echo "Installed: custodian → $$(readlink -f ~/.local/bin/custodian)" + @echo "Installed: statehub → $$(readlink -f ~/.local/bin/statehub)" + @echo "Make sure ~/.local/bin is on your PATH:" + @echo " echo 'export PATH=\"\$$HOME/.local/bin:\$$PATH\"' >> ~/.bashrc && source ~/.bashrc" + +db: + $(COMPOSE) up -d postgres + +db-tools: + $(COMPOSE) --profile tools up -d + +migrate: + $(UV) run alembic upgrade head + +seed: + $(UV) run python scripts/seed.py + +## Start (or restart) the MCP SSE server on :8001 — primary transport for Claude Code. +## Remote clients (e.g. COULOMBCORE) connect via the ops-bridge tunnel (port 18001). +## Registration: claude mcp add-json -s user state-hub '{"type":"sse","url":"http://127.0.0.1:8001/sse"}' +mcp-http: + @fuser -k 8001/tcp 2>/dev/null && echo "Stopped running MCP server" || true + MCP_TRANSPORT=sse MCP_PORT=8001 $(UV) run python mcp_server/server.py + +## Require the production identity before serving the normal dashboard. This +## prevents an accidental local `make api` from presenting an empty fallback DB +## as the live State Hub. +check-primary: + @health="$$(curl -fsS --max-time 5 http://127.0.0.1:8000/state/health 2>/dev/null)" || { \ + echo "ERROR: State Hub primary is not reachable on 127.0.0.1:8000." >&2; \ + echo "Run 'make bridges' first." >&2; \ + exit 1; \ + }; \ + identity="$$(printf '%s' "$$health" | $(PYTHON) -c 'import json, sys; d=json.load(sys.stdin); print("{}/{}".format(d.get("instance_role", ""), d.get("instance_label", "")))')"; \ + if [ "$$identity" != "primary/railiance01" ]; then \ + echo "ERROR: 127.0.0.1:8000 is '$$identity', not the primary/railiance01 State Hub." >&2; \ + echo "Stop the local API, then run 'make bridges'. Use 'make dashboard-local' only for intentional local development." >&2; \ + exit 1; \ + fi + +## Refuse to start the production tunnel when a non-primary API already owns +## port 8000. With no listener, bridge is free to establish the tunnel. +primary-port-clear: + @if health="$$(curl -fsS --max-time 2 http://127.0.0.1:8000/state/health 2>/dev/null)"; then \ + identity="$$(printf '%s' "$$health" | $(PYTHON) -c 'import json, sys; d=json.load(sys.stdin); print("{}/{}".format(d.get("instance_role", ""), d.get("instance_label", "")))')"; \ + if [ "$$identity" != "primary/railiance01" ]; then \ + echo "ERROR: port 8000 is occupied by '$$identity'." >&2; \ + echo "Stop the local 'make api' process before starting the production tunnel." >&2; \ + exit 1; \ + fi; \ + fi + +dashboard: check-primary + @fuser -k 3000/tcp 2>/dev/null && echo "Stopped running dashboard" || true + $(MAKE) dashboard-install + cd dashboard && npm run dev + +## Deliberate local-development dashboard; bypasses the production identity gate. +dashboard-local: + @echo "WARNING: serving the dashboard against the local/fallback API on :8000." + @fuser -k 3000/tcp 2>/dev/null && echo "Stopped running dashboard" || true + $(MAKE) dashboard-install + cd dashboard && npm run dev + +## Fast work-record path. Repo Manager assigns only missing deterministic IDs, +## verifies the primary/railliance01 bridge, and asks central to derive the +## exact pushed Forgejo commit in one transactional request. +sync: + $(RMGR) sync --path "$(SYNC_PATH)" --api-base "$(STATE_HUB_API_BASE)" $(if $(filter 1 true yes,$(SYNC_PUSH)),--push,) + +check: check-primary + @echo "State Hub primary/railiance01 is healthy." + +check-local: + curl -sf http://127.0.0.1:8000/state/health | python3 -m json.tool + +# CUST-WP-0067-T09. The chart ships a copy of the-custodian canon allowed-values +# because a container has no checkout to read it from. Regenerate after any +# canon vocabulary change; the -check target fails on drift so a release cannot +# validate classification against a stale vocabulary. +sync-classification-allowed: + python3 scripts/sync_classification_allowed.py + +check-classification-allowed: + python3 scripts/sync_classification_allowed.py --check + +# STATE-WP-0091: read-only surge-headroom check. Does not start Helm. +# Live: make railiance-state-hub-headroom +# Fixtures: make railiance-state-hub-headroom HEADROOM_FIXTURE=tests/fixtures/release_headroom/rev63-105m.json +HEADROOM_FIXTURE ?= +railiance-state-hub-headroom: + @if [ -n "$(HEADROOM_FIXTURE)" ]; then \ + $(PYTHON) scripts/release_headroom_preflight.py --fixture "$(HEADROOM_FIXTURE)"; \ + else \ + $(PYTHON) scripts/release_headroom_preflight.py --kubectl "$(KUBECTL)"; \ + fi + +railiance-state-hub-render: check-classification-allowed + $(HELM) template $(RAILIANCE_STATE_HUB_RELEASE) $(RAILIANCE_STATE_HUB_CHART) \ + --namespace $(RAILIANCE_STATE_HUB_NAMESPACE) \ + -f $(RAILIANCE_STATE_HUB_VALUES) \ + --set image.tag=$(RAILIANCE_STATE_HUB_IMAGE_TAG) + +railiance-state-hub-client-dry-run: + @set -e; \ + tmpdir="$$(mktemp -d)"; \ + trap 'rm -rf "$$tmpdir"' EXIT; \ + $(HELM) template $(RAILIANCE_STATE_HUB_RELEASE) $(RAILIANCE_STATE_HUB_CHART) \ + --namespace $(RAILIANCE_STATE_HUB_NAMESPACE) \ + -f $(RAILIANCE_STATE_HUB_VALUES) \ + --set image.tag=$(RAILIANCE_STATE_HUB_IMAGE_TAG) > "$$tmpdir/state-hub.yaml"; \ + $(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-credentials.sops.yaml.template; \ + $(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-cluster.yaml; \ + $(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-networkpolicies.yaml; \ + $(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_APP_MANIFESTS)/state-hub-namespace.yaml; \ + $(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_APP_MANIFESTS)/state-hub-env.secret.sops.yaml.template; \ + $(KUBECTL) apply --dry-run=client -n $(RAILIANCE_STATE_HUB_NAMESPACE) -f "$$tmpdir/state-hub.yaml" + +railiance-state-hub-server-dry-run: + @set -e; \ + tmpdir="$$(mktemp -d)"; \ + trap 'rm -rf "$$tmpdir"' EXIT; \ + $(HELM) template $(RAILIANCE_STATE_HUB_RELEASE) $(RAILIANCE_STATE_HUB_CHART) \ + --namespace $(RAILIANCE_STATE_HUB_NAMESPACE) \ + -f $(RAILIANCE_STATE_HUB_VALUES) \ + --set image.tag=$(RAILIANCE_STATE_HUB_IMAGE_TAG) > "$$tmpdir/state-hub.yaml"; \ + $(KUBECTL) apply --server-side --dry-run=server -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-credentials.sops.yaml.template; \ + $(KUBECTL) apply --dry-run=server -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-cluster.yaml; \ + $(KUBECTL) apply --dry-run=server -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-networkpolicies.yaml; \ + $(KUBECTL) apply --dry-run=server -f $(RAILIANCE_STATE_HUB_APP_MANIFESTS)/state-hub-namespace.yaml; \ + if $(KUBECTL) get namespace $(RAILIANCE_STATE_HUB_NAMESPACE) >/dev/null 2>&1; then \ + $(KUBECTL) apply --server-side --dry-run=server -f $(RAILIANCE_STATE_HUB_APP_MANIFESTS)/state-hub-env.secret.sops.yaml.template; \ + $(KUBECTL) apply --dry-run=server -n $(RAILIANCE_STATE_HUB_NAMESPACE) -f "$$tmpdir/state-hub.yaml"; \ + else \ + echo "Namespace $(RAILIANCE_STATE_HUB_NAMESPACE) does not exist; validating namespaced app manifests with client dry-run."; \ + $(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_APP_MANIFESTS)/state-hub-namespace.yaml; \ + $(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_APP_MANIFESTS)/state-hub-env.secret.sops.yaml.template; \ + $(KUBECTL) apply --dry-run=client -n $(RAILIANCE_STATE_HUB_NAMESPACE) -f "$$tmpdir/state-hub.yaml"; \ + fi + +test: test-python dashboard-check + +test-python: + TEST_DATABASE_URL=postgresql+asyncpg://custodian:changeme@127.0.0.1:5432/custodian_test \ + $(UV) run pytest -x -q + +## Benchmark /state/summary revision cache (API must be running on :8000) +benchmark-summary-cache: + $(UV) run python scripts/benchmark_summary_cache.py + +## ops-bridge managed tunnels +## Requires ops-bridge: bridge is at /home/worsch/.local/bin/bridge +tunnels-up: primary-port-clear + bridge up + +tunnels-status: + bridge status + +## End-to-end check: verifies SSH process alive + remote port listening on COULOMBCORE. +## Exits non-zero if any tunnel is not fully operational. +tunnels-check: + bridge check + +## Ensure all ops-bridge tunnels are up and healthy. +## Brings up any stopped/stale tunnels, shows final status, exits non-zero if anything is still down. +bridges: primary-port-clear + @echo "==> Bringing up all tunnels..." + bridge up + @echo "" + @echo "==> Tunnel status:" + bridge status + @echo "" + @echo "==> Checking tunnel health..." + bridge check + +## Start (or restart) the LOCAL FALLBACK backend — db + migrate + uvicorn. +## This replaces anything on :8000, including the production State Hub tunnel. +api: db + @echo "WARNING: starting the local fallback API; this is not the railiance01 primary." + @echo "Waiting for postgres..."; \ + for i in 1 2 3 4 5 6 7 8 9 10; do \ + nc -z 127.0.0.1 5432 2>/dev/null && break; \ + sleep 1; \ + done + $(MAKE) migrate + @fuser -k 8000/tcp 2>/dev/null && echo "Stopped running API" || true + $(UV) run uvicorn api.main:app --reload --reload-dir api --reload-dir mcp_server --reload-dir task_flow_engine --host 127.0.0.1 --port 8000 + +## Register a project (Claude Code): make register-project DOMAIN=railiance PROJECT_PATH=/home/worsch/railiance +register-project: + @test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN is required. Usage: make register-project DOMAIN= PROJECT_PATH="; exit 1) + @test -n "$(PROJECT_PATH)" || (echo "ERROR: PROJECT_PATH is required."; exit 1) + scripts/register_project.sh "$(DOMAIN)" "$(PROJECT_PATH)" + +## Register a Codex project (AGENTS.md + HTTP API): make register-codex-project DOMAIN=capabilities PROJECT_PATH=/home/worsch/my-repo +register-codex-project: + @test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN is required. Usage: make register-codex-project DOMAIN= PROJECT_PATH="; exit 1) + @test -n "$(PROJECT_PATH)" || (echo "ERROR: PROJECT_PATH is required."; exit 1) + scripts/register_project.sh "$(DOMAIN)" "$(PROJECT_PATH)" --codex + +## Register State Hub MCP for Claude Code. Optional: make register-mcp MCP_URL=http://127.0.0.1:18001/sse +register-mcp: + scripts/register-mcp.sh \ + $(if $(MCP_URL),--url "$(MCP_URL)",) \ + $(if $(API_BASE),--api-base "$(API_BASE)",) \ + $(if $(DRY_RUN),--dry-run,) + +## Configure Codex workspace-write networking for local State Hub access. +configure-codex: + scripts/configure-codex.sh \ + $(if $(CODEX_HOME),--codex-home "$(CODEX_HOME)",) \ + $(if $(API_BASE),--api-base "$(API_BASE)",) \ + $(if $(WITH_MCP),--with-mcp,) \ + $(if $(DRY_RUN),--dry-run,) + +## Bootstrap a new operator/collaborator environment. Optional: make bootstrap-env ARGS="--install-missing" +bootstrap-env: + scripts/bootstrap-env.sh $(ARGS) + +## Files-first local dev hub (CUST-WP-0054-T07): postgres + migrate + seed + API + repo registration. +## Optional: WITH_EDGE=1 WITH_MCP=1 make dev-hub +## Fleet tunnel when reachable: make dev-hub PROFILE=fleet +dev-hub: + bash scripts/dev_hub_up.sh $(if $(PROFILE),$(PROFILE),dev) + +## Local edge relay for offline write buffering (STATE-WP-0068). +edge-relay: + @fuser -k 18080/tcp 2>/dev/null && echo "Stopped running edge relay" || true + STATEHUB_UPSTREAM_URL=$${STATEHUB_UPSTREAM_URL:-http://127.0.0.1:8000} \ + $(UV) run uvicorn api.edge.relay:app --host 127.0.0.1 --port 18080 + +## Switch MCP/API profile: make mcp-profile PROFILE=dev|fleet|status +mcp-profile: + @test -n "$(PROFILE)" || (echo "ERROR: PROFILE is required (dev|fleet|status)."; exit 1) + bash scripts/mcp_hub_profile.sh "$(PROFILE)" + +## Add a second repo to an existing domain: make add-repo DOMAIN=railiance REPO_PATH=/home/worsch/railiance-infra +add-repo: + @test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN is required."; exit 1) + @test -n "$(REPO_PATH)" || (echo "ERROR: REPO_PATH is required."; exit 1) + scripts/register_project.sh "$(DOMAIN)" "$(REPO_PATH)" --additional + +## Create a new domain: make add-domain DOMAIN=my_domain NAME="My Domain" +add-domain: + @test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN is required (slug)."; exit 1) + @test -n "$(NAME)" || (echo "ERROR: NAME is required (display name)."; exit 1) + curl -sf -X POST http://127.0.0.1:8000/domains/ \ + -H "Content-Type: application/json" \ + -d "{\"slug\": \"$(DOMAIN)\", \"name\": \"$(NAME)\"}" | python3 -m json.tool + +## Rename a domain: make rename-domain DOMAIN=old_slug NEW_SLUG=new_slug NEW_NAME="New Name" +rename-domain: + @test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN (old slug) is required."; exit 1) + @test -n "$(NEW_SLUG)" || (echo "ERROR: NEW_SLUG is required."; exit 1) + @test -n "$(NEW_NAME)" || (echo "ERROR: NEW_NAME is required."; exit 1) + curl -sf -X PATCH http://127.0.0.1:8000/domains/$(DOMAIN)/rename \ + -H "Content-Type: application/json" \ + -d "{\"new_slug\": \"$(NEW_SLUG)\", \"new_name\": \"$(NEW_NAME)\"}" | python3 -m json.tool + +## Register this machine's local path for a repo: make register-path REPO=marki-docx PATH=/home/tegwick/marki-docx +register-path: + @test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make register-path REPO= PATH="; exit 1) + @test -n "$(PATH)" || (echo "ERROR: PATH is required. Usage: make register-path REPO= PATH="; exit 1) + curl -sf -X POST "http://127.0.0.1:8000/repos/$(REPO)/paths" \ + -H "Content-Type: application/json" \ + -d "{\"host\": \"$$(hostname)\", \"path\": \"$(PATH)\"}" | python3 -m json.tool + +## List repos for a domain: make list-repos DOMAIN=railiance +list-repos: + @test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN is required."; exit 1) + curl -sf "http://127.0.0.1:8000/repos/?domain=$(DOMAIN)" | python3 -m json.tool + +## Ingest SBOM data for a repo (all mechanisms: lockfiles + ansible + sbom-tools.yaml). +## Auto-detect all sources: make ingest-sbom REPO=the-custodian REPO_PATH=/home/worsch/the-custodian +## Single lockfile (explicit): make ingest-sbom REPO=the-custodian LOCKFILE=/path/to/uv.lock +## Dry-run (no submit): make ingest-sbom REPO=the-custodian REPO_PATH=... DRY_RUN=1 +## Tip: run capture-tools first for repos with system-level tool dependencies. +ingest-sbom: + @test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1) + $(UV) run python scripts/ingest_sbom.py --repo "$(REPO)" \ + $(if $(LOCKFILE),--lockfile "$(LOCKFILE)") \ + $(if $(REPO_PATH),--repo-path "$(REPO_PATH)") \ + $(if $(DRY_RUN),--dry-run) + +## Ingest capability declarations from SCOPE.md into the catalog. +## Usage: make ingest-capabilities REPO=the-custodian [REPO_PATH=/home/worsch/the-custodian] +## Or: make ingest-capabilities-all +## Add DRY_RUN=1 to preview without writing. +ingest-capabilities: + @test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1) + $(UV) run python scripts/ingest_capabilities.py --repo "$(REPO)" \ + $(if $(REPO_PATH),--repo-path "$(REPO_PATH)") \ + $(if $(DRY_RUN),--dry-run) + +ingest-capabilities-all: + $(UV) run python scripts/ingest_capabilities.py --all \ + $(if $(DRY_RUN),--dry-run) + +## Check Repository Definition of Integrated (DoI) criteria for a repo. +## Usage: make check-doi REPO=llm-connect +## Or: make check-doi-all +## Add JSON=1 for machine-readable output. +check-doi: + @test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1) + $(UV) run python scripts/check_doi.py --repo "$(REPO)" $(if $(JSON),--json) + +check-doi-all: + $(UV) run python scripts/check_doi.py --all $(if $(JSON),--json) + +## Ingest tpsc.yaml service declarations from a repo into the TPSC catalog. +## Usage: make ingest-tpsc REPO=llm-connect +## Or: make ingest-tpsc-all +## Add DRY_RUN=1 to preview without writing. +ingest-tpsc: + @test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1) + $(UV) run python scripts/ingest_tpsc.py --repo "$(REPO)" \ + $(if $(DRY_RUN),--dry-run) + +ingest-tpsc-all: + $(UV) run python scripts/ingest_tpsc.py --all \ + $(if $(DRY_RUN),--dry-run) + +## Run SBOM capture agent for a repo — generates/updates sbom-tools.yaml. +## Usage: make capture-tools REPO=railiance-infra [REPO_PATH=/home/worsch/railiance-infra] +## Add DRY_RUN=1 to preview without writing. +capture-tools: + @test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1) + $(UV) run python scripts/capture_sbom_tools.py --repo "$(REPO)" \ + $(if $(REPO_PATH),--repo-path "$(REPO_PATH)") \ + $(if $(DRY_RUN),--dry-run) + +## Check a repo for ADR-001 compliance: make validate-adr REPO=/path/to/repo [DOMAIN=custodian] +validate-adr: + @test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make validate-adr REPO= [DOMAIN=]"; exit 1) + $(UV) run python scripts/validate_repo_adr.py "$(REPO)" $(if $(DOMAIN),--domain "$(DOMAIN)",) + +## Consistency exit contract: +## - Direct scripts/consistency_check.py: 0 clean, 2 warnings-only, 1 failures. +## - Agent/operator Make wrappers below normalize warning-only 2 to shell success +## while preserving visible WARN output and keeping real failures non-zero. +## Check a single repo for ADR-001 consistency: make check-consistency REPO=the-custodian [REPO_PATH=/override] +## Exit 0 = clean or warnings-only (warnings stay visible), exit 1 = failures +check-consistency: + @test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make check-consistency REPO="; exit 1) + $(UV) run python scripts/consistency_check.py --repo "$(REPO)" \ + $(if $(API_BASE),--api-base "$(API_BASE)",) \ + $(if $(REPO_PATH),--repo-path "$(REPO_PATH)",); \ + e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e + +## Check and auto-fix a single repo: make fix-consistency REPO=the-custodian [REPO_PATH=/override] +## Exit 0 = clean or warnings-only (warnings stay visible), exit 1 = failures +fix-consistency: + @test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make fix-consistency REPO="; exit 1) + $(UV) run python scripts/consistency_check.py --repo "$(REPO)" --fix \ + $(if $(API_BASE),--api-base "$(API_BASE)",) \ + $(if $(REPO_PATH),--repo-path "$(REPO_PATH)",); \ + e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e + +## Normalize workplan frontmatter and task status literals in attached repos. +## Usage: make normalize-attached-workplans REPO=artifact-store +## make normalize-attached-workplans DIRTY=1 +normalize-attached-workplans: + $(UV) run python scripts/normalize_attached_repo_workplans.py \ + $(if $(REPO),--repo "$(REPO)",) \ + $(if $(DIRTY),--dirty,) \ + $(if $(DRY_RUN),--dry-run,) + @test -n "$(REPO)$(DIRTY)" || (echo "ERROR: set REPO= or DIRTY=1"; exit 1) + +## Regenerate AGENTS.md / CLAUDE.md / .claude/rules from templates. +## Usage: make update-agent-instructions REPO=artifact-store +## make update-agent-instructions DIRTY=1 +update-agent-instructions: + $(UV) run python scripts/update_agent_instruction_files.py \ + $(if $(REPO),--repo "$(REPO)",) \ + $(if $(DIRTY),--dirty,) + @test -n "$(REPO)$(DIRTY)" || (echo "ERROR: set REPO= or DIRTY=1"; exit 1) + +## Reconcile measured token sources against State Hub. +## Usage: make token-reconcile [SINCE=2026-05-19] [APPLY=1] [ZERO_FALLBACKS=1] +token-reconcile: + $(PYTHON) scripts/token_reconcile.py \ + $(if $(SINCE),--since "$(SINCE)",) \ + $(if $(API_BASE),--api-base "$(API_BASE)",) \ + $(if $(CODEX_HOME),--codex-home "$(CODEX_HOME)",) \ + $(if $(CLAUDE_HOME),--claude-home "$(CLAUDE_HOME)",) \ + $(if $(APPLY),--apply,) \ + $(if $(ZERO_FALLBACKS),--zero-superseded-fallbacks,) + +## Pull then fix: single repo or all repos if REPO omitted +## make fix-consistency-remote — smart pull+fix all repos that need it +## make fix-consistency-remote REPO=slug — pull+fix one repo +fix-consistency-remote: + $(UV) run python scripts/consistency_check.py \ + $(if $(REPO),--repo "$(REPO)",--all) \ + --remote \ + $(if $(API_BASE),--api-base "$(API_BASE)",) \ + $(if $(NO_WRITEBACK),--no-writeback,); \ + e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e + +## Infer repo slug from git remote URL and check: make check-consistency-here [REPO_PATH=/path/to/repo] +## Omit REPO_PATH to use the Python script's CWD (i.e. pass an empty --here flag). +check-consistency-here: + $(UV) run python scripts/consistency_check.py \ + --here $(if $(REPO_PATH),"$(REPO_PATH)",) \ + $(if $(API_BASE),--api-base "$(API_BASE)",); \ + e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e + +## Infer repo slug from git remote URL and fix: make fix-consistency-here [REPO_PATH=/path/to/repo] +fix-consistency-here: + $(UV) run python scripts/consistency_check.py \ + --here $(if $(REPO_PATH),"$(REPO_PATH)",) \ + --fix \ + $(if $(API_BASE),--api-base "$(API_BASE)",); \ + e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e + +## Check all registered repos for ADR-001 consistency +check-consistency-all: + $(UV) run python scripts/consistency_check.py --all $(if $(API_BASE),--api-base "$(API_BASE)",); \ + e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e + +## Check and auto-fix all registered repos +fix-consistency-all: + $(UV) run python scripts/consistency_check.py --all --fix $(if $(API_BASE),--api-base "$(API_BASE)",); \ + e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e + +## Cancel open tasks belonging to completed/archived workstreams. +## Safe to run at any time; also suitable for a daily cron job. +## Cron example: 0 3 * * * cd ~/state-hub && make cleanup-stale +cleanup-stale: + $(UV) run python scripts/cleanup_stale_tasks.py + +## Install custodian post-commit sync hook into one repo: make install-hooks REPO=marki-docx +install-hooks: + @test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make install-hooks REPO="; exit 1) + bash scripts/install_hooks.sh --repo "$(REPO)" + +## Install custodian post-commit sync hook into all active registered repos +install-hooks-all: + bash scripts/install_hooks.sh --all + +## Remove custodian post-commit sync hook from one repo: make remove-hooks REPO=marki-docx +remove-hooks: + @test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make remove-hooks REPO="; exit 1) + bash scripts/install_hooks.sh --repo "$(REPO)" --remove + +## Compare Gitea coulomb org repos against state-hub registered repos +## Requires GITEA_TOKEN in env or .env: make gitea-inventory GITEA_TOKEN= +gitea-inventory: + $(UV) run python scripts/gitea_inventory.py $(if $(JSON),--json) + +## Register/update one repo from .repo-classification.yaml: +## make register-from-classification REPO=state-hub +## make register-from-classification PATH=/path/to/repo +## Optional: DRY_RUN=1 +register-from-classification: + @test -n "$(REPO)" -o -n "$(PATH)" || (echo "ERROR: REPO or PATH is required."; exit 1) + $(UV) run python scripts/register_from_classification.py \ + $(if $(PATH),--repo-path "$(PATH)",--slug "$(REPO)") \ + $(if $(DRY_RUN),--dry-run,) + +## Bulk register/update all active repos with accessible local paths +register-from-classification-all: + $(UV) run python scripts/register_from_classification.py --bulk \ + $(if $(DRY_RUN),--dry-run,) + +clean: + $(COMPOSE) down -v diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r4/state-hub/api/routers/workstreams.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r4/state-hub/api/routers/workstreams.py new file mode 100644 index 00000000..cb6724a5 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r4/state-hub/api/routers/workstreams.py @@ -0,0 +1,591 @@ +import asyncio +import logging +import uuid +import socket +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import yaml +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status +from sqlalchemy import or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.events import EventEnvelope, publish_event +from api.models.managed_repo import ManagedRepo +from api.models.workplan import Workplan +from api.models.task import Task +from api.services.historical_projection import frozen_cluster_binding, validate_frozen_cluster_rows +from api.schemas.workplan import ( + WorkplanBindingsSync, + WorkplanCreate, + WorkplanRead, + WorkplanUpdate, +) +from api.services.lifecycle import transition_workplan_status +from api.services.legacy_compat import retire_legacy_route +from api.services.wait_kind import annotate_workplans +from api.work_record_flavor import ( + RESIDUAL_FLAVOR, + WORK_RECORD_FLAVORS, + normalize_flavor, +) +from api.workplan_status import ( + is_supported_workplan_status, + normalize_workplan_status, + ready_review_status, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/workstreams", tags=["workstreams"]) +workplan_router = APIRouter(prefix="/workplans", tags=["workplans"]) + +_INDEX_CACHE: dict[str, Any] | None = None +_INDEX_CACHE_AT: float = 0.0 +_INDEX_TTL = 30.0 +_INDEX_REFRESH_TASK: asyncio.Task | None = None +_INDEX_LAST_ERROR: str | None = None + +_COMPLETED_WORKPLAN_EVENT = "org.statehub.workplan.completed" + + +def _repo_path(repo: ManagedRepo) -> Path | None: + hostname = socket.gethostname() + candidates = [] + host_paths = repo.host_paths or {} + if host_paths.get(hostname): + candidates.append(host_paths[hostname]) + if repo.local_path: + candidates.append(repo.local_path) + for raw in candidates: + path = Path(raw).expanduser() + if path.is_dir(): + return path + return None + + +def _frontmatter(path: Path) -> dict[str, Any]: + try: + text = path.read_text(encoding="utf-8") + except OSError: + return {} + if not text.startswith("---\n"): + return {} + end = text.find("\n---", 4) + if end == -1: + return {} + + try: + return yaml.safe_load(text[4:end].strip()) or {} + except yaml.YAMLError: + return {} + + +def _legacy_key(method: str, route: str) -> str: + return f"rest_api:{method} {route}" + + +async def _list_workplans( + *, + topic_id: uuid.UUID | None, + repo_id: uuid.UUID | None, + repo_goal_id: uuid.UUID | None, + status_filter: str | None, + owner: str | None, + slug: str | None, + flavor: str | None = None, + include_residuals: bool = True, + session: AsyncSession, +) -> list[Workplan]: + q = select(Workplan) + if topic_id: + q = q.where(Workplan.topic_id == topic_id) + if repo_id: + q = q.where(Workplan.repo_id == repo_id) + if repo_goal_id: + q = q.where(Workplan.repo_goal_id == repo_goal_id) + if status_filter: + normalised_status = normalize_workplan_status(status_filter) + if not is_supported_workplan_status(status_filter): + raise HTTPException(status_code=422, detail=f"Unsupported workplan status '{status_filter}'") + q = q.where(Workplan.status == normalised_status) + if owner: + q = q.where(Workplan.owner == owner) + if slug: + q = q.where(Workplan.slug == slug) + wanted = normalize_flavor(flavor) + if wanted is not None: + if wanted not in WORK_RECORD_FLAVORS: + raise HTTPException( + status_code=422, + detail=( + f"Unknown work-record flavor {wanted!r}; " + f"expected one of {', '.join(WORK_RECORD_FLAVORS)}" + ), + ) + q = q.where(Workplan.flavor == wanted) + elif not include_residuals: + q = q.where(or_(Workplan.flavor.is_(None), Workplan.flavor != RESIDUAL_FLAVOR)) + q = q.order_by( + Workplan.planning_priority.asc().nullslast(), + Workplan.planning_order.asc().nullslast(), + Workplan.updated_at.desc(), + ) + result = await session.execute(q) + return list(result.scalars().all()) + + +async def _build_workplan_index(session: AsyncSession) -> dict[str, Any]: + result = await session.execute( + select(ManagedRepo).where(ManagedRepo.status == "active").order_by(ManagedRepo.slug) + ) + index: dict[str, Any] = {} + for repo in result.scalars().all(): + root = _repo_path(repo) + if root is None: + continue + for directory, archived in ( + (root / "workplans", False), + (root / "workplans" / "archived", True), + ): + if not directory.is_dir(): + continue + for path in sorted(directory.glob("*.md")): + data = _frontmatter(path) + workplan_id = data.get("state_hub_workstream_id") or data.get("state_hub_workplan_id") + if not workplan_id: + continue + file_status = normalize_workplan_status(data.get("status", "")) + review = ( + ready_review_status( + root, + data.get("reviewed_against_commit"), + data.get("context_paths"), + ) + if file_status == "ready" + else None + ) + index[str(workplan_id)] = { + "filename": path.name, + "relative_path": str(path.relative_to(root)), + "repo_slug": repo.slug, + "archived": archived, + "status": file_status or None, + "needs_review": bool(review and review.needs_review), + "health_labels": ["needs_review"] if review and review.needs_review else [], + } + await _merge_db_backing_index(session, index) + return {"workplans": index, "workstreams": index} + + +async def _merge_db_backing_index(session: AsyncSession, index: dict[str, Any]) -> None: + """Fill index gaps from DB-backed file bindings synced by fix-consistency.""" + result = await session.execute( + select(Workplan, ManagedRepo.slug) + .join(ManagedRepo, Workplan.repo_id == ManagedRepo.id) + .where(Workplan.backing_filename.isnot(None)) + ) + for wp, repo_slug in result.all(): + key = str(wp.id) + if key in index: + continue + index[key] = { + "filename": wp.backing_filename, + "relative_path": wp.backing_relative_path, + "repo_slug": repo_slug, + "archived": bool(wp.backing_archived), + "status": normalize_workplan_status(wp.status) if wp.status else None, + "needs_review": False, + "health_labels": [], + } + + +def _invalidate_workplan_index_cache() -> None: + global _INDEX_CACHE, _INDEX_CACHE_AT + _INDEX_CACHE = None + _INDEX_CACHE_AT = 0.0 + + +def _index_with_meta(*, stale: bool, refresh_in_progress: bool) -> dict[str, Any]: + age = time.monotonic() - _INDEX_CACHE_AT if _INDEX_CACHE_AT else None + return { + **(_INDEX_CACHE or {"workplans": {}, "workstreams": {}}), + "_meta": { + "generated_at": _INDEX_CACHE.get("_meta", {}).get("generated_at") if _INDEX_CACHE else None, + "stale": stale, + "cache_age_seconds": round(age, 3) if age is not None else None, + "refresh_in_progress": refresh_in_progress, + "last_error": _INDEX_LAST_ERROR, + }, + } + + +async def _refresh_workplan_index_background() -> None: + global _INDEX_CACHE, _INDEX_CACHE_AT, _INDEX_LAST_ERROR + from api.database import async_session_factory + + try: + async with async_session_factory() as session: + index = await _build_workplan_index(session) + index["_meta"] = { + "generated_at": datetime.now(timezone.utc).isoformat(), + "stale": False, + "cache_age_seconds": 0.0, + "refresh_in_progress": False, + "last_error": None, + } + _INDEX_CACHE = index + _INDEX_CACHE_AT = time.monotonic() + _INDEX_LAST_ERROR = None + except Exception as exc: + _INDEX_LAST_ERROR = str(exc) + + +def _ensure_index_refresh_started() -> None: + global _INDEX_REFRESH_TASK + if _INDEX_REFRESH_TASK is not None and not _INDEX_REFRESH_TASK.done(): + return + _INDEX_REFRESH_TASK = asyncio.create_task(_refresh_workplan_index_background()) + + +async def _workplan_index( + *, + refresh: bool, + session: AsyncSession, +) -> dict[str, Any]: + """Map file-backed workplan ids to their local workplan filenames.""" + global _INDEX_CACHE, _INDEX_CACHE_AT, _INDEX_LAST_ERROR + cache_age = time.monotonic() - _INDEX_CACHE_AT if _INDEX_CACHE_AT else None + if not refresh and _INDEX_CACHE is not None and cache_age is not None and cache_age < _INDEX_TTL: + refresh_running = _INDEX_REFRESH_TASK is not None and not _INDEX_REFRESH_TASK.done() + return _index_with_meta(stale=False, refresh_in_progress=refresh_running) + + if not refresh and _INDEX_CACHE is not None: + _ensure_index_refresh_started() + return _index_with_meta(stale=True, refresh_in_progress=True) + + index = await _build_workplan_index(session) + index["_meta"] = { + "generated_at": datetime.now(timezone.utc).isoformat(), + "stale": False, + "cache_age_seconds": 0.0, + "refresh_in_progress": False, + "last_error": None, + } + _INDEX_CACHE = index + _INDEX_CACHE_AT = time.monotonic() + _INDEX_LAST_ERROR = None + return _INDEX_CACHE + + +async def _create_workplan( + *, + body: WorkplanCreate, + session: AsyncSession, +) -> Workplan: + wp = Workplan(**body.model_dump()) + session.add(wp) + await session.commit() + await session.refresh(wp) + return wp + + +async def _get_workplan( + *, + workplan_id: uuid.UUID, + session: AsyncSession, +) -> Workplan: + wp = await session.get(Workplan, workplan_id) + if wp is None: + raise HTTPException(status_code=404, detail="Workplan not found") + return wp + + +async def _update_workplan( + *, + workplan_id: uuid.UUID, + body: WorkplanUpdate, + session: AsyncSession, +) -> Workplan: + wp = await session.get(Workplan, workplan_id) + if wp is None: + raise HTTPException(status_code=404, detail="Workplan not found") + update_data = body.model_dump(exclude_unset=True) + status_update = update_data.pop("status", None) + prev_status = wp.status + for field, value in update_data.items(): + setattr(wp, field, value) + if status_update is not None: + transition_workplan_status(wp, status_update) + await session.commit() + await session.refresh(wp) + + if normalize_workplan_status(prev_status) != "finished" and wp.status == "finished": + await _publish_completion_events(wp, session) + + return wp + + +async def _archive_workplan( + *, + workplan_id: uuid.UUID, + session: AsyncSession, +) -> Workplan: + wp = await session.get(Workplan, workplan_id) + if wp is None: + raise HTTPException(status_code=404, detail="Workplan not found") + transition_workplan_status(wp, "archived") + await session.commit() + await session.refresh(wp) + return wp + + +async def _publish_completion_events(wp: Workplan, session: AsyncSession) -> None: + workplan_envelope = EventEnvelope.new( + _COMPLETED_WORKPLAN_EVENT, + attributes={ + "workplan_id": str(wp.id), + "slug": wp.slug, + "title": wp.title, + "topic_id": str(wp.topic_id) if wp.topic_id else None, + "repo_id": str(wp.repo_id) if wp.repo_id else None, + "repo_goal_id": str(wp.repo_goal_id) if wp.repo_goal_id else None, + }, + ) + asyncio.create_task(publish_event(_COMPLETED_WORKPLAN_EVENT, workplan_envelope)) + + +@router.get("/", status_code=status.HTTP_410_GONE) +async def list_workstreams( + request: Request, + response: Response, + topic_id: uuid.UUID | None = None, + repo_id: uuid.UUID | None = None, + repo_goal_id: uuid.UUID | None = None, + status: str | None = None, + owner: str | None = None, + slug: str | None = None, + session: AsyncSession = Depends(get_session), +) -> None: + await retire_legacy_route( + session=session, + request=request, + response=response, + interface_key=_legacy_key("GET", "/workstreams/"), + replacement_ref="/workplans/", + detail="Legacy GET /workstreams/ retired; use GET /workplans/", + ) + + +@workplan_router.get("/", response_model=list[WorkplanRead]) +async def list_workplans( + topic_id: uuid.UUID | None = None, + repo_id: uuid.UUID | None = None, + repo_goal_id: uuid.UUID | None = None, + status: str | None = None, + owner: str | None = None, + slug: str | None = None, + flavor: str | None = Query(None), + include_residuals: bool = Query(True), + session: AsyncSession = Depends(get_session), +) -> list[Workplan]: + workplans = await _list_workplans( + topic_id=topic_id, + repo_id=repo_id, + repo_goal_id=repo_goal_id, + status_filter=status, + owner=owner, + slug=slug, + flavor=flavor, + include_residuals=include_residuals, + session=session, + ) + return list(await annotate_workplans(session, workplans)) + + +@router.get("/workplan-index", status_code=status.HTTP_410_GONE) +async def workplan_index( + request: Request, + response: Response, + refresh: bool = Query(False, description="Force cache invalidation"), + session: AsyncSession = Depends(get_session), +) -> None: + await retire_legacy_route( + session=session, + request=request, + response=response, + interface_key=_legacy_key("GET", "/workstreams/workplan-index"), + replacement_ref="/workplans/index", + detail="Legacy GET /workstreams/workplan-index retired; use GET /workplans/index", + ) + + +@workplan_router.get("/index") +async def workplan_index_preferred( + refresh: bool = Query(False, description="Force cache invalidation"), + session: AsyncSession = Depends(get_session), +) -> dict[str, Any]: + return await _workplan_index(refresh=refresh, session=session) + + +@workplan_router.put("/index/bindings") +async def sync_workplan_bindings( + body: WorkplanBindingsSync, + session: AsyncSession = Depends(get_session), +) -> dict[str, int]: + """Upsert workstation workplan file bindings for remote API index fallback.""" + # Preflight the complete batch before assigning even an ordinary row. + try: + frozen = {entry.workplan_id: bound for entry in body.bindings + if (bound := frozen_cluster_binding(entry.model_dump())) is not None} + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + requested_ids = {entry.workplan_id for entry in body.bindings} + rows = await session.execute( + select(Workplan).where(Workplan.id.in_(requested_ids)) + .order_by(Workplan.id).with_for_update().execution_options(populate_existing=True) + ) + workplans = {workplan.id: workplan for workplan in rows.scalars().all()} + if frozen: + repo_rows = await session.execute( + select(ManagedRepo).where(ManagedRepo.slug == "railiance-cluster") + ) + repo = repo_rows.scalar_one_or_none() + task_rows = await session.execute( + select(Task).where(Task.workplan_id.in_(frozen)) + .order_by(Task.id).with_for_update().execution_options(populate_existing=True) + ) + tasks = task_rows.scalars().all() + try: + for workplan_id, bound in frozen.items(): + validate_frozen_cluster_rows( + bound, workplans.get(workplan_id), tasks, repo.id if repo else None + ) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + synced_at = datetime.now(timezone.utc) + updated = 0 + preserved = 0 + for entry in body.bindings: + if entry.workplan_id in frozen: + preserved += 1 + continue # No setters, including backing_synced_at/updated_at. + wp = workplans.get(entry.workplan_id) + if wp is None: + continue + wp.backing_filename = entry.filename + wp.backing_relative_path = entry.relative_path + wp.backing_archived = entry.archived + wp.backing_synced_at = synced_at + updated += 1 + if updated: + await session.commit() + _invalidate_workplan_index_cache() + return {"updated": updated, "received": len(body.bindings), "preserved": preserved} + + +@router.post("/", status_code=status.HTTP_410_GONE) +async def create_workstream( + request: Request, + response: Response, + body: WorkplanCreate, + session: AsyncSession = Depends(get_session), +) -> None: + await retire_legacy_route( + session=session, + request=request, + response=response, + interface_key=_legacy_key("POST", "/workstreams/"), + replacement_ref="/workplans/", + detail="Legacy POST /workstreams/ retired; use POST /workplans/", + ) + + +@workplan_router.post("/", response_model=WorkplanRead, status_code=status.HTTP_201_CREATED) +async def create_workplan( + body: WorkplanCreate, + session: AsyncSession = Depends(get_session), +) -> Workplan: + return await _create_workplan(body=body, session=session) + + +@router.get("/{workstream_id}", status_code=status.HTTP_410_GONE) +async def get_workstream( + request: Request, + response: Response, + workstream_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> None: + await retire_legacy_route( + session=session, + request=request, + response=response, + interface_key=_legacy_key("GET", "/workstreams/{workstream_id}"), + replacement_ref="/workplans/{workplan_id}", + detail="Legacy GET /workstreams/{workstream_id} retired; use GET /workplans/{workplan_id}", + ) + + +@workplan_router.get("/{workplan_id}", response_model=WorkplanRead) +async def get_workplan( + workplan_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> Workplan: + wp = await _get_workplan(workplan_id=workplan_id, session=session) + await annotate_workplans(session, [wp]) + return wp + + +@router.patch("/{workstream_id}", status_code=status.HTTP_410_GONE) +async def update_workstream( + request: Request, + response: Response, + workstream_id: uuid.UUID, + body: WorkplanUpdate, + session: AsyncSession = Depends(get_session), +) -> None: + await retire_legacy_route( + session=session, + request=request, + response=response, + interface_key=_legacy_key("PATCH", "/workstreams/{workstream_id}"), + replacement_ref="/workplans/{workplan_id}", + detail="Legacy PATCH /workstreams/{workstream_id} retired; use PATCH /workplans/{workplan_id}", + ) + + +@workplan_router.patch("/{workplan_id}", response_model=WorkplanRead) +async def update_workplan( + workplan_id: uuid.UUID, + body: WorkplanUpdate, + session: AsyncSession = Depends(get_session), +) -> Workplan: + return await _update_workplan(workplan_id=workplan_id, body=body, session=session) + + +@router.delete("/{workstream_id}", status_code=status.HTTP_410_GONE) +async def archive_workstream( + request: Request, + response: Response, + workstream_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> None: + await retire_legacy_route( + session=session, + request=request, + response=response, + interface_key=_legacy_key("DELETE", "/workstreams/{workstream_id}"), + replacement_ref="DELETE /workplans/{workplan_id}", + detail="Legacy DELETE /workstreams/{workstream_id} retired; use DELETE /workplans/{workplan_id}", + ) + + +@workplan_router.delete("/{workplan_id}", response_model=WorkplanRead) +async def archive_workplan( + workplan_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> Workplan: + return await _archive_workplan(workplan_id=workplan_id, session=session) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r4/state-hub/api/services/historical_projection.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r4/state-hub/api/services/historical_projection.py new file mode 100644 index 00000000..97a50a3b --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r4/state-hub/api/services/historical_projection.py @@ -0,0 +1,238 @@ +"""Exact closed source bindings predating canonical Forge identity. + +These are frozen historical source views, not aliases or UUID allocation rules. +An altered/missing source requires explicit owner review; generic source UUIDs +remain untrusted. Shared Activity views represent the one archived parent. +""" +from pathlib import Path +from hashlib import sha256 +import json + +BINDINGS = json.loads(r'''[ + { + "repo": "activity-core", + "path": "workplans/archived/260707-custodian-WP-0003-event-bridge.md", + "sha256": "83ea8bcb151e003419b73c59dbd028f4a1ce3d1120a82e4a128a772503ff3b76", + "record_id": "custodian-WP-0003", + "uuid": "b4eb45a9-69e3-4ab0-b00c-67a53c3117c5", + "parent_record_id": "custodian-WP-0003" + }, + { + "repo": "activity-core", + "path": "workplans/custodian-WP-0001-temporal-backbone.md", + "sha256": "020e2eac973f2daa41c1687f8e867da9d521f945547e6aa82c87993b3ff6fc64", + "record_id": "custodian-WP-0001", + "uuid": "e22516cc-4fdf-4dad-a928-a9e04a9fa7c7", + "parent_record_id": "custodian-WP-0001" + }, + { + "repo": "activity-core", + "path": "workplans/custodian-WP-0002-triggers-ops.md", + "sha256": "d8cbc61631c01b6f11a03a63f2fb6275fc853ff7f7459ce65973ed1a233daa01", + "record_id": "custodian-WP-0002", + "uuid": "3a4f47d9-8bc1-434e-acb4-bed5d4dacda0", + "parent_record_id": "custodian-WP-0002" + }, + { + "repo": "activity-core", + "path": "workplans/custodian-WP-0003a-event-bridge-model-rules-registry.md", + "sha256": "ffa6682e9ba3aaf559bd44203d561c90dea69bcf1b1e778209a7b209e6e5cdd3", + "record_id": "custodian-WP-0003a", + "uuid": "b4eb45a9-69e3-4ab0-b00c-67a53c3117c5", + "parent_record_id": "custodian-WP-0003" + }, + { + "repo": "activity-core", + "path": "workplans/custodian-WP-0003b-event-bridge-parser-workflow-triggers.md", + "sha256": "4fc34e0f821e2df51e5c08c7ab34da35b65690381d01f0cfdf55d00c970b414c", + "record_id": "custodian-WP-0003b", + "uuid": "b4eb45a9-69e3-4ab0-b00c-67a53c3117c5", + "parent_record_id": "custodian-WP-0003" + }, + { + "repo": "activity-core", + "path": "workplans/custodian-WP-0003c-event-bridge-context-integration.md", + "sha256": "b6cd62b26515e509670ca39736b6bcec9633beedd46c9bd8f7598c06bac7276b", + "record_id": "custodian-WP-0003c", + "uuid": "b4eb45a9-69e3-4ab0-b00c-67a53c3117c5", + "parent_record_id": "custodian-WP-0003" + }, + { + "repo": "activity-core", + "path": "workplans/custodian-WP-0004-railiance-ops.md", + "sha256": "20df02eea740e109c937130e7f8801a636fc0990ed8c6d2ced55659f8957adb1", + "record_id": "custodian-WP-0004", + "uuid": "759b1255-aa78-42b5-8fab-c5fcf168f5f4", + "parent_record_id": "custodian-WP-0004" + }, + { + "repo": "the-custodian", + "path": "workplans/ADHOC-2026-08-25-retired.md", + "sha256": "344a1293dbb3a67a944eb34ca4a0337487d8f047226027a2107922346fbab2dd", + "record_id": "CUST-WP-ADHOC-2026-08-25-RETIRED", + "uuid": "1c53d3db-6633-5e2d-987f-10706d73be9e", + "parent_record_id": "CUST-WP-ADHOC-2026-08-25-RETIRED" + }, + { + "repo": "railiance-cluster", + "path": "workplans/archived/260622-RAIL-BS-WP-0002-k3s-baseline.md", + "sha256": "8d6f8533c119117686bc0d5bb9ed5349726aa6c3189ff98b53409c68ac685ca9", + "record_id": "RAIL-BS-WP-0002", + "uuid": "4c63dfc6-9eac-4e79-9f77-8f644ad7147d", + "parent_record_id": "RAIL-BS-WP-0002", + "tasks": [ + { + "label": "T01", + "uuid": "3f042630-eab0-4c6a-9167-e2b28ff20e40", + "status": "done" + }, + { + "label": "T02", + "uuid": "e8510646-46ed-4697-a345-f3d3009eea78", + "status": "done" + }, + { + "label": "T03", + "uuid": "dab2c07f-8aa0-4635-8df6-857e87e93fc5", + "status": "done" + }, + { + "label": "T04", + "uuid": "5c3d40e4-239b-488e-9519-6f7a38d2325f", + "status": "done" + }, + { + "label": "T05", + "uuid": "7f9e0e58-a130-467a-a2d0-b3f2564e496f", + "status": "done" + } + ] + }, + { + "repo": "railiance-cluster", + "path": "workplans/archived/260622-RAIL-BS-WP-0003-pgpool-ha-failover-fix.md", + "sha256": "4dc2f61e15adc3c5c81f9ecf8b933512275b95e8f829fac52ae7cde1385cea4a", + "record_id": "RAIL-BS-WP-0003", + "uuid": "7ee9ee22-1fae-4567-9194-8d70a9e0f45b", + "parent_record_id": "RAIL-BS-WP-0003", + "tasks": [ + { + "label": "T01", + "uuid": "6841c93a-f146-47eb-9f7c-8fa0e02c1bbc", + "status": "done" + }, + { + "label": "T02", + "uuid": "ab166073-30a7-4702-a037-4091e8706e20", + "status": "done" + }, + { + "label": "T03", + "uuid": "140da396-8e30-4f4d-b88c-c42c0cd46c01", + "status": "done" + }, + { + "label": "T04", + "uuid": "d8a3ba40-fda0-4c1f-a9f1-ffcd621a5b3d", + "status": "done" + } + ] + }, + { + "repo": "railiance-cluster", + "path": "workplans/archived/260622-RAIL-BS-WP-0004-safety-net.md", + "sha256": "04f787078232395bd2d43861de16c521848559fa286f06e1caa9cb779aa71ac3", + "record_id": "RAIL-BS-WP-0004", + "uuid": "7e8b0c20-51eb-40c9-9e3b-85dd380d7625", + "parent_record_id": "RAIL-BS-WP-0004", + "tasks": [ + { + "label": "T01", + "uuid": "4526a842-ea31-4874-9231-92ab556cfe7b", + "status": "done" + }, + { + "label": "T02", + "uuid": "a6313e06-1976-46a7-8e31-df4eb2eca880", + "status": "done" + }, + { + "label": "T03", + "uuid": "05d42a55-921f-4aa7-bb76-e8af9c7e0ac3", + "status": "done" + }, + { + "label": "T04", + "uuid": "08233868-d522-4117-bc4e-6c0f52545665", + "status": "done" + }, + { + "label": "T05", + "uuid": "2d5acff7-4a4e-4ddd-ad06-08237ad3dac8", + "status": "done" + }, + { + "label": "T06", + "uuid": "f8e4a094-c367-40eb-b895-da17bc144b07", + "status": "done" + } + ] + } +]''') + + +def bound_sources(repo_root: Path, repo_slug: str): + entries = [entry for entry in BINDINGS if entry["repo"] == repo_slug] + result = [] + for entry in entries: + path = repo_root / entry["path"] + if path.is_symlink() or not path.is_file(): + raise ValueError("Historical source binding missing or symlinked: " + entry["path"]) + data = path.read_bytes() + if sha256(data).hexdigest() != entry["sha256"]: + raise ValueError("Historical source binding changed: " + entry["path"]) + result.append((entry, data.decode("utf-8"))) + return result + + +# Only the three existing full-task Cluster bindings have immutable DB rows. +# Other historical views retain their existing compatibility behavior. +FROZEN_CLUSTER_BINDINGS = tuple( + entry for entry in BINDINGS + if entry["repo"] == "railiance-cluster" and "tasks" in entry +) + + +def frozen_cluster_binding(binding): + """Validate a claimed frozen binding; unrelated requests remain ordinary.""" + matches = [entry for entry in FROZEN_CLUSTER_BINDINGS + if str(binding["workplan_id"]) == entry["uuid"] + or (binding["repo_slug"] == "railiance-cluster" + and (binding["relative_path"] == entry["path"] + or binding["filename"] == Path(entry["path"]).name))] + if not matches: + return None + if len(matches) != 1: + raise ValueError("frozen historical binding is ambiguous") + entry = matches[0] + expected = { + "workplan_id": entry["uuid"], "repo_slug": entry["repo"], + "filename": Path(entry["path"]).name, "relative_path": entry["path"], + "archived": True, "status": "finished", + } + if any((str(binding[key]) if key == "workplan_id" else binding[key]) != value + for key, value in expected.items()): + raise ValueError("frozen historical binding metadata changed") + return entry + + +def validate_frozen_cluster_rows(entry, parent, tasks, repo_id): + """Receiving requires the exact existing terminal closure, never repair.""" + if (parent is None or str(parent.id) != entry["uuid"] + or repo_id is None or parent.repo_id != repo_id + or parent.status not in {"done", "finished", "archived", "completed"}): + raise ValueError("frozen historical parent missing, foreign or nonterminal") + local = {str(task.id): task for task in tasks if task.workplan_id == parent.id} + if any(task["uuid"] not in local or local[task["uuid"]].status not in {"done", "cancel"} + for task in entry["tasks"]): + raise ValueError("frozen historical task missing, foreign or nonterminal") diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r4/state-hub/deploy/railiance/apps/charts/state-hub/Chart.yaml b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r4/state-hub/deploy/railiance/apps/charts/state-hub/Chart.yaml new file mode 100644 index 00000000..1cb3040f --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r4/state-hub/deploy/railiance/apps/charts/state-hub/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: state-hub +description: State Hub API service for private Railiance operation +type: application +version: 0.1.0 +appVersion: "main-51bc70d" diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r4/state-hub/deploy/railiance/apps/charts/state-hub/files/repo-classification.allowed.yaml b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r4/state-hub/deploy/railiance/apps/charts/state-hub/files/repo-classification.allowed.yaml new file mode 100644 index 00000000..2eec2808 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r4/state-hub/deploy/railiance/apps/charts/state-hub/files/repo-classification.allowed.yaml @@ -0,0 +1,127 @@ +# GENERATED — do not edit. +# Synced from the-custodian/canon/standards/repo-classification.allowed.yaml +# by scripts/sync_classification_allowed.py (CUST-WP-0067-T09). +# Canon is authoritative; this copy exists only so the container has one. +# Machine-readable allowed-values for the Repo Classification Standard. +# +# Single source of truth for the standard's controlled vocabularies, derived +# from canon/standards/repo-classification-standard_v1.0.md. Consumed by: +# - the per-repo .repo-classification.yaml linter (tools/validate_repo_classification.py) +# - the State Hub registration validator (CUST-WP-0050 T04) +# +# When the standard's vocabularies change, update this file and bump `version` +# to match the standard version. CUST-WP-0050 T01. + +standard: "Repo Classification Standard" +version: "1.1" +canon_id: "canon-repo-classification" + +# category — exactly 1 required (§5) +categories: + - experimental + - research + - project + - tooling + - product + - business + - publication + +# domain / secondary_domains — primary exactly 1; secondaries 0..n (§6) +domains: + - infotech + - financials + - communication + - consumer + - health + - industrials + - energy + - utilities + - materials + - realestate + - crypto + - agents + - space + - government + +# business_stake — 0..n; 2..6 recommended (§8) +business_stake: + - execution + - intelligence + - finance + - legal + - sales + - experience + - technology + - operations + - product + - people + - procurement + - sustainability + - automation + +# business_mechanics — 0..n, optional (§9) +business_mechanics: + - intention + - control + - coordination + - operation + - adaptation + +# capability_tags are intentionally OPEN-ENDED (§7): lowercase kebab-case, not +# restricted to this set. The families below are the standard's recommended +# canonical tags — used to warn on likely synonyms/typos, never to reject. +capability_families: + identity_and_access: + - identity + - authentication + - authorization + - access-control + - user-management + - tenancy + knowledge_and_evidence: + - knowledge + - citations + - evidence + - source-management + - traceability + - documentation + - decision-support + - history + - participation + platform_and_operations: + - platform + - deployment + - operations + - observability + - feature-control + - configuration + - orchestration + market_and_coordination: + - marketplace + - pricing + - monetization + - reputation + - challenges + - bounties + - collaboration + - coordination + product_and_lifecycle: + - product-development + - lifecycle + governance_and_control: + - governance + - policy + - compliance + - risk + - audit + - control + games_and_entertainment: + - game + - entertainment + +# Validation guidance (advisory bounds the linter applies as warnings) +guidance: + secondary_domains_max: 3 + business_stake_recommended_min: 2 + business_stake_recommended_max: 6 + capability_tag_pattern: "^[a-z0-9]+(-[a-z0-9]+)*$" diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r4/state-hub/deploy/railiance/apps/helm/state-hub-values.yaml b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r4/state-hub/deploy/railiance/apps/helm/state-hub-values.yaml new file mode 100644 index 00000000..02a4bd62 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r4/state-hub/deploy/railiance/apps/helm/state-hub-values.yaml @@ -0,0 +1,69 @@ +# Production values for the State Hub Railiance chart handoff. +# Non-secret values only. DATABASE_URL comes from the Secret `state-hub-env`. + +namespace: + create: false + +image: + repository: forgejo.coulomb.social/coulomb/state-hub + tag: "main-51bc70d@sha256:c802690ee1811b3d77df4077936f9cdced1385514e8673fcbc9232b84d645015" + +ingress: + enabled: false + +# MCP layer on central (CUST-WP-0067-T08). Enabled together with the image tag +# above: MCP_HOST landed in main-6c1262e, and enabling this on an older image +# would deploy a pod that binds loopback and never becomes reachable. +# ClusterIP only — no ingress, same reason the API's is disabled. +mcp: + enabled: true + resources: + requests: + # 2026-09-10: 1,078 observed 5-minute samples, p99 2.35m, peak 30.45m. + # Preserve the 500m limit for bursts; free 40m for the API rollout surge. + cpu: 10m + +config: + # This deployment is the authoritative hub. Callers verify this rather than + # trusting that whatever answered on a port is central (CUST-WP-0067-T03). + instanceRole: primary + instanceLabel: railiance01 + sbomNexusUrl: "http://sbom-nexus.sbom-nexus.svc.cluster.local:8010" + # Reversible T04 read cutover; set back to `legacy` to roll back. + sbomNexusReadMode: nexus + # Reversible T04 write cutover; set back to `legacy` to roll back future writes. + sbomNexusWriteMode: nexus + +resources: + # Restored to the chart's 250m on 2026-09-21. The earlier 100m was a workaround + # for a node with <250m unallocated; ~830m of Knative/Kourier requests were + # released that day (the-custodian docs/kubernetes-change-gate-decision.md). + # State Hub idles near 5m but bursts past 400m under concurrent agent load. + requests: + cpu: 250m + memory: 512Mi + limits: + cpu: 1000m + memory: 2Gi + +sweep: + # RMGR-WP-0005-T11: disabled while railiance01 checkouts still target the + # stale gitea-remote lineage. Re-enable only after the governed remote + # reconciliation and registrar preflight are complete. + enabled: false + # Present in the live release but previously missing here, so deploying from + # this file alone silently dropped it. Inert while enabled is false. + hostname: 239.62.205.92.host.secureserver.net + hostPath: /home/tegwick + sshHostPath: /home/tegwick/.ssh + +# Forge read credential (STATE-WP-0084-T02). Coordinates only — the token lives +# in OpenBao at platform/workloads/state-hub/forge-derivation and reaches the +# pod through Kubernetes auth, never through this file or a Secret. +forgeRead: + enabled: true + +# RPF-WP-0035-T04: dedicated ESO signing key, API-only explicit reference. +renamePreflight: + enabled: true + secretName: state-hub-rename-preflight diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r4/state-hub/docs/evidence/2026-10-17-cluster-historical-receiving-rollout.json b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r4/state-hub/docs/evidence/2026-10-17-cluster-historical-receiving-rollout.json new file mode 100644 index 00000000..c2f7f1c3 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r4/state-hub/docs/evidence/2026-10-17-cluster-historical-receiving-rollout.json @@ -0,0 +1,116 @@ +{ + "schema": "statehub.cluster-historical-receiving-delivered.v1", + "status": "verified", + "proposal_id": "STATE-WP-0095-T06-cluster-frozen-receiving-r1", + "approved_revision": 4, + "approved_digest_sha256": "sha256:63a02e8296d126c0158b7eb2c4ce79c7eeb6f8d6f61298d473c1065c7557ba32", + "approval_decision_id": "e3798419-8aa9-5425-8c08-20df6bd82a19", + "source_revision": "51bc70d1ff8f25450a8650d8e50caf053093a02e", + "readiness_source_revision": "086d79a2736e34b916bd7de3c8a65e2ea38a3d64", + "helm_revision": 76, + "image_reference": "forgejo.coulomb.social/coulomb/state-hub:main-51bc70d@sha256:c802690ee1811b3d77df4077936f9cdced1385514e8673fcbc9232b84d645015", + "expected_schema": "c4d5e6f7a8b9", + "health": { + "status": "ok", + "instance_role": "primary", + "instance_label": "railiance01", + "schema": { + "status": "ok", + "applied": "c4d5e6f7a8b9", + "expected": "c4d5e6f7a8b9" + } + }, + "pods_ready": true, + "historical_module_sha256": "sha256:e3c4bf965764b32a6df5d61c71272ee22569df9a9ddae2a3fc3df97ec554e0b2", + "historical_preservation_verified": true, + "parents": 3, + "tasks": 15, + "full_historical_rows_equal": true, + "native_rollback_probes": { + "foreign-holder": { + "full_historical_rows_restored": true, + "historical_binding_refused": true, + "native_outcome_status": "refused", + "rolled_back": true, + "status": "passed" + }, + "missing-task": { + "full_historical_rows_restored": true, + "historical_binding_refused": true, + "native_outcome_status": "refused", + "rolled_back": true, + "status": "passed" + }, + "nonterminal-parent": { + "full_historical_rows_restored": true, + "historical_binding_refused": true, + "native_outcome_status": "refused", + "rolled_back": true, + "status": "passed" + }, + "nonterminal-task": { + "full_historical_rows_restored": true, + "historical_binding_refused": true, + "native_outcome_status": "refused", + "rolled_back": true, + "status": "passed" + }, + "preserve": { + "full_historical_rows_equal": true, + "full_historical_rows_restored": true, + "native_outcome_status": "applied", + "rolled_back": true, + "status": "passed" + } + }, + "native_probe_commits": 0, + "fresh_transaction_full_rows_equal": true, + "source_receiving_gate_delivered": true, + "normal_cluster_publication_verified": false, + "registrar_admission_or_cluster_publication_not_inferred": true, + "authority_limits": [ + "Rollback probe SQL effects rolled back; foreign-holder case is historical-binding refusal, not separate global holder branch.", + "Actual historical native receiving is verified; owner-native TLS/private admission remains unresolved.", + "Migration schema remains additive; Helm rollback does not downgrade schema or stop an active hook Job." + ], + "evidence": [ + { + "path": "docs/changes/netkingdom-target-2026-10-17/receiver-rollout-r4/approval-decision.json", + "sha256": "sha256:b51554c004daa882f3709f67bdae7f2e939d611120383e61be04ec6969878134" + }, + { + "path": "docs/changes/netkingdom-target-2026-10-17/receiver-rollout-r4/helm-return.json", + "sha256": "sha256:61727deacf22d37da2d28cbaf04f5a3ddee0f9ff7571c77f1e85dba97963b572" + }, + { + "path": "docs/changes/netkingdom-target-2026-10-17/receiver-rollout-r4/delivered-runtime.json", + "sha256": "sha256:89681066e8fe139456163ebfafa3efe77ffae8534ea167219230b0a74367f129" + }, + { + "path": "docs/changes/netkingdom-target-2026-10-17/receiver-rollout-r4/native-refusal-probes.json", + "sha256": "sha256:673ccef965c820704dc310a1bf7832b583c30c3edfeb814528cf892555199172" + }, + { + "path": "docs/changes/netkingdom-target-2026-10-17/receiver-rollout-r4/before-full-row-hashes.json", + "sha256": "sha256:75cdd87eef79059ff3f484ed24ca69e285899cdf8d851cc4e472653c417eb0ee" + }, + { + "path": "docs/changes/netkingdom-target-2026-10-17/receiver-rollout-r4/after-full-row-hashes.json", + "sha256": "sha256:2156aad18f190384838c27e792706a156a7423391408afbc6107e6c28c5872d8" + } + ], + "later_ordinary_cluster_publication": { + "status": "failed_full_parent_row_preservation", + "changed_parent_hashes": 3, + "unchanged_task_hashes": 15, + "exact_changed_columns": "unknown_hash_only_baseline", + "original_parent_preimages": "unavailable_no_guessed_restoration", + "receipt": "the-custodian/docs/changes/netkingdom-target-2026-10-17/receiver-rollout-r4/post-publication-full-row-hashes-FAILED.json", + "T07": "wait", + "RMGR_WP_0019_T06": "wait", + "followup": [ + "STATE-WP-0095-T09", + "STATE-WP-0095-T10" + ] + } +} diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r4/state-hub/scripts/consistency_check.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r4/state-hub/scripts/consistency_check.py new file mode 100644 index 00000000..38a59cee --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r4/state-hub/scripts/consistency_check.py @@ -0,0 +1,4852 @@ +#!/usr/bin/env python3 +"""consistency_check.py — ADR-001 consistency checking engine. + +Runs bidirectional checks between workplan files in a registered repo and the +state-hub DB. The file is always authoritative; the DB is the cache/index layer. + +Checks: + C-01 workplans-dir FAIL No workplans/ directory missing + C-02 workplan-parse FAIL No Workplan file cannot be parsed + C-03 workstream-stale-ref FAIL No state_hub_workstream_id in file not in DB + C-04 workstream-status-drift WARN Yes File status != DB status (file wins) + C-05 workstream-title-drift WARN Yes File title != DB title (file wins) + C-06 workstream-unlinked WARN Yes Workplan has no state_hub_workstream_id + --fix derives the UUID from the canonical record id and writes it back + C-07 orphan-db-active FAIL No Active DB workstream, no backing file + C-08 orphan-db-closed INFO No Finished/archived (or projection-retired) DB workstream, no file + C-09 workstream-repo-mismatch FAIL Yes DB workstream repo_id != file location + C-10 task-status-drift WARN Yes Task status differs between file and DB + C-11 task-unlinked WARN Yes Task block has no state_hub_task_id + C-12 orphan-db-task WARN Yes DB task in workstream has no file backing unless terminal in a closed workstream + C-13 workstream-auto-complete WARN Yes All DB tasks done but workstream still active + C-14 ghost-duplicate WARN No Active topic workstream with no repo_id matches a file-backed title — probable ghost from premature create_workstream() call + C-15 task-db-ahead WARN No DB task is terminal and file is not — report only; file wins (ADR-001) + C-16 repo-behind-remote WARN No Local repo is behind remote tracking branch — --fix skipped to avoid clobbering remote progress + C-17 repo-ahead-push-failed WARN No Local repo has unpushed commits and push failed — writes skipped to prevent runaway divergence + C-19 workstream-planning-drift WARN Yes planning_priority/planning_order differs between file and DB + C-20 workstream-dependency-missing WARN Yes Workplan dependency frontmatter missing from DB graph + C-22 task-description-drift WARN Yes Task description/content differs between file and DB + C-23 workstream-active-task-planning-status WARN Yes Workstream/workplan is planning while a task is progress or wait + C-24 repo-classification-missing WARN No Registered repo lacks a valid .repo-classification.yaml on disk + C-25 blocked-unblock-sweep WARN No Blocked workplan has unread inbox from counterpart — blocker may have cleared + C-26 workplan-id-prefix WARN No Workplan frontmatter id uses non-canonical prefix for this repo + C-27 workplan-id-collision WARN No Same workplan id appears in multiple repos + C-28 inbox-stale-unread WARN No Unread inbox messages older than INBOX_STALE_DAYS + C-29 inbox-work-unpromoted WARN No Stale unread message looks like a multi-step work request without a workplan + C-30 scope-current-state-stale WARN No SCOPE.md Current State contradicts live workplan statuses + C-31 work-record-unregistered WARN No YAML-block id matches no kind in the canon work-record type registry (sidetrack detector, CUST-WP-0060) + C-32 work-record-not-indexed WARN Yes kind: intake/decision YAML block has no hub id — not indexed in DB (registration, CUST-WP-0061-T02) + C-33 work-record-index-stale WARN Yes WORK-RECORDS.md missing or stale — generated per-repo index (CUST-WP-0061-T04) + C-34 quality-dor-ready WARN No status=ready without quality_dor DoR-Ok (STATE-WP-0077 soft) + C-35 repo-manager-conformance WARN No Repo Manager flavor/standards contract reports findings + C-36 work-record-flavor-unknown WARN No flavor is set but not in the closed list + C-37 residual-provenance-missing WARN No flavor: residual without origin/origin_ref + C-38 task-flavor-drift WARN Yes task flavor differs between file and DB (file wins) + C-39 task-wait-unqualified WARN No unrouted human wait (open non-residual) or wait task has none of task-block depends_on, needs_human: true, blocked_on: message-from:, wait_until: YYYY-MM-DD (CUST-WP-0074 rules 1/6) + C-40 task-wait-blocker-satisfied WARN No wait task whose depends_on targets are all terminal, whose decision_id is resolved, or whose wait_until has passed (CUST-WP-0074 rules 3/4) + C-41 task-wait-qualifier-drift WARN Yes needs_human/blocking_reason/decision_id/blocked_on/wait_until differ between file and DB (file wins) + C-42 workplan-blocked-no-wait WARN No blocked workplan has no task in status wait (CUST-WP-0074-T06) + C-43 decision-supersession-unlinked WARN No resolved decision of this repo says "supersedes " but the cited decision is not superseded_by it (STATE-WP-0097) + (C-20 also indexes task-block depends_on as workplan_dependencies rows with from_task_id set) + (finished¬DoD-Ok is listed by `statehub quality-debt`, not per-file C-warn — avoids historical flood) + +Usage: + python scripts/consistency_check.py --repo SLUG [--fix] [--no-writeback] [--json] [--api-base URL] + python scripts/consistency_check.py --all [--fix] [--no-writeback] [--json] [--api-base URL] + python scripts/consistency_check.py --here [PATH] [--fix] [--no-writeback] [--json] [--api-base URL] + +Identifiers (ADR-007 / RMGR-WP-0005): + C-06 / C-11 / C-32 derive UUIDv5 identifiers from the fleet namespace and + canonical record id. Any instance may reconcile; independent instances + produce the same identifier and byte-identical writeback. + +Exit codes (single-repo / local CLI): + 0 — clean (no FAILs or WARNs; INFOs are allowed) + 1 — one or more assessment FAILs or automation ERRORs (C-00) present + 2 — warnings-only strict CLI result (no FAILs, but WARNs present) + +Exit codes (--remote --all scheduled sweep): + 0 — automation completed and documented results (assessment failures OK) + 1 — automation error: API unreachable, repo list fetch failed, C-00 on + any repo, or other infrastructure fault that prevented a full run + +Assessment failures (C-01..C-24 except C-00) are repo hygiene gaps recorded +in the sweep report for later improvement. They do not fail the scheduler. + +Agent/operator Make wrappers normalize exit code 2 to shell success while +preserving visible warning output. Use the direct script when a machine caller +needs to distinguish clean from warnings-only. +""" +from __future__ import annotations + +import argparse +import os +import json +import re +import shutil +import socket +import subprocess +import sys +import time +import uuid +from contextlib import contextmanager +from dataclasses import dataclass, field +from collections import Counter +from datetime import date as _date, datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +_REPO_ROOT = Path(__file__).resolve().parent.parent +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) + +def _sweep_hostname() -> str: + """Hostname for host_paths resolution; override when pod hostname != fleet FQDN.""" + return os.environ.get("STATE_HUB_SWEEP_HOSTNAME", "").strip() or socket.gethostname() + + +from api.workplan_status import ( # noqa: E402 + CANONICAL_WORKSTREAM_STATUSES, + CLOSED_WORKSTREAM_STATUSES, + LEGACY_WORKSTREAM_STATUS_ALIASES, + OPEN_WORKSTREAM_STATUSES, + SUPPORTED_WORKSTREAM_STATUSES, + normalize_workstream_status as _normalize_workstream_status, + ready_review_status, +) +from api.classification import ( # noqa: E402 + CLASSIFICATION_FILENAME, + load_classification_file, + resolve_topic_domain_slug, +) +from api.services.lifecycle import should_activate_parent_for_active_tasks # noqa: E402 +from api.services.historical_projection import ( # noqa: E402 + BINDINGS as _HISTORICAL_BINDINGS, FROZEN_CLUSTER_BINDINGS, frozen_cluster_binding, +) +from api.task_status import ( # noqa: E402 + CANONICAL_TASK_STATUSES, + OPEN_TASK_STATUSES, + TASK_STATUS_ORDER, + TERMINAL_TASK_STATUSES, + normalize_task_status, +) +from api.work_record_flavor import ( # noqa: E402 + FLAVOR_PROMOTION_REASONS, + WORK_RECORD_FLAVORS, + is_residual_flavor, + normalize_flavor, + normalize_promotion_reason, +) + +_WORK_RECORD_NAMESPACE_UUID = uuid.UUID("a4058507-5c4a-5a00-ab06-fffa4fb46009") + + +def _derived_work_record_uuid(record_id: str) -> str: + return str(uuid.uuid5(_WORK_RECORD_NAMESPACE_UUID, f"helixforge\n{record_id}")) + +try: + import yaml as _yaml + _HAS_YAML = True +except ImportError: + _HAS_YAML = False + +try: + import httpx as _httpx + _HAS_HTTPX = True +except ImportError: + _HAS_HTTPX = False + + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_LEGACY_METER_COMPONENT = "state-hub.fix-consistency" + + +def _legacy_meter_headers() -> dict[str, str]: + return {"X-StateHub-Component": _LEGACY_METER_COMPONENT} + + +_TASK_BLOCK_RE = re.compile(r"```task\s*\n(.*?)\n```", re.DOTALL) +_HEADING_RE = re.compile(r"^(#{1,4})\s+(.+?)$", re.MULTILINE) +_ARCHIVED_WP_RE = re.compile(r"^\d{6}-(.+\.md)$") +VALID_WP_STATUSES = set(CANONICAL_WORKSTREAM_STATUSES) +SUPPORTED_WP_STATUSES = set(SUPPORTED_WORKSTREAM_STATUSES) +VALID_TASK_STATUSES = set(CANONICAL_TASK_STATUSES) +VALID_TASK_PRIORITIES = {"low", "medium", "high", "critical"} +VALID_DEP_RELATIONSHIPS = {"blocks", "starts_after", "informs", "soft_dependency"} +DEFAULT_REMOTE_ALL_MAX_SECONDS = int(os.environ.get("CONSISTENCY_REMOTE_ALL_MAX_SECONDS", "300")) +STALE_UNREAD_DAYS = int(os.environ.get("INBOX_STALE_DAYS", "3")) +_API_GET_RETRIES = int(os.environ.get("CONSISTENCY_API_GET_RETRIES", "3")) +_API_GET_RETRY_BASE_DELAY = float(os.environ.get("CONSISTENCY_API_GET_RETRY_DELAY", "0.5")) + +_WP_FILE_PREFIX_RE = re.compile(r"^([A-Za-z][A-Za-z0-9-]*-WP)-\d+", re.IGNORECASE) +_WP_FILE_BARE_RE = re.compile(r"^(WP)-\d+", re.IGNORECASE) +_WP_ID_PREFIX_RE = re.compile(r"^([A-Z][A-Z0-9-]*-WP)-\d+", re.IGNORECASE) +_WP_ID_BARE_RE = re.compile(r"^(WP)-\d+", re.IGNORECASE) +_BLOCKED_ON_RE = re.compile(r"^message-from:(?P.+)$") +_WAIT_UNTIL_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") +_WORK_REQUEST_RE = re.compile( + r"\b(implement|workplan|multi-?step|please\s+(implement|add|create|fix|build))\b", + re.IGNORECASE, +) +_OPEN_WORKPLAN_STATUSES = {"proposed", "ready", "active", "blocked", "backlog"} + + +def _check_repo_manager_conformance(repo_dir: Path, report: "ConsistencyReport") -> None: + """C-35: consume Repo Manager's conformance contract without reimplementing it.""" + configured = os.environ.get("RMGR_BIN", "").strip() + candidates = [configured] if configured else [] + candidates.extend( + [ + str(Path.home() / "repo-manager" / ".venv" / "bin" / "rmgr"), + shutil.which("rmgr") or "", + ] + ) + executable = next((item for item in candidates if item and Path(item).is_file()), None) + if executable is None: + report.add( + severity="INFO", + check_id="C-35", + message="Repo Manager conformance adapter unavailable; set RMGR_BIN or install rmgr", + fixable=False, + ) + return + try: + completed = subprocess.run( + [executable, "conform", "--path", str(repo_dir)], + capture_output=True, + text=True, + check=False, + timeout=30, + ) + payload = json.loads(completed.stdout) + except (OSError, subprocess.TimeoutExpired, json.JSONDecodeError) as exc: + report.add( + severity="INFO", + check_id="C-35", + message=f"Repo Manager conformance adapter could not run: {exc}", + fixable=False, + ) + return + for finding in payload.get("findings") or []: + severity = str(finding.get("severity") or "warning") + report.add( + severity="WARN" if severity in {"missing", "contradictory", "warning"} else "INFO", + check_id="C-35", + message=( + f"Repo Manager {severity}: {finding.get('path') or '(repository)'}: " + f"{finding.get('message') or finding.get('code') or 'conformance finding'}" + ), + file_path=finding.get("path"), + fixable=False, + ) + +# Legacy file/API aliases translated before comparison and PATCHing. +FILE_TO_DB_WORKSTREAM_STATUS: dict[str, str] = dict(LEGACY_WORKSTREAM_STATUS_ALIASES) + +# Ordinal ranking for task statuses used by the no-regress rule (T01/C-15). +STATUS_ORDER: dict[str, int] = dict(TASK_STATUS_ORDER) + + +def normalise_decision_type(meta: dict) -> str: + """Map a file's decision_type onto the hub's two values, made and pending. + + Repos record decided outcomes with words like ``approved``; the hub rejects + anything else with a 422, which left those decisions unindexed. + """ + value = str(meta.get("decision_type") or "").strip().lower() + if value in {"made", "pending"}: + return value + status = str(meta.get("status") or "").strip().lower() + if status == "resolved" or value in { + "approved", "accepted", "decided", "rejected", "declined", "resolved", + }: + return "made" + return "pending" + + +def historical_view_paths(repo_slug: str) -> set[str]: + """Repo-relative paths of bound historical views that share a parent row. + + A split view carries its parent's UUID but does not supply the parent's + fields; only the bound parent file does (see historical_projection). Letting + each view write the shared row made its status flip on every run. + """ + return { + entry["path"] + for entry in _HISTORICAL_BINDINGS + if entry["repo"] == repo_slug and entry["record_id"] != entry["parent_record_id"] + } + + +def normalise_workstream_status(status: str, *, has_started: bool | None = None) -> str: + """Translate a workplan file status value to its DB-canonical equivalent.""" + return _normalize_workstream_status(status, has_started=has_started) + + +def normalise_task_status(status: Any, *, default: str = "todo") -> str: + try: + return normalize_task_status(status, default=default) + except ValueError: + return default + + +def canonical_workplan_filename(path: Path) -> str: + """Return the workplan filename without an archive completion-date prefix.""" + return _ARCHIVED_WP_RE.sub(r"\1", path.name) + + +def workplan_display_path(repo_dir: Path, path: Path) -> str: + """Stable relative path for reports, including archived/ when applicable.""" + try: + return str(path.relative_to(repo_dir)) + except ValueError: + return path.name + + +def iter_workplan_files(workplans_dir: Path, include_archived: bool = True) -> list[Path]: + """Return active root workplans plus archived workplans when requested.""" + files = [ + path for path in sorted(workplans_dir.glob("*.md")) + if path.name not in _NON_WORKPLAN_WORKPLAN_FILES + ] + archived_dir = workplans_dir / "archived" + if include_archived and archived_dir.is_dir(): + files.extend(sorted(archived_dir.glob("*.md"))) + return files + + +# C-00 marks infrastructure/automation faults (API down, repo missing in DB). +# All other FAIL severities are assessment findings for follow-up. +_AUTOMATION_ERROR_CHECKS: frozenset[str] = frozenset({"C-00", "C-44"}) +_NON_WORKPLAN_WORKPLAN_FILES: frozenset[str] = frozenset({"README.md"}) + + +# --------------------------------------------------------------------------- +# Data types +# --------------------------------------------------------------------------- + +@dataclass +class Issue: + severity: str # FAIL | WARN | INFO + check_id: str # C-01 through C-12 + message: str + file_path: str = "" + db_id: str = "" + file_value: str = "" + db_value: str = "" + fixable: bool = False + _fix_context: dict = field(default_factory=dict, repr=False) + + +@dataclass +class ConsistencyReport: + repo_slug: str + repo_path: str + issues: list[Issue] = field(default_factory=list) + fixes_applied: list[str] = field(default_factory=list) + # Commit the workplans/ scan read (STATE-WP-0094): a full 40-hex SHA, or + # None with a machine-readable reason. Never a branch name or a guess. + source_revision: str | None = None + source_revision_reason: str | None = "not_scanned" + + def add(self, **kwargs) -> Issue: + issue = Issue(**kwargs) + self.issues.append(issue) + return issue + + @property + def failures(self) -> list[Issue]: + return [i for i in self.issues if i.severity == "FAIL"] + + @property + def automation_errors(self) -> list[Issue]: + return [ + i for i in self.issues + if i.severity == "FAIL" and i.check_id in _AUTOMATION_ERROR_CHECKS + ] + + @property + def assessment_failures(self) -> list[Issue]: + return [ + i for i in self.issues + if i.severity == "FAIL" and i.check_id not in _AUTOMATION_ERROR_CHECKS + ] + + @property + def warnings(self) -> list[Issue]: + return [i for i in self.issues if i.severity == "WARN"] + + @property + def infos(self) -> list[Issue]: + return [i for i in self.issues if i.severity == "INFO"] + + +@dataclass(frozen=True) +class RenormalizationRule: + check_id: str + invariant: str + detection: str + repair: str + test_anchor: str + + +RENORMALIZATION_RULES: tuple[RenormalizationRule, ...] = ( + RenormalizationRule( + check_id="C-23", + invariant="Planning-state workplans cannot contain active task work.", + detection=( + "Workplan status is proposed, ready, or backlog while a linked " + "task is progress or wait." + ), + repair=( + "ready/backlog: patch DB and file to active. " + "proposed: report only (ADR-007 decision 3)." + ), + test_anchor="tests/test_consistency_check.py::TestLifecycleRenormalization", + ), +) + + +RENORMALIZATION_NEXT_GUARD_CHECKLIST: tuple[str, ...] = ( + "Name the invariant and assign the next C-id.", + "Add rule metadata to RENORMALIZATION_RULES.", + "Add detection in check_repo before generic drift rules that could fight it.", + "Add the repair branch in fix_repo, using file writes only through helpers.", + "Add detection and repair tests under TestLifecycleRenormalization.", +) + + +@contextmanager +def run_lock(name: str): + """Hold a nonblocking process lock for long-running consistency modes.""" + try: + import fcntl + except ImportError: + yield True + return + + lock_path = Path(os.environ.get("CONSISTENCY_LOCK_DIR", "/tmp")) / f"custodian-{name}.lock" + lock_path.parent.mkdir(parents=True, exist_ok=True) + handle = lock_path.open("w", encoding="utf-8") + try: + try: + fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + yield False + return + handle.seek(0) + handle.truncate() + handle.write(f"{os.getpid()} {datetime.utcnow().isoformat()}Z\n") + handle.flush() + yield True + finally: + try: + fcntl.flock(handle, fcntl.LOCK_UN) + except OSError: + pass + handle.close() + + +# --------------------------------------------------------------------------- +# YAML / frontmatter parsing +# --------------------------------------------------------------------------- + +def _parse_yaml_block(raw: str) -> dict: + """Parse a YAML string; fallback to simple key:value if pyyaml unavailable.""" + if _HAS_YAML: + try: + return _yaml.safe_load(raw) or {} + except _yaml.YAMLError: + return {"_parse_error": True} + result: dict = {} + for line in raw.splitlines(): + if ":" in line and not line.startswith(" "): + k, _, v = line.partition(":") + result[k.strip()] = v.strip().strip('"').strip("'") + return result + + +def parse_frontmatter(text: str) -> tuple[dict, str]: + """Split YAML frontmatter from body. Returns ({}, text) if no frontmatter.""" + if not text.startswith("---"): + return {}, text + parts = text.split("---", 2) + if len(parts) < 3: + return {}, text + meta = _parse_yaml_block(parts[1].strip()) + return meta, parts[2] + + +def _clean_task_description(raw: str) -> str | None: + """Trim section chrome while preserving task markdown content.""" + lines = raw.splitlines() + while lines and not lines[0].strip(): + lines.pop(0) + while lines and not lines[-1].strip(): + lines.pop() + while lines and lines[-1].strip() in {"---", "***", "___"}: + lines.pop() + while lines and not lines[-1].strip(): + lines.pop() + while lines and lines[0].strip() in {"---", "***", "___"}: + lines.pop(0) + while lines and not lines[0].strip(): + lines.pop(0) + text = "\n".join(lines).strip() + return text or None + + +def parse_task_blocks(body: str) -> list[dict]: + """Extract task blocks, injecting title and markdown content from the task section.""" + headings = [ + (m.start(), len(m.group(1)), m.group(2).strip()) + for m in _HEADING_RE.finditer(body) + ] + task_matches = list(_TASK_BLOCK_RE.finditer(body)) + results = [] + for idx, m in enumerate(task_matches): + task = _parse_yaml_block(m.group(1).strip()) + prev = [(pos, level, text) for pos, level, text in headings if pos < m.start()] + prev_heading = prev[-1] if prev else None + if "title" not in task: + if prev_heading: + task["title"] = prev_heading[2] + + content_end = len(body) + if idx + 1 < len(task_matches): + content_end = min(content_end, task_matches[idx + 1].start()) + if prev_heading: + current_level = prev_heading[1] + next_peer_heading = next( + ( + pos + for pos, level, _text in headings + if pos > m.end() and level <= current_level + ), + None, + ) + if next_peer_heading is not None: + content_end = min(content_end, next_peer_heading) + else: + next_heading = next((pos for pos, _level, _text in headings if pos > m.end()), None) + if next_heading is not None: + content_end = min(content_end, next_heading) + + description = _clean_task_description(body[m.end():content_end]) + if description: + task["description"] = description + results.append(task) + return results + + +def get_tasks_from_workplan(meta: dict, body: str) -> list[dict]: + """Get tasks from workplan — handles both ```task``` blocks and tasks: YAML list.""" + blocks = parse_task_blocks(body) + if blocks: + return blocks + # Fallback: tasks embedded as a YAML list in frontmatter (activity-core style) + tasks = meta.get("tasks") + if isinstance(tasks, list): + return tasks + return [] + + +def _as_list(value: Any) -> list[str]: + if value is None: + return [] + if isinstance(value, list): + return [str(item).strip().strip('"') for item in value if str(item).strip()] + if isinstance(value, str): + return [item.strip().strip('"') for item in value.split(",") if item.strip()] + return [str(value).strip().strip('"')] + + +_TASK_RECORD_ID_RE = re.compile(r"-T\d{2,3}$") + + +def _dedupe_preserve(items: list[str]) -> list[str]: + seen: set[str] = set() + out: list[str] = [] + for item in items: + if item in seen: + continue + seen.add(item) + out.append(item) + return out + + +def _frontmatter_depends_on_workplans(meta: dict) -> list[str]: + """Canonical `depends_on` plus legacy `depends_on_workplans` alias.""" + combined = _as_list(meta.get("depends_on")) + _as_list(meta.get("depends_on_workplans")) + return _dedupe_preserve( + [item for item in combined if item and not _TASK_RECORD_ID_RE.search(item)] + ) + + +def _frontmatter_depends_on_tasks(meta: dict) -> list[str]: + from_alias = _as_list(meta.get("depends_on_tasks")) + from_depends_on = [ + item for item in _as_list(meta.get("depends_on")) if _TASK_RECORD_ID_RE.search(item) + ] + return _dedupe_preserve(from_alias + from_depends_on) + + +_UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I) + + +def _as_bool(value: Any) -> bool: + if isinstance(value, bool): + return value + return str(value).strip().lower() in {"true", "yes", "1", "on"} + + +def _task_wait_until(task: dict) -> str | None: + """``wait_until`` as an ISO date string, or None when absent or not a + ``YYYY-MM-DD`` date (PyYAML hands a bare date over as ``datetime.date``).""" + raw = task.get("wait_until") + if raw in (None, ""): + return None + if isinstance(raw, datetime): + raw = raw.date() + if isinstance(raw, _date): + return raw.isoformat() + text = str(raw).strip().strip('"').strip("'") + if not _WAIT_UNTIL_RE.match(text): + return None + try: + return _date.fromisoformat(text).isoformat() + except ValueError: + return None + + +def _task_wait_fields(task: dict) -> dict[str, Any]: + """File → hub fields that qualify a wait (CUST-WP-0074): needs_human, + blocking_reason, decision_id, blocked_on, wait_until. TaskCreate/TaskUpdate + require an intervention_note whenever needs_human is set; the blocking + reason is by rule 2 what the human must do, so it stands in when the block + has none.""" + needs_human = _as_bool(task.get("needs_human")) + blocking_reason = str(task.get("blocking_reason") or "").strip() or None + decision_id = str(task.get("decision_id") or "").strip().strip('"') or None + blocked_on = str(task.get("blocked_on") or "").strip().strip('"') or None + fields: dict[str, Any] = { + "needs_human": needs_human, + "blocking_reason": blocking_reason, + "decision_id": decision_id, + "blocked_on": blocked_on, + "wait_until": _task_wait_until(task), + } + if needs_human: + fields["intervention_note"] = ( + str(task.get("intervention_note") or "").strip() or blocking_reason + ) + return fields + + +_TASK_WAIT_FIELD_KEYS = ("needs_human", "blocking_reason", "decision_id", "blocked_on", "wait_until") + + +def _dependency_target_satisfied(record_id: str, status: str) -> bool: + """Rule 3: task targets are satisfied at done/cancel, workplan targets at + finished/archived.""" + if _TASK_RECORD_ID_RE.search(record_id): + return normalise_task_status(status, default="") in {"done", "cancel"} + return normalise_workstream_status(status) in CLOSED_WORKSTREAM_STATUSES + + +def _repo_decision_hub_ids(repo_dir: Path) -> dict[str, str]: + """Map decision record ids (e.g. CCR-2026-0004) to the hub uuid C-32 wrote + back into their YAML block.""" + out: dict[str, str] = {} + for md in sorted(repo_dir.rglob("*.md")): + if any(part in _WORK_RECORD_SKIP_DIRS for part in md.parts): + continue + try: + text = md.read_text(errors="replace") + except OSError: + continue + if "state_hub_decision_id" not in text: + continue + for block in _YAML_ONLY_FENCE_RE.findall(text): + meta = _parse_yaml_block(block.strip()) + if not isinstance(meta, dict) or meta.get("_parse_error"): + continue + rid = str(meta.get("id", "")).strip() + hub_id = str(meta.get("state_hub_decision_id", "")).strip().strip('"') + if rid and hub_id and hub_id not in ("~", "null", "None", "none"): + out[rid] = hub_id + return out + + +def _as_int_or_none(value: Any) -> int | None: + if value in (None, "", "~", "null", "None", "none"): + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +# --------------------------------------------------------------------------- +# File update helpers +# --------------------------------------------------------------------------- + +def _add_frontmatter_field(file_path: Path, key: str, value: str) -> None: + """Insert key: "value" into frontmatter before the closing --- line.""" + text = file_path.read_text(encoding="utf-8") + lines = text.split("\n") + close_idx = None + for i, line in enumerate(lines[1:], 1): + if line.strip() == "---": + close_idx = i + break + if close_idx is None: + return + lines.insert(close_idx, f'{key}: "{value}"') + file_path.write_text("\n".join(lines), encoding="utf-8") + + +def _patch_frontmatter_field(file_path: Path, key: str, value: str) -> bool: + """Update or insert a scalar frontmatter field without rewriting the file.""" + text = file_path.read_text(encoding="utf-8") + if not text.startswith("---"): + return False + lines = text.split("\n") + close_idx = None + for i, line in enumerate(lines[1:], 1): + if line.strip() == "---": + close_idx = i + break + if close_idx is None: + return False + + new_line = f"{key}: {value}" + for i in range(1, close_idx): + if re.match(rf"^\s*{re.escape(key)}\s*:", lines[i]): + if lines[i] == new_line: + return False + lines[i] = new_line + file_path.write_text("\n".join(lines), encoding="utf-8") + return True + + lines.insert(close_idx, new_line) + file_path.write_text("\n".join(lines), encoding="utf-8") + return True + + +def _inject_task_id_into_block( + file_path: Path, field_name: str, field_value: str, match_id: str +) -> bool: + """Inject state_hub_task_id into the ```task``` block whose id == match_id.""" + text = file_path.read_text(encoding="utf-8") + + def _replace(m: re.Match) -> str: + block_content = m.group(1) + task_meta = _parse_yaml_block(block_content.strip()) + if str(task_meta.get("id", "")) != match_id: + return m.group(0) + existing_val = task_meta.get(field_name) + if existing_val is not None and str(existing_val).strip() not in ("", "~", "null", "None", "none"): + return m.group(0) + # Replace existing null/~ line if present, otherwise append + new_content = re.sub( + rf"^{re.escape(field_name)}:.*$", + f'{field_name}: "{field_value}"', + block_content, + flags=re.MULTILINE, + ) + if new_content == block_content: + new_content = block_content.rstrip() + f"\n{field_name}: \"{field_value}\"" + return f"```task\n{new_content}\n```" + + new_text = _TASK_BLOCK_RE.sub(_replace, text) + if new_text != text: + file_path.write_text(new_text, encoding="utf-8") + return True + return False + + +def _inject_task_id_frontmatter_list( + file_path: Path, field_value: str, match_id: str +) -> bool: + """Inject state_hub_task_id into a task entry in frontmatter tasks: list.""" + if not _HAS_YAML: + return False + import yaml + text = file_path.read_text(encoding="utf-8") + meta, body = parse_frontmatter(text) + tasks = meta.get("tasks", []) + changed = False + for t in tasks: + if str(t.get("id", "")) == match_id and "state_hub_task_id" not in t: + t["state_hub_task_id"] = field_value + changed = True + if not changed: + return False + try: + new_meta_str = yaml.dump(meta, allow_unicode=True, default_flow_style=False) + parts = text.split("---", 2) + if len(parts) < 3: + return False + new_text = f"---\n{new_meta_str}---{parts[2]}" + file_path.write_text(new_text, encoding="utf-8") + return True + except Exception: + return False + + +# --------------------------------------------------------------------------- +# API helpers +# --------------------------------------------------------------------------- + +_API_CLIENTS: dict[tuple[str, object], Any] = {} + + +def _api_client(api_base: str) -> Any: + """Reuse one keep-alive pool per API/client implementation for this run.""" + key = (api_base.rstrip("/"), _httpx.Client) + client = _API_CLIENTS.get(key) + if client is None: + client = _httpx.Client( + base_url=api_base, + timeout=10.0, + follow_redirects=True, + headers=_legacy_meter_headers(), + ) + _API_CLIENTS[key] = client + return client + + +def _api_get( + api_base: str, + path: str, + params: dict | None = None, + *, + return_error: bool = False, +) -> Any: + if not _HAS_HTTPX: + return {"_error": "httpx is not installed"} if return_error else None + # Only append trailing slash to the path component, not to query strings + if "?" not in path and not path.endswith("/"): + path += "/" + filtered = {k: v for k, v in (params or {}).items() if v is not None} + last_error: Exception | None = None + for attempt in range(_API_GET_RETRIES): + try: + r = _api_client(api_base).get(path, params=filtered if filtered else None) + r.raise_for_status() + return r.json() + except _httpx.HTTPStatusError as exc: + if exc.response.status_code == 404: + return None + last_error = exc + if exc.response.status_code >= 500 and attempt < _API_GET_RETRIES - 1: + time.sleep(_API_GET_RETRY_BASE_DELAY * (attempt + 1)) + continue + if return_error: + return {"_error": str(exc)} + return None + except (_httpx.TimeoutException, _httpx.ConnectError, _httpx.NetworkError) as exc: + last_error = exc + if attempt < _API_GET_RETRIES - 1: + time.sleep(_API_GET_RETRY_BASE_DELAY * (attempt + 1)) + continue + if return_error: + return {"_error": str(exc)} + return None + except Exception as exc: + last_error = exc + if return_error: + return {"_error": str(exc)} + return None + if return_error and last_error is not None: + return {"_error": str(last_error)} + return None + + +def _api_patch(api_base: str, path: str, body: dict) -> Any: + if not _HAS_HTTPX: + return None + if not path.endswith("/"): + path += "/" + try: + r = _api_client(api_base).patch(path, json=body) + r.raise_for_status() + return r.json() + except Exception as exc: + # Return a sentinel dict so callers can distinguish "API error" from "success" + # and report it rather than silently dropping the fix. + return {"_error": str(exc)} + + +def _api_put(api_base: str, path: str, body: dict) -> Any: + if not _HAS_HTTPX: + return {"_error": "httpx is not installed"} + if not path.endswith("/"): + path += "/" + try: + r = _api_client(api_base).put(path, json=body, timeout=30.0) + r.raise_for_status() + return r.json() + except Exception as exc: + return {"_error": str(exc)} + + +_FROM_TASK_ID_SUPPORT: dict[str, bool] = {} + + +def _hub_accepts_from_task_id(api_base: str) -> bool: + """True when the hub's dependency-create schema declares from_task_id. + + Cached per api_base. An unreadable schema counts as unsupported so the + fixer never writes a task-scoped edge into a hub that would drop the + attribution silently. + """ + if api_base not in _FROM_TASK_ID_SUPPORT: + # Not via _api_get: it appends a trailing slash, which /openapi.json rejects. + try: + r = _api_client(api_base).get("/openapi.json") + spec = r.json() if r.status_code == 200 else {} + except Exception: + spec = {} + schemas = spec.get("components", {}).get("schemas", {}) if isinstance(spec, dict) else {} + _FROM_TASK_ID_SUPPORT[api_base] = any( + "from_task_id" in (schemas.get(name) or {}).get("properties", {}) + for name in ("WorkplanDependencyCreate", "WorkstreamDependencyCreate") + ) + return _FROM_TASK_ID_SUPPORT[api_base] + + +def _api_post(api_base: str, path: str, body: dict) -> Any: + if not _HAS_HTTPX: + return {"_error": "httpx is not installed"} + if not path.endswith("/"): + path += "/" + try: + r = _api_client(api_base).post(path, json=body) + r.raise_for_status() + return r.json() + except _httpx.HTTPStatusError as exc: + detail = exc.response.text + if len(detail) > 500: + detail = detail[:497] + "..." + return {"_error": f"{exc.response.status_code} {exc.response.reason_phrase}: {detail}"} + except Exception as exc: + return {"_error": str(exc)} + + +# --------------------------------------------------------------------------- +# Core check engine +# --------------------------------------------------------------------------- + +def resolve_repo_path(repo: dict, override: str | None = None) -> str: + """Resolve the local filesystem path for a repo on the current machine. + + Priority: + 1. Explicit --repo-path CLI override + 2. host_paths[current_hostname] — per-machine path registered via POST /repos/{slug}/paths/ + 3. local_path — legacy single-path field (backward compat) + """ + if override: + return override + hostname = _sweep_hostname() + host_paths = repo.get("host_paths") or {} + return host_paths.get(hostname) or repo.get("local_path") or "" + + +def _infer_slug_from_path(api_base: str, path: str) -> "tuple[str, str] | None": + """Identify a registered repo from a local checkout path. + + Strategy (in order): + 1. Root-commit fingerprint — ``git rev-list --max-parents=0 HEAD`` produces + the same SHA-1 on every clone, independent of remote URL or checkout path. + Looked up via ``GET /repos/by-fingerprint?hash=``. + 2. Remote URL fallback — exact string match against ``remote_url`` field. + Less reliable across machines (SSH aliases, HTTP vs HTTPS, etc.) but + useful when fingerprint is not yet stored. + + Returns ``(slug, git_root)`` on success, ``None`` if no match found. + """ + try: + git_root = subprocess.check_output( + ["git", "rev-parse", "--show-toplevel"], + cwd=path, stderr=subprocess.DEVNULL, text=True, + ).strip() + except (subprocess.CalledProcessError, FileNotFoundError, OSError): + return None + + # Strategy 1: fingerprint lookup (most reliable) + try: + fingerprint = subprocess.check_output( + ["git", "rev-list", "--max-parents=0", "HEAD"], + cwd=git_root, stderr=subprocess.DEVNULL, text=True, + ).strip() + except (subprocess.CalledProcessError, FileNotFoundError, OSError): + fingerprint = "" + + # Get local remote URL once — used for both disambiguation and fallback + try: + remote_url = subprocess.check_output( + ["git", "remote", "get-url", "origin"], + cwd=git_root, stderr=subprocess.DEVNULL, text=True, + ).strip() + except (subprocess.CalledProcessError, FileNotFoundError, OSError): + remote_url = "" + + if fingerprint: + # Try fingerprint + remote URL for precise match + if remote_url: + import urllib.parse as _up + candidates = _api_get( + api_base, + f"/repos/by-fingerprint?hash={fingerprint}&remote_url={_up.quote(remote_url, safe='')}", + ) + if isinstance(candidates, list) and len(candidates) == 1: + return candidates[0]["slug"], git_root + + # Fingerprint alone (works when repos don't share ancestry) + candidates = _api_get(api_base, f"/repos/by-fingerprint?hash={fingerprint}") + if isinstance(candidates, list): + if len(candidates) == 1: + return candidates[0]["slug"], git_root + if len(candidates) > 1: + # Disambiguate: prefer the repo whose slug appears in the git_root path + for repo in candidates: + if repo["slug"] in git_root: + return repo["slug"], git_root + # Can't disambiguate — return first match with a warning + print( + f" WARNING: {len(candidates)} repos share fingerprint {fingerprint[:12]}… " + f"— using '{candidates[0]['slug']}'. " + "Set remote_url on each repo for accurate matching.", + file=sys.stderr, + ) + return candidates[0]["slug"], git_root + + # Strategy 2: remote URL exact match (fallback) + if remote_url: + import urllib.parse as _up + repo = _api_get(api_base, f"/repos/by-remote?url={_up.quote(remote_url, safe='')}") + if repo and isinstance(repo, dict) and "slug" in repo: + return repo["slug"], git_root + + return None + + +_WORK_RECORD_FENCE_RE = re.compile(r"```(?:yaml|task)\n(.*?)```", re.S) +_WORK_RECORD_ID_LINE_RE = re.compile(r"^id:\s*[\"']?([A-Za-z0-9_-]+)[\"']?\s*$", re.M) +_WORK_RECORD_ID_LIKE_RE = re.compile(r"^[A-Z][A-Z0-9]*(-[A-Z0-9]+)+$") +_WORK_RECORD_SKIP_DIRS = frozenset({".git", "node_modules", ".venv", "history"}) + + +def _load_work_record_kind_registry() -> list[dict] | None: + """Load [{kind, patterns}] from the canon work-record type registry. + + Location: $WORK_RECORD_REGISTRY, else ~/the-custodian/canon/standards/ + work-record-types.yaml. Returns None (checks skipped) when unavailable. + Kind-aware: shared by C-31 (flat sidetrack detection) and C-32 + (kind-aware registration — needs to know intake vs. decision vs. + engagement to pick the right hub entity). + """ + if not _HAS_YAML: + return None + candidates = [] + env = os.environ.get("WORK_RECORD_REGISTRY") + if env: + candidates.append(Path(env)) + candidates.append( + Path.home() / "the-custodian/canon/standards/work-record-types.yaml") + for path in candidates: + if not path.is_file(): + continue + try: + reg = _yaml.safe_load(path.read_text()) + kinds = [] + for kind in reg.get("kinds", []): + patterns = [re.compile(p) for p in kind.get("id_patterns", [])] + patterns += [re.compile(lp["pattern"]) + for lp in kind.get("legacy_patterns", [])] + kinds.append({"kind": kind["kind"], "patterns": patterns}) + return kinds + except Exception: + return None + return None + + +def _load_work_record_patterns() -> list[re.Pattern] | None: + """Flat id-pattern list across all kinds — used by C-31's sidetrack + detector, which only needs to know "is this id registered at all", + not which kind it belongs to.""" + kinds = _load_work_record_kind_registry() + if kinds is None: + return None + return [p for k in kinds for p in k["patterns"]] + + +def _check_unregistered_work_records(repo_dir: Path, report: "ConsistencyReport") -> None: + """C-31: warn on YAML-block ids matching no registered work-record kind. + + Detection only (CUST-WP-0060-T05); registration of non-workplan kinds is + a later stage. An unregistered id scheme is a coordination sidetrack that + will need manual reintegration — surface it the day it appears. + """ + patterns = _load_work_record_patterns() + if patterns is None: + return + seen: set[str] = set() + for md in sorted(repo_dir.rglob("*.md")): + if any(part in _WORK_RECORD_SKIP_DIRS for part in md.parts): + continue + try: + text = md.read_text(errors="replace") + except OSError: + continue + for body in _WORK_RECORD_FENCE_RE.findall(text): + for rid in _WORK_RECORD_ID_LINE_RE.findall(body): + if rid in seen or "NNN" in rid or rid.endswith("-TNN"): + continue + if not _WORK_RECORD_ID_LIKE_RE.match(rid): + continue + if any(p.match(rid) for p in patterns): + continue + seen.add(rid) + report.add( + severity="WARN", + check_id="C-31", + message=( + f"[{md.relative_to(repo_dir)}] id '{rid}' matches no " + f"kind in the canon work-record type registry — " + f"unregistered species become sidetracks needing " + f"manual reintegration (work-record-types_v0.1)" + ), + fixable=False, + ) + + +_YAML_ONLY_FENCE_RE = re.compile(r"```yaml\n(.*?)\n```", re.S) +_WORK_RECORD_ID_FIELD_BY_KIND = { + "intake": "state_hub_intake_id", + "decision": "state_hub_decision_id", + "engagement": "state_hub_engagement_id", +} +_WORK_RECORD_CREATE_ENDPOINT_BY_KIND = { + "intake": "/intakes", + "decision": "/decisions", +} + + +def _classify_work_record_kind(record_id: str, kind_registry: list[dict]) -> str | None: + for k in kind_registry: + if any(p.match(record_id) for p in k["patterns"]): + return k["kind"] + return None + + +def _inject_yaml_block_field( + file_path: Path, field_name: str, field_value: str, match_id: str +) -> bool: + """Inject a field into the ```yaml``` block whose id == match_id. + Mirrors _inject_task_id_into_block, adapted for fenced yaml blocks + (intake/decision/engagement records live as ```yaml, not ```task).""" + text = file_path.read_text(encoding="utf-8") + + def _replace(m: re.Match) -> str: + block_content = m.group(1) + meta = _parse_yaml_block(block_content.strip()) + if not isinstance(meta, dict) or str(meta.get("id", "")) != match_id: + return m.group(0) + existing_val = meta.get(field_name) + if existing_val is not None and str(existing_val).strip() not in ("", "~", "null", "None", "none"): + return m.group(0) + new_content = re.sub( + rf"^{re.escape(field_name)}:.*$", + f'{field_name}: "{field_value}"', + block_content, + flags=re.MULTILINE, + ) + if new_content == block_content: + new_content = block_content.rstrip() + f"\n{field_name}: \"{field_value}\"" + return f"```yaml\n{new_content}\n```" + + new_text = _YAML_ONLY_FENCE_RE.sub(_replace, text) + if new_text != text: + file_path.write_text(new_text, encoding="utf-8") + return True + return False + + +def _check_work_record_registration( + repo_dir: Path, + report: "ConsistencyReport", + api_base: str = "http://127.0.0.1:8000", + bootstrap_empty_projection: bool = False, +) -> None: + """C-32: register kind: intake / kind: decision YAML blocks found + anywhere in the repo against the hub (CUST-WP-0061-T02, work-record + stage 3) — writes state_hub_intake_id / state_hub_decision_id back into + the source block, same write-back pattern as C-06 for workplans. + + kind: engagement has no hub entity yet (deferred; reported INFO, not + fixable) — building one is a separate stage-3 follow-on, not this task. + """ + kind_registry = _load_work_record_kind_registry() + if kind_registry is None: + return + for md in sorted(repo_dir.rglob("*.md")): + if any(part in _WORK_RECORD_SKIP_DIRS for part in md.parts): + continue + try: + text = md.read_text(errors="replace") + except OSError: + continue + for block in _YAML_ONLY_FENCE_RE.findall(text): + meta = _parse_yaml_block(block.strip()) + if not isinstance(meta, dict) or meta.get("_parse_error"): + continue + rid = str(meta.get("id", "")).strip() + if not rid or "NNN" in rid: + continue + kind = _classify_work_record_kind(rid, kind_registry) + if kind not in _WORK_RECORD_ID_FIELD_BY_KIND: + continue + + id_field = _WORK_RECORD_ID_FIELD_BY_KIND[kind] + existing = str(meta.get(id_field, "")).strip().strip('"') + if existing and existing not in ("~", "null", "None", "none"): + if not bootstrap_empty_projection: + continue + endpoint = _WORK_RECORD_CREATE_ENDPOINT_BY_KIND.get(kind) + if endpoint and _api_get(api_base, f"{endpoint}/{existing}") is not None: + continue + + rel = md.relative_to(repo_dir) + if kind not in _WORK_RECORD_CREATE_ENDPOINT_BY_KIND: + report.add( + severity="INFO", check_id="C-32", + message=( + f"[{rel}] engagement '{rid}' has no hub entity yet — " + f"registration deferred (CUST-WP-0061 stage-3 seed)" + ), + fixable=False, + ) + continue + + report.add( + severity="WARN", check_id="C-32", + message=( + f"[{rel}] {kind} '{rid}' is absent from the explicitly empty projection" + if existing + else f"[{rel}] {kind} '{rid}' has no {id_field} — not indexed in DB" + ), + file_path=str(rel), + file_value=rid, + fixable=True, + _fix_context={ + "md_path": md, + "kind": kind, + "rid": rid, + "meta": meta, + "desired_record_id": existing or None, + }, + ) + + +_WORK_RECORD_INDEX_NAME = "WORK-RECORDS.md" +_WORK_RECORD_INDEX_KIND_ORDER = { + "workplan": 0, "task": 1, "intake": 2, "decision": 3, "engagement": 4, +} + + +def _generate_work_record_index(repo_dir: Path, repo_slug: str) -> str | None: + """CUST-WP-0061-T04: the generated per-repo work-record index — every + registered work record (workplan, task, intake, decision, engagement) + with kind/id/status/lane/source, derivable purely from repo files (no + hub query — matches the ADR-001 rebuild principle). Full transclusion + rendering (markitect integration) is a follow-on; this is the flat + first cut. Returns None (no file written) when the repo has no work + records at all, to avoid clutter in small repos. + """ + rows: list[tuple[str, str, str, str, str]] = [] # kind, id, status, lane, source + + workplans_dir = repo_dir / "workplans" + if workplans_dir.is_dir(): + for wp_file in iter_workplan_files(workplans_dir, include_archived=False): + try: + text = wp_file.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + meta, body = parse_frontmatter(text) + wp_id = str(meta.get("id", "")).strip() + if not wp_id: + continue + source = str(wp_file.relative_to(repo_dir)) + rows.append(("workplan", wp_id, str(meta.get("status", "")).strip() or "—", "—", source)) + for task in get_tasks_from_workplan(meta, body): + if task.get("_parse_error"): + continue + t_id = str(task.get("id", "")).strip() + if not t_id: + continue + rows.append(("task", t_id, str(task.get("status", "")).strip() or "—", "—", source)) + + kind_registry = _load_work_record_kind_registry() + if kind_registry: + for md in sorted(repo_dir.rglob("*.md")): + if any(part in _WORK_RECORD_SKIP_DIRS for part in md.parts): + continue + if md.name == _WORK_RECORD_INDEX_NAME: + continue + try: + text = md.read_text(errors="replace") + except OSError: + continue + for block in _YAML_ONLY_FENCE_RE.findall(text): + meta = _parse_yaml_block(block.strip()) + if not isinstance(meta, dict) or meta.get("_parse_error"): + continue + rid = str(meta.get("id", "")).strip() + if not rid or "NNN" in rid: + continue + kind = _classify_work_record_kind(rid, kind_registry) + if kind not in ("intake", "decision", "engagement"): + continue + status = str(meta.get("status", "")).strip() or "—" + lane = str(meta.get("lane", "")).strip() or "—" + source = str(md.relative_to(repo_dir)) + rows.append((kind, rid, status, lane, source)) + + if not rows: + return None + + rows.sort(key=lambda r: (_WORK_RECORD_INDEX_KIND_ORDER.get(r[0], 99), r[4], r[1])) + return _work_record_index_document(repo_slug, rows) + + +def _work_record_index_document( + repo_slug: str, rows: list[tuple[str, str, str, str, str]] +) -> str: + """Render WORK-RECORDS.md. An empty row list still produces the header + and table so a leftover index can be rewritten after every live record + is archived or removed. + """ + lines = [ + f"# Work Records — {repo_slug}", + "", + "> Generated by `statehub fix-consistency` (CUST-WP-0061-T04, work-record", + "> stage 3). Do not edit by hand — edit the source file/block listed for", + "> each record and re-run fix-consistency to refresh this index. Archived", + "> workplans are omitted; closed decisions/intakes/engagements stay listed", + "> so recently-resolved work is still visible. [auto]", + "", + "| Kind | ID | Status | Lane | Source |", + "| --- | --- | --- | --- | --- |", + ] + lines.extend( + f"| {kind} | {rid} | {status} | {lane} | {source} |" + for kind, rid, status, lane, source in rows + ) + lines.append("") + return "\n".join(lines) + + +def _check_work_record_index_freshness( + repo_dir: Path, repo_slug: str, report: "ConsistencyReport" +) -> None: + """C-33: WORK-RECORDS.md missing or stale relative to the current + file-derived work-record set.""" + content = _generate_work_record_index(repo_dir, repo_slug) + index_file = repo_dir / _WORK_RECORD_INDEX_NAME + existing = index_file.read_text(encoding="utf-8") if index_file.is_file() else None + if content is None: + if existing is None: + return + empty = _work_record_index_document(repo_slug, []) + if existing == empty: + return + content = empty + elif existing == content: + return + report.add( + severity="WARN", check_id="C-33", + message=( + f"{_WORK_RECORD_INDEX_NAME} is missing or stale — regenerate via " + f"fix-consistency (CUST-WP-0061-T04 generated work-record index)" + ), + file_path=_WORK_RECORD_INDEX_NAME, + fixable=True, + _fix_context={"index_file": index_file, "content": content}, + ) + + +def check_repo( + api_base: str, + repo_slug: str, + repo_path_override: str | None = None, + bootstrap_empty_projection: bool = False, +) -> ConsistencyReport: + """Run all consistency checks for a registered repo.""" + repo = _api_get(api_base, f"/repos/{repo_slug}", return_error=True) + if isinstance(repo, dict) and "_error" in repo: + report = ConsistencyReport(repo_slug=repo_slug, repo_path=repo_path_override or "(unknown)") + report.add( + severity="FAIL", check_id="C-00", + message=( + f"Could not query State Hub API at {api_base}: {repo['_error']}" + ), + fixable=False, + ) + return report + if repo is None: + report = ConsistencyReport(repo_slug=repo_slug, repo_path="(unknown)") + report.add( + severity="FAIL", check_id="C-00", + message=f"Repo '{repo_slug}' not found in state-hub DB", + fixable=False, + ) + return report + + # Repository aliases are accepted at the API boundary, but every new + # binding, inbox write, and generated file must use the current identity. + repo_slug = str(repo.get("canonical_slug") or repo.get("slug") or repo_slug) + repo_id: str = repo["id"] + repo_path: str = resolve_repo_path(repo, repo_path_override) + report = ConsistencyReport(repo_slug=repo_slug, repo_path=repo_path) + + if bootstrap_empty_projection: + projected = _api_get(api_base, "/workplans", {"repo_id": repo_id}, return_error=True) + if not isinstance(projected, list): + report.add( + severity="FAIL", + check_id="C-36", + message="Could not prove that the repository projection is empty", + fixable=False, + ) + return report + if projected: + report.add( + severity="FAIL", + check_id="C-36", + message=( + "Empty-projection bootstrap refused: repository already has " + f"{len(projected)} projected workplan(s)" + ), + fixable=False, + ) + return report + + if not repo_path: + report.add( + severity="FAIL", check_id="C-00", + message=f"Repo '{repo_slug}' has no local_path — cannot check workplan files", + fixable=False, + ) + _check_orphan_db(api_base, repo_id, set(), report) + return report + + repo_dir = Path(repo_path) + workplans_dir = repo_dir / "workplans" + repo_market_domain = str(repo.get("domain_slug") or "").strip() + + # C-24: repo classification file missing or invalid (always WARN — migration rows too) + class_data, class_errors, class_warnings = load_classification_file(repo_dir) + if class_data is None: + classified_by = str(repo.get("classified_by") or "").strip() + if class_errors: + detail = "; ".join(class_errors) + else: + detail = f"{CLASSIFICATION_FILENAME} missing on disk" + if classified_by == "migration": + detail = f"{detail} (DB row is migration-derived — commit a human-reviewed file when ready)" + report.add( + severity="WARN", + check_id="C-24", + message=f"Repo classification gap: {detail}", + fixable=False, + ) + for warning in class_warnings: + report.add( + severity="WARN", + check_id="C-24", + message=f"Repo classification advisory: {warning}", + fixable=False, + ) + + # C-35: consume the repository-standard answer from Repo Manager. + _check_repo_manager_conformance(repo_dir, report) + + # C-31: work-record sidetrack detector (canon work-record-types registry) + _check_unregistered_work_records(repo_dir, report) + + # C-32: intake/decision work-record registration (CUST-WP-0061-T02) + _check_work_record_registration( + repo_dir, + report, + api_base=api_base, + bootstrap_empty_projection=bootstrap_empty_projection, + ) + + # C-33: generated per-repo work-record index (CUST-WP-0061-T04) + _check_work_record_index_freshness(repo_dir, repo_slug, report) + + # C-01: workplans/ directory missing + if not workplans_dir.is_dir(): + report.add( + severity="FAIL", check_id="C-01", + message=( + "workplans/ directory missing — ADR-001 requires workplan files " + "at /workplans/-.md" + ), + fixable=False, + ) + _check_orphan_db(api_base, repo_id, set(), report) + return report + + # Bind the scan to the commit it reads; re-verified after C-25 below. + report.source_revision, report.source_revision_reason = scan_source_revision(repo_dir) + + # Parse workplan files + workplan_infos: list[tuple[Path, dict, str]] = [] + file_ws_ids: dict[str, tuple[Path, dict, str]] = {} # ws_id → (file, meta, body) + active_file_ws_ids: set[str] = set() + + for wp_file in iter_workplan_files(workplans_dir): + try: + text = wp_file.read_text(encoding="utf-8") + except OSError as e: + report.add(severity="FAIL", check_id="C-02", + message=f"Cannot read file: {e}", file_path=workplan_display_path(repo_dir, wp_file)) + continue + if not text.startswith("---"): + report.add(severity="FAIL", check_id="C-02", + message="No YAML frontmatter found", file_path=workplan_display_path(repo_dir, wp_file)) + continue + meta, body = parse_frontmatter(text) + if not meta or meta.get("_parse_error"): + report.add(severity="FAIL", check_id="C-02", + message="YAML frontmatter parse error", file_path=workplan_display_path(repo_dir, wp_file)) + continue + workplan_infos.append((wp_file, meta, body)) + ws_id = str(meta.get("state_hub_workstream_id", "")).strip().strip('"') + if ws_id: + file_ws_ids[ws_id] = (wp_file, meta, body) + if wp_file.parent == workplans_dir: + active_file_ws_ids.add(ws_id) + + workplan_id_to_ws_id: dict[str, str] = {} + task_file_id_to_sh_id: dict[str, str] = {} + for _wp_file, meta, body in workplan_infos: + mapped_ws_id = str(meta.get("state_hub_workstream_id", "")).strip().strip('"') + wp_id = str(meta.get("id", "")).strip() + if wp_id and mapped_ws_id: + workplan_id_to_ws_id[wp_id] = mapped_ws_id + for task in get_tasks_from_workplan(meta, body): + if task.get("_parse_error"): + continue + task_file_id = str(task.get("id", "")).strip() + raw_sh = task.get("state_hub_task_id") + task_sh_id = "" if raw_sh is None else str(raw_sh).strip().strip('"') + if task_file_id and task_sh_id and task_sh_id not in ("~", "null", "None", "none"): + task_file_id_to_sh_id[task_file_id] = task_sh_id + + # Modern hubs expose the entire repo-scoped read model in one response. + # Keep the older per-record calls as a rolling-deploy compatibility path. + projection_snapshot = _api_get( + api_base, + f"/repos/{repo_slug}/work-record-projection/snapshot", + return_error=True, + ) + snapshot_available = ( + isinstance(projection_snapshot, dict) + and projection_snapshot.get("schema") + == "state-hub.repository-projection-snapshot.v1" + and str(projection_snapshot.get("repo_id")) == repo_id + ) + snapshot_workplans_by_id: dict[str, dict] = {} + snapshot_tasks_by_workplan: dict[str, list[dict]] = {} + snapshot_dependencies_by_workplan: dict[str, list[dict]] = {} + if snapshot_available: + for row in projection_snapshot.get("workplans", []): + if isinstance(row, dict) and row.get("id"): + snapshot_workplans_by_id[str(row["id"])] = row + for row in projection_snapshot.get("tasks", []): + if isinstance(row, dict) and row.get("workplan_id"): + snapshot_tasks_by_workplan.setdefault( + str(row["workplan_id"]), [] + ).append(row) + for row in projection_snapshot.get("dependencies", []): + if isinstance(row, dict) and row.get("from_workplan_id"): + snapshot_dependencies_by_workplan.setdefault( + str(row["from_workplan_id"]), [] + ).append(row) + snapshot_tasks_by_id: dict[str, dict] = { + str(row["id"]): row + for rows in snapshot_tasks_by_workplan.values() + for row in rows + if row.get("id") + } + + # Task-block depends_on targets (CUST-WP-0074) may live in other repos, so + # resolve them by the file mapping first and the ADR-007 derived uuid second. + dependency_target_cache: dict[str, tuple[str | None, str | None]] = {} + + def _resolve_dependency_target(record_id: str) -> tuple[str | None, str | None]: + """Return (hub uuid, hub status) for a workplan/task record id, or (None, None).""" + if record_id in dependency_target_cache: + return dependency_target_cache[record_id] + is_task = bool(_TASK_RECORD_ID_RE.search(record_id)) + mapped = (task_file_id_to_sh_id if is_task else workplan_id_to_ws_id).get(record_id) + hub_id = mapped or _derived_work_record_uuid(record_id) + row = (snapshot_tasks_by_id if is_task else snapshot_workplans_by_id).get(hub_id) + if row is None: + row = _api_get(api_base, f"/{'tasks' if is_task else 'workplans'}/{hub_id}") + result: tuple[str | None, str | None] = ( + (hub_id, str(row.get("status") or "")) if isinstance(row, dict) else (None, None) + ) + dependency_target_cache[record_id] = result + return result + + decision_hub_ids: dict[str, str] | None = None + + def _resolve_decision(decision_id: str) -> dict | None: + nonlocal decision_hub_ids + if decision_hub_ids is None: + decision_hub_ids = _repo_decision_hub_ids(repo_dir) + hub_id = ( + decision_id + if _UUID_RE.match(decision_id) + else decision_hub_ids.get(decision_id) or _derived_work_record_uuid(decision_id) + ) + row = _api_get(api_base, f"/decisions/{hub_id}") + return row if isinstance(row, dict) else None + + # Per-workplan checks + for wp_file, meta, body in workplan_infos: + fname = workplan_display_path(repo_dir, wp_file) + archived_file = wp_file.parent.name == "archived" + ws_id = str(meta.get("state_hub_workstream_id", "")).strip().strip('"') + file_status = str(meta.get("status", "")).strip() + file_title = str(meta.get("title", "")).strip() + file_domain = str(meta.get("domain", "")).strip() + + normalised_file_status = normalise_workstream_status(file_status) + if archived_file and normalised_file_status not in CLOSED_WORKSTREAM_STATUSES: + report.add( + severity="FAIL", check_id="C-18", + message="Archived workplan file has an open or planning status", + file_path=fname, + file_value=file_status, + fixable=False, + ) + + if not ws_id: + # C-06: workplan not linked to any DB workstream + report.add( + severity="WARN", check_id="C-06", + message="Workplan has no state_hub_workstream_id — not indexed in DB", + file_path=fname, + file_value=file_title or fname, + fixable=True, + _fix_context={ + "wp_file": str(wp_file), + "meta": meta, + "body": body, + "repo_id": repo_id, + "domain": file_domain, + "repo_market_domain": repo_market_domain, + "repo_slug": repo_slug, + }, + ) + continue + + ws = ( + snapshot_workplans_by_id.get(ws_id) + if snapshot_available + else _api_get(api_base, f"/workplans/{ws_id}") + ) + if ws is None: + wp_id = str(meta.get("id", "")).strip() + if wp_id and ( + ws_id == _derived_work_record_uuid(wp_id) or bootstrap_empty_projection + ): + # A deterministic file identifier missing from a replaceable + # projection is registration work, not a stale reference. + report.add( + severity="WARN", + check_id="C-06", + message=( + f"Authoritative workplan {ws_id[:8]}… is absent from this " + + ( + "explicitly empty projection" + if bootstrap_empty_projection + else "projection" + ) + ), + file_path=fname, + db_id=ws_id, + fixable=True, + _fix_context={ + "wp_file": str(wp_file), + "meta": meta, + "body": body, + "repo_id": repo_id, + "domain": file_domain, + "repo_market_domain": repo_market_domain, + "repo_slug": repo_slug, + "desired_ws_id": ws_id, + }, + ) + else: + # A non-derived missing identifier may be genuine stale state; + # preserve the conservative manual-review behavior. + report.add( + severity="FAIL", check_id="C-03", + message=f"state_hub_workstream_id {ws_id[:8]}… not found in DB (stale reference)", + file_path=fname, + db_id=ws_id, + fixable=False, + ) + continue + + # C-09: repo mismatch — file is here but DB says different repo + db_repo_id: str = ws.get("repo_id") or "" + if db_repo_id != repo_id: + report.add( + severity="FAIL", check_id="C-09", + message=( + f"Workstream '{ws.get('slug')}' repo_id in DB is " + f"{db_repo_id[:8] if db_repo_id else 'None'} " + f"but backing file lives in {repo_slug} ({repo_id[:8]}…)" + ), + file_path=fname, + db_id=ws_id, + file_value=repo_id, + db_value=db_repo_id, + fixable=True, + _fix_context={"ws_id": ws_id, "correct_repo_id": repo_id}, + ) + # Continue to check drift even with mismatched repo + + tasks = get_tasks_from_workplan(meta, body) + db_tasks = ( + snapshot_tasks_by_workplan.get(ws_id, []) + if snapshot_available + else _api_get(api_base, "/tasks", {"workplan_id": ws_id}) + ) + db_tasks_by_id = { + str(row.get("id")): row + for row in db_tasks + if isinstance(row, dict) and row.get("id") + } if isinstance(db_tasks, list) else {} + file_task_statuses = [ + str(task.get("status", "")).strip() + for task in tasks + if not task.get("_parse_error") + ] + db_task_statuses = [ + str(task.get("status", "")).strip() + for task in db_tasks + if isinstance(db_tasks, list) + ] if isinstance(db_tasks, list) else [] + active_task_requires_activation = should_activate_parent_for_active_tasks( + parent_workstream_status=file_status, + task_statuses=[*file_task_statuses, *db_task_statuses], + ) + if active_task_requires_activation: + # ADR-007 decision 3 / RMGR-WP-0005-T06: automation may report + # that a proposed workplan has started work, but must not promote + # proposed → active. proposed is a human review gate. + proposed_gate = file_status == "proposed" + report.add( + severity="WARN", + check_id="C-23", + message=( + f"Lifecycle drift in '{ws.get('slug')}': workplan status " + f"{file_status!r} has an active task" + + ( + " — report only (ADR-007: do not auto-promote proposed)" + if proposed_gate + else " — repair to 'active'" + ) + ), + file_path=fname, + db_id=ws_id, + file_value=file_status, + db_value=ws.get("status", ""), + fixable=not proposed_gate, + _fix_context={ + "ws_id": ws_id, + "wp_file": str(wp_file), + "file_status": file_status, + "db_status": ws.get("status", ""), + "target_status": "active", + }, + ) + + # C-42: a blocked workplan must say what it waits for — at least one + # task in `wait` (which C-39 then requires to be qualified). Blocked + # with no wait task is a stale status, not a block (CUST-WP-0074-T06). + if file_status == "blocked" and not any( + normalise_task_status(s) == "wait" for s in file_task_statuses + ): + report.add( + severity="WARN", + check_id="C-42", + message=( + f"Blocked workplan '{ws.get('slug')}' has no task in status " + f"wait — nothing states what it waits for; qualify a wait " + f"task or re-status the workplan" + ), + file_path=fname, + db_id=ws_id, + file_value=file_status, + fixable=False, + ) + + # A bound historical split view shares its parent's row but supplies + # none of its fields or edges; only the bound parent file does. + historical_view = workplan_display_path(repo_dir, wp_file) in historical_view_paths(repo_slug) + + # C-04: status drift — normalise file value before comparing so that + # legacy file/API aliases are not treated as drift. Lifecycle repairs + # take precedence so a stale planning file cannot regress active work. + db_status = ws.get("status", "") + normalised_db_status = normalise_workstream_status(db_status) + if ( + not active_task_requires_activation + and file_status + and db_status + and normalised_file_status != normalised_db_status + and not historical_view + ): + report.add( + severity="WARN", check_id="C-04", + message=( + f"Status drift in '{ws.get('slug')}': " + f"file={file_status!r} db={db_status!r} (file wins)" + ), + file_path=fname, + db_id=ws_id, + file_value=file_status, + db_value=db_status, + fixable=True, + _fix_context={ + "ws_id": ws_id, + "field": "status", + "value": normalised_file_status, # always send DB-valid value + }, + ) + + if normalised_file_status == "ready": + review = ready_review_status( + repo_dir, + meta.get("reviewed_against_commit"), + meta.get("context_paths"), + ) + if review.needs_review: + detail = f"Ready workplan may be stale: {review.reason}" + if review.changed_paths: + preview = ", ".join(review.changed_paths[:5]) + extra = "" if len(review.changed_paths) <= 5 else ", ..." + detail = f"{detail}; changed paths: {preview}{extra}" + report.add( + severity="WARN", + check_id="C-21", + message=detail, + file_path=fname, + file_value=file_status, + db_value="needs_review", + fixable=False, + ) + # C-34: soft DoR quality debt (STATE-WP-0077) — never blocks + try: + from quality_assessment import assessment_from_mapping, is_ok + except ImportError: + sys.path.insert(0, str(Path(__file__).resolve().parent)) + from quality_assessment import assessment_from_mapping, is_ok # type: ignore + dor = assessment_from_mapping(meta, "DoR") + if not is_ok(dor): + report.add( + severity="WARN", + check_id="C-34", + message=( + "status=ready without quality_dor DoR-Ok — process claim " + "without DoR assessment (soft; see docs/work-record-quality-gates.md)" + ), + file_path=fname, + file_value=str(dor or "unassessed"), + fixable=False, + ) + + # C-05: title drift + db_title = ws.get("title", "") + if file_title and db_title and file_title != db_title: + report.add( + severity="WARN", check_id="C-05", + message=( + f"Title drift in '{ws.get('slug')}': " + f"file={file_title!r} db={db_title!r} (file wins)" + ), + file_path=fname, + db_id=ws_id, + file_value=file_title, + db_value=db_title, + fixable=True, + _fix_context={"ws_id": ws_id, "field": "title", "value": file_title}, + ) + + planning_priority = str(meta.get("planning_priority", "")).strip() or None + if planning_priority != (ws.get("planning_priority") or None): + report.add( + severity="WARN", check_id="C-19", + message=( + f"Planning priority drift in '{ws.get('slug')}': " + f"file={planning_priority!r} db={ws.get('planning_priority')!r} (file wins)" + ), + file_path=fname, + db_id=ws_id, + file_value=planning_priority, + db_value=ws.get("planning_priority"), + fixable=True, + _fix_context={"ws_id": ws_id, "field": "planning_priority", "value": planning_priority}, + ) + + planning_order = _as_int_or_none(meta.get("planning_order")) + if planning_order != ws.get("planning_order"): + report.add( + severity="WARN", check_id="C-19", + message=( + f"Planning order drift in '{ws.get('slug')}': " + f"file={planning_order!r} db={ws.get('planning_order')!r} (file wins)" + ), + file_path=fname, + db_id=ws_id, + file_value=planning_order, + db_value=ws.get("planning_order"), + fixable=True, + _fix_context={"ws_id": ws_id, "field": "planning_order", "value": planning_order}, + ) + + file_flavor = normalize_flavor(meta.get("flavor")) + if file_flavor is not None and file_flavor not in WORK_RECORD_FLAVORS: + report.add( + severity="WARN", + check_id="C-36", + message=( + f"Unknown work-record flavor {file_flavor!r} in '{ws.get('slug')}'; " + f"expected one of {', '.join(WORK_RECORD_FLAVORS)}" + ), + file_path=fname, + db_id=ws_id, + file_value=file_flavor, + fixable=False, + ) + file_flavor = None + db_flavor = normalize_flavor(ws.get("flavor")) + if file_flavor != db_flavor: + report.add( + severity="WARN", check_id="C-19", + message=( + f"Flavor drift in '{ws.get('slug')}': " + f"file={file_flavor!r} db={db_flavor!r} (file wins)" + ), + file_path=fname, + db_id=ws_id, + file_value=file_flavor, + db_value=db_flavor, + fixable=True, + _fix_context={"ws_id": ws_id, "field": "flavor", "value": file_flavor}, + ) + file_reason = normalize_promotion_reason(meta.get("flavor_promotion_reason")) + if file_reason is not None and file_reason not in FLAVOR_PROMOTION_REASONS: + report.add( + severity="WARN", + check_id="C-36", + message=( + f"Unknown flavor_promotion_reason {file_reason!r} in '{ws.get('slug')}'; " + f"expected one of {', '.join(FLAVOR_PROMOTION_REASONS)}" + ), + file_path=fname, + db_id=ws_id, + file_value=file_reason, + fixable=False, + ) + file_reason = None + db_reason = normalize_promotion_reason(ws.get("flavor_promotion_reason")) + if file_reason != db_reason: + report.add( + severity="WARN", check_id="C-19", + message=( + f"Flavor promotion reason drift in '{ws.get('slug')}': " + f"file={file_reason!r} db={db_reason!r} (file wins)" + ), + file_path=fname, + db_id=ws_id, + file_value=file_reason, + db_value=db_reason, + fixable=True, + _fix_context={ + "ws_id": ws_id, + "field": "flavor_promotion_reason", + "value": file_reason, + }, + ) + if is_residual_flavor(file_flavor): + origin = str(meta.get("origin") or "").strip() + origin_ref = str(meta.get("origin_ref") or "").strip() + if not origin and not origin_ref: + report.add( + severity="WARN", + check_id="C-37", + message=( + f"Residual workplan '{ws.get('slug')}' has no origin/origin_ref " + "provenance (not invented)" + ), + file_path=fname, + db_id=ws_id, + fixable=False, + ) + + # C-10, C-11, C-12: task-level checks + db_task_by_id: dict[str, dict] = {} + if isinstance(db_tasks, list): + for t in db_tasks: + db_task_by_id[t["id"]] = t + + existing_deps = ( + snapshot_dependencies_by_workplan.get(ws_id, []) + if snapshot_available + else _api_get(api_base, f"/workplans/{ws_id}/dependencies") or [] + ) + existing_dep_keys = set() + if isinstance(existing_deps, list): + for dep in existing_deps: + from_id = dep.get("from_workstream_id") or dep.get("from_workplan_id") + if from_id != ws_id: + continue + rel = dep.get("relationship_type") or "blocks" + # Frontmatter edges carry no from_task_id; task-block edges do. + from_task_id = dep.get("from_task_id") or None + to_workplan_id = dep.get("to_workstream_id") or dep.get("to_workplan_id") + if to_workplan_id: + existing_dep_keys.add((from_task_id, "workstream", to_workplan_id, rel)) + if dep.get("to_task_id"): + existing_dep_keys.add((from_task_id, "task", dep["to_task_id"], rel)) + + for target_wp_id in ([] if historical_view else _frontmatter_depends_on_workplans(meta)): + target_ws_id = workplan_id_to_ws_id.get(target_wp_id) + if not target_ws_id: + report.add( + severity="WARN", + check_id="C-20", + message=f"Workplan dependency target '{target_wp_id}' is not linked to State Hub", + file_path=fname, + file_value=target_wp_id, + fixable=False, + ) + continue + dep_key = (None, "workstream", target_ws_id, "blocks") + if dep_key not in existing_dep_keys: + report.add( + severity="WARN", + check_id="C-20", + message=f"Missing DB dependency edge: {ws_id[:8]}… depends on workplan {target_wp_id}", + file_path=fname, + db_id=ws_id, + file_value=target_wp_id, + fixable=True, + _fix_context={ + "from_workstream_id": ws_id, + "to_workstream_id": target_ws_id, + "relationship_type": "blocks", + }, + ) + + for target_task_id in _frontmatter_depends_on_tasks(meta): + target_sh_id = task_file_id_to_sh_id.get(target_task_id) + if not target_sh_id: + report.add( + severity="WARN", + check_id="C-20", + message=f"Task dependency target '{target_task_id}' is not linked to State Hub", + file_path=fname, + file_value=target_task_id, + fixable=False, + ) + continue + dep_key = (None, "task", target_sh_id, "starts_after") + if dep_key not in existing_dep_keys: + report.add( + severity="WARN", + check_id="C-20", + message=f"Missing DB dependency edge: {ws_id[:8]}… starts after task {target_task_id}", + file_path=fname, + db_id=ws_id, + file_value=target_task_id, + fixable=True, + _fix_context={ + "from_workstream_id": ws_id, + "to_task_id": target_sh_id, + "relationship_type": "starts_after", + }, + ) + + file_task_sh_ids: set[str] = set() + + for task in tasks: + if task.get("_parse_error"): + continue + t_id = str(task.get("id", "")).strip() + _raw_sh = task.get("state_hub_task_id") + t_sh_id = "" if _raw_sh is None else str(_raw_sh).strip().strip('"') + if t_sh_id in ("~", "null", "None", "none"): + t_sh_id = "" + t_status = normalise_task_status(task.get("status", "todo")) + + # CUST-WP-0074: task-block depends_on and wait qualifiers. + task_dep_targets = _frontmatter_depends_on_workplans(task) + _frontmatter_depends_on_tasks(task) + wait_fields = _task_wait_fields(task) + if t_status == "wait" and normalised_file_status not in CLOSED_WORKSTREAM_STATUSES: + # Rule 6: an owner-return wait with no workplan id yet is + # qualified by the C-25 vocabulary on the task block; a + # wait_until date is a window that clears by itself. + owner_return = bool(_BLOCKED_ON_RE.match(wait_fields["blocked_on"] or "")) + wait_until = wait_fields["wait_until"] + if ( + not task_dep_targets and not wait_fields["needs_human"] + and not owner_return and not wait_until + ): + report.add( + severity="WARN", check_id="C-39", + message=( + f"Wait task '{t_id}' in workplan '{meta.get('id', fname)}' has " + f"neither depends_on, needs_human: true, blocked_on: message-from:… " + f"nor wait_until — unqualified wait" + ), + file_path=f"{fname}#{t_id}", + file_value=t_status, + fixable=False, + ) + # CUST-WP-0075 P1: human gates belong in the decision queue. + if ( + wait_fields["needs_human"] and not wait_fields["decision_id"] + and not is_residual_flavor(meta.get("flavor")) + ): + report.add( + severity="WARN", check_id="C-39", + message=( + f"Human wait task '{t_id}' in workplan '{meta.get('id', fname)}' " + "has needs_human: true without decision_id — unrouted human gate" + ), + file_path=f"{fname}#{t_id}", + file_value="needs_human: true", + fixable=False, + ) + # C-40: rule 3 for a wait window — the date has passed (UTC today). + if wait_until and _date.fromisoformat(wait_until) < datetime.now(timezone.utc).date(): + report.add( + severity="WARN", check_id="C-40", + message=( + f"blocker satisfied, task still wait: '{t_id}' in workplan " + f"'{meta.get('id', fname)}' — wait_until {wait_until} has passed" + ), + file_path=f"{fname}#{t_id}", + file_value=wait_until, + fixable=False, + ) + # C-40: rule 3 — every depends_on target terminal. A target the + # hub cannot resolve is skipped, which also withholds the warning. + resolved_targets = [ + (target, _resolve_dependency_target(target)) for target in task_dep_targets + ] + if resolved_targets and all( + hub_status is not None + and _dependency_target_satisfied(target, hub_status) + for target, (_hub_id, hub_status) in resolved_targets + ): + report.add( + severity="WARN", check_id="C-40", + message=( + f"blocker satisfied, task still wait: '{t_id}' in workplan " + f"'{meta.get('id', fname)}' — every depends_on target is terminal " + f"({', '.join(task_dep_targets)})" + ), + file_path=f"{fname}#{t_id}", + file_value=", ".join(task_dep_targets), + fixable=False, + ) + # C-40: rule 4 — the tracked decision has been resolved. + if wait_fields["decision_id"]: + decision = _resolve_decision(wait_fields["decision_id"]) + if decision is not None and str(decision.get("status") or "") == "resolved": + report.add( + severity="WARN", check_id="C-40", + message=( + f"blocker satisfied, task still wait: '{t_id}' in workplan " + f"'{meta.get('id', fname)}' — decision " + f"{wait_fields['decision_id']} is resolved" + ), + file_path=f"{fname}#{t_id}", + file_value=wait_fields["decision_id"], + fixable=False, + ) + + if ( + t_sh_id + and task_dep_targets + and normalised_file_status not in CLOSED_WORKSTREAM_STATUSES + and t_status not in ("done", "cancel") + ): + # C-20 for task blocks: the waiting task is the from side. + # Closed workplans and closed tasks are not indexed — their + # ordering is history, not a live commitment. + for target in task_dep_targets: + target_hub_id, _target_status = _resolve_dependency_target(target) + is_task_target = bool(_TASK_RECORD_ID_RE.search(target)) + if not target_hub_id: + report.add( + severity="WARN", + check_id="C-20", + message=( + f"{'Task' if is_task_target else 'Workplan'} dependency target " + f"'{target}' of task '{t_id}' is not linked to State Hub" + ), + file_path=f"{fname}#{t_id}", + file_value=target, + fixable=False, + ) + continue + rel = "starts_after" if is_task_target else "blocks" + dep_key = (t_sh_id, "task" if is_task_target else "workstream", target_hub_id, rel) + if dep_key not in existing_dep_keys: + report.add( + severity="WARN", + check_id="C-20", + message=( + f"Missing DB dependency edge: task '{t_id}' " + f"{'starts after task' if is_task_target else 'depends on workplan'} {target}" + ), + file_path=f"{fname}#{t_id}", + db_id=ws_id, + file_value=target, + fixable=True, + _fix_context={ + "from_workstream_id": ws_id, + "from_task_id": t_sh_id, + "to_task_id" if is_task_target else "to_workstream_id": target_hub_id, + "relationship_type": rel, + "description": f"{t_id} depends_on {target}", + }, + ) + + if t_sh_id: + file_task_sh_ids.add(t_sh_id) + db_task = db_tasks_by_id.get(t_sh_id) + if db_task is None: + if t_id and t_sh_id == _derived_work_record_uuid(t_id): + report.add( + severity="WARN", + check_id="C-11", + message=f"Derived task '{t_id}' is absent from this projection", + file_path=f"{fname}#{t_id}", + db_id=t_sh_id, + fixable=True, + _fix_context={ + "ws_id": ws_id, + "ws_status": ws.get("status", ""), + "task": task, + "wp_file": str(wp_file), + "meta": meta, + "body": body, + "desired_task_id": t_sh_id, + }, + ) + else: + report.add( + severity="FAIL", check_id="C-03", + message=f"state_hub_task_id {t_sh_id[:8]}… not found in DB", + file_path=f"{fname}#{t_id}", + db_id=t_sh_id, + fixable=False, + ) + continue + # C-10 / C-15: task status drift. ADR-001: the file originates + # work. Same-rank wait/progress is not "DB ahead" — writing + # wait over progress/todo is the CFED/RMGR-WP-0004-T02 bug. + # C-15 remains a report-only review signal when the DB is + # terminal and the file is not. + db_t_status = normalise_task_status(db_task.get("status", "todo")) + if t_status and db_t_status and t_status != db_t_status: + db_rank = STATUS_ORDER.get(db_t_status, 0) + file_rank = STATUS_ORDER.get(t_status, 0) + db_terminal = db_t_status in {"done", "cancel"} + file_terminal = t_status in {"done", "cancel"} + if db_terminal and not file_terminal: + report.add( + severity="WARN", check_id="C-15", + message=( + f"DB task '{t_id}' is terminal " + f"(db={db_t_status!r}, file={t_status!r}) " + f"— report only; file wins (ADR-001 / RMGR-WP-0005-T06)" + ), + file_path=f"{fname}#{t_id}", + db_id=t_sh_id, + file_value=t_status, + db_value=db_t_status, + fixable=False, + ) + # File always wins, including same-rank wait ↔ progress. + report.add( + severity="WARN", check_id="C-10", + message=( + f"Task status drift '{t_id}': " + f"file={t_status!r} db={db_t_status!r} (file wins)" + ), + file_path=f"{fname}#{t_id}", + db_id=t_sh_id, + file_value=t_status, + db_value=db_t_status, + fixable=True, + _fix_context={ + "task_id": t_sh_id, + "status": t_status, + # A wait needs its qualifiers in the same PATCH. + "wait_fields": wait_fields if t_status == "wait" else {}, + }, + ) + file_description = task.get("description") + if isinstance(file_description, str): + file_description = file_description.strip() or None + else: + file_description = None + db_description = db_task.get("description") + if isinstance(db_description, str): + db_description = db_description.strip() or None + else: + db_description = None + if file_description and file_description != db_description: + report.add( + severity="WARN", check_id="C-22", + message=f"Task description drift '{t_id}': file content differs from DB (file wins)", + file_path=f"{fname}#{t_id}", + db_id=t_sh_id, + file_value=file_description[:120], + db_value=(db_description or "")[:120], + fixable=True, + _fix_context={"task_id": t_sh_id, "description": file_description}, + ) + file_task_flavor = normalize_flavor(task.get("flavor")) + if file_task_flavor is not None and file_task_flavor not in WORK_RECORD_FLAVORS: + report.add( + severity="WARN", + check_id="C-36", + message=( + f"Unknown work-record flavor {file_task_flavor!r} on task '{t_id}'" + ), + file_path=f"{fname}#{t_id}", + db_id=t_sh_id, + file_value=file_task_flavor, + fixable=False, + ) + file_task_flavor = None + db_task_flavor = normalize_flavor(db_task.get("flavor")) + if file_task_flavor != db_task_flavor: + report.add( + severity="WARN", + check_id="C-38", + message=( + f"Task flavor drift '{t_id}': " + f"file={file_task_flavor!r} db={db_task_flavor!r} (file wins)" + ), + file_path=f"{fname}#{t_id}", + db_id=t_sh_id, + file_value=file_task_flavor, + db_value=db_task_flavor, + fixable=True, + _fix_context={"task_id": t_sh_id, "flavor": file_task_flavor}, + ) + # C-41: wait qualifiers — only where the file speaks, so hub-set + # flags on blocks that never carried the keys are left alone. + if any(key in task for key in _TASK_WAIT_FIELD_KEYS): + db_wait_fields = { + "needs_human": bool(db_task.get("needs_human")), + "blocking_reason": str(db_task.get("blocking_reason") or "").strip() or None, + "decision_id": str(db_task.get("decision_id") or "").strip() or None, + "blocked_on": str(db_task.get("blocked_on") or "").strip() or None, + "wait_until": str(db_task.get("wait_until") or "").strip() or None, + } + file_wait_fields = {key: wait_fields[key] for key in _TASK_WAIT_FIELD_KEYS} + if file_wait_fields != db_wait_fields: + report.add( + severity="WARN", check_id="C-41", + message=( + f"Task wait-qualifier drift '{t_id}': " + f"file={file_wait_fields!r} db={db_wait_fields!r} (file wins)" + ), + file_path=f"{fname}#{t_id}", + db_id=t_sh_id, + file_value=json.dumps(file_wait_fields, sort_keys=True), + db_value=json.dumps(db_wait_fields, sort_keys=True), + fixable=True, + _fix_context={"task_id": t_sh_id, "wait_fields": wait_fields}, + ) + elif t_id: + # C-11: task exists in file but not linked to DB + ws_status = ws.get("status", "") + report.add( + severity="WARN", check_id="C-11", + message=f"Task '{t_id}' has no state_hub_task_id", + file_path=f"{fname}#{t_id}", + fixable=True, + _fix_context={ + "ws_id": ws_id, + "ws_status": ws_status, + "task": task, + "wp_file": str(wp_file), + "meta": meta, + "body": body, + }, + ) + + # C-12: DB tasks with no file backing + if isinstance(db_tasks, list): + ws_status = ws.get("status", "") + ws_finished = normalise_workstream_status(ws_status) in CLOSED_WORKSTREAM_STATUSES + for db_t in db_tasks: + if db_t["id"] not in file_task_sh_ids: + db_t_status = normalise_task_status(db_t.get("status", "todo")) + open_task = db_t_status not in TERMINAL_TASK_STATUSES + # Terminal orphans (cancel/done) are historical duplicates after + # file linkage or explicit retirement. The public task DELETE route + # is a cancel operation, so these are not further actionable. + if not open_task: + continue + # Auto-cancel fixable when workstream is finished and task is open. + fixable = ws_finished and open_task + report.add( + severity="WARN", check_id="C-12", + message=( + f"DB task '{db_t.get('title', '')}' " + f"(id={db_t['id'][:8]}…, status={db_t_status}) " + f"in workstream '{ws.get('slug')}' has no file backing" + ), + db_id=db_t["id"], + fixable=fixable, + _fix_context={ + "task_id": db_t["id"], + "ws_finished": ws_finished, + }, + ) + + # C-13: all DB tasks done but workstream still active — worker forgot to close + db_status = ws.get("status", "") + if normalise_workstream_status(db_status) == "active" and isinstance(db_tasks, list) and db_tasks: + non_terminal = [ + t for t in db_tasks + if normalise_task_status(t.get("status", "todo")) not in TERMINAL_TASK_STATUSES + ] + if not non_terminal: + report.add( + severity="WARN", check_id="C-13", + message=( + f"All {len(db_tasks)} DB tasks for '{ws.get('slug')}' are " + f"done/cancel but workstream status is still 'active' — " + f"worker likely forgot update_workstream_status()" + ), + file_path=fname, + db_id=ws_id, + db_value="active", + fixable=True, + _fix_context={ + "ws_id": ws_id, + "field": "status", + "value": "finished", + }, + ) + + # C-07 / C-08: orphan DB workstreams (have repo_id=this_repo but no backing file) + _check_orphan_db(api_base, repo_id, set(file_ws_ids.keys()), report, active_file_ws_ids) + + # C-14: ghost duplicate — active workstream on same topic with no repo_id whose + # title matches a file-backed workstream. Root cause: create_workstream() called + # before the workplan file was written; fix-consistency then created a second + # workstream from the file, leaving the first as an invisible orphan. + _check_ghost_duplicates(api_base, workplan_infos, file_ws_ids, report) + + _check_blocked_unblock_sweep(api_base, repo_slug, repo_dir, workplan_infos, report) + if report.source_revision is not None: + # The tree must not have moved while the files were read and C-25 ran. + if scan_source_revision(repo_dir) != (report.source_revision, None): + report.source_revision, report.source_revision_reason = None, "changed_during_scan" + _check_inbox_hygiene(api_base, repo_slug, report) + _check_unlinked_supersession(api_base, set(file_ws_ids), report) + _check_workplan_prefixes(repo_dir, repo_slug, workplan_infos, report) + _check_workplan_id_collisions(api_base, repo_slug, repo_dir, workplan_infos, report) + _check_scope_freshness(repo_dir, workplan_infos, report) + + if _refuse_frozen_fix_batch(report, repo_slug): + return report + _sync_workplan_bindings(api_base, repo_slug, workplan_infos, repo_dir, report) + + return report + + +def _sync_workplan_bindings( + api_base: str, + repo_slug: str, + workplan_infos: list[tuple[Path, dict, str]], + repo_dir: Path, + report: ConsistencyReport, +) -> None: + if repo_slug == "railiance-cluster": + scanned_paths = {workplan_display_path(repo_dir, path) for path, _, _ in workplan_infos} + missing = [entry["path"] for entry in FROZEN_CLUSTER_BINDINGS + if entry["path"] not in scanned_paths] + if missing: + report.add(severity="FAIL", check_id="C-44", + message="Frozen binding batch refused: bound source missing from scan", + fixable=False) + return + bindings: list[dict[str, Any]] = [] + for wp_file, meta, _ in workplan_infos: + ws_id = str(meta.get("state_hub_workstream_id", "")).strip().strip('"') + archived = wp_file.parent.name == "archived" + file_status = normalise_workstream_status(str(meta.get("status", "")).strip()) + binding = { + "workplan_id": ws_id, "filename": wp_file.name, + "relative_path": workplan_display_path(repo_dir, wp_file), + "repo_slug": repo_slug, "archived": archived, + "status": file_status or None, + } + try: + frozen = frozen_cluster_binding(binding) + if frozen is not None: + # Verify exact source before omitting the immutable binding. + # This is safe even against a receiver predating the API guard. + from hashlib import sha256 + if (wp_file.is_symlink() or not wp_file.is_file() + or sha256(wp_file.read_bytes()).hexdigest() != frozen["sha256"]): + raise ValueError("frozen historical source missing or changed") + continue + except ValueError as exc: + report.add(severity="FAIL", check_id="C-44", + message=f"Frozen binding batch refused: {exc}", + file_path=str(wp_file), fixable=False) + return # Never send a partial batch after a frozen-source refusal. + if not ws_id: + continue + bindings.append(binding) + if not bindings: + return + result = _api_put(api_base, "/workplans/index/bindings", {"bindings": bindings}) + if isinstance(result, dict) and "_error" in result: + report.fixes_applied.append(f"bindings WARN: {result['_error']}") + elif isinstance(result, dict): + report.fixes_applied.append( + f"bindings: synced {result.get('updated', 0)}/{result.get('received', len(bindings))}" + ) + + +RETIRED_SLUG_MARK = "@retired-" # mirrors api.services.forge_projection + + +def _is_projection_retired(ws: dict) -> bool: + """True for a row forge projection retired (field, or the slug stamp on older APIs).""" + return bool(ws.get("projection_retired_at")) or RETIRED_SLUG_MARK in (ws.get("slug") or "") + + +def _check_orphan_db( + api_base: str, + repo_id: str, + file_ws_ids: set[str], + report: ConsistencyReport, + active_file_ws_ids: set[str] | None = None, +) -> None: + """Flag DB workstreams with repo_id=this_repo that have no backing workplan file. + + A row retired by forge projection is a tombstone: retirement stamps the + slug with ``@retired-YYYYMMDD`` and sets ``projection_retired_at`` but + deliberately leaves status alone, so a row retired while blocked stays + blocked. Its canonical successor is file-backed under a new id. Such rows + are reported as C-08 INFO, never C-07 FAIL — the repo has nothing to fix, + and closing them by hand would rewrite history the retirement preserved. + """ + active_file_ws_ids = active_file_ws_ids or file_ws_ids + all_ws = _api_get(api_base, "/workplans", {"repo_id": repo_id}) + if not isinstance(all_ws, list): + return + for ws in all_ws: + ws_id = ws["id"] + ws_status = ws.get("status", "") + normalised_status = normalise_workstream_status(ws_status) + if normalised_status not in CLOSED_WORKSTREAM_STATUSES and ws_id in active_file_ws_ids: + continue + if normalised_status in CLOSED_WORKSTREAM_STATUSES and ws_id in file_ws_ids: + continue + ws_slug = ws.get("slug", "") + if _is_projection_retired(ws) and ws_id not in file_ws_ids: + report.add( + severity="INFO", check_id="C-08", + message=( + f"Retired DB workstream '{ws_slug}' " + f"(id={ws_id[:8]}…, status={ws_status}) has no backing workplan file " + f"— projection tombstone, not live work" + ), + db_id=ws_id, + fixable=False, + ) + continue + if normalised_status not in CLOSED_WORKSTREAM_STATUSES: + report.add( + severity="FAIL", check_id="C-07", + message=( + f"Non-closed DB workstream '{ws_slug}' (id={ws_id[:8]}…) " + f"has no backing workplan file — ADR-001 violation" + ), + db_id=ws_id, + fixable=False, + ) + elif normalised_status in CLOSED_WORKSTREAM_STATUSES: + report.add( + severity="INFO", check_id="C-08", + message=( + f"Closed DB workstream '{ws_slug}' " + f"(id={ws_id[:8]}…, status={ws_status}) has no backing workplan file" + ), + db_id=ws_id, + fixable=False, + ) + + +def _check_ghost_duplicates( + api_base: str, + workplan_infos: list[tuple], + file_ws_ids: dict[str, tuple], + report: ConsistencyReport, +) -> None: + """C-14: detect active workstreams with repo_id=null whose title matches a + file-backed workstream — these are ghosts created by premature create_workstream() + calls before the workplan file existed. + """ + # Build lookup: normalised title → file-backed workstream id + file_titles: dict[str, str] = {} + for _, meta, _ in workplan_infos: + ws_id = str(meta.get("state_hub_workstream_id", "")).strip().strip('"') + title = str(meta.get("title", "")).strip().lower() + if title and ws_id: + file_titles[title] = ws_id + + if not file_titles: + return + + # Gather topic_ids from all file-backed workstreams so we can query by topic + topic_ids: set[str] = set() + for ws_id in file_ws_ids: + ws = _api_get(api_base, f"/workplans/{ws_id}") + if ws and ws.get("topic_id"): + topic_ids.add(ws["topic_id"]) + + for topic_id in topic_ids: + topic_ws: list[dict] = [] + for status in OPEN_WORKSTREAM_STATUSES: + status_rows = _api_get(api_base, "/workplans", {"topic_id": topic_id, "status": status}) + if isinstance(status_rows, list): + topic_ws.extend(status_rows) + for ws in topic_ws: + ws_id = ws["id"] + if ws_id in file_ws_ids: + continue # legitimately linked + if ws.get("repo_id"): + continue # C-07 covers repo-scoped orphans + ws_title = ws.get("title", "").strip().lower() + if ws_title in file_titles: + file_backed_id = file_titles[ws_title] + report.add( + severity="WARN", + check_id="C-14", + message=( + f"Ghost duplicate: active workstream '{ws.get('slug')}' " + f"(id={ws_id[:8]}…, repo_id=null) has same title as " + f"file-backed workstream {file_backed_id[:8]}… — " + f"likely created via create_workstream() before workplan file existed; " + f"archive it" + ), + db_id=ws_id, + fixable=False, + ) + + +# --------------------------------------------------------------------------- +# Git sync (T02–T04: pull gate, writeback, push seal) — see repo_sync.py +# --------------------------------------------------------------------------- +# repo_sync.py owns the push-seal invariant and all git lifecycle primitives. +# The aliases below keep internal call sites and existing tests unchanged. + +from repo_sync import ( # noqa: E402 (import after top-level imports intentional) + count_local_ahead as _detect_ahead_of_remote, + count_remote_ahead as _count_remote_ahead, + pull_ff as _git_pull, + push_ff as _git_push, +) + + +def _detect_behind_remote(repo_path: str) -> bool: + """True if remote has commits the local repo lacks (C-16 predicate). + + Delegates to repo_sync.count_remote_ahead, which fetches before counting. + Returns False on any error so C-16 is never spuriously triggered. + """ + return _count_remote_ahead(repo_path) > 0 + + +def _patch_task_status_in_file( + file_path: Path, task_block_id: str, new_status: str +) -> bool: + """Update the ``status:`` field inside a task block identified by its id. + + Only modifies lines inside a ```task … ``` fenced block whose ``id:`` + matches *task_block_id*. Returns True if the file was changed. + """ + text = file_path.read_text(encoding="utf-8") + + def _replace(m: re.Match) -> str: + # m.group(0) is the full ```task...``` block including fences. + # m.group(1) is the inner YAML content (no fences). + full_block = m.group(0) + inner = m.group(1).strip() + task_meta = _parse_yaml_block(inner) + if str(task_meta.get("id", "")).strip() != task_block_id: + return full_block + # Replace the status line only, leave everything else untouched. + return re.sub( + r"^(status:\s*)\S+", + rf"\g<1>{new_status}", + full_block, + flags=re.MULTILINE, + ) + + new_text = _TASK_BLOCK_RE.sub(_replace, text) + if new_text != text: + file_path.write_text(new_text, encoding="utf-8") + return True + return False + + +def _git_identity_config() -> list[str]: + import os as _os + + name = _os.environ.get("GIT_SYNC_USER_NAME", "custodian-sync") + email = _os.environ.get("GIT_SYNC_USER_EMAIL", "custodian-sync@railiance.local") + return ["-c", f"user.name={name}", "-c", f"user.email={email}"] + + +def _git_commit_writeback( + repo_path: str, + file_path: Path, + changes: list[str], + *, + subject: str = "chore(consistency): sync task status from DB [auto]", +) -> bool: + """Stage *file_path* and commit with a standard writeback message. + + Returns True on success, False on any error (errors are logged to stderr + but do not abort the consistency run). + """ + from datetime import date as _date + summary = "\n".join(f" - {c}" for c in changes) + msg = ( + f"{subject}\n\n" + f"Updated by fix-consistency on {_date.today().isoformat()}:\n" + f"{summary}" + ) + import os as _os + # Pass GIT_CUSTODIAN_SYNC=1 so the post-commit hook can detect it is + # running from within a sync pass and exit early, preventing re-entrancy. + sync_env = {**_os.environ, "GIT_CUSTODIAN_SYNC": "1"} + try: + subprocess.run( + ["git", "-C", repo_path, "add", str(file_path)], + check=True, capture_output=True, env=sync_env, + ) + subprocess.run( + ["git", "-C", repo_path, *_git_identity_config(), "commit", "-m", msg], + check=True, capture_output=True, env=sync_env, + ) + return True + except subprocess.CalledProcessError as e: + print( + f"WARN: git commit failed for writeback: {e.stderr.decode().strip()}", + file=sys.stderr, + ) + return False + + +# --------------------------------------------------------------------------- +# Worker orientation brief (.custodian-brief.md) +# --------------------------------------------------------------------------- + +def default_wp_prefix(repo_slug: str) -> str: + first = repo_slug.split("-", 1)[0].upper() + return f"{first}-WP" + + +def infer_wp_prefix(repo_path: Path, repo_slug: str) -> str: + """Prefer established on-disk workplan prefixes over first-token derivation.""" + counts: Counter[str] = Counter() + workplans_dir = repo_path / "workplans" + if workplans_dir.is_dir(): + for workplan in iter_workplan_files(workplans_dir): + if workplan.name.startswith("ADHOC"): + continue + try: + text = workplan.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + id_match = _WP_ID_PREFIX_RE.search(text) or _WP_ID_BARE_RE.search(text) + if id_match: + counts[id_match.group(1).upper()] += 1 + continue + file_match = _WP_FILE_PREFIX_RE.match(workplan.name) or _WP_FILE_BARE_RE.match(workplan.name) + if file_match: + counts[file_match.group(1).upper()] += 1 + if not counts: + return default_wp_prefix(repo_slug) + return counts.most_common(1)[0][0] + + +def parse_blocked_on(value: str) -> str | None: + if not value: + return None + match = _BLOCKED_ON_RE.match(value.strip()) + return match.group("agent").strip() if match else None + + +def _parse_message_timestamp(value: Any) -> datetime | None: + if not value: + return None + if isinstance(value, datetime): + return value if value.tzinfo else value.replace(tzinfo=timezone.utc) + try: + parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except ValueError: + return None + return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) + + +def _summarise_inbox_message(msg: dict[str, Any], *, now: datetime) -> dict[str, Any]: + created = _parse_message_timestamp(msg.get("created_at")) + age_days = (now - created).days if created else None + return { + "id": str(msg.get("id", ""))[:8], + "from_agent": msg.get("from_agent", ""), + "subject": msg.get("subject", ""), + "age_days": age_days, + } + + +def collect_inbox_hygiene(api_base: str, repo_slug: str) -> dict[str, Any]: + messages = _api_get( + api_base, + "/messages", + {"to_agent": repo_slug, "unread_only": True, "limit": 100}, + ) or [] + if not isinstance(messages, list): + return { + "stale_unread": [], + "missing_thread": [], + "work_requests_unpromoted": [], + "stale_unread_count": 0, + } + + now = datetime.now(timezone.utc) + stale_cutoff = now - timedelta(days=STALE_UNREAD_DAYS) + thread_cutoff = now - timedelta(days=1) + + stale_unread: list[dict[str, Any]] = [] + missing_thread: list[dict[str, Any]] = [] + work_requests_unpromoted: list[dict[str, Any]] = [] + + for msg in messages: + created = _parse_message_timestamp(msg.get("created_at")) + if created is None: + continue + summary = _summarise_inbox_message(msg, now=now) + if created < stale_cutoff: + stale_unread.append(summary) + if not msg.get("thread_id") and created < thread_cutoff: + missing_thread.append(summary) + body = f"{msg.get('subject', '')} {msg.get('body', '')}" + if _WORK_REQUEST_RE.search(body) and created < stale_cutoff: + work_requests_unpromoted.append(summary) + + return { + "stale_unread": stale_unread, + "missing_thread": missing_thread, + "work_requests_unpromoted": work_requests_unpromoted, + "stale_unread_count": len(stale_unread), + } + + +_FULL_SHA = re.compile(r"^[0-9a-f]{40}$") + + +def scan_source_revision(repo_dir: Path) -> tuple[str | None, str | None]: + """Return ``(sha, None)`` for a clean ``workplans/`` tree, else ``(None, reason)``. + + ``sha`` is the full 40-hex HEAD commit. Any change under ``workplans/`` + (modified, staged, untracked or ignored) means the scanned files are not + that commit, so no revision is claimed. Reasons: ``not_a_git_tree``, + ``no_head_commit``, ``unsupported_object_format``, ``dirty_workplans``, + ``git_status_failed``. + """ + def git(*args: str) -> str | None: + try: + return subprocess.run( + ["git", *args], cwd=repo_dir, capture_output=True, text=True, + timeout=30, check=True, + ).stdout + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError): + return None + + if not Path(repo_dir).is_dir() or git("rev-parse", "--is-inside-work-tree") is None: + return None, "not_a_git_tree" + head = git("rev-parse", "--verify", "--quiet", "HEAD^{commit}") + if head is None or not head.strip(): + return None, "no_head_commit" + sha = head.strip() + if not _FULL_SHA.fullmatch(sha): + return None, "unsupported_object_format" + status = git("status", "--porcelain", "--untracked-files=all", "--ignored", + "--", "workplans") + if status is None: + return None, "git_status_failed" + if status.strip(): + return None, "dirty_workplans" + return sha, None + + +C25_PAGE_SIZE = 200 +C25_MAX_PAGES = 50 + + +def _collect_counterpart_messages( + api_base: str, repo_slug: str, counterpart: str +) -> tuple[list[dict], bool]: + """Page through every reply from ``counterpart`` (STATE-WP-0094-T03). + + Returns ``(messages, complete)``. Messages are deduplicated by id, so a + reply shifting between pages while new mail arrives is counted once and the + finding's message ids stay stable. ``complete`` is False when the hub + ignored ``offset`` (a page repeated only known ids) or the page bound was + hit; the caller reports that instead of silently capping. + """ + seen: dict[str, dict] = {} + anonymous: list[dict] = [] + for page in range(C25_MAX_PAGES): + rows = _api_get(api_base, "/messages", { + "to_agent": repo_slug, "from_agent": counterpart, + "unread_only": False, "limit": C25_PAGE_SIZE, "offset": page * C25_PAGE_SIZE, + }) or [] + if not isinstance(rows, list): + rows = [] + new = 0 + for row in rows: + if not isinstance(row, dict): + continue + message_id = str(row.get("id") or "") + if not message_id: + anonymous.append(row) + new += 1 + elif message_id not in seen: + seen[message_id] = row + new += 1 + if len(rows) < C25_PAGE_SIZE: + return list(seen.values()) + anonymous, True + if new == 0: + return list(seen.values()) + anonymous, False + return list(seen.values()) + anonymous, False + + +def _check_blocked_unblock_sweep( + api_base: str, + repo_slug: str, + repo_dir: Path, + workplan_infos: list[tuple[Path, dict, str]], + report: ConsistencyReport, +) -> None: + for wp_file, meta, _ in workplan_infos: + file_status = normalise_workstream_status(str(meta.get("status", "")).strip()) + if file_status != "blocked": + continue + blocked_on = str(meta.get("blocked_on", "")).strip() + counterpart = parse_blocked_on(blocked_on) + if not counterpart: + continue + messages = _api_get( + api_base, + "/messages", + { + "to_agent": repo_slug, + "from_agent": counterpart, + "unread_only": True, + "limit": 20, + }, + ) or [] + if not isinstance(messages, list) or not messages: + continue + fname = workplan_display_path(repo_dir, wp_file) + wp_id = str(meta.get("id", wp_file.stem)).strip() + report.add( + severity="WARN", + check_id="C-25", + message=( + f"Blocked workplan '{wp_id}' waiting on {blocked_on!r} has " + f"{len(messages)} unread message(s) from {counterpart} — " + "blocker may have cleared" + ), + file_path=fname, + file_value=blocked_on, + fixable=False, + ) + + + # Task-level returns can clear a wait even after somebody read the reply. + # Preserve the legacy workplan check above; task findings carry stable IDs + # for an owner runner to deduplicate without changing task status here. + messages_by_agent: dict[str, list[dict]] = {} + for wp_file, meta, body in workplan_infos: + if ( + normalise_workstream_status(str(meta.get("status", ""))) in CLOSED_WORKSTREAM_STATUSES + or is_residual_flavor(meta.get("flavor")) + ): + continue + for task in get_tasks_from_workplan(meta, body): + if normalise_task_status(task.get("status", "todo")) != "wait": + continue + counterpart = parse_blocked_on(str(task.get("blocked_on") or "")) + if not counterpart: + continue + if counterpart not in messages_by_agent: + rows, complete = _collect_counterpart_messages(api_base, repo_slug, counterpart) + messages_by_agent[counterpart] = rows + if not complete: + report.add( + severity="WARN", check_id="C-25", + message=(f"Owner-return reply collection from {counterpart} is incomplete " + f"({len(rows)} message(s) read; hub ignored paging or the " + f"{C25_MAX_PAGES}-page bound was reached) — returns may be missing"), + file_path=workplan_display_path(repo_dir, wp_file), + file_value=f"message-from:{counterpart}", + fixable=False, + ) + # Dated notes are the available acknowledgement watermark. Date-only + # notes cover the whole UTC day, avoiding same-day re-notification. + note_dates = [] + for value in re.findall(r"(?m)^\s*(?:[-*]\s+)?(?:\*\*)?(\d{4}-\d{2}-\d{2})\b", str(task.get("description") or "")): + parsed = _parse_message_timestamp(value) + if parsed: + note_dates.append(parsed + timedelta(days=1)) + explicit_updated = _parse_message_timestamp(task.get("updated")) + cutoff = max(note_dates + ([explicit_updated] if explicit_updated else []), default=None) + hits = [] + for message in messages_by_agent[counterpart]: + if message.get("from_agent") != counterpart or message.get("archived_at"): + continue + created = _parse_message_timestamp(message.get("created_at")) + if created and (created >= cutoff if cutoff else not message.get("read_at")): + hits.append(message) + if not hits: + continue + hits.sort(key=lambda message: str(message.get("created_at", ""))) + task_id = str(task.get("id", "")).strip() + message_ids = [str(message["id"]) for message in hits if message.get("id")] + report.add( + severity="WARN", check_id="C-25", + message=(f"Owner-return task '{task_id}' waiting on {counterpart} has " + f"{len(hits)} reply/replies after its last dated note " + f"(or unread when undated) — owner action needed: {', '.join(message_ids)}"), + file_path=f"{workplan_display_path(repo_dir, wp_file)}#{task_id}", + file_value=str(task.get("blocked_on")), + fixable=False, + _fix_context={"task_record_id": task_id, "message_ids": message_ids}, + ) + + +_SUPERSEDES_RE = re.compile( + r"\bsupersedes\s+(?:decision\s+)?([0-9a-f]{8}(?:-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})?)\b", + re.IGNORECASE, +) + + +def find_unlinked_supersessions( + decisions: list[dict], workplan_ids: set[str] +) -> list[tuple[dict, dict]]: + """(citing, cited) pairs where a resolved decision of these workplans says + "supersedes " but the cited decision is not superseded_by it. "Superseded + by" is the other direction and is not matched; ambiguous prefixes are skipped.""" + pairs: list[tuple[dict, dict]] = [] + for citing in decisions: + if str(citing.get("status") or "") != "resolved": + continue + if str(citing.get("workplan_id") or "") not in workplan_ids: + continue + text = " ".join(str(citing.get(k) or "") for k in ("title", "rationale", "description")) + for ref in dict.fromkeys(m.lower() for m in _SUPERSEDES_RE.findall(text)): + matches = [d for d in decisions if str(d.get("id") or "").lower().startswith(ref)] + if len(matches) != 1 or matches[0] is citing: + continue + cited = matches[0] + if ( + str(cited.get("status") or "") != "superseded" + or str(cited.get("superseded_by") or "") != str(citing["id"]) + ): + pairs.append((citing, cited)) + return pairs + + +def _check_unlinked_supersession( + api_base: str, workplan_ids: set[str], report: ConsistencyReport +) -> None: + workplan_ids = {str(w) for w in workplan_ids if w} + if not workplan_ids: + return + decisions = _api_get(api_base, "/decisions/") + if not isinstance(decisions, list): + return + for citing, cited in find_unlinked_supersessions(decisions, workplan_ids): + report.add( + severity="WARN", + check_id="C-43", + message=( + f"Decision {citing['id']} supersedes {cited['id']}, but the cited " + f"decision is status={cited.get('status')!r} " + f"superseded_by={cited.get('superseded_by')!r}; the decider should " + f"set status=superseded and superseded_by" + ), + db_id=str(cited["id"]), + fixable=False, + ) + + +def _check_inbox_hygiene(api_base: str, repo_slug: str, report: ConsistencyReport) -> None: + hygiene = collect_inbox_hygiene(api_base, repo_slug) + if hygiene["stale_unread_count"]: + preview = ", ".join( + f"{m['from_agent']}:{m['id']}" for m in hygiene["stale_unread"][:5] + ) + extra = "" if hygiene["stale_unread_count"] <= 5 else ", ..." + report.add( + severity="WARN", + check_id="C-28", + message=( + f"{hygiene['stale_unread_count']} unread inbox message(s) older than " + f"{STALE_UNREAD_DAYS} day(s): {preview}{extra}" + ), + fixable=False, + ) + if hygiene["missing_thread"]: + preview = ", ".join( + f"{m['from_agent']}:{m['id']}" for m in hygiene["missing_thread"][:5] + ) + report.add( + severity="WARN", + check_id="C-28", + message=( + f"{len(hygiene['missing_thread'])} unread message(s) lack thread_id " + f"for supersession tracking: {preview}" + ), + fixable=False, + ) + for msg in hygiene["work_requests_unpromoted"]: + report.add( + severity="WARN", + check_id="C-29", + message=( + f"Unread work request from {msg['from_agent']} ({msg['id']}) may need " + f"a workplan file: {msg['subject'][:120]}" + ), + fixable=False, + ) + + +def _check_workplan_prefixes( + repo_dir: Path, + repo_slug: str, + workplan_infos: list[tuple[Path, dict, str]], + report: ConsistencyReport, +) -> None: + canonical = infer_wp_prefix(repo_dir, repo_slug) + for wp_file, meta, _ in workplan_infos: + if wp_file.name.startswith("ADHOC"): + continue + wp_id = str(meta.get("id", "")).strip() + if not wp_id: + continue + match = _WP_ID_PREFIX_RE.match(wp_id) or _WP_ID_BARE_RE.match(wp_id) + if not match: + continue + prefix = match.group(1).upper() + if prefix == canonical.upper(): + continue + report.add( + severity="WARN", + check_id="C-26", + message=( + f"Workplan id '{wp_id}' uses prefix '{prefix}' but repo canonical " + f"prefix is '{canonical}' — new plans must conform" + ), + file_path=workplan_display_path(repo_dir, wp_file), + file_value=wp_id, + db_value=canonical, + fixable=False, + ) + + +def _scan_repo_workplan_ids(repos: list[dict[str, Any]]) -> dict[str, list[tuple[str, str]]]: + id_map: dict[str, list[tuple[str, str]]] = {} + for repo in repos: + slug = repo["slug"] + path = resolve_repo_path(repo) + if not path or not Path(path).is_dir(): + continue + workplans_dir = Path(path) / "workplans" + if not workplans_dir.is_dir(): + continue + for wp_file in iter_workplan_files(workplans_dir): + if wp_file.name.startswith("ADHOC"): + continue + try: + text = wp_file.read_text(encoding="utf-8") + except OSError: + continue + if not text.startswith("---"): + continue + meta, _ = parse_frontmatter(text) + if not meta or meta.get("_parse_error"): + continue + wp_id = str(meta.get("id", "")).strip() + if wp_id: + id_map.setdefault(wp_id, []).append((slug, wp_file.name)) + return id_map + + +def _check_workplan_id_collisions( + api_base: str, + repo_slug: str, + repo_dir: Path, + workplan_infos: list[tuple[Path, dict, str]], + report: ConsistencyReport, +) -> None: + repos = _api_get(api_base, "/repos") or [] + if not isinstance(repos, list): + return + id_map = _scan_repo_workplan_ids(repos) + for wp_file, meta, _ in workplan_infos: + wp_id = str(meta.get("id", "")).strip() + if not wp_id: + continue + locations = id_map.get(wp_id, []) + if len(locations) <= 1: + continue + report.add( + severity="WARN", + check_id="C-27", + message=( + f"Workplan id '{wp_id}' collides across repos: " + + ", ".join(f"{slug}/{fname}" for slug, fname in locations) + ), + file_path=workplan_display_path(repo_dir, wp_file), + file_value=wp_id, + fixable=False, + ) + + +def _scope_current_state_lines(repo_dir: Path) -> dict[str, str]: + scope_path = repo_dir / "SCOPE.md" + if not scope_path.exists(): + return {} + text = scope_path.read_text(encoding="utf-8", errors="replace") + match = re.search(r"## Current State\s*\n(.*?)(?:\n## |\Z)", text, re.DOTALL) + if not match: + return {} + result: dict[str, str] = {} + for line in match.group(1).splitlines(): + stripped = line.strip() + if not stripped.startswith("-"): + continue + parts = stripped[1:].split(":", 1) + if len(parts) != 2: + continue + result[parts[0].strip().lower()] = parts[1].strip() + return result + + +def _repo_has_open_workplans(workplan_infos: list[tuple[Path, dict, str]]) -> bool: + for wp_file, meta, _ in workplan_infos: + if wp_file.parent.name == "archived": + continue + status = normalise_workstream_status(str(meta.get("status", "")).strip()) + if status in _OPEN_WORKPLAN_STATUSES: + return True + return False + + +def _check_scope_freshness( + repo_dir: Path, + workplan_infos: list[tuple[Path, dict, str]], + report: ConsistencyReport, +) -> None: + scope = _scope_current_state_lines(repo_dir) + if not scope: + return + has_open_workplans = _repo_has_open_workplans(workplan_infos) + scope_status = scope.get("status", "").lower() + warnings: list[str] = [] + if "active" in scope_status and not has_open_workplans: + warnings.append( + "SCOPE.md Current State says active but no open workplans remain — may be stale" + ) + if has_open_workplans and any( + token in scope_status for token in ("finished", "archived", "idle", "dormant") + ): + warnings.append( + "SCOPE.md Current State does not reflect active workplans — may be stale" + ) + implementation = scope.get("implementation", "").lower() + if has_open_workplans and implementation and "not yet started" in implementation: + warnings.append( + "SCOPE.md mentions work not yet started but active workplans exist — may be stale" + ) + for warning in warnings: + report.add( + severity="WARN", + check_id="C-30", + message=warning, + file_path="SCOPE.md", + fixable=False, + ) + + +_BRIEF_HEADER = "" +_TASK_STATUS_ICON = {"done": "✓", "cancel": "✗", "progress": "►", "wait": "!", "todo": "·"} +_OPEN_STATUSES = set(OPEN_TASK_STATUSES) + + +def _commit_working_memory_notes(repo_slug: str, repo_path: str) -> bool: + """Stage and commit activity-core daily-triage working-memory notes. + + Only the-custodian carries the fleet working-memory sink. Notes are + repo-relative under memory/working/ and are written by activity-core on + Railiance via hostPath into the clone checkout. + """ + if repo_slug != "the-custodian": + return False + + wm_dir = Path(repo_path) / "memory" / "working" + if not wm_dir.is_dir(): + return False + + notes = sorted(wm_dir.glob("daily-triage-*.md")) + if not notes: + return False + + import os as _os + + sync_env = {**_os.environ, "GIT_CUSTODIAN_SYNC": "1"} + rel_paths = [str(note.relative_to(repo_path)) for note in notes] + try: + subprocess.run( + ["git", "-C", repo_path, "add", "--", *rel_paths], + check=True, + capture_output=True, + env=sync_env, + ) + staged = subprocess.run( + ["git", "-C", repo_path, "diff", "--cached", "--quiet"], + capture_output=True, + env=sync_env, + ) + if staged.returncode == 0: + return False + from datetime import date as _date + + msg = ( + "chore(working-memory): sync daily-triage notes from activity-core " + f"[auto {_date.today().isoformat()}]" + ) + subprocess.run( + ["git", "-C", repo_path, *_git_identity_config(), "commit", "-m", msg], + check=True, + capture_output=True, + env=sync_env, + ) + return True + except subprocess.CalledProcessError as e: + print( + f"WARN: working-memory commit failed: {e.stderr.decode().strip()}", + file=sys.stderr, + ) + return False + + +def _write_custodian_brief(api_base: str, repo_slug: str, repo_path: str) -> bool: + """Generate .custodian-brief.md at the repo root and git-commit if changed. + + The brief gives any agent — including subagents without MCP access and + workers on remote machines — instant orientation without a live hub + connection. Returns True if the file was written (content changed). + """ + import datetime as _dt + from datetime import timezone as _tz + + repo = _api_get(api_base, f"/repos/{repo_slug}") + if not repo: + return False + + repo_id: str = repo.get("id", "") + domain_slug = str(repo.get("domain_slug") or "") + + # The repository projection owns domain identity. Retain the workplan/topic + # lookup only for compatibility with older API responses that omitted it. + workstreams: list[dict] = [] + for status in OPEN_WORKSTREAM_STATUSES: + rows = _api_get(api_base, "/workplans", {"repo_id": repo_id, "status": status}) or [] + if isinstance(rows, list): + workstreams.extend(rows) + _ws_for_domain = workstreams if workstreams else [] + if not _ws_for_domain: + all_ws = _api_get(api_base, "/workplans", {"repo_id": repo_id}) or [] + _ws_for_domain = all_ws if isinstance(all_ws, list) else [] + if not domain_slug and _ws_for_domain: + topic = _api_get(api_base, f"/topics/{_ws_for_domain[0].get('topic_id', '')}") + if topic: + domain_slug = topic.get("domain_slug", "") + + # Active repo goal (first active one if multiple) + goal_text = "" + goals = _api_get(api_base, "/repo-goals", {"repo_slug": repo_slug}) or [] + if isinstance(goals, list): + active_goals = [g for g in goals if g.get("status") == "active"] + if active_goals: + g = active_goals[0] + goal_text = g.get("title", "") or g.get("description", "") + + now_utc = _dt.datetime.now(_tz.utc) + ts = now_utc.strftime("%Y-%m-%d %H:%M UTC") + + lines = [ + _BRIEF_HEADER, + f"# Custodian Brief — {repo_slug}", + "", + f"**Domain:** {domain_slug or '(unknown)'} ", + f"**Last synced:** {ts} ", + "**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*", + "", + ] + + if goal_text: + lines += ["## Current Goal", "", goal_text, ""] + + if isinstance(workstreams, list) and workstreams: + lines.append("## Active Workstreams") + for ws in workstreams: + ws_title = ws.get("title", ws.get("slug", "?")) + ws_id = ws["id"] + tasks = _api_get(api_base, "/tasks", {"workplan_id": ws_id}) or [] + if not isinstance(tasks, list): + tasks = [] + + done = sum( + 1 for t in tasks + if normalise_task_status(t.get("status", "todo")) in TERMINAL_TASK_STATUSES + ) + total = len(tasks) + pct = f"{done}/{total}" if total else "no tasks" + + open_tasks = [ + t for t in tasks + if normalise_task_status(t.get("status", "todo")) in _OPEN_STATUSES + ] + # Show wait first, then progress, then todo (cap at 5). + priority_order = {"wait": 0, "progress": 1, "todo": 2} + open_tasks.sort( + key=lambda t: priority_order.get( + normalise_task_status(t.get("status", "todo")), 9 + ) + ) + + lines += [ + "", + f"### {ws_title}", + f"Progress: {pct} done | workplan_id: `{ws_id}`", + ] + + if open_tasks: + lines.append("") + lines.append("**Open tasks:**") + for t in open_tasks[:7]: + status = normalise_task_status(t.get("status", "todo")) + icon = _TASK_STATUS_ICON.get(status, "·") + title = t.get("title", t["id"]) + tid = t["id"] + blocker = t.get("blocking_reason", "") + task_line = f"- {icon} {title} `{tid[:8]}`" + if status == "wait" and blocker: + task_line += f"\n *(wait: {blocker})*" + lines.append(task_line) + if len(open_tasks) > 7: + lines.append(f"- … and {len(open_tasks) - 7} more open tasks") + else: + lines += ["## Active Workstreams", "", "*(none — repo may need first-session setup)*"] + + hygiene = collect_inbox_hygiene(api_base, repo_slug) + if hygiene["stale_unread_count"] or hygiene["missing_thread"] or hygiene["work_requests_unpromoted"]: + lines += ["", "## Inbox Hygiene", ""] + if hygiene["stale_unread_count"]: + lines.append( + f"**Stale unread:** {hygiene['stale_unread_count']} message(s) older than " + f"{STALE_UNREAD_DAYS} day(s) — triage at session start." + ) + if hygiene["missing_thread"]: + lines.append( + f"**Missing thread_id:** {len(hygiene['missing_thread'])} unread message(s) " + "lack supersession chains." + ) + for msg in hygiene["work_requests_unpromoted"][:3]: + lines.append( + f"- ! {msg['from_agent']}: {msg['subject'][:100]} `{msg['id']}`" + ) + + blocked_warnings: list[str] = [] + workplans_dir = Path(repo_path) / "workplans" + if workplans_dir.is_dir(): + for wp_file in iter_workplan_files(workplans_dir): + try: + text = wp_file.read_text(encoding="utf-8") + except OSError: + continue + if not text.startswith("---"): + continue + meta, _ = parse_frontmatter(text) + if not meta or meta.get("_parse_error"): + continue + if normalise_workstream_status(str(meta.get("status", "")).strip()) != "blocked": + continue + blocked_on = str(meta.get("blocked_on", "")).strip() + counterpart = parse_blocked_on(blocked_on) + if not counterpart: + continue + messages = _api_get( + api_base, + "/messages", + { + "to_agent": repo_slug, + "from_agent": counterpart, + "unread_only": True, + "limit": 5, + }, + ) or [] + if isinstance(messages, list) and messages: + wp_id = str(meta.get("id", wp_file.stem)).strip() + blocked_warnings.append( + f"- ! **{wp_id}** — blocker may have cleared: " + f"{len(messages)} unread message(s) from `{counterpart}`" + ) + if blocked_warnings: + lines += ["", "## Blocked Workplans", ""] + lines.extend(blocked_warnings) + + scope_warnings: list[str] = [] + scope = _scope_current_state_lines(Path(repo_path)) + has_open_workplans = False + if workplans_dir.is_dir(): + for wp_file in iter_workplan_files(workplans_dir): + try: + text = wp_file.read_text(encoding="utf-8") + except OSError: + continue + if not text.startswith("---"): + continue + meta, _ = parse_frontmatter(text) + if not meta or meta.get("_parse_error"): + continue + if wp_file.parent.name == "archived": + continue + status = normalise_workstream_status(str(meta.get("status", "")).strip()) + if status in _OPEN_WORKPLAN_STATUSES: + has_open_workplans = True + break + if scope: + scope_status = scope.get("status", "").lower() + if "active" in scope_status and not has_open_workplans: + scope_warnings.append( + "SCOPE.md says active but no open workplans remain — refresh Current State." + ) + if has_open_workplans and any( + token in scope_status for token in ("finished", "archived", "idle", "dormant") + ): + scope_warnings.append( + "SCOPE.md Current State does not reflect active workplans — may be stale." + ) + if scope_warnings: + lines += ["", "## SCOPE Freshness", ""] + lines.extend(f"- {warning}" for warning in scope_warnings) + + lines += [ + "", + "---", + "## MCP Orientation (when available)", + "", + "If the state-hub MCP server is reachable, call:", + f"`get_domain_summary(\"{domain_slug}\")`", + "This provides richer cross-domain context.", + "If the MCP call fails, use this file as your orientation source.", + ] + + content = "\n".join(lines) + "\n" + + brief_path = Path(repo_path) / ".custodian-brief.md" + existing = brief_path.read_text(encoding="utf-8") if brief_path.exists() else "" + + # Strip the timestamp line before comparing to avoid spurious writes + def _strip_ts(text: str) -> str: + return "\n".join( + ln for ln in text.splitlines() + if not ln.startswith("**Last synced:**") + ) + + if _strip_ts(content) == _strip_ts(existing): + return False # no meaningful change + + brief_path.write_text(content, encoding="utf-8") + + # Commit the brief so remote workers can pull it + _git_commit_writeback( + repo_path, + brief_path, + [f"update .custodian-brief.md for {repo_slug}"], + ) + return True + + +# --------------------------------------------------------------------------- +# Fix engine +# --------------------------------------------------------------------------- + +def _refuse_frozen_fix_batch(report: ConsistencyReport, repo_slug: str) -> bool: + """Refuse a complete repair batch touching the exact bound histories.""" + if any(issue.check_id == "C-44" and issue.severity == "FAIL" for issue in report.issues): + return True + protected_ids = {entry["uuid"] for entry in FROZEN_CLUSTER_BINDINGS} + protected_ids.update(task["uuid"] for entry in FROZEN_CLUSTER_BINDINGS + for task in entry["tasks"]) + paths = {entry["path"] for entry in FROZEN_CLUSTER_BINDINGS} + root = Path(report.repo_path).absolute() if report.repo_path else None + + def protected(value, key=""): + if isinstance(value, dict): + return any(protected(item, name) for name, item in value.items()) + if isinstance(value, (list, tuple)): + return any(protected(item, key) for item in value) + text = str(value) + if (key in {"id", "db_id", "ws_id", "task_id"} + or key.endswith(("_id", "_uuid"))): + if text in protected_ids: + return True + if key in {"file_path", "wp_file", "md_path", "path"} and text: + candidate = Path(text) + if candidate.is_absolute(): + return root is not None and any(candidate.absolute() == root / path for path in paths) + return repo_slug == "railiance-cluster" and candidate.as_posix() in paths + return False + + for issue in report.issues: + if issue.fixable and (protected(issue.db_id, "db_id") + or protected(issue.file_path, "file_path") + or protected(issue._fix_context)): + report.add(severity="FAIL", check_id="C-44", + message="Frozen historical repair batch refused; owner review required", + fixable=False) + return True + return False + + +def fix_repo( + api_base: str, + repo_slug: str, + repo_path_override: str | None = None, + no_writeback: bool = False, + bootstrap_empty_projection: bool = False, +) -> ConsistencyReport: + """Run checks then apply all auto-fixable issues. Returns updated report.""" + report = check_repo( + api_base, + repo_slug, + repo_path_override, + bootstrap_empty_projection=bootstrap_empty_projection, + ) + if _refuse_frozen_fix_batch(report, repo_slug): + return report + if any(i.check_id in {"C-00", "C-36", "C-44"} for i in report.failures): + return report + + # RMGR-WP-0002: optional repo-manager reconcile proxy (index rebuild) for pilots + try: + from api.services.repo_manager_dual_run import reconcile_for_repo, rm_reconcile + + if reconcile_for_repo(repo_slug) and report.repo_path: + rm_rec = rm_reconcile(repo_path=report.repo_path, repo_slug=repo_slug) + if rm_rec.get("ok") or rm_rec.get("exit_code") == 0: + report.fixes_applied.append( + f"RM dual-run reconcile ok: index={rm_rec.get('index_path') or 'written'}" + ) + else: + report.fixes_applied.append( + f"RM dual-run reconcile note: {rm_rec.get('error') or rm_rec}" + ) + except Exception as rec_exc: # noqa: BLE001 + report.fixes_applied.append(f"RM dual-run reconcile skipped: {rec_exc}") + + # Auto-register this machine's path in host_paths so future runs work + # without --repo-path. Idempotent: skipped when already correct. + repo_path = report.repo_path + if repo_path: + repo_record = _api_get(api_base, f"/repos/{repo_slug}") + if repo_record: + hostname = _sweep_hostname() + if (repo_record.get("host_paths") or {}).get(hostname) != repo_path: + result = _api_post( + api_base, f"/repos/{repo_slug}/paths", + {"host": hostname, "path": repo_path}, + ) + if result and "_error" not in result: + report.fixes_applied.append( + f"host_paths[{hostname}] → {repo_path}" + ) + + # T02 — pull gate: warn and skip all write operations when local repo is + # behind its remote tracking branch. + if repo_path and _detect_behind_remote(repo_path): + report.add( + severity="WARN", check_id="C-16", + message=( + f"Repo '{repo_slug}' is behind its remote tracking branch — " + f"pull before fixing to avoid clobbering remote progress. " + f"Run: git -C {repo_path} pull --ff-only" + ), + fixable=False, + ) + report.fixes_applied.append( + "C-16: all write operations skipped — local repo is behind remote" + ) + return report + + # C-17: backlog guard — if local has unpushed commits from a prior failed push, + # try to push them before making more. Skipping writes prevents runaway divergence. + if repo_path: + ahead = _detect_ahead_of_remote(repo_path) + if ahead > 0: + push_ok, push_msg = _git_push(repo_path) + if not push_ok: + report.add( + severity="WARN", check_id="C-17", + message=( + f"Repo '{repo_slug}' has {ahead} unpushed commit(s) and push " + f"failed ({push_msg}) — skipping writes to prevent runaway divergence" + ), + fixable=False, + ) + report.fixes_applied.append( + "C-17: all write operations skipped — unpushed commits, push failed" + ) + return report + # Push succeeded — local is now in sync; proceed normally + report.fixes_applied.append(f"C-17 cleared: pushed {ahead} backlogged commit(s)") + + fixable = [i for i in report.issues if i.fixable] + + for issue in fixable: + ctx = issue._fix_context + try: + if issue.check_id in ("C-04", "C-05", "C-13", "C-19"): + ws_id = ctx["ws_id"] + result = _api_patch(api_base, f"/workplans/{ws_id}", + {ctx["field"]: ctx["value"]}) + if result is not None and "_error" not in result: + report.fixes_applied.append( + f"{issue.check_id} fixed: workstream {ws_id[:8]}… " + f"{ctx['field']} → {ctx['value']!r}" + ) + elif result is not None: + report.fixes_applied.append( + f"{issue.check_id} FAILED: workstream {ws_id[:8]}… " + f"{ctx['field']} → {ctx['value']!r}: {result['_error']}" + ) + + elif issue.check_id == "C-23": + ws_id = ctx["ws_id"] + target_status = ctx["target_status"] + result = _api_patch(api_base, f"/workplans/{ws_id}", {"status": target_status}) + if result is not None and "_error" not in result: + report.fixes_applied.append( + f"C-23 fixed: workstream {ws_id[:8]}… status → {target_status!r}" + ) + elif result is not None: + report.fixes_applied.append( + f"C-23 FAILED: workstream {ws_id[:8]}… " + f"status → {target_status!r}: {result['_error']}" + ) + + if no_writeback: + report.fixes_applied.append( + f"C-23 skipped file repair (--no-writeback): {Path(ctx['wp_file']).name}" + ) + else: + wp_file = Path(ctx["wp_file"]) + old_status = ctx["file_status"] + if _patch_frontmatter_field(wp_file, "status", target_status): + committed = _git_commit_writeback( + repo_path, + wp_file, + [f"workplan status: {old_status} → {target_status}"], + subject="chore(consistency): renormalize lifecycle state [auto]", + ) + suffix = " (committed)" if committed else " (file patched, commit failed)" + report.fixes_applied.append( + f"C-23 fixed: {wp_file.name} status " + f"{old_status} → {target_status}{suffix}" + ) + else: + report.fixes_applied.append( + f"C-23 SKIP: {wp_file.name} already has status {target_status!r}" + ) + + elif issue.check_id == "C-06": + wp_file = Path(ctx["wp_file"]) + meta = ctx["meta"] + domain = ctx["domain"] + repo_market_domain = str(ctx.get("repo_market_domain") or "").strip() + repo_id_val = ctx["repo_id"] + body = ctx.get("body", "") + wp_id = str(meta.get("id", "")).strip() + desired_ws_id = str( + ctx.get("desired_ws_id") or _derived_work_record_uuid(wp_id) + ) + title = str(meta.get("title", "")).strip() + status = str(meta.get("status", "active")).strip() + status = normalise_workstream_status(status) + if status not in VALID_WP_STATUSES: + status = "active" + + # Find topic_id — workplan frontmatter may still use legacy + # coordination slugs (e.g. custodian); map to market domain first. + topic_domain = resolve_topic_domain_slug( + domain, + repo_market_domain=repo_market_domain or None, + ) + topics = _api_get(api_base, "/topics") + topic_id = None + if isinstance(topics, list): + for t in topics: + if t.get("domain_slug") == topic_domain: + topic_id = t["id"] + break + if topic_id is None: + report.fixes_applied.append( + f"C-06 SKIP {wp_id}: no topic found for domain " + f"'{topic_domain}' (workplan domain={domain!r})" + ) + continue + + base_slug = re.sub(r"[^a-z0-9-]", "-", wp_id.lower()).strip("-") or "workplan" + repo_slug_part = re.sub( + r"[^a-z0-9-]", "-", str(ctx.get("repo_slug") or "").lower() + ).strip("-") + slug_candidates = [base_slug] + repo_qualified_slug = base_slug + if repo_slug_part and not base_slug.startswith(f"{repo_slug_part}-"): + repo_qualified_slug = f"{repo_slug_part}-{base_slug}" + slug_candidates.append(repo_qualified_slug) + for suffix in range(2, 21): + slug_candidates.append(f"{repo_qualified_slug}-{suffix}") + + ws_data = None + last_error = None + for slug in slug_candidates: + existing = _api_get(api_base, "/workplans", {"slug": slug}, return_error=True) + if isinstance(existing, dict) and "_error" in existing: + last_error = existing["_error"] + continue + if isinstance(existing, list) and existing: + existing_same_repo = next( + (w for w in existing if w.get("repo_id") == repo_id_val), + None, + ) + if ( + existing_same_repo + and existing_same_repo.get("title") == (title or wp_id) + and existing_same_repo.get("id") == desired_ws_id + ): + ws_data = existing_same_repo + report.fixes_applied.append( + f"C-06 reusing existing workstream {ws_data['id'][:8]}... for {wp_id}" + ) + break + last_error = ( + f"slug {slug!r} already belongs to a different workplan identity" + ) + continue + + create_flavor = normalize_flavor(meta.get("flavor")) + if create_flavor is not None and create_flavor not in WORK_RECORD_FLAVORS: + create_flavor = None + create_reason = normalize_promotion_reason(meta.get("flavor_promotion_reason")) + if create_reason is not None and create_reason not in FLAVOR_PROMOTION_REASONS: + create_reason = None + create_from = normalize_flavor(meta.get("flavor_promoted_from")) + if create_from is not None and create_from not in WORK_RECORD_FLAVORS: + create_from = None + ws_data = _api_post(api_base, "/workplans", { + "id": desired_ws_id, + "topic_id": topic_id, + "repo_id": repo_id_val, + "slug": slug, + "title": title or wp_id, + "status": status, + "owner": str(meta.get("owner", "")).strip() or None, + "planning_priority": str(meta.get("planning_priority", "")).strip() or None, + "planning_order": _as_int_or_none(meta.get("planning_order")), + "flavor": create_flavor, + "flavor_promotion_reason": create_reason, + "flavor_promoted_from": create_from, + }) + if ws_data is None or (isinstance(ws_data, dict) and "_error" in ws_data): + last_error = ws_data.get("_error") if isinstance(ws_data, dict) else "no response" + ws_data = None + continue + break + + if ws_data is None: + report.fixes_applied.append( + f"C-06 FAIL {wp_id}: could not create workstream in DB: {last_error or 'no usable slug'}" + ) + continue + + new_ws_id = ws_data["id"] + if new_ws_id != desired_ws_id: + report.fixes_applied.append( + f"C-06 FAIL {wp_id}: projection returned unexpected UUID {new_ws_id}" + ) + continue + if not meta.get("state_hub_workstream_id"): + _add_frontmatter_field(wp_file, "state_hub_workstream_id", new_ws_id) + report.fixes_applied.append( + f"C-06 fixed: created workstream {new_ws_id[:8]}… " + f"for {wp_id}, wrote ID to {wp_file.name}" + ) + + # Create tasks and inject IDs + tasks = get_tasks_from_workplan(meta, body) + for task in tasks: + if task.get("_parse_error"): + continue + t_id = str(task.get("id", "")).strip() + if not t_id: + continue + t_status = normalise_task_status(task.get("status", "todo")) + t_priority = str(task.get("priority", "medium")).strip() + if t_priority not in VALID_TASK_PRIORITIES: + t_priority = "medium" + raw_task_id = task.get("state_hub_task_id") + desired_task_id = ( + str(raw_task_id).strip().strip('"') + if raw_task_id not in (None, "", "~", "null", "None", "none") + else _derived_work_record_uuid(t_id) + ) + task_flavor = normalize_flavor(task.get("flavor")) + if task_flavor is not None and task_flavor not in WORK_RECORD_FLAVORS: + task_flavor = None + t_data = _api_post(api_base, "/tasks", { + "id": desired_task_id, + "workplan_id": new_ws_id, + "title": str(task.get("title", t_id)).strip() or t_id, + "description": task.get("description") or None, + "status": t_status, + "priority": t_priority, + "assignee": task.get("assignee") or None, + "flavor": task_flavor, + **_task_wait_fields(task), + }) + if t_data and "_error" not in t_data: + t_db_id = t_data["id"] + if t_db_id != desired_task_id: + report.fixes_applied.append( + f" ! task {t_id} returned unexpected UUID {t_db_id}" + ) + continue + if not raw_task_id: + injected = _inject_task_id_into_block( + wp_file, "state_hub_task_id", t_db_id, t_id + ) + if not injected: + _inject_task_id_frontmatter_list(wp_file, t_db_id, t_id) + report.fixes_applied.append(f" + task {t_id} → {t_db_id[:8]}…") + elif t_data: + report.fixes_applied.append( + f" ! task {t_id} not created: {t_data.get('_error', t_data)}" + ) + + elif issue.check_id == "C-32": + md_path = ctx["md_path"] + kind = ctx["kind"] + rid = ctx["rid"] + meta = ctx["meta"] + id_field = _WORK_RECORD_ID_FIELD_BY_KIND[kind] + title = str(meta.get("title") or rid) + desired_record_id = ctx.get("desired_record_id") + + repo_record = _api_get(api_base, f"/repos/{repo_slug}") + if not repo_record: + report.fixes_applied.append( + f"C-32 SKIP {rid}: could not look up repo '{repo_slug}'" + ) + continue + created = None + if kind == "intake": + payload = { + "id": desired_record_id, + "title": title, + "repo_id": repo_record.get("id"), + "description": meta.get("description"), + "lane": meta.get("lane", "green"), + "origin": meta.get("origin"), + "origin_ref": meta.get("origin_ref"), + "source_repo_path": str(md_path.relative_to(Path(report.repo_path))), + } + created = _api_post(api_base, "/intakes", payload) + elif kind == "decision": + repo_market_domain = str(repo_record.get("domain_slug") or "").strip() + topic_domain = resolve_topic_domain_slug( + repo_market_domain, repo_market_domain=repo_market_domain or None + ) + topics = _api_get(api_base, "/topics") + topic_id = None + if isinstance(topics, list): + for t in topics: + if t.get("domain_slug") == topic_domain: + topic_id = t["id"] + break + if topic_id is None: + report.fixes_applied.append( + f"C-32 SKIP {rid}: no topic found for domain '{topic_domain}'" + ) + continue + payload = { + "id": desired_record_id, + "title": title, + "topic_id": topic_id, + "decision_type": normalise_decision_type(meta), + "description": meta.get("description"), + "rationale": meta.get("agent_recommendation") or meta.get("rationale"), + } + created = _api_post(api_base, "/decisions", payload) + + if not created or "_error" in created: + report.fixes_applied.append( + f"C-32 FAIL {rid}: could not create {kind} in DB: " + f"{(created or {}).get('_error', 'no response')}" + ) + continue + + new_id = created["id"] + if desired_record_id and new_id != desired_record_id: + report.fixes_applied.append( + f"C-32 FAIL {rid}: projection returned unexpected UUID {new_id}" + ) + continue + if desired_record_id: + report.fixes_applied.append( + f"C-32 fixed: created {kind} {new_id[:8]}… for {rid} " + "with the authoritative ID" + ) + continue + if _inject_yaml_block_field(md_path, id_field, new_id, rid): + report.fixes_applied.append( + f"C-32 fixed: created {kind} {new_id[:8]}… for {rid}, " + f"wrote {id_field} to {md_path.name}" + ) + else: + report.fixes_applied.append( + f"C-32 WARN {rid}: created {kind} {new_id[:8]}… but " + f"failed to write {id_field} back to {md_path.name}" + ) + + elif issue.check_id == "C-33": + ctx["index_file"].write_text(ctx["content"], encoding="utf-8") + report.fixes_applied.append( + f"C-33 fixed: regenerated {ctx['index_file'].name}" + ) + + elif issue.check_id == "C-09": + ws_id = ctx["ws_id"] + correct_repo_id = ctx["correct_repo_id"] + result = _api_patch(api_base, f"/workplans/{ws_id}", + {"repo_id": correct_repo_id}) + if result is not None: + report.fixes_applied.append( + f"C-09 fixed: workstream {ws_id[:8]}… " + f"repo_id → {correct_repo_id[:8]}…" + ) + + elif issue.check_id == "C-20": + from_workstream_id = ctx["from_workstream_id"] + body = { + "to_workstream_id": ctx.get("to_workstream_id"), + "to_task_id": ctx.get("to_task_id"), + "relationship_type": ctx["relationship_type"], + } + if ctx.get("from_task_id"): + # A hub without migration e8f9a0b1c2d3 ignores the field + # and would store a workplan-level edge that later + # duplicates the task-scoped one. Skip until deployed. + if not _hub_accepts_from_task_id(api_base): + report.fixes_applied.append( + f"C-20 SKIP: hub at {api_base} does not accept " + f"from_task_id yet (task '{ctx.get('description', '')}')" + ) + continue + body["from_task_id"] = ctx["from_task_id"] + body["description"] = ctx.get("description") + result = _api_post(api_base, f"/workplans/{from_workstream_id}/dependencies", body) + if result is not None and "_error" not in result: + target = ctx.get("to_workstream_id") or ctx.get("to_task_id") + report.fixes_applied.append( + f"C-20 fixed: dependency {from_workstream_id[:8]}… " + f"{ctx['relationship_type']} → {target[:8]}…" + ) + elif result is not None: + report.fixes_applied.append( + f"C-20 FAILED: {result['_error']}" + ) + + elif issue.check_id == "C-10": + task_id = ctx["task_id"] + status = ctx["status"] + result = _api_patch(api_base, f"/tasks/{task_id}", + {"status": status, "suppress_token_event": True, + **ctx.get("wait_fields", {})}) + if result is not None and "_error" not in result: + report.fixes_applied.append( + f"C-10 fixed: task {task_id[:8]}… status → {status!r}" + ) + elif result is not None: + report.fixes_applied.append( + f"C-10 FAILED: task {task_id[:8]}… status → {status!r}: {result['_error']}" + ) + + elif issue.check_id == "C-11": + ws_id = ctx["ws_id"] + ws_status = ctx.get("ws_status", "") + task = ctx["task"] + wp_file = Path(ctx["wp_file"]) + meta = ctx["meta"] + body = ctx.get("body", "") + t_id = str(task.get("id", "")).strip() + # Skip creating tasks for finished workstreams — the workstream is + # done/archived so unlinked tasks are stale file artefacts, not gaps. + if normalise_workstream_status(ws_status) in CLOSED_WORKSTREAM_STATUSES: + report.fixes_applied.append( + f"C-11 skipped: task '{t_id}' in {ws_status} workstream — not created" + ) + else: + t_status = normalise_task_status(task.get("status", "todo")) + t_priority = str(task.get("priority", "medium")).strip() + if t_priority not in VALID_TASK_PRIORITIES: + t_priority = "medium" + desired_task_id = str( + ctx.get("desired_task_id") or _derived_work_record_uuid(t_id) + ) + t_data = _api_post(api_base, "/tasks", { + "id": desired_task_id, + "workplan_id": ws_id, + "title": str(task.get("title", t_id)).strip() or t_id, + "description": task.get("description") or None, + "status": t_status, + "priority": t_priority, + "assignee": task.get("assignee") or None, + **_task_wait_fields(task), + }) + if t_data: + t_db_id = t_data["id"] + if t_db_id != desired_task_id: + report.fixes_applied.append( + f"C-11 FAIL: task '{t_id}' returned unexpected UUID {t_db_id}" + ) + continue + if not task.get("state_hub_task_id"): + injected = _inject_task_id_into_block( + wp_file, "state_hub_task_id", t_db_id, t_id + ) + if not injected: + _inject_task_id_frontmatter_list(wp_file, t_db_id, t_id) + report.fixes_applied.append( + f"C-11 fixed: task '{t_id}' → {t_db_id[:8]}…" + ) + + elif issue.check_id == "C-12": + task_id = ctx["task_id"] + if ctx.get("ws_finished"): + result = _api_patch(api_base, f"/tasks/{task_id}", {"status": "cancel"}) + if result is not None: + report.fixes_applied.append( + f"C-12 fixed: orphan task {task_id[:8]}… canceled (workstream finished)" + ) + + elif issue.check_id == "C-22": + task_id = ctx["task_id"] + description = ctx["description"] + result = _api_patch(api_base, f"/tasks/{task_id}", {"description": description}) + if result is not None and "_error" not in result: + report.fixes_applied.append( + f"C-22 fixed: task {task_id[:8]}… description updated" + ) + elif result is not None: + report.fixes_applied.append( + f"C-22 FAILED: task {task_id[:8]}… description update: {result['_error']}" + ) + + elif issue.check_id == "C-38": + task_id = ctx["task_id"] + flavor = ctx.get("flavor") + result = _api_patch(api_base, f"/tasks/{task_id}", {"flavor": flavor}) + if result is not None and "_error" not in result: + report.fixes_applied.append( + f"C-38 fixed: task {task_id[:8]}… flavor → {flavor!r}" + ) + elif result is not None: + report.fixes_applied.append( + f"C-38 FAILED: task {task_id[:8]}… flavor → {flavor!r}: {result['_error']}" + ) + + elif issue.check_id == "C-41": + task_id = ctx["task_id"] + wait_fields = ctx["wait_fields"] + result = _api_patch(api_base, f"/tasks/{task_id}", wait_fields) + if result is not None and "_error" not in result: + report.fixes_applied.append( + f"C-41 fixed: task {task_id[:8]}… wait qualifiers synced" + ) + elif result is not None: + report.fixes_applied.append( + f"C-41 FAILED: task {task_id[:8]}… wait qualifiers: {result['_error']}" + ) + + elif issue.check_id == "C-15": + # T03 — writeback: DB is ahead of file — patch file to match DB. + if no_writeback: + report.fixes_applied.append( + f"C-15 skipped (--no-writeback): task '{ctx['task_block_id']}' " + f"db={ctx['db_status']!r}" + ) + else: + wp_file = Path(ctx["wp_file"]) + task_block_id = ctx["task_block_id"] + db_status = ctx["db_status"] + old_status = issue.file_value + # RMGR-WP-0002 dual-run: prefer repo-manager writeback when flagged + rm_done = False + try: + from api.services.repo_manager_dual_run import ( + try_writeback_for_task, + writeback_for_repo, + ) + + if writeback_for_repo(repo_slug) and repo_path: + # Prefer hub UUID when available for robust matching + task_ref = ctx.get("task_id") or task_block_id + rm_result = try_writeback_for_task( + repo_path=repo_path, + repo_slug=repo_slug, + task_id=str(task_ref), + status=db_status, + reason="state-hub C-15 dual-run writeback", + ) + if rm_result and rm_result.get("status") == "applied": + git_sha = (rm_result.get("evidence") or {}).get("git_sha") + suffix = f" (repo-manager git_sha={git_sha})" if git_sha else " (repo-manager)" + report.fixes_applied.append( + f"C-15 fixed: task '{task_block_id}' " + f"{old_status} → {db_status}{suffix}" + ) + rm_done = True + except Exception as rm_exc: # noqa: BLE001 + report.fixes_applied.append( + f"C-15 dual-run fallback to native: {rm_exc}" + ) + if not rm_done: + if _patch_task_status_in_file(wp_file, task_block_id, db_status): + committed = _git_commit_writeback( + repo_path, + wp_file, + [f"{task_block_id}: {old_status} → {db_status}"], + ) + try: + from api.services.repo_manager_dual_run import record_mutation + + record_mutation( + source="state-hub", + kind="task_status_writeback_c15", + repo_slug=repo_slug, + detail={ + "task_block_id": task_block_id, + "status": db_status, + "committed": committed, + }, + ) + except Exception: + pass + suffix = " (committed)" if committed else " (file patched, commit failed)" + report.fixes_applied.append( + f"C-15 fixed: task '{task_block_id}' " + f"{old_status} → {db_status}{suffix}" + ) + else: + report.fixes_applied.append( + f"C-15 SKIP: could not locate task block '{task_block_id}' " + f"in {wp_file.name}" + ) + + except Exception as e: + report.fixes_applied.append(f"{issue.check_id} ERROR: {e}") + + # Record that a sync run happened for this repo + from datetime import timezone as _tz + import datetime as _dt + now_iso = _dt.datetime.now(_tz.utc).isoformat() + _api_patch(api_base, f"/repos/{repo_slug}", {"last_state_synced_at": now_iso}) + + # Write the worker orientation brief (.custodian-brief.md) + if repo_path: + brief_written = _write_custodian_brief(api_base, repo_slug, repo_path) + if brief_written: + report.fixes_applied.append("brief: .custodian-brief.md updated") + + if repo_path: + wm_committed = _commit_working_memory_notes(repo_slug, repo_path) + if wm_committed: + report.fixes_applied.append("working-memory: daily-triage notes committed") + + # Push all commits made this run (writebacks + brief) to close the loop. + # This ensures the next run starts with local == remote, making the + # timer idempotent and preventing commit pile-up. + if repo_path: + push_ok, push_msg = _git_push(repo_path) + if push_ok: + report.fixes_applied.append(f"push: {push_msg}") + else: + report.fixes_applied.append(f"push WARN: {push_msg}") + + return report + + +# Check IDs that are known-background noise in multi-machine setups: +# C-08 = finished/archived DB workstream with no file (pre-ADR-001 legacy) +# These alone do not warrant a pull+fix cycle. +_BACKGROUND_CHECKS: frozenset[str] = frozenset({"C-08"}) + + +def _report_needs_action( + report: ConsistencyReport, behind_remote: bool, ahead_of_remote: int = 0 +) -> bool: + """Return True if the repo warrants a pull+fix cycle. + + A repo is considered clean (no action needed) when: + - It is not behind its remote tracking branch, AND + - It has no unpushed local commits (from a prior failed push), AND + - It has no FAIL issues, AND + - Every WARN/INFO issue is in the background-noise set (C-08). + """ + if behind_remote or ahead_of_remote > 0: + return True + if report.assessment_failures or report.automation_errors: + return True + actionable_warns = [ + i for i in report.warnings + report.infos + if i.check_id not in _BACKGROUND_CHECKS + ] + return bool(actionable_warns) + + +def fix_all_remote( + api_base: str, + no_writeback: bool = False, + max_seconds: int = DEFAULT_REMOTE_ALL_MAX_SECONDS, +) -> list[ConsistencyReport]: + """Pull-then-fix all registered repos that need attention. + + For each repo with a resolvable local path on this machine: + 1. Skip if the path does not exist (repo lives on another machine). + 2. Run check_repo() and detect_behind_remote() — read-only. + 3. If the repo is clean (no actionable issues, not behind remote): skip. + 4. Otherwise: git pull --ff-only, then fix_repo(). + + Returns one ConsistencyReport per repo that was *not* skipped as clean. + Repos skipped as clean are printed to stdout but not included in the return value. + """ + repos = _api_get(api_base, "/repos") + if not isinstance(repos, list): + print("ERROR: Could not fetch repos from state-hub API", file=sys.stderr) + return None + + started = time.monotonic() + reports: list[ConsistencyReport] = [] + skipped_clean: list[str] = [] + skipped_missing: list[str] = [] + skipped_budget: list[str] = [] + + for repo in repos: + slug = repo["slug"] + if max_seconds > 0 and time.monotonic() - started > max_seconds: + skipped_budget.append(slug) + skipped_budget.extend(r.get("slug", "?") for r in repos[repos.index(repo) + 1:]) + break + + # Resolve path using the same priority as check_repo + path = resolve_repo_path(repo) + if not path or not Path(path).is_dir(): + skipped_missing.append(slug) + continue + + # Read-only pass: detect issues and remote staleness. + # _detect_behind_remote does a fetch, so _detect_ahead_of_remote + # after it sees an up-to-date tracking branch. + pre_report = check_repo(api_base, slug) + behind = _detect_behind_remote(path) + ahead = _detect_ahead_of_remote(path) + + if not _report_needs_action(pre_report, behind, ahead): + skipped_clean.append(slug) + continue + + # Pull before fixing + pull_ok, pull_msg = _git_pull(path) + pull_tag = f"pull: {pull_msg}" + if not pull_ok: + pull_tag = f"pull WARN: {pull_msg} — proceeding anyway" + + report = fix_repo(api_base, slug, no_writeback=no_writeback) + report.fixes_applied.insert(0, pull_tag) + reports.append(report) + + # Print clean/missing summary lines before the detailed reports + if skipped_clean: + print(f" CLEAN (skipped): {', '.join(skipped_clean)}") + if skipped_missing: + print(f" NOT ON THIS HOST (skipped): {', '.join(skipped_missing)}") + if skipped_budget: + print( + f" BUDGET EXHAUSTED after {max_seconds}s (skipped): " + f"{', '.join(skipped_budget)}" + ) + if skipped_clean or skipped_missing or skipped_budget: + print() + + return reports + + +def archive_closed_workplans( + repo_path: str, + completion_date: str | None = None, + workplan: str | None = None, +) -> list[str]: + """Move closed root workplans into workplans/archived/ with YYMMDD prefix. + + Only root-level files whose frontmatter status normalises to finished or + archived are moved. Files with any open task blocks are left in place. + """ + repo_dir = Path(repo_path) + workplans_dir = repo_dir / "workplans" + archived_dir = workplans_dir / "archived" + if not workplans_dir.is_dir(): + return [] + + date_prefix = completion_date or datetime.now().strftime("%y%m%d") + archived_dir.mkdir(exist_ok=True) + moved: list[str] = [] + + for wp_file in sorted(workplans_dir.glob("*.md")): + text = wp_file.read_text(encoding="utf-8") + if not text.startswith("---"): + continue + meta, body = parse_frontmatter(text) + if not meta or meta.get("_parse_error"): + continue + if workplan: + wanted = workplan.removesuffix(".md") + if wanted not in {str(meta.get("id", "")), wp_file.stem, wp_file.name}: + continue + status = normalise_workstream_status(str(meta.get("status", "")).strip()) + if status not in CLOSED_WORKSTREAM_STATUSES: + continue + tasks = get_tasks_from_workplan(meta, body) + open_tasks = [ + t for t in tasks + if normalise_task_status(t.get("status", "todo")) not in TERMINAL_TASK_STATUSES + ] + if open_tasks: + continue + + target = archived_dir / f"{date_prefix}-{canonical_workplan_filename(wp_file)}" + if target.exists(): + raise FileExistsError(f"Archived workplan already exists: {target}") + wp_file.rename(target) + moved.append(f"{wp_file.relative_to(repo_dir)} -> {target.relative_to(repo_dir)}") + + return moved + + +# --------------------------------------------------------------------------- +# Output / rendering +# --------------------------------------------------------------------------- + +def render_renormalization_guide() -> str: + lines = ["Lifecycle Renormalization Rules", "=" * 33] + for rule in RENORMALIZATION_RULES: + lines.extend( + [ + "", + f"{rule.check_id}: {rule.invariant}", + f" Detect: {rule.detection}", + f" Repair: {rule.repair}", + f" Tests: {rule.test_anchor}", + ] + ) + lines.extend(["", "Add The Next Guard"]) + for idx, item in enumerate(RENORMALIZATION_NEXT_GUARD_CHECKLIST, 1): + lines.append(f" {idx}. {item}") + return "\n".join(lines) + + +def render_text(report: ConsistencyReport, show_info: bool = True) -> str: + SEP = "=" * 66 + lines = [ + f"ADR-001 Consistency Report", + f"Repo: {report.repo_slug}", + f"Path: {report.repo_path}", + SEP, + ] + + error_section = report.automation_errors + fail_section = report.assessment_failures + if error_section: + lines.append(f"\n AUTOMATION ERRORS ({len(error_section)}):") + for i in error_section: + loc = f" [{i.file_path}]" if i.file_path else "" + lines.append(f" {i.check_id}{loc}") + lines.append(f" {i.message}") + + if fail_section: + lines.append(f"\n ASSESSMENT FAILURES ({len(fail_section)}):") + for i in fail_section: + loc = f" [{i.file_path}]" if i.file_path else "" + fix_tag = " [fixable]" if i.fixable else "" + lines.append(f" {i.check_id}{loc}{fix_tag}") + lines.append(f" {i.message}") + if i.file_value or i.db_value: + lines.append(f" file={i.file_value!r} db={i.db_value!r}") + + for sev in ("WARN", "INFO"): + section = [i for i in report.issues if i.severity == sev] + if not section or (sev == "INFO" and not show_info): + continue + lines.append(f"\n {sev}S ({len(section)}):") + for i in section: + loc = f" [{i.file_path}]" if i.file_path else "" + fix_tag = " [fixable]" if i.fixable else "" + lines.append(f" {i.check_id}{loc}{fix_tag}") + lines.append(f" {i.message}") + if i.file_value or i.db_value: + lines.append(f" file={i.file_value!r} db={i.db_value!r}") + + if report.fixes_applied: + lines.append(f"\n FIXES APPLIED ({len(report.fixes_applied)}):") + for f in report.fixes_applied: + lines.append(f" {f}") + + lines.append(f"\n{SEP}") + n_err = len(report.automation_errors) + n_fail = len(report.assessment_failures) + n_warn = len(report.warnings) + n_info = len(report.infos) + lines.append( + f" {n_err} automation-error | {n_fail} assessment-fail | " + f"{n_warn} warn | {n_info} info" + ) + if n_err: + lines.append(" RESULT: ✗ AUTOMATION ERROR") + elif n_fail: + lines.append(" RESULT: ✗ ASSESSMENT FAIL (follow-up needed)") + elif n_warn: + lines.append(" RESULT: ✓ PASS (with warnings)") + else: + lines.append(" RESULT: ✓ PASS") + lines.append(SEP) + return "\n".join(lines) + + +def report_to_dict(report: ConsistencyReport) -> dict: + revision = { + "source_revision": report.source_revision, + "source_revision_reason": report.source_revision_reason, + } + return { + "repo_slug": report.repo_slug, + "repo_path": report.repo_path, + **revision, + "issues": [ + { + "severity": i.severity, + "check_id": i.check_id, + "message": i.message, + "file_path": i.file_path, + "db_id": i.db_id, + "file_value": i.file_value, + "db_value": i.db_value, + "fixable": i.fixable, + **({"repo_slug": report.repo_slug, **revision} if i.check_id == "C-25" else {}), + **({"owner_return": i._fix_context} if i.check_id == "C-25" and i._fix_context.get("task_record_id") else {}), + } + for i in report.issues + ], + "fixes_applied": report.fixes_applied, + "summary": { + "fail": len(report.assessment_failures), + "automation_error": len(report.automation_errors), + "warn": len(report.warnings), + "info": len(report.infos), + }, + "result": ( + "error" if report.automation_errors else + "fail" if report.assessment_failures else + "warn" if report.warnings else + "pass" + ), + } + + +def consistency_exit_code(reports: list[ConsistencyReport], *, remote_all: bool = False) -> int: + """Return the strict CLI exit code for consistency reports.""" + any_automation_error = any(r.automation_errors for r in reports) + any_assessment_fail = any(r.assessment_failures for r in reports) + any_warn = any(r.warnings for r in reports) + if remote_all: + return 1 if any_automation_error else 0 + if any_automation_error or any_assessment_fail: + return 1 + return 2 if any_warn else 0 + + +# --------------------------------------------------------------------------- +# CLI entry point +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="ADR-001 consistency checker — bidirectional file↔DB validation", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--repo", metavar="SLUG", + help="Registered repo slug (e.g. the-custodian)") + group.add_argument("--all", action="store_true", + help="Run checks against all repos with a resolvable path on this host") + group.add_argument("--here", metavar="PATH", nargs="?", const="", + help="Infer repo slug from git remote URL at PATH (default: CWD)") + group.add_argument("--renormalization-guide", action="store_true", + help="Print lifecycle renormalization rules and the add-next-guard checklist") + parser.add_argument("--fix", action="store_true", + help="Apply auto-fixable issues (status drift, repo mismatch, etc.)") + parser.add_argument("--remote", action="store_true", + help="Pull each repo before fixing; when used with --all, skips repos " + "that are already clean (no actionable issues, not behind remote). " + "Implies --fix.") + parser.add_argument("--max-seconds", type=int, default=DEFAULT_REMOTE_ALL_MAX_SECONDS, + help="Wall-clock budget for --remote --all before remaining repos are skipped " + f"(default: {DEFAULT_REMOTE_ALL_MAX_SECONDS}; 0 disables)") + parser.add_argument("--no-writeback", action="store_true", dest="no_writeback", + help="Disable DB→file status writeback (C-15) while keeping other fixes") + parser.add_argument( + "--bootstrap-empty-projection", + action="store_true", + help=( + "Rebuild authoritative legacy UUIDs only after proving the target " + "repository has zero projected workplans" + ), + ) + parser.add_argument("--archive-closed", action="store_true", + help="Move closed root workplans to workplans/archived/YYMMDD-*.md") + parser.add_argument("--archive-workplan", metavar="ID_OR_FILE", default=None, + help="When archiving, only move the matching workplan id or filename") + parser.add_argument("--archive-date", metavar="YYMMDD", default=None, + help="Completion date prefix for --archive-closed (default: today)") + parser.add_argument("--repo-path", metavar="PATH", default=None, + help="Override the local repo path (useful when the DB has a different " + "machine's path). Takes priority over host_paths and local_path.") + parser.add_argument("--api-base", default="http://127.0.0.1:8000", + help="State Hub API base URL") + parser.add_argument("--json", action="store_true", dest="as_json", + help="Output JSON instead of human-readable text") + args = parser.parse_args() + + if args.renormalization_guide: + if args.as_json: + print(json.dumps({ + "rules": [ + { + "check_id": rule.check_id, + "invariant": rule.invariant, + "detection": rule.detection, + "repair": rule.repair, + "test_anchor": rule.test_anchor, + } + for rule in RENORMALIZATION_RULES + ], + "add_next_guard": list(RENORMALIZATION_NEXT_GUARD_CHECKLIST), + }, indent=2)) + else: + print(render_renormalization_guide()) + sys.exit(0) + + import os as _os + no_wb = getattr(args, "no_writeback", False) + do_fix = args.fix or args.remote + if args.bootstrap_empty_projection and (not do_fix or args.all or args.remote): + parser.error( + "--bootstrap-empty-projection requires --fix for one local repository " + "and cannot be combined with --all or --remote" + ) + + # --here: infer slug from git remote URL, then run as single-repo check/fix + if args.here is not None: + search_path = args.here or _os.getcwd() + inferred = _infer_slug_from_path(args.api_base, search_path) + if inferred is None: + print( + f"ERROR: No registered repo with a matching remote_url found at '{search_path}'.", + file=sys.stderr, + ) + print( + " Register the repo first: POST /repos/ with remote_url set.", + file=sys.stderr, + ) + sys.exit(1) + inferred_slug, git_root = inferred + print(f" Detected: {inferred_slug} ({git_root})") + if do_fix: + reports = [ + fix_repo( + args.api_base, + inferred_slug, + git_root, + no_writeback=no_wb, + bootstrap_empty_projection=args.bootstrap_empty_projection, + ) + ] + else: + reports = [check_repo(args.api_base, inferred_slug, git_root)] + if args.archive_closed: + moved = archive_closed_workplans(git_root, args.archive_date, args.archive_workplan) + reports[0].fixes_applied.extend(f"archive: {m}" for m in moved) + # --remote --all: smart pull+fix across all repos + elif args.remote and args.all: + with run_lock("consistency-remote-all") as acquired: + if not acquired: + print( + "SKIP: another fix-consistency-remote --all run is already active", + file=sys.stderr, + ) + sys.exit(0) + reports = fix_all_remote( + args.api_base, + no_writeback=no_wb, + max_seconds=args.max_seconds, + ) + if reports is None: + sys.exit(1) + if not reports: + sys.exit(0) + else: + # Resolve repo list + hostname = _sweep_hostname() + repo_slugs: list[str] = [] + if args.all: + repos = _api_get(args.api_base, "/repos") + if not isinstance(repos, list): + print("ERROR: Could not fetch repos from state-hub API", file=sys.stderr) + sys.exit(1) + repo_slugs = [ + r["slug"] for r in repos + if r.get("local_path") or (r.get("host_paths") or {}).get(hostname) + ] + if not repo_slugs: + print( + f"No repos with a path registered for host '{hostname}'.", + file=sys.stderr, + ) + sys.exit(0) + else: + repo_slugs = [args.repo] + + # --repo-path only applies to single-repo runs; silently ignored with --all + path_override = args.repo_path if not args.all else None + + if args.remote: + # Single-repo remote: pull first, then fix + slug = repo_slugs[0] + repo = _api_get(args.api_base, f"/repos/{slug}") + path = resolve_repo_path(repo or {}, path_override) if repo else (path_override or "") + if path: + pull_ok, pull_msg = _git_pull(path) + prefix = "pull" if pull_ok else "pull WARN" + print(f" {prefix}: {pull_msg}") + report = fix_repo(args.api_base, slug, path_override, no_writeback=no_wb) + reports = [report] + elif do_fix: + reports = [ + fix_repo( + args.api_base, + slug, + path_override, + no_writeback=no_wb, + bootstrap_empty_projection=args.bootstrap_empty_projection, + ) + for slug in repo_slugs + ] + else: + reports = [check_repo(args.api_base, slug, path_override) for slug in repo_slugs] + + if args.archive_closed: + for report in reports: + moved = archive_closed_workplans(report.repo_path, args.archive_date, args.archive_workplan) + report.fixes_applied.extend(f"archive: {m}" for m in moved) + + if args.as_json: + output = ( + report_to_dict(reports[0]) + if len(reports) == 1 + else [report_to_dict(r) for r in reports] + ) + print(json.dumps(output, indent=2)) + else: + for report in reports: + print(render_text(report)) + print() + + sys.exit(consistency_exit_code(reports, remote_all=args.remote and args.all)) + + +if __name__ == "__main__": + main() diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r4/state-hub/scripts/sync_classification_allowed.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r4/state-hub/scripts/sync_classification_allowed.py new file mode 100644 index 00000000..322770f5 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r4/state-hub/scripts/sync_classification_allowed.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Sync the canon classification allowed-values into the chart (CUST-WP-0067-T09). + +The State Hub validates repository classification against +``the-custodian/canon/standards/repo-classification.allowed.yaml``. That file is +canon and must stay authoritative, but a container has no the-custodian +checkout, so a copy has to travel with the release. + +A copy that nobody checks is a copy that silently drifts. This script owns the +copy: ``--check`` fails when it diverges from canon, so the release can refuse +to ship a stale vocabulary rather than validate against yesterday's rules. + +Usage: + python scripts/sync_classification_allowed.py # write the copy + python scripts/sync_classification_allowed.py --check # verify, exit 1 on drift +""" +from __future__ import annotations + +import argparse +import difflib +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +CHART_COPY = ( + REPO_ROOT + / "deploy/railiance/apps/charts/state-hub/files/repo-classification.allowed.yaml" +) +CANON_CANDIDATES = ( + Path("/home/worsch/the-custodian/canon/standards/repo-classification.allowed.yaml"), + Path("/home/tegwick/the-custodian/canon/standards/repo-classification.allowed.yaml"), + REPO_ROOT.parent / "the-custodian/canon/standards/repo-classification.allowed.yaml", +) + +HEADER = ( + "# GENERATED — do not edit.\n" + "# Synced from the-custodian/canon/standards/repo-classification.allowed.yaml\n" + "# by scripts/sync_classification_allowed.py (CUST-WP-0067-T09).\n" + "# Canon is authoritative; this copy exists only so the container has one.\n" +) + + +def find_canon() -> Path: + for candidate in CANON_CANDIDATES: + if candidate.is_file(): + return candidate + raise SystemExit( + "ERROR: canon allowed-values not found. Looked in:\n " + + "\n ".join(str(c) for c in CANON_CANDIDATES) + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--check", + action="store_true", + help="Verify the chart copy matches canon; exit 1 on drift", + ) + args = parser.parse_args() + + canon = find_canon() + desired = HEADER + canon.read_text(encoding="utf-8") + + if args.check: + if not CHART_COPY.is_file(): + print(f"DRIFT: {CHART_COPY} is missing; run without --check to create it") + return 1 + current = CHART_COPY.read_text(encoding="utf-8") + if current != desired: + print(f"DRIFT: chart copy differs from canon ({canon})") + sys.stdout.writelines( + difflib.unified_diff( + current.splitlines(keepends=True), + desired.splitlines(keepends=True), + fromfile="chart copy", + tofile="canon", + ) + ) + return 1 + print(f"OK: chart copy matches canon ({canon})") + return 0 + + CHART_COPY.parent.mkdir(parents=True, exist_ok=True) + CHART_COPY.write_text(desired, encoding="utf-8") + print(f"Synced {canon} -> {CHART_COPY}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r4/state-hub/tests/test_frozen_binding_guard_isolated.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r4/state-hub/tests/test_frozen_binding_guard_isolated.py new file mode 100644 index 00000000..a1da62fa --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r4/state-hub/tests/test_frozen_binding_guard_isolated.py @@ -0,0 +1,180 @@ +from pathlib import Path +import ast, asyncio, importlib.util, types, uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +import pytest + +ROOT = Path(__file__).resolve().parents[1] +spec=importlib.util.spec_from_file_location('historical',ROOT/'api/services/historical_projection.py') +h=importlib.util.module_from_spec(spec);spec.loader.exec_module(h) +B=h.FROZEN_CLUSTER_BINDINGS[0] +def binding(_bound=B,**changes): + B=_bound + d=dict(workplan_id=uuid.UUID(B['uuid']),repo_slug=B['repo'],filename=Path(B['path']).name,relative_path=B['path'],archived=True,status='finished');d.update(changes);return d +class Obj: + def __init__(self,**kw):self.__dict__.update(kw);self.writes=[] + def __setattr__(self,k,v): + if 'writes' in self.__dict__:self.writes.append(k) + object.__setattr__(self,k,v) +class Entry(Obj): + def model_dump(self):return {k:v for k,v in self.__dict__.items() if k!='writes'} +class Col: + def in_(self,x):return self + def __eq__(self,x):return self +class Query: + def __init__(self):self.locked=False;self.refreshed=False;self.ordered=False + def where(self,*a):return self + def order_by(self,*a):self.ordered=True;return self + def with_for_update(self):self.locked=True;return self + def execution_options(self,**kw):self.refreshed=kw.get("populate_existing") is True;return self +class Model: id=Col();slug=Col();workplan_id=Col() +class Result: + def __init__(self,values):self.values=values + def scalars(self):return self + def all(self):return self.values + def scalar_one_or_none(self):return self.values[0] if self.values else None +class Session: + def __init__(self,rows):self.results=iter(rows);self.commits=0;self.queries=[] + async def execute(self,q):self.queries.append(q);return Result(next(self.results)) + async def commit(self):self.commits+=1 +class HTTPException(Exception): + def __init__(self,status_code,detail):self.status_code=status_code;super().__init__(detail) +def extracted(path,name,extra): + tree=ast.parse((ROOT/path).read_text());node=next(n for n in tree.body if isinstance(n,(ast.FunctionDef,ast.AsyncFunctionDef)) and n.name==name) + node.decorator_list=[];node.returns=None + for a in node.args.args + node.args.kwonlyargs:a.annotation=None + node.args.defaults=[] + ns=dict(Path=Path,datetime=datetime,timezone=timezone,**extra) + exec(compile(ast.Module(body=[node],type_ignores=[]),str(path),'exec'),ns);return ns[name] +def actual_report(): + tree=ast.parse((ROOT/'scripts/consistency_check.py').read_text()) + nodes=[n for n in tree.body if (isinstance(n,ast.ClassDef) and n.name in {'Issue','ConsistencyReport'}) or (isinstance(n,ast.AnnAssign) and isinstance(n.target,ast.Name) and n.target.id=='_AUTOMATION_ERROR_CHECKS')] + ns=dict(dataclass=dataclass,field=field,_AUTOMATION_ERROR_CHECKS=frozenset({'C-00','C-44'})) + exec(compile(ast.Module(body=nodes,type_ignores=[]),'actual_report','exec'),ns) + return ns['ConsistencyReport']('railiance-cluster','/unused') + +def closure(B=B): + repo=Obj(id=uuid.uuid4());parent=Obj(id=uuid.UUID(B['uuid']),repo_id=repo.id,status='finished') + tasks=[Obj(id=uuid.UUID(t['uuid']),workplan_id=parent.id,status='done') for t in B['tasks']] + return repo,parent,tasks +async def run_api(entries,repo,parent,tasks,ordinary=None): + rows=([parent] if parent else [])+([ordinary] if ordinary else []) + session=Session([rows,[repo] if repo else [],tasks]) + fn=extracted('api/routers/workstreams.py','sync_workplan_bindings',dict(frozen_cluster_binding=h.frozen_cluster_binding,validate_frozen_cluster_rows=h.validate_frozen_cluster_rows,HTTPException=HTTPException,select=lambda *_:Query(),Workplan=Model,Task=Model,ManagedRepo=Model,_invalidate_workplan_index_cache=lambda:None)) + result=await fn(Obj(bindings=[Entry(**e) for e in entries]),session) + return result,session +@pytest.mark.parametrize('change',[dict(repo_slug='foreign'),dict(workplan_id=uuid.uuid4()),dict(relative_path='workplans/other.md'),dict(filename='other.md'),dict(archived=False),dict(status='active')]) +def test_metadata_refuses(change): + with pytest.raises(ValueError):h.frozen_cluster_binding(binding(**change)) +def test_unrelated_binding_unchanged():assert h.frozen_cluster_binding(binding(workplan_id=uuid.uuid4(),filename='ordinary.md',relative_path='workplans/ordinary.md',repo_slug='other')) is None +@pytest.mark.parametrize('fault',['parent_missing','foreign_repo','parent_active','task_missing','task_active','task_foreign','repo_missing']) +@pytest.mark.parametrize("bound",h.FROZEN_CLUSTER_BINDINGS) +def test_mixed_batch_refusal_precedes_all_writes(fault,bound): + repo,parent,tasks=closure(bound);ordinary=Obj(id=uuid.uuid4()) + if fault=='repo_missing':repo=None + if fault=='parent_missing':parent=None + if fault=='foreign_repo':parent.repo_id=uuid.uuid4() + if fault=='parent_active':parent.status='active' + if fault=='task_missing':tasks.pop() + if fault=='task_active':tasks[0].status='progress' + if fault=='task_foreign':tasks[0].workplan_id=uuid.uuid4() + if parent:parent.writes.clear() + for t in tasks:t.writes.clear() + normal=binding(workplan_id=ordinary.id,repo_slug='ordinary',filename='ordinary.md',relative_path='workplans/ordinary.md') + with pytest.raises(HTTPException):asyncio.run(run_api([normal,binding(_bound=bound)],repo,parent,tasks,ordinary)) + assert ordinary.writes==[] + if parent:assert parent.writes==[] + assert all(t.writes==[] for t in tasks) +@pytest.mark.parametrize("bound",h.FROZEN_CLUSTER_BINDINGS) +def test_exact_frozen_no_setters_or_commit(bound): + repo,parent,tasks=closure(bound);result,session=asyncio.run(run_api([binding(_bound=bound)],repo,parent,tasks)) + assert result==dict(updated=0,received=1,preserved=1);assert session.commits==0;assert parent.writes==[] + assert all(q.locked and q.refreshed and q.ordered for q in (session.queries[0],session.queries[2])) +@pytest.mark.parametrize("bound",h.FROZEN_CLUSTER_BINDINGS) +def test_mixed_valid_preserves_frozen_and_updates_ordinary(bound): + repo,parent,tasks=closure(bound);ordinary=Obj(id=uuid.uuid4()) + normal=binding(workplan_id=ordinary.id,repo_slug='ordinary',filename='ordinary.md',relative_path='workplans/ordinary.md') + result,session=asyncio.run(run_api([normal,binding(_bound=bound)],repo,parent,tasks,ordinary)) + assert result==dict(updated=1,received=2,preserved=1);assert session.commits==1;assert parent.writes==[] + assert ordinary.writes==['backing_filename','backing_relative_path','backing_archived','backing_synced_at'] +@pytest.mark.parametrize('tamper',['metadata','bytes','symlink','missing_scan','missing_uuid','valid']) +@pytest.mark.parametrize("bound_index", range(3)) +def test_cli_exact_source_omission_or_whole_batch_refusal(tmp_path,tamper,bound_index): + bound=h.FROZEN_CLUSTER_BINDINGS[bound_index] + import shutil + infos=[] + for entry in h.FROZEN_CLUSTER_BINDINGS: + target=tmp_path/entry['path'];target.parent.mkdir(parents=True,exist_ok=True) + shutil.copyfile(Path('/home/worsch/railiance-cluster')/entry['path'],target) + infos.append((target,dict(state_hub_workstream_id=entry['uuid'],status='finished'),'')) + path=tmp_path/bound['path'] + meta=dict(state_hub_workstream_id=bound['uuid'],status='finished') + if tamper=='metadata':meta['status']='active' + if tamper=='missing_uuid':meta.pop('state_hub_workstream_id') + if tamper=='bytes':path.write_text(path.read_text()+'\n') + if tamper=='symlink':path.unlink();path.symlink_to(Path('/home/worsch/railiance-cluster')/bound['path']) + ordinary=tmp_path/'workplans/ordinary.md';ordinary.write_text('ordinary') + if tamper=='missing_scan':infos.pop(bound_index) + else:infos[bound_index]=(path,meta,'') + calls=[];report=actual_report() + fn=extracted('scripts/consistency_check.py','_sync_workplan_bindings',dict(FROZEN_CLUSTER_BINDINGS=h.FROZEN_CLUSTER_BINDINGS,frozen_cluster_binding=h.frozen_cluster_binding,normalise_workstream_status=lambda s:s,workplan_display_path=lambda root,p:p.relative_to(root).as_posix(),_api_put=lambda *a:calls.append(a) or dict(updated=1,received=1))) + fn('http://unused','railiance-cluster',[(ordinary,dict(state_hub_workstream_id=str(uuid.uuid4()),status='active'),''),*infos],tmp_path,report) + if tamper=='valid':assert len(calls)==1 and len(calls[0][2]['bindings'])==1 + else:assert calls==[] and report.failures[0].check_id=='C-44' and report.automation_errors + + +def test_api_tampered_mixed_batch_refuses_before_queries_or_assignments(): + repo,parent,tasks=closure();ordinary=Obj(id=uuid.uuid4()) + with pytest.raises(HTTPException): + asyncio.run(run_api([binding(workplan_id=ordinary.id,repo_slug='ordinary',filename='ordinary.md',relative_path='workplans/ordinary.md'),binding(archived=False)],repo,parent,tasks,ordinary)) + assert ordinary.writes==[] and parent.writes==[] + + +def test_foreign_repo_same_filename_path_is_ordinary(): + assert h.frozen_cluster_binding(binding(workplan_id=uuid.uuid4(),repo_slug='foreign')) is None + + +@pytest.mark.parametrize('entry',h.FROZEN_CLUSTER_BINDINGS) +def test_all_three_registry_bindings_are_guarded(entry): + good=dict(workplan_id=entry['uuid'],repo_slug=entry['repo'],filename=Path(entry['path']).name,relative_path=entry['path'],archived=True,status='finished') + assert h.frozen_cluster_binding(good) is entry + with pytest.raises(ValueError):h.frozen_cluster_binding(dict(good,status='active')) + + +def test_real_cli_exit_criterion_is_nonzero_including_remote_all(): + report=actual_report();report.add(severity='FAIL',check_id='C-44',message='refused') + fn=extracted('scripts/consistency_check.py','consistency_exit_code',{}) + assert fn([report])==1 and fn([report],remote_all=True)==1 + + +@pytest.mark.parametrize('check,key,protected_kind',[ + ('C-04','ws_id','parent'),('C-10','task_id','task'),('C-22','task_id','task'), + ('C-09','db_id','parent'),('C-23','wp_file','path'), +]) +@pytest.mark.parametrize('bound',h.FROZEN_CLUSTER_BINDINGS) +def test_complete_fix_batch_preflight_before_ordinary_writes(check,key,protected_kind,bound): + report=actual_report();report.repo_path='/tmp/fixture-cluster' + report.add(severity='WARN',check_id='C-05',message='ordinary first',fixable=True,_fix_context={'ws_id':str(uuid.uuid4()),'field':'title','value':'ordinary'}) + value=bound['uuid'] if protected_kind=='parent' else bound['tasks'][0]['uuid'] if protected_kind=='task' else str(Path(report.repo_path)/bound['path']) + report.add(severity='WARN',check_id=check,message='protected later',fixable=True,db_id=value if key=='db_id' else '',_fix_context={key:value}) + guard=extracted('scripts/consistency_check.py','_refuse_frozen_fix_batch',dict(FROZEN_CLUSTER_BINDINGS=h.FROZEN_CLUSTER_BINDINGS)) + assert guard(report,'railiance-cluster') and report.automation_errors + calls=[] + fn=extracted('scripts/consistency_check.py','fix_repo',dict(check_repo=lambda *a,**kw:report,_refuse_frozen_fix_batch=guard,_api_post=lambda *a:calls.append(a),_api_patch=lambda *a:calls.append(a))) + assert fn('http://unused','railiance-cluster',None,False,False) is report and calls==[] + +def test_unrelated_fix_context_not_refused(): + report=actual_report();report.add(severity='WARN',check_id='C-05',message='ordinary',fixable=True,_fix_context={'ws_id':str(uuid.uuid4())}) + guard=extracted('scripts/consistency_check.py','_refuse_frozen_fix_batch',dict(FROZEN_CLUSTER_BINDINGS=h.FROZEN_CLUSTER_BINDINGS)) + assert not guard(report,'other') + + +def test_check_repo_guard_precedes_binding_sync_and_does_not_duplicate(): + tree=ast.parse((ROOT/'scripts/consistency_check.py').read_text()) + node=next(n for n in tree.body if isinstance(n,ast.FunctionDef) and n.name=='check_repo') + guard_lines=[n.lineno for n in ast.walk(node) if isinstance(n,ast.Call) and isinstance(n.func,ast.Name) and n.func.id=='_refuse_frozen_fix_batch'] + sync_lines=[n.lineno for n in ast.walk(node) if isinstance(n,ast.Call) and isinstance(n.func,ast.Name) and n.func.id=='_sync_workplan_bindings'] + assert len(guard_lines)==len(sync_lines)==1 and guard_lines[0]/dev/null || if [ -x "$$HOME/.local/bin/helm" ]; then printf "%s" "$$HOME/.local/bin/helm"; else printf "%s" "helm"; fi) +KUBECTL ?= $(shell command -v kubectl 2>/dev/null || if [ -x "$$HOME/.local/bin/kubectl" ]; then printf "%s" "$$HOME/.local/bin/kubectl"; else printf "%s" "kubectl"; fi) + +RAILIANCE_STATE_HUB_RELEASE ?= state-hub +RAILIANCE_STATE_HUB_NAMESPACE ?= state-hub +RAILIANCE_STATE_HUB_CHART ?= deploy/railiance/apps/charts/state-hub +RAILIANCE_STATE_HUB_VALUES ?= deploy/railiance/apps/helm/state-hub-values.yaml +RAILIANCE_STATE_HUB_IMAGE_TAG ?= main-51bc70d@sha256:c802690ee1811b3d77df4077936f9cdced1385514e8673fcbc9232b84d645015 +RAILIANCE_STATE_HUB_PLATFORM_DIR ?= deploy/railiance/platform +RAILIANCE_STATE_HUB_APP_MANIFESTS ?= deploy/railiance/apps/manifests +# Codex/WSL non-login shells may not source ~/.profile; keep uv discoverable. +UV ?= $(shell command -v uv 2>/dev/null || if [ -x "$$HOME/.local/bin/uv" ]; then printf "%s" "$$HOME/.local/bin/uv"; else printf "%s" "uv"; fi) +RMGR ?= $(shell command -v rmgr 2>/dev/null || if [ -x "$$HOME/repo-manager/.venv/bin/rmgr" ]; then printf "%s" "$$HOME/repo-manager/.venv/bin/rmgr"; else printf "%s" "rmgr"; fi) +STATE_HUB_API_BASE ?= http://127.0.0.1:8000 +SYNC_PATH ?= . +SYNC_PUSH ?= 1 + +start: + @echo "# Normal production UI access (run in order)" + @echo "make bridges # connect local :8000 to the railiance01 primary" + @echo "make dashboard # verify the primary, then serve the UI on :3000" + @echo "make sync # push file-backed records and reconcile the exact forge commit" + @echo "" + @echo "# Optional local MCP adapter" + @echo "make mcp-http # local SSE adapter on :8001; not needed by the UI" + @echo "" + @echo "# Local fallback/development only" + @echo "make api # local Postgres + API; conflicts with the production :8000 tunnel" + @echo "make dashboard-local # dashboard against the deliberate local API" + +install: + $(UV) sync + +dashboard/node_modules/.bin/observable: dashboard/package.json dashboard/package-lock.json + cd dashboard && npm ci + +dashboard-install: dashboard/node_modules/.bin/observable + +dashboard-check: dashboard-install + cd dashboard && npm run build + +## Symlink the custodian CLI into ~/.local/bin so it's on PATH system-wide +install-cli: install + mkdir -p ~/.local/bin + ln -sf "$(shell pwd)/.venv/bin/custodian" ~/.local/bin/custodian + ln -sf "$(shell pwd)/.venv/bin/statehub" ~/.local/bin/statehub + @echo "Installed: custodian → $$(readlink -f ~/.local/bin/custodian)" + @echo "Installed: statehub → $$(readlink -f ~/.local/bin/statehub)" + @echo "Make sure ~/.local/bin is on your PATH:" + @echo " echo 'export PATH=\"\$$HOME/.local/bin:\$$PATH\"' >> ~/.bashrc && source ~/.bashrc" + +db: + $(COMPOSE) up -d postgres + +db-tools: + $(COMPOSE) --profile tools up -d + +migrate: + $(UV) run alembic upgrade head + +seed: + $(UV) run python scripts/seed.py + +## Start (or restart) the MCP SSE server on :8001 — primary transport for Claude Code. +## Remote clients (e.g. COULOMBCORE) connect via the ops-bridge tunnel (port 18001). +## Registration: claude mcp add-json -s user state-hub '{"type":"sse","url":"http://127.0.0.1:8001/sse"}' +mcp-http: + @fuser -k 8001/tcp 2>/dev/null && echo "Stopped running MCP server" || true + MCP_TRANSPORT=sse MCP_PORT=8001 $(UV) run python mcp_server/server.py + +## Require the production identity before serving the normal dashboard. This +## prevents an accidental local `make api` from presenting an empty fallback DB +## as the live State Hub. +check-primary: + @health="$$(curl -fsS --max-time 5 http://127.0.0.1:8000/state/health 2>/dev/null)" || { \ + echo "ERROR: State Hub primary is not reachable on 127.0.0.1:8000." >&2; \ + echo "Run 'make bridges' first." >&2; \ + exit 1; \ + }; \ + identity="$$(printf '%s' "$$health" | $(PYTHON) -c 'import json, sys; d=json.load(sys.stdin); print("{}/{}".format(d.get("instance_role", ""), d.get("instance_label", "")))')"; \ + if [ "$$identity" != "primary/railiance01" ]; then \ + echo "ERROR: 127.0.0.1:8000 is '$$identity', not the primary/railiance01 State Hub." >&2; \ + echo "Stop the local API, then run 'make bridges'. Use 'make dashboard-local' only for intentional local development." >&2; \ + exit 1; \ + fi + +## Refuse to start the production tunnel when a non-primary API already owns +## port 8000. With no listener, bridge is free to establish the tunnel. +primary-port-clear: + @if health="$$(curl -fsS --max-time 2 http://127.0.0.1:8000/state/health 2>/dev/null)"; then \ + identity="$$(printf '%s' "$$health" | $(PYTHON) -c 'import json, sys; d=json.load(sys.stdin); print("{}/{}".format(d.get("instance_role", ""), d.get("instance_label", "")))')"; \ + if [ "$$identity" != "primary/railiance01" ]; then \ + echo "ERROR: port 8000 is occupied by '$$identity'." >&2; \ + echo "Stop the local 'make api' process before starting the production tunnel." >&2; \ + exit 1; \ + fi; \ + fi + +dashboard: check-primary + @fuser -k 3000/tcp 2>/dev/null && echo "Stopped running dashboard" || true + $(MAKE) dashboard-install + cd dashboard && npm run dev + +## Deliberate local-development dashboard; bypasses the production identity gate. +dashboard-local: + @echo "WARNING: serving the dashboard against the local/fallback API on :8000." + @fuser -k 3000/tcp 2>/dev/null && echo "Stopped running dashboard" || true + $(MAKE) dashboard-install + cd dashboard && npm run dev + +## Fast work-record path. Repo Manager assigns only missing deterministic IDs, +## verifies the primary/railliance01 bridge, and asks central to derive the +## exact pushed Forgejo commit in one transactional request. +sync: + $(RMGR) sync --path "$(SYNC_PATH)" --api-base "$(STATE_HUB_API_BASE)" $(if $(filter 1 true yes,$(SYNC_PUSH)),--push,) + +check: check-primary + @echo "State Hub primary/railiance01 is healthy." + +check-local: + curl -sf http://127.0.0.1:8000/state/health | python3 -m json.tool + +# CUST-WP-0067-T09. The chart ships a copy of the-custodian canon allowed-values +# because a container has no checkout to read it from. Regenerate after any +# canon vocabulary change; the -check target fails on drift so a release cannot +# validate classification against a stale vocabulary. +sync-classification-allowed: + python3 scripts/sync_classification_allowed.py + +check-classification-allowed: + python3 scripts/sync_classification_allowed.py --check + +# STATE-WP-0091: read-only surge-headroom check. Does not start Helm. +# Live: make railiance-state-hub-headroom +# Fixtures: make railiance-state-hub-headroom HEADROOM_FIXTURE=tests/fixtures/release_headroom/rev63-105m.json +HEADROOM_FIXTURE ?= +railiance-state-hub-headroom: + @if [ -n "$(HEADROOM_FIXTURE)" ]; then \ + $(PYTHON) scripts/release_headroom_preflight.py --fixture "$(HEADROOM_FIXTURE)"; \ + else \ + $(PYTHON) scripts/release_headroom_preflight.py --kubectl "$(KUBECTL)"; \ + fi + +railiance-state-hub-render: check-classification-allowed + $(HELM) template $(RAILIANCE_STATE_HUB_RELEASE) $(RAILIANCE_STATE_HUB_CHART) \ + --namespace $(RAILIANCE_STATE_HUB_NAMESPACE) \ + -f $(RAILIANCE_STATE_HUB_VALUES) \ + --set image.tag=$(RAILIANCE_STATE_HUB_IMAGE_TAG) + +railiance-state-hub-client-dry-run: + @set -e; \ + tmpdir="$$(mktemp -d)"; \ + trap 'rm -rf "$$tmpdir"' EXIT; \ + $(HELM) template $(RAILIANCE_STATE_HUB_RELEASE) $(RAILIANCE_STATE_HUB_CHART) \ + --namespace $(RAILIANCE_STATE_HUB_NAMESPACE) \ + -f $(RAILIANCE_STATE_HUB_VALUES) \ + --set image.tag=$(RAILIANCE_STATE_HUB_IMAGE_TAG) > "$$tmpdir/state-hub.yaml"; \ + $(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-credentials.sops.yaml.template; \ + $(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-cluster.yaml; \ + $(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-networkpolicies.yaml; \ + $(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_APP_MANIFESTS)/state-hub-namespace.yaml; \ + $(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_APP_MANIFESTS)/state-hub-env.secret.sops.yaml.template; \ + $(KUBECTL) apply --dry-run=client -n $(RAILIANCE_STATE_HUB_NAMESPACE) -f "$$tmpdir/state-hub.yaml" + +railiance-state-hub-server-dry-run: + @set -e; \ + tmpdir="$$(mktemp -d)"; \ + trap 'rm -rf "$$tmpdir"' EXIT; \ + $(HELM) template $(RAILIANCE_STATE_HUB_RELEASE) $(RAILIANCE_STATE_HUB_CHART) \ + --namespace $(RAILIANCE_STATE_HUB_NAMESPACE) \ + -f $(RAILIANCE_STATE_HUB_VALUES) \ + --set image.tag=$(RAILIANCE_STATE_HUB_IMAGE_TAG) > "$$tmpdir/state-hub.yaml"; \ + $(KUBECTL) apply --server-side --dry-run=server -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-credentials.sops.yaml.template; \ + $(KUBECTL) apply --dry-run=server -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-cluster.yaml; \ + $(KUBECTL) apply --dry-run=server -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-networkpolicies.yaml; \ + $(KUBECTL) apply --dry-run=server -f $(RAILIANCE_STATE_HUB_APP_MANIFESTS)/state-hub-namespace.yaml; \ + if $(KUBECTL) get namespace $(RAILIANCE_STATE_HUB_NAMESPACE) >/dev/null 2>&1; then \ + $(KUBECTL) apply --server-side --dry-run=server -f $(RAILIANCE_STATE_HUB_APP_MANIFESTS)/state-hub-env.secret.sops.yaml.template; \ + $(KUBECTL) apply --dry-run=server -n $(RAILIANCE_STATE_HUB_NAMESPACE) -f "$$tmpdir/state-hub.yaml"; \ + else \ + echo "Namespace $(RAILIANCE_STATE_HUB_NAMESPACE) does not exist; validating namespaced app manifests with client dry-run."; \ + $(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_APP_MANIFESTS)/state-hub-namespace.yaml; \ + $(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_APP_MANIFESTS)/state-hub-env.secret.sops.yaml.template; \ + $(KUBECTL) apply --dry-run=client -n $(RAILIANCE_STATE_HUB_NAMESPACE) -f "$$tmpdir/state-hub.yaml"; \ + fi + +test: test-python dashboard-check + +test-python: + TEST_DATABASE_URL=postgresql+asyncpg://custodian:changeme@127.0.0.1:5432/custodian_test \ + $(UV) run pytest -x -q + +## Benchmark /state/summary revision cache (API must be running on :8000) +benchmark-summary-cache: + $(UV) run python scripts/benchmark_summary_cache.py + +## ops-bridge managed tunnels +## Requires ops-bridge: bridge is at /home/worsch/.local/bin/bridge +tunnels-up: primary-port-clear + bridge up + +tunnels-status: + bridge status + +## End-to-end check: verifies SSH process alive + remote port listening on COULOMBCORE. +## Exits non-zero if any tunnel is not fully operational. +tunnels-check: + bridge check + +## Ensure all ops-bridge tunnels are up and healthy. +## Brings up any stopped/stale tunnels, shows final status, exits non-zero if anything is still down. +bridges: primary-port-clear + @echo "==> Bringing up all tunnels..." + bridge up + @echo "" + @echo "==> Tunnel status:" + bridge status + @echo "" + @echo "==> Checking tunnel health..." + bridge check + +## Start (or restart) the LOCAL FALLBACK backend — db + migrate + uvicorn. +## This replaces anything on :8000, including the production State Hub tunnel. +api: db + @echo "WARNING: starting the local fallback API; this is not the railiance01 primary." + @echo "Waiting for postgres..."; \ + for i in 1 2 3 4 5 6 7 8 9 10; do \ + nc -z 127.0.0.1 5432 2>/dev/null && break; \ + sleep 1; \ + done + $(MAKE) migrate + @fuser -k 8000/tcp 2>/dev/null && echo "Stopped running API" || true + $(UV) run uvicorn api.main:app --reload --reload-dir api --reload-dir mcp_server --reload-dir task_flow_engine --host 127.0.0.1 --port 8000 + +## Register a project (Claude Code): make register-project DOMAIN=railiance PROJECT_PATH=/home/worsch/railiance +register-project: + @test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN is required. Usage: make register-project DOMAIN= PROJECT_PATH="; exit 1) + @test -n "$(PROJECT_PATH)" || (echo "ERROR: PROJECT_PATH is required."; exit 1) + scripts/register_project.sh "$(DOMAIN)" "$(PROJECT_PATH)" + +## Register a Codex project (AGENTS.md + HTTP API): make register-codex-project DOMAIN=capabilities PROJECT_PATH=/home/worsch/my-repo +register-codex-project: + @test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN is required. Usage: make register-codex-project DOMAIN= PROJECT_PATH="; exit 1) + @test -n "$(PROJECT_PATH)" || (echo "ERROR: PROJECT_PATH is required."; exit 1) + scripts/register_project.sh "$(DOMAIN)" "$(PROJECT_PATH)" --codex + +## Register State Hub MCP for Claude Code. Optional: make register-mcp MCP_URL=http://127.0.0.1:18001/sse +register-mcp: + scripts/register-mcp.sh \ + $(if $(MCP_URL),--url "$(MCP_URL)",) \ + $(if $(API_BASE),--api-base "$(API_BASE)",) \ + $(if $(DRY_RUN),--dry-run,) + +## Configure Codex workspace-write networking for local State Hub access. +configure-codex: + scripts/configure-codex.sh \ + $(if $(CODEX_HOME),--codex-home "$(CODEX_HOME)",) \ + $(if $(API_BASE),--api-base "$(API_BASE)",) \ + $(if $(WITH_MCP),--with-mcp,) \ + $(if $(DRY_RUN),--dry-run,) + +## Bootstrap a new operator/collaborator environment. Optional: make bootstrap-env ARGS="--install-missing" +bootstrap-env: + scripts/bootstrap-env.sh $(ARGS) + +## Files-first local dev hub (CUST-WP-0054-T07): postgres + migrate + seed + API + repo registration. +## Optional: WITH_EDGE=1 WITH_MCP=1 make dev-hub +## Fleet tunnel when reachable: make dev-hub PROFILE=fleet +dev-hub: + bash scripts/dev_hub_up.sh $(if $(PROFILE),$(PROFILE),dev) + +## Local edge relay for offline write buffering (STATE-WP-0068). +edge-relay: + @fuser -k 18080/tcp 2>/dev/null && echo "Stopped running edge relay" || true + STATEHUB_UPSTREAM_URL=$${STATEHUB_UPSTREAM_URL:-http://127.0.0.1:8000} \ + $(UV) run uvicorn api.edge.relay:app --host 127.0.0.1 --port 18080 + +## Switch MCP/API profile: make mcp-profile PROFILE=dev|fleet|status +mcp-profile: + @test -n "$(PROFILE)" || (echo "ERROR: PROFILE is required (dev|fleet|status)."; exit 1) + bash scripts/mcp_hub_profile.sh "$(PROFILE)" + +## Add a second repo to an existing domain: make add-repo DOMAIN=railiance REPO_PATH=/home/worsch/railiance-infra +add-repo: + @test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN is required."; exit 1) + @test -n "$(REPO_PATH)" || (echo "ERROR: REPO_PATH is required."; exit 1) + scripts/register_project.sh "$(DOMAIN)" "$(REPO_PATH)" --additional + +## Create a new domain: make add-domain DOMAIN=my_domain NAME="My Domain" +add-domain: + @test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN is required (slug)."; exit 1) + @test -n "$(NAME)" || (echo "ERROR: NAME is required (display name)."; exit 1) + curl -sf -X POST http://127.0.0.1:8000/domains/ \ + -H "Content-Type: application/json" \ + -d "{\"slug\": \"$(DOMAIN)\", \"name\": \"$(NAME)\"}" | python3 -m json.tool + +## Rename a domain: make rename-domain DOMAIN=old_slug NEW_SLUG=new_slug NEW_NAME="New Name" +rename-domain: + @test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN (old slug) is required."; exit 1) + @test -n "$(NEW_SLUG)" || (echo "ERROR: NEW_SLUG is required."; exit 1) + @test -n "$(NEW_NAME)" || (echo "ERROR: NEW_NAME is required."; exit 1) + curl -sf -X PATCH http://127.0.0.1:8000/domains/$(DOMAIN)/rename \ + -H "Content-Type: application/json" \ + -d "{\"new_slug\": \"$(NEW_SLUG)\", \"new_name\": \"$(NEW_NAME)\"}" | python3 -m json.tool + +## Register this machine's local path for a repo: make register-path REPO=marki-docx PATH=/home/tegwick/marki-docx +register-path: + @test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make register-path REPO= PATH="; exit 1) + @test -n "$(PATH)" || (echo "ERROR: PATH is required. Usage: make register-path REPO= PATH="; exit 1) + curl -sf -X POST "http://127.0.0.1:8000/repos/$(REPO)/paths" \ + -H "Content-Type: application/json" \ + -d "{\"host\": \"$$(hostname)\", \"path\": \"$(PATH)\"}" | python3 -m json.tool + +## List repos for a domain: make list-repos DOMAIN=railiance +list-repos: + @test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN is required."; exit 1) + curl -sf "http://127.0.0.1:8000/repos/?domain=$(DOMAIN)" | python3 -m json.tool + +## Ingest SBOM data for a repo (all mechanisms: lockfiles + ansible + sbom-tools.yaml). +## Auto-detect all sources: make ingest-sbom REPO=the-custodian REPO_PATH=/home/worsch/the-custodian +## Single lockfile (explicit): make ingest-sbom REPO=the-custodian LOCKFILE=/path/to/uv.lock +## Dry-run (no submit): make ingest-sbom REPO=the-custodian REPO_PATH=... DRY_RUN=1 +## Tip: run capture-tools first for repos with system-level tool dependencies. +ingest-sbom: + @test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1) + $(UV) run python scripts/ingest_sbom.py --repo "$(REPO)" \ + $(if $(LOCKFILE),--lockfile "$(LOCKFILE)") \ + $(if $(REPO_PATH),--repo-path "$(REPO_PATH)") \ + $(if $(DRY_RUN),--dry-run) + +## Ingest capability declarations from SCOPE.md into the catalog. +## Usage: make ingest-capabilities REPO=the-custodian [REPO_PATH=/home/worsch/the-custodian] +## Or: make ingest-capabilities-all +## Add DRY_RUN=1 to preview without writing. +ingest-capabilities: + @test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1) + $(UV) run python scripts/ingest_capabilities.py --repo "$(REPO)" \ + $(if $(REPO_PATH),--repo-path "$(REPO_PATH)") \ + $(if $(DRY_RUN),--dry-run) + +ingest-capabilities-all: + $(UV) run python scripts/ingest_capabilities.py --all \ + $(if $(DRY_RUN),--dry-run) + +## Check Repository Definition of Integrated (DoI) criteria for a repo. +## Usage: make check-doi REPO=llm-connect +## Or: make check-doi-all +## Add JSON=1 for machine-readable output. +check-doi: + @test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1) + $(UV) run python scripts/check_doi.py --repo "$(REPO)" $(if $(JSON),--json) + +check-doi-all: + $(UV) run python scripts/check_doi.py --all $(if $(JSON),--json) + +## Ingest tpsc.yaml service declarations from a repo into the TPSC catalog. +## Usage: make ingest-tpsc REPO=llm-connect +## Or: make ingest-tpsc-all +## Add DRY_RUN=1 to preview without writing. +ingest-tpsc: + @test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1) + $(UV) run python scripts/ingest_tpsc.py --repo "$(REPO)" \ + $(if $(DRY_RUN),--dry-run) + +ingest-tpsc-all: + $(UV) run python scripts/ingest_tpsc.py --all \ + $(if $(DRY_RUN),--dry-run) + +## Run SBOM capture agent for a repo — generates/updates sbom-tools.yaml. +## Usage: make capture-tools REPO=railiance-infra [REPO_PATH=/home/worsch/railiance-infra] +## Add DRY_RUN=1 to preview without writing. +capture-tools: + @test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1) + $(UV) run python scripts/capture_sbom_tools.py --repo "$(REPO)" \ + $(if $(REPO_PATH),--repo-path "$(REPO_PATH)") \ + $(if $(DRY_RUN),--dry-run) + +## Check a repo for ADR-001 compliance: make validate-adr REPO=/path/to/repo [DOMAIN=custodian] +validate-adr: + @test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make validate-adr REPO= [DOMAIN=]"; exit 1) + $(UV) run python scripts/validate_repo_adr.py "$(REPO)" $(if $(DOMAIN),--domain "$(DOMAIN)",) + +## Consistency exit contract: +## - Direct scripts/consistency_check.py: 0 clean, 2 warnings-only, 1 failures. +## - Agent/operator Make wrappers below normalize warning-only 2 to shell success +## while preserving visible WARN output and keeping real failures non-zero. +## Check a single repo for ADR-001 consistency: make check-consistency REPO=the-custodian [REPO_PATH=/override] +## Exit 0 = clean or warnings-only (warnings stay visible), exit 1 = failures +check-consistency: + @test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make check-consistency REPO="; exit 1) + $(UV) run python scripts/consistency_check.py --repo "$(REPO)" \ + $(if $(API_BASE),--api-base "$(API_BASE)",) \ + $(if $(REPO_PATH),--repo-path "$(REPO_PATH)",); \ + e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e + +## Check and auto-fix a single repo: make fix-consistency REPO=the-custodian [REPO_PATH=/override] +## Exit 0 = clean or warnings-only (warnings stay visible), exit 1 = failures +fix-consistency: + @test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make fix-consistency REPO="; exit 1) + $(UV) run python scripts/consistency_check.py --repo "$(REPO)" --fix \ + $(if $(API_BASE),--api-base "$(API_BASE)",) \ + $(if $(REPO_PATH),--repo-path "$(REPO_PATH)",); \ + e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e + +## Normalize workplan frontmatter and task status literals in attached repos. +## Usage: make normalize-attached-workplans REPO=artifact-store +## make normalize-attached-workplans DIRTY=1 +normalize-attached-workplans: + $(UV) run python scripts/normalize_attached_repo_workplans.py \ + $(if $(REPO),--repo "$(REPO)",) \ + $(if $(DIRTY),--dirty,) \ + $(if $(DRY_RUN),--dry-run,) + @test -n "$(REPO)$(DIRTY)" || (echo "ERROR: set REPO= or DIRTY=1"; exit 1) + +## Regenerate AGENTS.md / CLAUDE.md / .claude/rules from templates. +## Usage: make update-agent-instructions REPO=artifact-store +## make update-agent-instructions DIRTY=1 +update-agent-instructions: + $(UV) run python scripts/update_agent_instruction_files.py \ + $(if $(REPO),--repo "$(REPO)",) \ + $(if $(DIRTY),--dirty,) + @test -n "$(REPO)$(DIRTY)" || (echo "ERROR: set REPO= or DIRTY=1"; exit 1) + +## Reconcile measured token sources against State Hub. +## Usage: make token-reconcile [SINCE=2026-05-19] [APPLY=1] [ZERO_FALLBACKS=1] +token-reconcile: + $(PYTHON) scripts/token_reconcile.py \ + $(if $(SINCE),--since "$(SINCE)",) \ + $(if $(API_BASE),--api-base "$(API_BASE)",) \ + $(if $(CODEX_HOME),--codex-home "$(CODEX_HOME)",) \ + $(if $(CLAUDE_HOME),--claude-home "$(CLAUDE_HOME)",) \ + $(if $(APPLY),--apply,) \ + $(if $(ZERO_FALLBACKS),--zero-superseded-fallbacks,) + +## Pull then fix: single repo or all repos if REPO omitted +## make fix-consistency-remote — smart pull+fix all repos that need it +## make fix-consistency-remote REPO=slug — pull+fix one repo +fix-consistency-remote: + $(UV) run python scripts/consistency_check.py \ + $(if $(REPO),--repo "$(REPO)",--all) \ + --remote \ + $(if $(API_BASE),--api-base "$(API_BASE)",) \ + $(if $(NO_WRITEBACK),--no-writeback,); \ + e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e + +## Infer repo slug from git remote URL and check: make check-consistency-here [REPO_PATH=/path/to/repo] +## Omit REPO_PATH to use the Python script's CWD (i.e. pass an empty --here flag). +check-consistency-here: + $(UV) run python scripts/consistency_check.py \ + --here $(if $(REPO_PATH),"$(REPO_PATH)",) \ + $(if $(API_BASE),--api-base "$(API_BASE)",); \ + e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e + +## Infer repo slug from git remote URL and fix: make fix-consistency-here [REPO_PATH=/path/to/repo] +fix-consistency-here: + $(UV) run python scripts/consistency_check.py \ + --here $(if $(REPO_PATH),"$(REPO_PATH)",) \ + --fix \ + $(if $(API_BASE),--api-base "$(API_BASE)",); \ + e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e + +## Check all registered repos for ADR-001 consistency +check-consistency-all: + $(UV) run python scripts/consistency_check.py --all $(if $(API_BASE),--api-base "$(API_BASE)",); \ + e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e + +## Check and auto-fix all registered repos +fix-consistency-all: + $(UV) run python scripts/consistency_check.py --all --fix $(if $(API_BASE),--api-base "$(API_BASE)",); \ + e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e + +## Cancel open tasks belonging to completed/archived workstreams. +## Safe to run at any time; also suitable for a daily cron job. +## Cron example: 0 3 * * * cd ~/state-hub && make cleanup-stale +cleanup-stale: + $(UV) run python scripts/cleanup_stale_tasks.py + +## Install custodian post-commit sync hook into one repo: make install-hooks REPO=marki-docx +install-hooks: + @test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make install-hooks REPO="; exit 1) + bash scripts/install_hooks.sh --repo "$(REPO)" + +## Install custodian post-commit sync hook into all active registered repos +install-hooks-all: + bash scripts/install_hooks.sh --all + +## Remove custodian post-commit sync hook from one repo: make remove-hooks REPO=marki-docx +remove-hooks: + @test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make remove-hooks REPO="; exit 1) + bash scripts/install_hooks.sh --repo "$(REPO)" --remove + +## Compare Gitea coulomb org repos against state-hub registered repos +## Requires GITEA_TOKEN in env or .env: make gitea-inventory GITEA_TOKEN= +gitea-inventory: + $(UV) run python scripts/gitea_inventory.py $(if $(JSON),--json) + +## Register/update one repo from .repo-classification.yaml: +## make register-from-classification REPO=state-hub +## make register-from-classification PATH=/path/to/repo +## Optional: DRY_RUN=1 +register-from-classification: + @test -n "$(REPO)" -o -n "$(PATH)" || (echo "ERROR: REPO or PATH is required."; exit 1) + $(UV) run python scripts/register_from_classification.py \ + $(if $(PATH),--repo-path "$(PATH)",--slug "$(REPO)") \ + $(if $(DRY_RUN),--dry-run,) + +## Bulk register/update all active repos with accessible local paths +register-from-classification-all: + $(UV) run python scripts/register_from_classification.py --bulk \ + $(if $(DRY_RUN),--dry-run,) + +clean: + $(COMPOSE) down -v diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r5/state-hub/api/routers/workstreams.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r5/state-hub/api/routers/workstreams.py new file mode 100644 index 00000000..cb6724a5 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r5/state-hub/api/routers/workstreams.py @@ -0,0 +1,591 @@ +import asyncio +import logging +import uuid +import socket +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import yaml +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status +from sqlalchemy import or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.events import EventEnvelope, publish_event +from api.models.managed_repo import ManagedRepo +from api.models.workplan import Workplan +from api.models.task import Task +from api.services.historical_projection import frozen_cluster_binding, validate_frozen_cluster_rows +from api.schemas.workplan import ( + WorkplanBindingsSync, + WorkplanCreate, + WorkplanRead, + WorkplanUpdate, +) +from api.services.lifecycle import transition_workplan_status +from api.services.legacy_compat import retire_legacy_route +from api.services.wait_kind import annotate_workplans +from api.work_record_flavor import ( + RESIDUAL_FLAVOR, + WORK_RECORD_FLAVORS, + normalize_flavor, +) +from api.workplan_status import ( + is_supported_workplan_status, + normalize_workplan_status, + ready_review_status, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/workstreams", tags=["workstreams"]) +workplan_router = APIRouter(prefix="/workplans", tags=["workplans"]) + +_INDEX_CACHE: dict[str, Any] | None = None +_INDEX_CACHE_AT: float = 0.0 +_INDEX_TTL = 30.0 +_INDEX_REFRESH_TASK: asyncio.Task | None = None +_INDEX_LAST_ERROR: str | None = None + +_COMPLETED_WORKPLAN_EVENT = "org.statehub.workplan.completed" + + +def _repo_path(repo: ManagedRepo) -> Path | None: + hostname = socket.gethostname() + candidates = [] + host_paths = repo.host_paths or {} + if host_paths.get(hostname): + candidates.append(host_paths[hostname]) + if repo.local_path: + candidates.append(repo.local_path) + for raw in candidates: + path = Path(raw).expanduser() + if path.is_dir(): + return path + return None + + +def _frontmatter(path: Path) -> dict[str, Any]: + try: + text = path.read_text(encoding="utf-8") + except OSError: + return {} + if not text.startswith("---\n"): + return {} + end = text.find("\n---", 4) + if end == -1: + return {} + + try: + return yaml.safe_load(text[4:end].strip()) or {} + except yaml.YAMLError: + return {} + + +def _legacy_key(method: str, route: str) -> str: + return f"rest_api:{method} {route}" + + +async def _list_workplans( + *, + topic_id: uuid.UUID | None, + repo_id: uuid.UUID | None, + repo_goal_id: uuid.UUID | None, + status_filter: str | None, + owner: str | None, + slug: str | None, + flavor: str | None = None, + include_residuals: bool = True, + session: AsyncSession, +) -> list[Workplan]: + q = select(Workplan) + if topic_id: + q = q.where(Workplan.topic_id == topic_id) + if repo_id: + q = q.where(Workplan.repo_id == repo_id) + if repo_goal_id: + q = q.where(Workplan.repo_goal_id == repo_goal_id) + if status_filter: + normalised_status = normalize_workplan_status(status_filter) + if not is_supported_workplan_status(status_filter): + raise HTTPException(status_code=422, detail=f"Unsupported workplan status '{status_filter}'") + q = q.where(Workplan.status == normalised_status) + if owner: + q = q.where(Workplan.owner == owner) + if slug: + q = q.where(Workplan.slug == slug) + wanted = normalize_flavor(flavor) + if wanted is not None: + if wanted not in WORK_RECORD_FLAVORS: + raise HTTPException( + status_code=422, + detail=( + f"Unknown work-record flavor {wanted!r}; " + f"expected one of {', '.join(WORK_RECORD_FLAVORS)}" + ), + ) + q = q.where(Workplan.flavor == wanted) + elif not include_residuals: + q = q.where(or_(Workplan.flavor.is_(None), Workplan.flavor != RESIDUAL_FLAVOR)) + q = q.order_by( + Workplan.planning_priority.asc().nullslast(), + Workplan.planning_order.asc().nullslast(), + Workplan.updated_at.desc(), + ) + result = await session.execute(q) + return list(result.scalars().all()) + + +async def _build_workplan_index(session: AsyncSession) -> dict[str, Any]: + result = await session.execute( + select(ManagedRepo).where(ManagedRepo.status == "active").order_by(ManagedRepo.slug) + ) + index: dict[str, Any] = {} + for repo in result.scalars().all(): + root = _repo_path(repo) + if root is None: + continue + for directory, archived in ( + (root / "workplans", False), + (root / "workplans" / "archived", True), + ): + if not directory.is_dir(): + continue + for path in sorted(directory.glob("*.md")): + data = _frontmatter(path) + workplan_id = data.get("state_hub_workstream_id") or data.get("state_hub_workplan_id") + if not workplan_id: + continue + file_status = normalize_workplan_status(data.get("status", "")) + review = ( + ready_review_status( + root, + data.get("reviewed_against_commit"), + data.get("context_paths"), + ) + if file_status == "ready" + else None + ) + index[str(workplan_id)] = { + "filename": path.name, + "relative_path": str(path.relative_to(root)), + "repo_slug": repo.slug, + "archived": archived, + "status": file_status or None, + "needs_review": bool(review and review.needs_review), + "health_labels": ["needs_review"] if review and review.needs_review else [], + } + await _merge_db_backing_index(session, index) + return {"workplans": index, "workstreams": index} + + +async def _merge_db_backing_index(session: AsyncSession, index: dict[str, Any]) -> None: + """Fill index gaps from DB-backed file bindings synced by fix-consistency.""" + result = await session.execute( + select(Workplan, ManagedRepo.slug) + .join(ManagedRepo, Workplan.repo_id == ManagedRepo.id) + .where(Workplan.backing_filename.isnot(None)) + ) + for wp, repo_slug in result.all(): + key = str(wp.id) + if key in index: + continue + index[key] = { + "filename": wp.backing_filename, + "relative_path": wp.backing_relative_path, + "repo_slug": repo_slug, + "archived": bool(wp.backing_archived), + "status": normalize_workplan_status(wp.status) if wp.status else None, + "needs_review": False, + "health_labels": [], + } + + +def _invalidate_workplan_index_cache() -> None: + global _INDEX_CACHE, _INDEX_CACHE_AT + _INDEX_CACHE = None + _INDEX_CACHE_AT = 0.0 + + +def _index_with_meta(*, stale: bool, refresh_in_progress: bool) -> dict[str, Any]: + age = time.monotonic() - _INDEX_CACHE_AT if _INDEX_CACHE_AT else None + return { + **(_INDEX_CACHE or {"workplans": {}, "workstreams": {}}), + "_meta": { + "generated_at": _INDEX_CACHE.get("_meta", {}).get("generated_at") if _INDEX_CACHE else None, + "stale": stale, + "cache_age_seconds": round(age, 3) if age is not None else None, + "refresh_in_progress": refresh_in_progress, + "last_error": _INDEX_LAST_ERROR, + }, + } + + +async def _refresh_workplan_index_background() -> None: + global _INDEX_CACHE, _INDEX_CACHE_AT, _INDEX_LAST_ERROR + from api.database import async_session_factory + + try: + async with async_session_factory() as session: + index = await _build_workplan_index(session) + index["_meta"] = { + "generated_at": datetime.now(timezone.utc).isoformat(), + "stale": False, + "cache_age_seconds": 0.0, + "refresh_in_progress": False, + "last_error": None, + } + _INDEX_CACHE = index + _INDEX_CACHE_AT = time.monotonic() + _INDEX_LAST_ERROR = None + except Exception as exc: + _INDEX_LAST_ERROR = str(exc) + + +def _ensure_index_refresh_started() -> None: + global _INDEX_REFRESH_TASK + if _INDEX_REFRESH_TASK is not None and not _INDEX_REFRESH_TASK.done(): + return + _INDEX_REFRESH_TASK = asyncio.create_task(_refresh_workplan_index_background()) + + +async def _workplan_index( + *, + refresh: bool, + session: AsyncSession, +) -> dict[str, Any]: + """Map file-backed workplan ids to their local workplan filenames.""" + global _INDEX_CACHE, _INDEX_CACHE_AT, _INDEX_LAST_ERROR + cache_age = time.monotonic() - _INDEX_CACHE_AT if _INDEX_CACHE_AT else None + if not refresh and _INDEX_CACHE is not None and cache_age is not None and cache_age < _INDEX_TTL: + refresh_running = _INDEX_REFRESH_TASK is not None and not _INDEX_REFRESH_TASK.done() + return _index_with_meta(stale=False, refresh_in_progress=refresh_running) + + if not refresh and _INDEX_CACHE is not None: + _ensure_index_refresh_started() + return _index_with_meta(stale=True, refresh_in_progress=True) + + index = await _build_workplan_index(session) + index["_meta"] = { + "generated_at": datetime.now(timezone.utc).isoformat(), + "stale": False, + "cache_age_seconds": 0.0, + "refresh_in_progress": False, + "last_error": None, + } + _INDEX_CACHE = index + _INDEX_CACHE_AT = time.monotonic() + _INDEX_LAST_ERROR = None + return _INDEX_CACHE + + +async def _create_workplan( + *, + body: WorkplanCreate, + session: AsyncSession, +) -> Workplan: + wp = Workplan(**body.model_dump()) + session.add(wp) + await session.commit() + await session.refresh(wp) + return wp + + +async def _get_workplan( + *, + workplan_id: uuid.UUID, + session: AsyncSession, +) -> Workplan: + wp = await session.get(Workplan, workplan_id) + if wp is None: + raise HTTPException(status_code=404, detail="Workplan not found") + return wp + + +async def _update_workplan( + *, + workplan_id: uuid.UUID, + body: WorkplanUpdate, + session: AsyncSession, +) -> Workplan: + wp = await session.get(Workplan, workplan_id) + if wp is None: + raise HTTPException(status_code=404, detail="Workplan not found") + update_data = body.model_dump(exclude_unset=True) + status_update = update_data.pop("status", None) + prev_status = wp.status + for field, value in update_data.items(): + setattr(wp, field, value) + if status_update is not None: + transition_workplan_status(wp, status_update) + await session.commit() + await session.refresh(wp) + + if normalize_workplan_status(prev_status) != "finished" and wp.status == "finished": + await _publish_completion_events(wp, session) + + return wp + + +async def _archive_workplan( + *, + workplan_id: uuid.UUID, + session: AsyncSession, +) -> Workplan: + wp = await session.get(Workplan, workplan_id) + if wp is None: + raise HTTPException(status_code=404, detail="Workplan not found") + transition_workplan_status(wp, "archived") + await session.commit() + await session.refresh(wp) + return wp + + +async def _publish_completion_events(wp: Workplan, session: AsyncSession) -> None: + workplan_envelope = EventEnvelope.new( + _COMPLETED_WORKPLAN_EVENT, + attributes={ + "workplan_id": str(wp.id), + "slug": wp.slug, + "title": wp.title, + "topic_id": str(wp.topic_id) if wp.topic_id else None, + "repo_id": str(wp.repo_id) if wp.repo_id else None, + "repo_goal_id": str(wp.repo_goal_id) if wp.repo_goal_id else None, + }, + ) + asyncio.create_task(publish_event(_COMPLETED_WORKPLAN_EVENT, workplan_envelope)) + + +@router.get("/", status_code=status.HTTP_410_GONE) +async def list_workstreams( + request: Request, + response: Response, + topic_id: uuid.UUID | None = None, + repo_id: uuid.UUID | None = None, + repo_goal_id: uuid.UUID | None = None, + status: str | None = None, + owner: str | None = None, + slug: str | None = None, + session: AsyncSession = Depends(get_session), +) -> None: + await retire_legacy_route( + session=session, + request=request, + response=response, + interface_key=_legacy_key("GET", "/workstreams/"), + replacement_ref="/workplans/", + detail="Legacy GET /workstreams/ retired; use GET /workplans/", + ) + + +@workplan_router.get("/", response_model=list[WorkplanRead]) +async def list_workplans( + topic_id: uuid.UUID | None = None, + repo_id: uuid.UUID | None = None, + repo_goal_id: uuid.UUID | None = None, + status: str | None = None, + owner: str | None = None, + slug: str | None = None, + flavor: str | None = Query(None), + include_residuals: bool = Query(True), + session: AsyncSession = Depends(get_session), +) -> list[Workplan]: + workplans = await _list_workplans( + topic_id=topic_id, + repo_id=repo_id, + repo_goal_id=repo_goal_id, + status_filter=status, + owner=owner, + slug=slug, + flavor=flavor, + include_residuals=include_residuals, + session=session, + ) + return list(await annotate_workplans(session, workplans)) + + +@router.get("/workplan-index", status_code=status.HTTP_410_GONE) +async def workplan_index( + request: Request, + response: Response, + refresh: bool = Query(False, description="Force cache invalidation"), + session: AsyncSession = Depends(get_session), +) -> None: + await retire_legacy_route( + session=session, + request=request, + response=response, + interface_key=_legacy_key("GET", "/workstreams/workplan-index"), + replacement_ref="/workplans/index", + detail="Legacy GET /workstreams/workplan-index retired; use GET /workplans/index", + ) + + +@workplan_router.get("/index") +async def workplan_index_preferred( + refresh: bool = Query(False, description="Force cache invalidation"), + session: AsyncSession = Depends(get_session), +) -> dict[str, Any]: + return await _workplan_index(refresh=refresh, session=session) + + +@workplan_router.put("/index/bindings") +async def sync_workplan_bindings( + body: WorkplanBindingsSync, + session: AsyncSession = Depends(get_session), +) -> dict[str, int]: + """Upsert workstation workplan file bindings for remote API index fallback.""" + # Preflight the complete batch before assigning even an ordinary row. + try: + frozen = {entry.workplan_id: bound for entry in body.bindings + if (bound := frozen_cluster_binding(entry.model_dump())) is not None} + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + requested_ids = {entry.workplan_id for entry in body.bindings} + rows = await session.execute( + select(Workplan).where(Workplan.id.in_(requested_ids)) + .order_by(Workplan.id).with_for_update().execution_options(populate_existing=True) + ) + workplans = {workplan.id: workplan for workplan in rows.scalars().all()} + if frozen: + repo_rows = await session.execute( + select(ManagedRepo).where(ManagedRepo.slug == "railiance-cluster") + ) + repo = repo_rows.scalar_one_or_none() + task_rows = await session.execute( + select(Task).where(Task.workplan_id.in_(frozen)) + .order_by(Task.id).with_for_update().execution_options(populate_existing=True) + ) + tasks = task_rows.scalars().all() + try: + for workplan_id, bound in frozen.items(): + validate_frozen_cluster_rows( + bound, workplans.get(workplan_id), tasks, repo.id if repo else None + ) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + synced_at = datetime.now(timezone.utc) + updated = 0 + preserved = 0 + for entry in body.bindings: + if entry.workplan_id in frozen: + preserved += 1 + continue # No setters, including backing_synced_at/updated_at. + wp = workplans.get(entry.workplan_id) + if wp is None: + continue + wp.backing_filename = entry.filename + wp.backing_relative_path = entry.relative_path + wp.backing_archived = entry.archived + wp.backing_synced_at = synced_at + updated += 1 + if updated: + await session.commit() + _invalidate_workplan_index_cache() + return {"updated": updated, "received": len(body.bindings), "preserved": preserved} + + +@router.post("/", status_code=status.HTTP_410_GONE) +async def create_workstream( + request: Request, + response: Response, + body: WorkplanCreate, + session: AsyncSession = Depends(get_session), +) -> None: + await retire_legacy_route( + session=session, + request=request, + response=response, + interface_key=_legacy_key("POST", "/workstreams/"), + replacement_ref="/workplans/", + detail="Legacy POST /workstreams/ retired; use POST /workplans/", + ) + + +@workplan_router.post("/", response_model=WorkplanRead, status_code=status.HTTP_201_CREATED) +async def create_workplan( + body: WorkplanCreate, + session: AsyncSession = Depends(get_session), +) -> Workplan: + return await _create_workplan(body=body, session=session) + + +@router.get("/{workstream_id}", status_code=status.HTTP_410_GONE) +async def get_workstream( + request: Request, + response: Response, + workstream_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> None: + await retire_legacy_route( + session=session, + request=request, + response=response, + interface_key=_legacy_key("GET", "/workstreams/{workstream_id}"), + replacement_ref="/workplans/{workplan_id}", + detail="Legacy GET /workstreams/{workstream_id} retired; use GET /workplans/{workplan_id}", + ) + + +@workplan_router.get("/{workplan_id}", response_model=WorkplanRead) +async def get_workplan( + workplan_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> Workplan: + wp = await _get_workplan(workplan_id=workplan_id, session=session) + await annotate_workplans(session, [wp]) + return wp + + +@router.patch("/{workstream_id}", status_code=status.HTTP_410_GONE) +async def update_workstream( + request: Request, + response: Response, + workstream_id: uuid.UUID, + body: WorkplanUpdate, + session: AsyncSession = Depends(get_session), +) -> None: + await retire_legacy_route( + session=session, + request=request, + response=response, + interface_key=_legacy_key("PATCH", "/workstreams/{workstream_id}"), + replacement_ref="/workplans/{workplan_id}", + detail="Legacy PATCH /workstreams/{workstream_id} retired; use PATCH /workplans/{workplan_id}", + ) + + +@workplan_router.patch("/{workplan_id}", response_model=WorkplanRead) +async def update_workplan( + workplan_id: uuid.UUID, + body: WorkplanUpdate, + session: AsyncSession = Depends(get_session), +) -> Workplan: + return await _update_workplan(workplan_id=workplan_id, body=body, session=session) + + +@router.delete("/{workstream_id}", status_code=status.HTTP_410_GONE) +async def archive_workstream( + request: Request, + response: Response, + workstream_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> None: + await retire_legacy_route( + session=session, + request=request, + response=response, + interface_key=_legacy_key("DELETE", "/workstreams/{workstream_id}"), + replacement_ref="DELETE /workplans/{workplan_id}", + detail="Legacy DELETE /workstreams/{workstream_id} retired; use DELETE /workplans/{workplan_id}", + ) + + +@workplan_router.delete("/{workplan_id}", response_model=WorkplanRead) +async def archive_workplan( + workplan_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> Workplan: + return await _archive_workplan(workplan_id=workplan_id, session=session) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r5/state-hub/api/services/historical_projection.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r5/state-hub/api/services/historical_projection.py new file mode 100644 index 00000000..97a50a3b --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r5/state-hub/api/services/historical_projection.py @@ -0,0 +1,238 @@ +"""Exact closed source bindings predating canonical Forge identity. + +These are frozen historical source views, not aliases or UUID allocation rules. +An altered/missing source requires explicit owner review; generic source UUIDs +remain untrusted. Shared Activity views represent the one archived parent. +""" +from pathlib import Path +from hashlib import sha256 +import json + +BINDINGS = json.loads(r'''[ + { + "repo": "activity-core", + "path": "workplans/archived/260707-custodian-WP-0003-event-bridge.md", + "sha256": "83ea8bcb151e003419b73c59dbd028f4a1ce3d1120a82e4a128a772503ff3b76", + "record_id": "custodian-WP-0003", + "uuid": "b4eb45a9-69e3-4ab0-b00c-67a53c3117c5", + "parent_record_id": "custodian-WP-0003" + }, + { + "repo": "activity-core", + "path": "workplans/custodian-WP-0001-temporal-backbone.md", + "sha256": "020e2eac973f2daa41c1687f8e867da9d521f945547e6aa82c87993b3ff6fc64", + "record_id": "custodian-WP-0001", + "uuid": "e22516cc-4fdf-4dad-a928-a9e04a9fa7c7", + "parent_record_id": "custodian-WP-0001" + }, + { + "repo": "activity-core", + "path": "workplans/custodian-WP-0002-triggers-ops.md", + "sha256": "d8cbc61631c01b6f11a03a63f2fb6275fc853ff7f7459ce65973ed1a233daa01", + "record_id": "custodian-WP-0002", + "uuid": "3a4f47d9-8bc1-434e-acb4-bed5d4dacda0", + "parent_record_id": "custodian-WP-0002" + }, + { + "repo": "activity-core", + "path": "workplans/custodian-WP-0003a-event-bridge-model-rules-registry.md", + "sha256": "ffa6682e9ba3aaf559bd44203d561c90dea69bcf1b1e778209a7b209e6e5cdd3", + "record_id": "custodian-WP-0003a", + "uuid": "b4eb45a9-69e3-4ab0-b00c-67a53c3117c5", + "parent_record_id": "custodian-WP-0003" + }, + { + "repo": "activity-core", + "path": "workplans/custodian-WP-0003b-event-bridge-parser-workflow-triggers.md", + "sha256": "4fc34e0f821e2df51e5c08c7ab34da35b65690381d01f0cfdf55d00c970b414c", + "record_id": "custodian-WP-0003b", + "uuid": "b4eb45a9-69e3-4ab0-b00c-67a53c3117c5", + "parent_record_id": "custodian-WP-0003" + }, + { + "repo": "activity-core", + "path": "workplans/custodian-WP-0003c-event-bridge-context-integration.md", + "sha256": "b6cd62b26515e509670ca39736b6bcec9633beedd46c9bd8f7598c06bac7276b", + "record_id": "custodian-WP-0003c", + "uuid": "b4eb45a9-69e3-4ab0-b00c-67a53c3117c5", + "parent_record_id": "custodian-WP-0003" + }, + { + "repo": "activity-core", + "path": "workplans/custodian-WP-0004-railiance-ops.md", + "sha256": "20df02eea740e109c937130e7f8801a636fc0990ed8c6d2ced55659f8957adb1", + "record_id": "custodian-WP-0004", + "uuid": "759b1255-aa78-42b5-8fab-c5fcf168f5f4", + "parent_record_id": "custodian-WP-0004" + }, + { + "repo": "the-custodian", + "path": "workplans/ADHOC-2026-08-25-retired.md", + "sha256": "344a1293dbb3a67a944eb34ca4a0337487d8f047226027a2107922346fbab2dd", + "record_id": "CUST-WP-ADHOC-2026-08-25-RETIRED", + "uuid": "1c53d3db-6633-5e2d-987f-10706d73be9e", + "parent_record_id": "CUST-WP-ADHOC-2026-08-25-RETIRED" + }, + { + "repo": "railiance-cluster", + "path": "workplans/archived/260622-RAIL-BS-WP-0002-k3s-baseline.md", + "sha256": "8d6f8533c119117686bc0d5bb9ed5349726aa6c3189ff98b53409c68ac685ca9", + "record_id": "RAIL-BS-WP-0002", + "uuid": "4c63dfc6-9eac-4e79-9f77-8f644ad7147d", + "parent_record_id": "RAIL-BS-WP-0002", + "tasks": [ + { + "label": "T01", + "uuid": "3f042630-eab0-4c6a-9167-e2b28ff20e40", + "status": "done" + }, + { + "label": "T02", + "uuid": "e8510646-46ed-4697-a345-f3d3009eea78", + "status": "done" + }, + { + "label": "T03", + "uuid": "dab2c07f-8aa0-4635-8df6-857e87e93fc5", + "status": "done" + }, + { + "label": "T04", + "uuid": "5c3d40e4-239b-488e-9519-6f7a38d2325f", + "status": "done" + }, + { + "label": "T05", + "uuid": "7f9e0e58-a130-467a-a2d0-b3f2564e496f", + "status": "done" + } + ] + }, + { + "repo": "railiance-cluster", + "path": "workplans/archived/260622-RAIL-BS-WP-0003-pgpool-ha-failover-fix.md", + "sha256": "4dc2f61e15adc3c5c81f9ecf8b933512275b95e8f829fac52ae7cde1385cea4a", + "record_id": "RAIL-BS-WP-0003", + "uuid": "7ee9ee22-1fae-4567-9194-8d70a9e0f45b", + "parent_record_id": "RAIL-BS-WP-0003", + "tasks": [ + { + "label": "T01", + "uuid": "6841c93a-f146-47eb-9f7c-8fa0e02c1bbc", + "status": "done" + }, + { + "label": "T02", + "uuid": "ab166073-30a7-4702-a037-4091e8706e20", + "status": "done" + }, + { + "label": "T03", + "uuid": "140da396-8e30-4f4d-b88c-c42c0cd46c01", + "status": "done" + }, + { + "label": "T04", + "uuid": "d8a3ba40-fda0-4c1f-a9f1-ffcd621a5b3d", + "status": "done" + } + ] + }, + { + "repo": "railiance-cluster", + "path": "workplans/archived/260622-RAIL-BS-WP-0004-safety-net.md", + "sha256": "04f787078232395bd2d43861de16c521848559fa286f06e1caa9cb779aa71ac3", + "record_id": "RAIL-BS-WP-0004", + "uuid": "7e8b0c20-51eb-40c9-9e3b-85dd380d7625", + "parent_record_id": "RAIL-BS-WP-0004", + "tasks": [ + { + "label": "T01", + "uuid": "4526a842-ea31-4874-9231-92ab556cfe7b", + "status": "done" + }, + { + "label": "T02", + "uuid": "a6313e06-1976-46a7-8e31-df4eb2eca880", + "status": "done" + }, + { + "label": "T03", + "uuid": "05d42a55-921f-4aa7-bb76-e8af9c7e0ac3", + "status": "done" + }, + { + "label": "T04", + "uuid": "08233868-d522-4117-bc4e-6c0f52545665", + "status": "done" + }, + { + "label": "T05", + "uuid": "2d5acff7-4a4e-4ddd-ad06-08237ad3dac8", + "status": "done" + }, + { + "label": "T06", + "uuid": "f8e4a094-c367-40eb-b895-da17bc144b07", + "status": "done" + } + ] + } +]''') + + +def bound_sources(repo_root: Path, repo_slug: str): + entries = [entry for entry in BINDINGS if entry["repo"] == repo_slug] + result = [] + for entry in entries: + path = repo_root / entry["path"] + if path.is_symlink() or not path.is_file(): + raise ValueError("Historical source binding missing or symlinked: " + entry["path"]) + data = path.read_bytes() + if sha256(data).hexdigest() != entry["sha256"]: + raise ValueError("Historical source binding changed: " + entry["path"]) + result.append((entry, data.decode("utf-8"))) + return result + + +# Only the three existing full-task Cluster bindings have immutable DB rows. +# Other historical views retain their existing compatibility behavior. +FROZEN_CLUSTER_BINDINGS = tuple( + entry for entry in BINDINGS + if entry["repo"] == "railiance-cluster" and "tasks" in entry +) + + +def frozen_cluster_binding(binding): + """Validate a claimed frozen binding; unrelated requests remain ordinary.""" + matches = [entry for entry in FROZEN_CLUSTER_BINDINGS + if str(binding["workplan_id"]) == entry["uuid"] + or (binding["repo_slug"] == "railiance-cluster" + and (binding["relative_path"] == entry["path"] + or binding["filename"] == Path(entry["path"]).name))] + if not matches: + return None + if len(matches) != 1: + raise ValueError("frozen historical binding is ambiguous") + entry = matches[0] + expected = { + "workplan_id": entry["uuid"], "repo_slug": entry["repo"], + "filename": Path(entry["path"]).name, "relative_path": entry["path"], + "archived": True, "status": "finished", + } + if any((str(binding[key]) if key == "workplan_id" else binding[key]) != value + for key, value in expected.items()): + raise ValueError("frozen historical binding metadata changed") + return entry + + +def validate_frozen_cluster_rows(entry, parent, tasks, repo_id): + """Receiving requires the exact existing terminal closure, never repair.""" + if (parent is None or str(parent.id) != entry["uuid"] + or repo_id is None or parent.repo_id != repo_id + or parent.status not in {"done", "finished", "archived", "completed"}): + raise ValueError("frozen historical parent missing, foreign or nonterminal") + local = {str(task.id): task for task in tasks if task.workplan_id == parent.id} + if any(task["uuid"] not in local or local[task["uuid"]].status not in {"done", "cancel"} + for task in entry["tasks"]): + raise ValueError("frozen historical task missing, foreign or nonterminal") diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r5/state-hub/deploy/railiance/apps/charts/state-hub/Chart.yaml b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r5/state-hub/deploy/railiance/apps/charts/state-hub/Chart.yaml new file mode 100644 index 00000000..1cb3040f --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r5/state-hub/deploy/railiance/apps/charts/state-hub/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: state-hub +description: State Hub API service for private Railiance operation +type: application +version: 0.1.0 +appVersion: "main-51bc70d" diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r5/state-hub/deploy/railiance/apps/charts/state-hub/files/repo-classification.allowed.yaml b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r5/state-hub/deploy/railiance/apps/charts/state-hub/files/repo-classification.allowed.yaml new file mode 100644 index 00000000..2eec2808 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r5/state-hub/deploy/railiance/apps/charts/state-hub/files/repo-classification.allowed.yaml @@ -0,0 +1,127 @@ +# GENERATED — do not edit. +# Synced from the-custodian/canon/standards/repo-classification.allowed.yaml +# by scripts/sync_classification_allowed.py (CUST-WP-0067-T09). +# Canon is authoritative; this copy exists only so the container has one. +# Machine-readable allowed-values for the Repo Classification Standard. +# +# Single source of truth for the standard's controlled vocabularies, derived +# from canon/standards/repo-classification-standard_v1.0.md. Consumed by: +# - the per-repo .repo-classification.yaml linter (tools/validate_repo_classification.py) +# - the State Hub registration validator (CUST-WP-0050 T04) +# +# When the standard's vocabularies change, update this file and bump `version` +# to match the standard version. CUST-WP-0050 T01. + +standard: "Repo Classification Standard" +version: "1.1" +canon_id: "canon-repo-classification" + +# category — exactly 1 required (§5) +categories: + - experimental + - research + - project + - tooling + - product + - business + - publication + +# domain / secondary_domains — primary exactly 1; secondaries 0..n (§6) +domains: + - infotech + - financials + - communication + - consumer + - health + - industrials + - energy + - utilities + - materials + - realestate + - crypto + - agents + - space + - government + +# business_stake — 0..n; 2..6 recommended (§8) +business_stake: + - execution + - intelligence + - finance + - legal + - sales + - experience + - technology + - operations + - product + - people + - procurement + - sustainability + - automation + +# business_mechanics — 0..n, optional (§9) +business_mechanics: + - intention + - control + - coordination + - operation + - adaptation + +# capability_tags are intentionally OPEN-ENDED (§7): lowercase kebab-case, not +# restricted to this set. The families below are the standard's recommended +# canonical tags — used to warn on likely synonyms/typos, never to reject. +capability_families: + identity_and_access: + - identity + - authentication + - authorization + - access-control + - user-management + - tenancy + knowledge_and_evidence: + - knowledge + - citations + - evidence + - source-management + - traceability + - documentation + - decision-support + - history + - participation + platform_and_operations: + - platform + - deployment + - operations + - observability + - feature-control + - configuration + - orchestration + market_and_coordination: + - marketplace + - pricing + - monetization + - reputation + - challenges + - bounties + - collaboration + - coordination + product_and_lifecycle: + - product-development + - lifecycle + governance_and_control: + - governance + - policy + - compliance + - risk + - audit + - control + games_and_entertainment: + - game + - entertainment + +# Validation guidance (advisory bounds the linter applies as warnings) +guidance: + secondary_domains_max: 3 + business_stake_recommended_min: 2 + business_stake_recommended_max: 6 + capability_tag_pattern: "^[a-z0-9]+(-[a-z0-9]+)*$" diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r5/state-hub/deploy/railiance/apps/helm/state-hub-values.yaml b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r5/state-hub/deploy/railiance/apps/helm/state-hub-values.yaml new file mode 100644 index 00000000..02a4bd62 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r5/state-hub/deploy/railiance/apps/helm/state-hub-values.yaml @@ -0,0 +1,69 @@ +# Production values for the State Hub Railiance chart handoff. +# Non-secret values only. DATABASE_URL comes from the Secret `state-hub-env`. + +namespace: + create: false + +image: + repository: forgejo.coulomb.social/coulomb/state-hub + tag: "main-51bc70d@sha256:c802690ee1811b3d77df4077936f9cdced1385514e8673fcbc9232b84d645015" + +ingress: + enabled: false + +# MCP layer on central (CUST-WP-0067-T08). Enabled together with the image tag +# above: MCP_HOST landed in main-6c1262e, and enabling this on an older image +# would deploy a pod that binds loopback and never becomes reachable. +# ClusterIP only — no ingress, same reason the API's is disabled. +mcp: + enabled: true + resources: + requests: + # 2026-09-10: 1,078 observed 5-minute samples, p99 2.35m, peak 30.45m. + # Preserve the 500m limit for bursts; free 40m for the API rollout surge. + cpu: 10m + +config: + # This deployment is the authoritative hub. Callers verify this rather than + # trusting that whatever answered on a port is central (CUST-WP-0067-T03). + instanceRole: primary + instanceLabel: railiance01 + sbomNexusUrl: "http://sbom-nexus.sbom-nexus.svc.cluster.local:8010" + # Reversible T04 read cutover; set back to `legacy` to roll back. + sbomNexusReadMode: nexus + # Reversible T04 write cutover; set back to `legacy` to roll back future writes. + sbomNexusWriteMode: nexus + +resources: + # Restored to the chart's 250m on 2026-09-21. The earlier 100m was a workaround + # for a node with <250m unallocated; ~830m of Knative/Kourier requests were + # released that day (the-custodian docs/kubernetes-change-gate-decision.md). + # State Hub idles near 5m but bursts past 400m under concurrent agent load. + requests: + cpu: 250m + memory: 512Mi + limits: + cpu: 1000m + memory: 2Gi + +sweep: + # RMGR-WP-0005-T11: disabled while railiance01 checkouts still target the + # stale gitea-remote lineage. Re-enable only after the governed remote + # reconciliation and registrar preflight are complete. + enabled: false + # Present in the live release but previously missing here, so deploying from + # this file alone silently dropped it. Inert while enabled is false. + hostname: 239.62.205.92.host.secureserver.net + hostPath: /home/tegwick + sshHostPath: /home/tegwick/.ssh + +# Forge read credential (STATE-WP-0084-T02). Coordinates only — the token lives +# in OpenBao at platform/workloads/state-hub/forge-derivation and reaches the +# pod through Kubernetes auth, never through this file or a Secret. +forgeRead: + enabled: true + +# RPF-WP-0035-T04: dedicated ESO signing key, API-only explicit reference. +renamePreflight: + enabled: true + secretName: state-hub-rename-preflight diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r5/state-hub/docs/evidence/2026-10-17-cluster-historical-receiving-rollout.json b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r5/state-hub/docs/evidence/2026-10-17-cluster-historical-receiving-rollout.json new file mode 100644 index 00000000..c2f7f1c3 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r5/state-hub/docs/evidence/2026-10-17-cluster-historical-receiving-rollout.json @@ -0,0 +1,116 @@ +{ + "schema": "statehub.cluster-historical-receiving-delivered.v1", + "status": "verified", + "proposal_id": "STATE-WP-0095-T06-cluster-frozen-receiving-r1", + "approved_revision": 4, + "approved_digest_sha256": "sha256:63a02e8296d126c0158b7eb2c4ce79c7eeb6f8d6f61298d473c1065c7557ba32", + "approval_decision_id": "e3798419-8aa9-5425-8c08-20df6bd82a19", + "source_revision": "51bc70d1ff8f25450a8650d8e50caf053093a02e", + "readiness_source_revision": "086d79a2736e34b916bd7de3c8a65e2ea38a3d64", + "helm_revision": 76, + "image_reference": "forgejo.coulomb.social/coulomb/state-hub:main-51bc70d@sha256:c802690ee1811b3d77df4077936f9cdced1385514e8673fcbc9232b84d645015", + "expected_schema": "c4d5e6f7a8b9", + "health": { + "status": "ok", + "instance_role": "primary", + "instance_label": "railiance01", + "schema": { + "status": "ok", + "applied": "c4d5e6f7a8b9", + "expected": "c4d5e6f7a8b9" + } + }, + "pods_ready": true, + "historical_module_sha256": "sha256:e3c4bf965764b32a6df5d61c71272ee22569df9a9ddae2a3fc3df97ec554e0b2", + "historical_preservation_verified": true, + "parents": 3, + "tasks": 15, + "full_historical_rows_equal": true, + "native_rollback_probes": { + "foreign-holder": { + "full_historical_rows_restored": true, + "historical_binding_refused": true, + "native_outcome_status": "refused", + "rolled_back": true, + "status": "passed" + }, + "missing-task": { + "full_historical_rows_restored": true, + "historical_binding_refused": true, + "native_outcome_status": "refused", + "rolled_back": true, + "status": "passed" + }, + "nonterminal-parent": { + "full_historical_rows_restored": true, + "historical_binding_refused": true, + "native_outcome_status": "refused", + "rolled_back": true, + "status": "passed" + }, + "nonterminal-task": { + "full_historical_rows_restored": true, + "historical_binding_refused": true, + "native_outcome_status": "refused", + "rolled_back": true, + "status": "passed" + }, + "preserve": { + "full_historical_rows_equal": true, + "full_historical_rows_restored": true, + "native_outcome_status": "applied", + "rolled_back": true, + "status": "passed" + } + }, + "native_probe_commits": 0, + "fresh_transaction_full_rows_equal": true, + "source_receiving_gate_delivered": true, + "normal_cluster_publication_verified": false, + "registrar_admission_or_cluster_publication_not_inferred": true, + "authority_limits": [ + "Rollback probe SQL effects rolled back; foreign-holder case is historical-binding refusal, not separate global holder branch.", + "Actual historical native receiving is verified; owner-native TLS/private admission remains unresolved.", + "Migration schema remains additive; Helm rollback does not downgrade schema or stop an active hook Job." + ], + "evidence": [ + { + "path": "docs/changes/netkingdom-target-2026-10-17/receiver-rollout-r4/approval-decision.json", + "sha256": "sha256:b51554c004daa882f3709f67bdae7f2e939d611120383e61be04ec6969878134" + }, + { + "path": "docs/changes/netkingdom-target-2026-10-17/receiver-rollout-r4/helm-return.json", + "sha256": "sha256:61727deacf22d37da2d28cbaf04f5a3ddee0f9ff7571c77f1e85dba97963b572" + }, + { + "path": "docs/changes/netkingdom-target-2026-10-17/receiver-rollout-r4/delivered-runtime.json", + "sha256": "sha256:89681066e8fe139456163ebfafa3efe77ffae8534ea167219230b0a74367f129" + }, + { + "path": "docs/changes/netkingdom-target-2026-10-17/receiver-rollout-r4/native-refusal-probes.json", + "sha256": "sha256:673ccef965c820704dc310a1bf7832b583c30c3edfeb814528cf892555199172" + }, + { + "path": "docs/changes/netkingdom-target-2026-10-17/receiver-rollout-r4/before-full-row-hashes.json", + "sha256": "sha256:75cdd87eef79059ff3f484ed24ca69e285899cdf8d851cc4e472653c417eb0ee" + }, + { + "path": "docs/changes/netkingdom-target-2026-10-17/receiver-rollout-r4/after-full-row-hashes.json", + "sha256": "sha256:2156aad18f190384838c27e792706a156a7423391408afbc6107e6c28c5872d8" + } + ], + "later_ordinary_cluster_publication": { + "status": "failed_full_parent_row_preservation", + "changed_parent_hashes": 3, + "unchanged_task_hashes": 15, + "exact_changed_columns": "unknown_hash_only_baseline", + "original_parent_preimages": "unavailable_no_guessed_restoration", + "receipt": "the-custodian/docs/changes/netkingdom-target-2026-10-17/receiver-rollout-r4/post-publication-full-row-hashes-FAILED.json", + "T07": "wait", + "RMGR_WP_0019_T06": "wait", + "followup": [ + "STATE-WP-0095-T09", + "STATE-WP-0095-T10" + ] + } +} diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r5/state-hub/scripts/consistency_check.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r5/state-hub/scripts/consistency_check.py new file mode 100644 index 00000000..30cc11b4 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r5/state-hub/scripts/consistency_check.py @@ -0,0 +1,4853 @@ +#!/usr/bin/env python3 +"""consistency_check.py — ADR-001 consistency checking engine. + +Runs bidirectional checks between workplan files in a registered repo and the +state-hub DB. The file is always authoritative; the DB is the cache/index layer. + +Checks: + C-01 workplans-dir FAIL No workplans/ directory missing + C-02 workplan-parse FAIL No Workplan file cannot be parsed + C-03 workstream-stale-ref FAIL No state_hub_workstream_id in file not in DB + C-04 workstream-status-drift WARN Yes File status != DB status (file wins) + C-05 workstream-title-drift WARN Yes File title != DB title (file wins) + C-06 workstream-unlinked WARN Yes Workplan has no state_hub_workstream_id + --fix derives the UUID from the canonical record id and writes it back + C-07 orphan-db-active FAIL No Active DB workstream, no backing file + C-08 orphan-db-closed INFO No Finished/archived (or projection-retired) DB workstream, no file + C-09 workstream-repo-mismatch FAIL Yes DB workstream repo_id != file location + C-10 task-status-drift WARN Yes Task status differs between file and DB + C-11 task-unlinked WARN Yes Task block has no state_hub_task_id + C-12 orphan-db-task WARN Yes DB task in workstream has no file backing unless terminal in a closed workstream + C-13 workstream-auto-complete WARN Yes All DB tasks done but workstream still active + C-14 ghost-duplicate WARN No Active topic workstream with no repo_id matches a file-backed title — probable ghost from premature create_workstream() call + C-15 task-db-ahead WARN No DB task is terminal and file is not — report only; file wins (ADR-001) + C-16 repo-behind-remote WARN No Local repo is behind remote tracking branch — --fix skipped to avoid clobbering remote progress + C-17 repo-ahead-push-failed WARN No Local repo has unpushed commits and push failed — writes skipped to prevent runaway divergence + C-19 workstream-planning-drift WARN Yes planning_priority/planning_order differs between file and DB + C-20 workstream-dependency-missing WARN Yes Workplan dependency frontmatter missing from DB graph + C-22 task-description-drift WARN Yes Task description/content differs between file and DB + C-23 workstream-active-task-planning-status WARN Yes Workstream/workplan is planning while a task is progress or wait + C-24 repo-classification-missing WARN No Registered repo lacks a valid .repo-classification.yaml on disk + C-25 blocked-unblock-sweep WARN No Blocked workplan has unread inbox from counterpart — blocker may have cleared + C-26 workplan-id-prefix WARN No Workplan frontmatter id uses non-canonical prefix for this repo + C-27 workplan-id-collision WARN No Same workplan id appears in multiple repos + C-28 inbox-stale-unread WARN No Unread inbox messages older than INBOX_STALE_DAYS + C-29 inbox-work-unpromoted WARN No Stale unread message looks like a multi-step work request without a workplan + C-30 scope-current-state-stale WARN No SCOPE.md Current State contradicts live workplan statuses + C-31 work-record-unregistered WARN No YAML-block id matches no kind in the canon work-record type registry (sidetrack detector, CUST-WP-0060) + C-32 work-record-not-indexed WARN Yes kind: intake/decision YAML block has no hub id — not indexed in DB (registration, CUST-WP-0061-T02) + C-33 work-record-index-stale WARN Yes WORK-RECORDS.md missing or stale — generated per-repo index (CUST-WP-0061-T04) + C-34 quality-dor-ready WARN No status=ready without quality_dor DoR-Ok (STATE-WP-0077 soft) + C-35 repo-manager-conformance WARN No Repo Manager flavor/standards contract reports findings + C-36 work-record-flavor-unknown WARN No flavor is set but not in the closed list + C-37 residual-provenance-missing WARN No flavor: residual without origin/origin_ref + C-38 task-flavor-drift WARN Yes task flavor differs between file and DB (file wins) + C-39 task-wait-unqualified WARN No unrouted human wait (open non-residual) or wait task has none of task-block depends_on, needs_human: true, blocked_on: message-from:, wait_until: YYYY-MM-DD (CUST-WP-0074 rules 1/6) + C-40 task-wait-blocker-satisfied WARN No wait task whose depends_on targets are all terminal, whose decision_id is resolved, or whose wait_until has passed (CUST-WP-0074 rules 3/4) + C-41 task-wait-qualifier-drift WARN Yes needs_human/blocking_reason/decision_id/blocked_on/wait_until differ between file and DB (file wins) + C-42 workplan-blocked-no-wait WARN No blocked workplan has no task in status wait (CUST-WP-0074-T06) + C-43 decision-supersession-unlinked WARN No resolved decision of this repo says "supersedes " but the cited decision is not superseded_by it (STATE-WP-0097) + (C-20 also indexes task-block depends_on as workplan_dependencies rows with from_task_id set) + (finished¬DoD-Ok is listed by `statehub quality-debt`, not per-file C-warn — avoids historical flood) + +Usage: + python scripts/consistency_check.py --repo SLUG [--fix] [--no-writeback] [--json] [--api-base URL] + python scripts/consistency_check.py --all [--fix] [--no-writeback] [--json] [--api-base URL] + python scripts/consistency_check.py --here [PATH] [--fix] [--no-writeback] [--json] [--api-base URL] + +Identifiers (ADR-007 / RMGR-WP-0005): + C-06 / C-11 / C-32 derive UUIDv5 identifiers from the fleet namespace and + canonical record id. Any instance may reconcile; independent instances + produce the same identifier and byte-identical writeback. + +Exit codes (single-repo / local CLI): + 0 — clean (no FAILs or WARNs; INFOs are allowed) + 1 — one or more assessment FAILs or automation ERRORs (C-00) present + 2 — warnings-only strict CLI result (no FAILs, but WARNs present) + +Exit codes (--remote --all scheduled sweep): + 0 — automation completed and documented results (assessment failures OK) + 1 — automation error: API unreachable, repo list fetch failed, C-00 on + any repo, or other infrastructure fault that prevented a full run + +Assessment failures (C-01..C-24 except C-00) are repo hygiene gaps recorded +in the sweep report for later improvement. They do not fail the scheduler. + +Agent/operator Make wrappers normalize exit code 2 to shell success while +preserving visible warning output. Use the direct script when a machine caller +needs to distinguish clean from warnings-only. +""" +from __future__ import annotations + +import argparse +import os +import json +import re +import shutil +import socket +import subprocess +import sys +import time +import uuid +from contextlib import contextmanager +from dataclasses import dataclass, field +from collections import Counter +from datetime import date as _date, datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +_REPO_ROOT = Path(__file__).resolve().parent.parent +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) + +def _sweep_hostname() -> str: + """Hostname for host_paths resolution; override when pod hostname != fleet FQDN.""" + return os.environ.get("STATE_HUB_SWEEP_HOSTNAME", "").strip() or socket.gethostname() + + +from api.workplan_status import ( # noqa: E402 + CANONICAL_WORKSTREAM_STATUSES, + CLOSED_WORKSTREAM_STATUSES, + LEGACY_WORKSTREAM_STATUS_ALIASES, + OPEN_WORKSTREAM_STATUSES, + SUPPORTED_WORKSTREAM_STATUSES, + normalize_workstream_status as _normalize_workstream_status, + ready_review_status, +) +from api.classification import ( # noqa: E402 + CLASSIFICATION_FILENAME, + load_classification_file, + resolve_topic_domain_slug, +) +from api.services.lifecycle import should_activate_parent_for_active_tasks # noqa: E402 +from api.services.historical_projection import ( # noqa: E402 + BINDINGS as _HISTORICAL_BINDINGS, FROZEN_CLUSTER_BINDINGS, frozen_cluster_binding, +) +from api.task_status import ( # noqa: E402 + CANONICAL_TASK_STATUSES, + OPEN_TASK_STATUSES, + TASK_STATUS_ORDER, + TERMINAL_TASK_STATUSES, + normalize_task_status, +) +from api.work_record_flavor import ( # noqa: E402 + FLAVOR_PROMOTION_REASONS, + WORK_RECORD_FLAVORS, + is_residual_flavor, + normalize_flavor, + normalize_promotion_reason, +) + +_WORK_RECORD_NAMESPACE_UUID = uuid.UUID("a4058507-5c4a-5a00-ab06-fffa4fb46009") + + +def _derived_work_record_uuid(record_id: str) -> str: + return str(uuid.uuid5(_WORK_RECORD_NAMESPACE_UUID, f"helixforge\n{record_id}")) + +try: + import yaml as _yaml + _HAS_YAML = True +except ImportError: + _HAS_YAML = False + +try: + import httpx as _httpx + _HAS_HTTPX = True +except ImportError: + _HAS_HTTPX = False + + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_LEGACY_METER_COMPONENT = "state-hub.fix-consistency" + + +def _legacy_meter_headers() -> dict[str, str]: + return {"X-StateHub-Component": _LEGACY_METER_COMPONENT} + + +_TASK_BLOCK_RE = re.compile(r"```task\s*\n(.*?)\n```", re.DOTALL) +_HEADING_RE = re.compile(r"^(#{1,4})\s+(.+?)$", re.MULTILINE) +_ARCHIVED_WP_RE = re.compile(r"^\d{6}-(.+\.md)$") +VALID_WP_STATUSES = set(CANONICAL_WORKSTREAM_STATUSES) +SUPPORTED_WP_STATUSES = set(SUPPORTED_WORKSTREAM_STATUSES) +VALID_TASK_STATUSES = set(CANONICAL_TASK_STATUSES) +VALID_TASK_PRIORITIES = {"low", "medium", "high", "critical"} +VALID_DEP_RELATIONSHIPS = {"blocks", "starts_after", "informs", "soft_dependency"} +DEFAULT_REMOTE_ALL_MAX_SECONDS = int(os.environ.get("CONSISTENCY_REMOTE_ALL_MAX_SECONDS", "300")) +STALE_UNREAD_DAYS = int(os.environ.get("INBOX_STALE_DAYS", "3")) +_API_GET_RETRIES = int(os.environ.get("CONSISTENCY_API_GET_RETRIES", "3")) +_API_GET_RETRY_BASE_DELAY = float(os.environ.get("CONSISTENCY_API_GET_RETRY_DELAY", "0.5")) + +_WP_FILE_PREFIX_RE = re.compile(r"^([A-Za-z][A-Za-z0-9-]*-WP)-\d+", re.IGNORECASE) +_WP_FILE_BARE_RE = re.compile(r"^(WP)-\d+", re.IGNORECASE) +_WP_ID_PREFIX_RE = re.compile(r"^([A-Z][A-Z0-9-]*-WP)-\d+", re.IGNORECASE) +_WP_ID_BARE_RE = re.compile(r"^(WP)-\d+", re.IGNORECASE) +_BLOCKED_ON_RE = re.compile(r"^message-from:(?P.+)$") +_WAIT_UNTIL_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") +_WORK_REQUEST_RE = re.compile( + r"\b(implement|workplan|multi-?step|please\s+(implement|add|create|fix|build))\b", + re.IGNORECASE, +) +_OPEN_WORKPLAN_STATUSES = {"proposed", "ready", "active", "blocked", "backlog"} + + +def _check_repo_manager_conformance(repo_dir: Path, report: "ConsistencyReport") -> None: + """C-35: consume Repo Manager's conformance contract without reimplementing it.""" + configured = os.environ.get("RMGR_BIN", "").strip() + candidates = [configured] if configured else [] + candidates.extend( + [ + str(Path.home() / "repo-manager" / ".venv" / "bin" / "rmgr"), + shutil.which("rmgr") or "", + ] + ) + executable = next((item for item in candidates if item and Path(item).is_file()), None) + if executable is None: + report.add( + severity="INFO", + check_id="C-35", + message="Repo Manager conformance adapter unavailable; set RMGR_BIN or install rmgr", + fixable=False, + ) + return + try: + completed = subprocess.run( + [executable, "conform", "--path", str(repo_dir)], + capture_output=True, + text=True, + check=False, + timeout=30, + ) + payload = json.loads(completed.stdout) + except (OSError, subprocess.TimeoutExpired, json.JSONDecodeError) as exc: + report.add( + severity="INFO", + check_id="C-35", + message=f"Repo Manager conformance adapter could not run: {exc}", + fixable=False, + ) + return + for finding in payload.get("findings") or []: + severity = str(finding.get("severity") or "warning") + report.add( + severity="WARN" if severity in {"missing", "contradictory", "warning"} else "INFO", + check_id="C-35", + message=( + f"Repo Manager {severity}: {finding.get('path') or '(repository)'}: " + f"{finding.get('message') or finding.get('code') or 'conformance finding'}" + ), + file_path=finding.get("path"), + fixable=False, + ) + +# Legacy file/API aliases translated before comparison and PATCHing. +FILE_TO_DB_WORKSTREAM_STATUS: dict[str, str] = dict(LEGACY_WORKSTREAM_STATUS_ALIASES) + +# Ordinal ranking for task statuses used by the no-regress rule (T01/C-15). +STATUS_ORDER: dict[str, int] = dict(TASK_STATUS_ORDER) + + +def normalise_decision_type(meta: dict) -> str: + """Map a file's decision_type onto the hub's two values, made and pending. + + Repos record decided outcomes with words like ``approved``; the hub rejects + anything else with a 422, which left those decisions unindexed. + """ + value = str(meta.get("decision_type") or "").strip().lower() + if value in {"made", "pending"}: + return value + status = str(meta.get("status") or "").strip().lower() + if status == "resolved" or value in { + "approved", "accepted", "decided", "rejected", "declined", "resolved", + }: + return "made" + return "pending" + + +def historical_view_paths(repo_slug: str) -> set[str]: + """Repo-relative paths of bound historical views that share a parent row. + + A split view carries its parent's UUID but does not supply the parent's + fields; only the bound parent file does (see historical_projection). Letting + each view write the shared row made its status flip on every run. + """ + return { + entry["path"] + for entry in _HISTORICAL_BINDINGS + if entry["repo"] == repo_slug and entry["record_id"] != entry["parent_record_id"] + } + + +def normalise_workstream_status(status: str, *, has_started: bool | None = None) -> str: + """Translate a workplan file status value to its DB-canonical equivalent.""" + return _normalize_workstream_status(status, has_started=has_started) + + +def normalise_task_status(status: Any, *, default: str = "todo") -> str: + try: + return normalize_task_status(status, default=default) + except ValueError: + return default + + +def canonical_workplan_filename(path: Path) -> str: + """Return the workplan filename without an archive completion-date prefix.""" + return _ARCHIVED_WP_RE.sub(r"\1", path.name) + + +def workplan_display_path(repo_dir: Path, path: Path) -> str: + """Stable relative path for reports, including archived/ when applicable.""" + try: + return str(path.relative_to(repo_dir)) + except ValueError: + return path.name + + +def iter_workplan_files(workplans_dir: Path, include_archived: bool = True) -> list[Path]: + """Return active root workplans plus archived workplans when requested.""" + files = [ + path for path in sorted(workplans_dir.glob("*.md")) + if path.name not in _NON_WORKPLAN_WORKPLAN_FILES + ] + archived_dir = workplans_dir / "archived" + if include_archived and archived_dir.is_dir(): + files.extend(sorted(archived_dir.glob("*.md"))) + return files + + +# C-00 marks infrastructure/automation faults (API down, repo missing in DB). +# All other FAIL severities are assessment findings for follow-up. +_AUTOMATION_ERROR_CHECKS: frozenset[str] = frozenset({"C-00", "C-44"}) +_NON_WORKPLAN_WORKPLAN_FILES: frozenset[str] = frozenset({"README.md"}) + + +# --------------------------------------------------------------------------- +# Data types +# --------------------------------------------------------------------------- + +@dataclass +class Issue: + severity: str # FAIL | WARN | INFO + check_id: str # C-01 through C-12 + message: str + file_path: str = "" + db_id: str = "" + file_value: str = "" + db_value: str = "" + fixable: bool = False + _fix_context: dict = field(default_factory=dict, repr=False) + + +@dataclass +class ConsistencyReport: + repo_slug: str + repo_path: str + issues: list[Issue] = field(default_factory=list) + fixes_applied: list[str] = field(default_factory=list) + # Commit the workplans/ scan read (STATE-WP-0094): a full 40-hex SHA, or + # None with a machine-readable reason. Never a branch name or a guess. + source_revision: str | None = None + source_revision_reason: str | None = "not_scanned" + + def add(self, **kwargs) -> Issue: + issue = Issue(**kwargs) + self.issues.append(issue) + return issue + + @property + def failures(self) -> list[Issue]: + return [i for i in self.issues if i.severity == "FAIL"] + + @property + def automation_errors(self) -> list[Issue]: + return [ + i for i in self.issues + if i.severity == "FAIL" and i.check_id in _AUTOMATION_ERROR_CHECKS + ] + + @property + def assessment_failures(self) -> list[Issue]: + return [ + i for i in self.issues + if i.severity == "FAIL" and i.check_id not in _AUTOMATION_ERROR_CHECKS + ] + + @property + def warnings(self) -> list[Issue]: + return [i for i in self.issues if i.severity == "WARN"] + + @property + def infos(self) -> list[Issue]: + return [i for i in self.issues if i.severity == "INFO"] + + +@dataclass(frozen=True) +class RenormalizationRule: + check_id: str + invariant: str + detection: str + repair: str + test_anchor: str + + +RENORMALIZATION_RULES: tuple[RenormalizationRule, ...] = ( + RenormalizationRule( + check_id="C-23", + invariant="Planning-state workplans cannot contain active task work.", + detection=( + "Workplan status is proposed, ready, or backlog while a linked " + "task is progress or wait." + ), + repair=( + "ready/backlog: patch DB and file to active. " + "proposed: report only (ADR-007 decision 3)." + ), + test_anchor="tests/test_consistency_check.py::TestLifecycleRenormalization", + ), +) + + +RENORMALIZATION_NEXT_GUARD_CHECKLIST: tuple[str, ...] = ( + "Name the invariant and assign the next C-id.", + "Add rule metadata to RENORMALIZATION_RULES.", + "Add detection in check_repo before generic drift rules that could fight it.", + "Add the repair branch in fix_repo, using file writes only through helpers.", + "Add detection and repair tests under TestLifecycleRenormalization.", +) + + +@contextmanager +def run_lock(name: str): + """Hold a nonblocking process lock for long-running consistency modes.""" + try: + import fcntl + except ImportError: + yield True + return + + lock_path = Path(os.environ.get("CONSISTENCY_LOCK_DIR", "/tmp")) / f"custodian-{name}.lock" + lock_path.parent.mkdir(parents=True, exist_ok=True) + handle = lock_path.open("w", encoding="utf-8") + try: + try: + fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + yield False + return + handle.seek(0) + handle.truncate() + handle.write(f"{os.getpid()} {datetime.utcnow().isoformat()}Z\n") + handle.flush() + yield True + finally: + try: + fcntl.flock(handle, fcntl.LOCK_UN) + except OSError: + pass + handle.close() + + +# --------------------------------------------------------------------------- +# YAML / frontmatter parsing +# --------------------------------------------------------------------------- + +def _parse_yaml_block(raw: str) -> dict: + """Parse a YAML string; fallback to simple key:value if pyyaml unavailable.""" + if _HAS_YAML: + try: + return _yaml.safe_load(raw) or {} + except _yaml.YAMLError: + return {"_parse_error": True} + result: dict = {} + for line in raw.splitlines(): + if ":" in line and not line.startswith(" "): + k, _, v = line.partition(":") + result[k.strip()] = v.strip().strip('"').strip("'") + return result + + +def parse_frontmatter(text: str) -> tuple[dict, str]: + """Split YAML frontmatter from body. Returns ({}, text) if no frontmatter.""" + if not text.startswith("---"): + return {}, text + parts = text.split("---", 2) + if len(parts) < 3: + return {}, text + meta = _parse_yaml_block(parts[1].strip()) + return meta, parts[2] + + +def _clean_task_description(raw: str) -> str | None: + """Trim section chrome while preserving task markdown content.""" + lines = raw.splitlines() + while lines and not lines[0].strip(): + lines.pop(0) + while lines and not lines[-1].strip(): + lines.pop() + while lines and lines[-1].strip() in {"---", "***", "___"}: + lines.pop() + while lines and not lines[-1].strip(): + lines.pop() + while lines and lines[0].strip() in {"---", "***", "___"}: + lines.pop(0) + while lines and not lines[0].strip(): + lines.pop(0) + text = "\n".join(lines).strip() + return text or None + + +def parse_task_blocks(body: str) -> list[dict]: + """Extract task blocks, injecting title and markdown content from the task section.""" + headings = [ + (m.start(), len(m.group(1)), m.group(2).strip()) + for m in _HEADING_RE.finditer(body) + ] + task_matches = list(_TASK_BLOCK_RE.finditer(body)) + results = [] + for idx, m in enumerate(task_matches): + task = _parse_yaml_block(m.group(1).strip()) + prev = [(pos, level, text) for pos, level, text in headings if pos < m.start()] + prev_heading = prev[-1] if prev else None + if "title" not in task: + if prev_heading: + task["title"] = prev_heading[2] + + content_end = len(body) + if idx + 1 < len(task_matches): + content_end = min(content_end, task_matches[idx + 1].start()) + if prev_heading: + current_level = prev_heading[1] + next_peer_heading = next( + ( + pos + for pos, level, _text in headings + if pos > m.end() and level <= current_level + ), + None, + ) + if next_peer_heading is not None: + content_end = min(content_end, next_peer_heading) + else: + next_heading = next((pos for pos, _level, _text in headings if pos > m.end()), None) + if next_heading is not None: + content_end = min(content_end, next_heading) + + description = _clean_task_description(body[m.end():content_end]) + if description: + task["description"] = description + results.append(task) + return results + + +def get_tasks_from_workplan(meta: dict, body: str) -> list[dict]: + """Get tasks from workplan — handles both ```task``` blocks and tasks: YAML list.""" + blocks = parse_task_blocks(body) + if blocks: + return blocks + # Fallback: tasks embedded as a YAML list in frontmatter (activity-core style) + tasks = meta.get("tasks") + if isinstance(tasks, list): + return tasks + return [] + + +def _as_list(value: Any) -> list[str]: + if value is None: + return [] + if isinstance(value, list): + return [str(item).strip().strip('"') for item in value if str(item).strip()] + if isinstance(value, str): + return [item.strip().strip('"') for item in value.split(",") if item.strip()] + return [str(value).strip().strip('"')] + + +_TASK_RECORD_ID_RE = re.compile(r"-T\d{2,3}$") + + +def _dedupe_preserve(items: list[str]) -> list[str]: + seen: set[str] = set() + out: list[str] = [] + for item in items: + if item in seen: + continue + seen.add(item) + out.append(item) + return out + + +def _frontmatter_depends_on_workplans(meta: dict) -> list[str]: + """Canonical `depends_on` plus legacy `depends_on_workplans` alias.""" + combined = _as_list(meta.get("depends_on")) + _as_list(meta.get("depends_on_workplans")) + return _dedupe_preserve( + [item for item in combined if item and not _TASK_RECORD_ID_RE.search(item)] + ) + + +def _frontmatter_depends_on_tasks(meta: dict) -> list[str]: + from_alias = _as_list(meta.get("depends_on_tasks")) + from_depends_on = [ + item for item in _as_list(meta.get("depends_on")) if _TASK_RECORD_ID_RE.search(item) + ] + return _dedupe_preserve(from_alias + from_depends_on) + + +_UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I) + + +def _as_bool(value: Any) -> bool: + if isinstance(value, bool): + return value + return str(value).strip().lower() in {"true", "yes", "1", "on"} + + +def _task_wait_until(task: dict) -> str | None: + """``wait_until`` as an ISO date string, or None when absent or not a + ``YYYY-MM-DD`` date (PyYAML hands a bare date over as ``datetime.date``).""" + raw = task.get("wait_until") + if raw in (None, ""): + return None + if isinstance(raw, datetime): + raw = raw.date() + if isinstance(raw, _date): + return raw.isoformat() + text = str(raw).strip().strip('"').strip("'") + if not _WAIT_UNTIL_RE.match(text): + return None + try: + return _date.fromisoformat(text).isoformat() + except ValueError: + return None + + +def _task_wait_fields(task: dict) -> dict[str, Any]: + """File → hub fields that qualify a wait (CUST-WP-0074): needs_human, + blocking_reason, decision_id, blocked_on, wait_until. TaskCreate/TaskUpdate + require an intervention_note whenever needs_human is set; the blocking + reason is by rule 2 what the human must do, so it stands in when the block + has none.""" + needs_human = _as_bool(task.get("needs_human")) + blocking_reason = str(task.get("blocking_reason") or "").strip() or None + decision_id = str(task.get("decision_id") or "").strip().strip('"') or None + blocked_on = str(task.get("blocked_on") or "").strip().strip('"') or None + fields: dict[str, Any] = { + "needs_human": needs_human, + "blocking_reason": blocking_reason, + "decision_id": decision_id, + "blocked_on": blocked_on, + "wait_until": _task_wait_until(task), + } + if needs_human: + fields["intervention_note"] = ( + str(task.get("intervention_note") or "").strip() or blocking_reason + ) + return fields + + +_TASK_WAIT_FIELD_KEYS = ("needs_human", "blocking_reason", "decision_id", "blocked_on", "wait_until") + + +def _dependency_target_satisfied(record_id: str, status: str) -> bool: + """Rule 3: task targets are satisfied at done/cancel, workplan targets at + finished/archived.""" + if _TASK_RECORD_ID_RE.search(record_id): + return normalise_task_status(status, default="") in {"done", "cancel"} + return normalise_workstream_status(status) in CLOSED_WORKSTREAM_STATUSES + + +def _repo_decision_hub_ids(repo_dir: Path) -> dict[str, str]: + """Map decision record ids (e.g. CCR-2026-0004) to the hub uuid C-32 wrote + back into their YAML block.""" + out: dict[str, str] = {} + for md in sorted(repo_dir.rglob("*.md")): + if any(part in _WORK_RECORD_SKIP_DIRS for part in md.parts): + continue + try: + text = md.read_text(errors="replace") + except OSError: + continue + if "state_hub_decision_id" not in text: + continue + for block in _YAML_ONLY_FENCE_RE.findall(text): + meta = _parse_yaml_block(block.strip()) + if not isinstance(meta, dict) or meta.get("_parse_error"): + continue + rid = str(meta.get("id", "")).strip() + hub_id = str(meta.get("state_hub_decision_id", "")).strip().strip('"') + if rid and hub_id and hub_id not in ("~", "null", "None", "none"): + out[rid] = hub_id + return out + + +def _as_int_or_none(value: Any) -> int | None: + if value in (None, "", "~", "null", "None", "none"): + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +# --------------------------------------------------------------------------- +# File update helpers +# --------------------------------------------------------------------------- + +def _add_frontmatter_field(file_path: Path, key: str, value: str) -> None: + """Insert key: "value" into frontmatter before the closing --- line.""" + text = file_path.read_text(encoding="utf-8") + lines = text.split("\n") + close_idx = None + for i, line in enumerate(lines[1:], 1): + if line.strip() == "---": + close_idx = i + break + if close_idx is None: + return + lines.insert(close_idx, f'{key}: "{value}"') + file_path.write_text("\n".join(lines), encoding="utf-8") + + +def _patch_frontmatter_field(file_path: Path, key: str, value: str) -> bool: + """Update or insert a scalar frontmatter field without rewriting the file.""" + text = file_path.read_text(encoding="utf-8") + if not text.startswith("---"): + return False + lines = text.split("\n") + close_idx = None + for i, line in enumerate(lines[1:], 1): + if line.strip() == "---": + close_idx = i + break + if close_idx is None: + return False + + new_line = f"{key}: {value}" + for i in range(1, close_idx): + if re.match(rf"^\s*{re.escape(key)}\s*:", lines[i]): + if lines[i] == new_line: + return False + lines[i] = new_line + file_path.write_text("\n".join(lines), encoding="utf-8") + return True + + lines.insert(close_idx, new_line) + file_path.write_text("\n".join(lines), encoding="utf-8") + return True + + +def _inject_task_id_into_block( + file_path: Path, field_name: str, field_value: str, match_id: str +) -> bool: + """Inject state_hub_task_id into the ```task``` block whose id == match_id.""" + text = file_path.read_text(encoding="utf-8") + + def _replace(m: re.Match) -> str: + block_content = m.group(1) + task_meta = _parse_yaml_block(block_content.strip()) + if str(task_meta.get("id", "")) != match_id: + return m.group(0) + existing_val = task_meta.get(field_name) + if existing_val is not None and str(existing_val).strip() not in ("", "~", "null", "None", "none"): + return m.group(0) + # Replace existing null/~ line if present, otherwise append + new_content = re.sub( + rf"^{re.escape(field_name)}:.*$", + f'{field_name}: "{field_value}"', + block_content, + flags=re.MULTILINE, + ) + if new_content == block_content: + new_content = block_content.rstrip() + f"\n{field_name}: \"{field_value}\"" + return f"```task\n{new_content}\n```" + + new_text = _TASK_BLOCK_RE.sub(_replace, text) + if new_text != text: + file_path.write_text(new_text, encoding="utf-8") + return True + return False + + +def _inject_task_id_frontmatter_list( + file_path: Path, field_value: str, match_id: str +) -> bool: + """Inject state_hub_task_id into a task entry in frontmatter tasks: list.""" + if not _HAS_YAML: + return False + import yaml + text = file_path.read_text(encoding="utf-8") + meta, body = parse_frontmatter(text) + tasks = meta.get("tasks", []) + changed = False + for t in tasks: + if str(t.get("id", "")) == match_id and "state_hub_task_id" not in t: + t["state_hub_task_id"] = field_value + changed = True + if not changed: + return False + try: + new_meta_str = yaml.dump(meta, allow_unicode=True, default_flow_style=False) + parts = text.split("---", 2) + if len(parts) < 3: + return False + new_text = f"---\n{new_meta_str}---{parts[2]}" + file_path.write_text(new_text, encoding="utf-8") + return True + except Exception: + return False + + +# --------------------------------------------------------------------------- +# API helpers +# --------------------------------------------------------------------------- + +_API_CLIENTS: dict[tuple[str, object], Any] = {} + + +def _api_client(api_base: str) -> Any: + """Reuse one keep-alive pool per API/client implementation for this run.""" + key = (api_base.rstrip("/"), _httpx.Client) + client = _API_CLIENTS.get(key) + if client is None: + client = _httpx.Client( + base_url=api_base, + timeout=10.0, + follow_redirects=True, + headers=_legacy_meter_headers(), + ) + _API_CLIENTS[key] = client + return client + + +def _api_get( + api_base: str, + path: str, + params: dict | None = None, + *, + return_error: bool = False, +) -> Any: + if not _HAS_HTTPX: + return {"_error": "httpx is not installed"} if return_error else None + # Only append trailing slash to the path component, not to query strings + if "?" not in path and not path.endswith("/"): + path += "/" + filtered = {k: v for k, v in (params or {}).items() if v is not None} + last_error: Exception | None = None + for attempt in range(_API_GET_RETRIES): + try: + r = _api_client(api_base).get(path, params=filtered if filtered else None) + r.raise_for_status() + return r.json() + except _httpx.HTTPStatusError as exc: + if exc.response.status_code == 404: + return None + last_error = exc + if exc.response.status_code >= 500 and attempt < _API_GET_RETRIES - 1: + time.sleep(_API_GET_RETRY_BASE_DELAY * (attempt + 1)) + continue + if return_error: + return {"_error": str(exc)} + return None + except (_httpx.TimeoutException, _httpx.ConnectError, _httpx.NetworkError) as exc: + last_error = exc + if attempt < _API_GET_RETRIES - 1: + time.sleep(_API_GET_RETRY_BASE_DELAY * (attempt + 1)) + continue + if return_error: + return {"_error": str(exc)} + return None + except Exception as exc: + last_error = exc + if return_error: + return {"_error": str(exc)} + return None + if return_error and last_error is not None: + return {"_error": str(last_error)} + return None + + +def _api_patch(api_base: str, path: str, body: dict) -> Any: + if not _HAS_HTTPX: + return None + if not path.endswith("/"): + path += "/" + try: + r = _api_client(api_base).patch(path, json=body) + r.raise_for_status() + return r.json() + except Exception as exc: + # Return a sentinel dict so callers can distinguish "API error" from "success" + # and report it rather than silently dropping the fix. + return {"_error": str(exc)} + + +def _api_put(api_base: str, path: str, body: dict) -> Any: + if not _HAS_HTTPX: + return {"_error": "httpx is not installed"} + if not path.endswith("/"): + path += "/" + try: + r = _api_client(api_base).put(path, json=body, timeout=30.0) + r.raise_for_status() + return r.json() + except Exception as exc: + return {"_error": str(exc)} + + +_FROM_TASK_ID_SUPPORT: dict[str, bool] = {} + + +def _hub_accepts_from_task_id(api_base: str) -> bool: + """True when the hub's dependency-create schema declares from_task_id. + + Cached per api_base. An unreadable schema counts as unsupported so the + fixer never writes a task-scoped edge into a hub that would drop the + attribution silently. + """ + if api_base not in _FROM_TASK_ID_SUPPORT: + # Not via _api_get: it appends a trailing slash, which /openapi.json rejects. + try: + r = _api_client(api_base).get("/openapi.json") + spec = r.json() if r.status_code == 200 else {} + except Exception: + spec = {} + schemas = spec.get("components", {}).get("schemas", {}) if isinstance(spec, dict) else {} + _FROM_TASK_ID_SUPPORT[api_base] = any( + "from_task_id" in (schemas.get(name) or {}).get("properties", {}) + for name in ("WorkplanDependencyCreate", "WorkstreamDependencyCreate") + ) + return _FROM_TASK_ID_SUPPORT[api_base] + + +def _api_post(api_base: str, path: str, body: dict) -> Any: + if not _HAS_HTTPX: + return {"_error": "httpx is not installed"} + if not path.endswith("/"): + path += "/" + try: + r = _api_client(api_base).post(path, json=body) + r.raise_for_status() + return r.json() + except _httpx.HTTPStatusError as exc: + detail = exc.response.text + if len(detail) > 500: + detail = detail[:497] + "..." + return {"_error": f"{exc.response.status_code} {exc.response.reason_phrase}: {detail}"} + except Exception as exc: + return {"_error": str(exc)} + + +# --------------------------------------------------------------------------- +# Core check engine +# --------------------------------------------------------------------------- + +def resolve_repo_path(repo: dict, override: str | None = None) -> str: + """Resolve the local filesystem path for a repo on the current machine. + + Priority: + 1. Explicit --repo-path CLI override + 2. host_paths[current_hostname] — per-machine path registered via POST /repos/{slug}/paths/ + 3. local_path — legacy single-path field (backward compat) + """ + if override: + return override + hostname = _sweep_hostname() + host_paths = repo.get("host_paths") or {} + return host_paths.get(hostname) or repo.get("local_path") or "" + + +def _infer_slug_from_path(api_base: str, path: str) -> "tuple[str, str] | None": + """Identify a registered repo from a local checkout path. + + Strategy (in order): + 1. Root-commit fingerprint — ``git rev-list --max-parents=0 HEAD`` produces + the same SHA-1 on every clone, independent of remote URL or checkout path. + Looked up via ``GET /repos/by-fingerprint?hash=``. + 2. Remote URL fallback — exact string match against ``remote_url`` field. + Less reliable across machines (SSH aliases, HTTP vs HTTPS, etc.) but + useful when fingerprint is not yet stored. + + Returns ``(slug, git_root)`` on success, ``None`` if no match found. + """ + try: + git_root = subprocess.check_output( + ["git", "rev-parse", "--show-toplevel"], + cwd=path, stderr=subprocess.DEVNULL, text=True, + ).strip() + except (subprocess.CalledProcessError, FileNotFoundError, OSError): + return None + + # Strategy 1: fingerprint lookup (most reliable) + try: + fingerprint = subprocess.check_output( + ["git", "rev-list", "--max-parents=0", "HEAD"], + cwd=git_root, stderr=subprocess.DEVNULL, text=True, + ).strip() + except (subprocess.CalledProcessError, FileNotFoundError, OSError): + fingerprint = "" + + # Get local remote URL once — used for both disambiguation and fallback + try: + remote_url = subprocess.check_output( + ["git", "remote", "get-url", "origin"], + cwd=git_root, stderr=subprocess.DEVNULL, text=True, + ).strip() + except (subprocess.CalledProcessError, FileNotFoundError, OSError): + remote_url = "" + + if fingerprint: + # Try fingerprint + remote URL for precise match + if remote_url: + import urllib.parse as _up + candidates = _api_get( + api_base, + f"/repos/by-fingerprint?hash={fingerprint}&remote_url={_up.quote(remote_url, safe='')}", + ) + if isinstance(candidates, list) and len(candidates) == 1: + return candidates[0]["slug"], git_root + + # Fingerprint alone (works when repos don't share ancestry) + candidates = _api_get(api_base, f"/repos/by-fingerprint?hash={fingerprint}") + if isinstance(candidates, list): + if len(candidates) == 1: + return candidates[0]["slug"], git_root + if len(candidates) > 1: + # Disambiguate: prefer the repo whose slug appears in the git_root path + for repo in candidates: + if repo["slug"] in git_root: + return repo["slug"], git_root + # Can't disambiguate — return first match with a warning + print( + f" WARNING: {len(candidates)} repos share fingerprint {fingerprint[:12]}… " + f"— using '{candidates[0]['slug']}'. " + "Set remote_url on each repo for accurate matching.", + file=sys.stderr, + ) + return candidates[0]["slug"], git_root + + # Strategy 2: remote URL exact match (fallback) + if remote_url: + import urllib.parse as _up + repo = _api_get(api_base, f"/repos/by-remote?url={_up.quote(remote_url, safe='')}") + if repo and isinstance(repo, dict) and "slug" in repo: + return repo["slug"], git_root + + return None + + +_WORK_RECORD_FENCE_RE = re.compile(r"```(?:yaml|task)\n(.*?)```", re.S) +_WORK_RECORD_ID_LINE_RE = re.compile(r"^id:\s*[\"']?([A-Za-z0-9_-]+)[\"']?\s*$", re.M) +_WORK_RECORD_ID_LIKE_RE = re.compile(r"^[A-Z][A-Z0-9]*(-[A-Z0-9]+)+$") +_WORK_RECORD_SKIP_DIRS = frozenset({".git", "node_modules", ".venv", "history"}) + + +def _load_work_record_kind_registry() -> list[dict] | None: + """Load [{kind, patterns}] from the canon work-record type registry. + + Location: $WORK_RECORD_REGISTRY, else ~/the-custodian/canon/standards/ + work-record-types.yaml. Returns None (checks skipped) when unavailable. + Kind-aware: shared by C-31 (flat sidetrack detection) and C-32 + (kind-aware registration — needs to know intake vs. decision vs. + engagement to pick the right hub entity). + """ + if not _HAS_YAML: + return None + candidates = [] + env = os.environ.get("WORK_RECORD_REGISTRY") + if env: + candidates.append(Path(env)) + candidates.append( + Path.home() / "the-custodian/canon/standards/work-record-types.yaml") + for path in candidates: + if not path.is_file(): + continue + try: + reg = _yaml.safe_load(path.read_text()) + kinds = [] + for kind in reg.get("kinds", []): + patterns = [re.compile(p) for p in kind.get("id_patterns", [])] + patterns += [re.compile(lp["pattern"]) + for lp in kind.get("legacy_patterns", [])] + kinds.append({"kind": kind["kind"], "patterns": patterns}) + return kinds + except Exception: + return None + return None + + +def _load_work_record_patterns() -> list[re.Pattern] | None: + """Flat id-pattern list across all kinds — used by C-31's sidetrack + detector, which only needs to know "is this id registered at all", + not which kind it belongs to.""" + kinds = _load_work_record_kind_registry() + if kinds is None: + return None + return [p for k in kinds for p in k["patterns"]] + + +def _check_unregistered_work_records(repo_dir: Path, report: "ConsistencyReport") -> None: + """C-31: warn on YAML-block ids matching no registered work-record kind. + + Detection only (CUST-WP-0060-T05); registration of non-workplan kinds is + a later stage. An unregistered id scheme is a coordination sidetrack that + will need manual reintegration — surface it the day it appears. + """ + patterns = _load_work_record_patterns() + if patterns is None: + return + seen: set[str] = set() + for md in sorted(repo_dir.rglob("*.md")): + if any(part in _WORK_RECORD_SKIP_DIRS for part in md.parts): + continue + try: + text = md.read_text(errors="replace") + except OSError: + continue + for body in _WORK_RECORD_FENCE_RE.findall(text): + for rid in _WORK_RECORD_ID_LINE_RE.findall(body): + if rid in seen or "NNN" in rid or rid.endswith("-TNN"): + continue + if not _WORK_RECORD_ID_LIKE_RE.match(rid): + continue + if any(p.match(rid) for p in patterns): + continue + seen.add(rid) + report.add( + severity="WARN", + check_id="C-31", + message=( + f"[{md.relative_to(repo_dir)}] id '{rid}' matches no " + f"kind in the canon work-record type registry — " + f"unregistered species become sidetracks needing " + f"manual reintegration (work-record-types_v0.1)" + ), + fixable=False, + ) + + +_YAML_ONLY_FENCE_RE = re.compile(r"```yaml\n(.*?)\n```", re.S) +_WORK_RECORD_ID_FIELD_BY_KIND = { + "intake": "state_hub_intake_id", + "decision": "state_hub_decision_id", + "engagement": "state_hub_engagement_id", +} +_WORK_RECORD_CREATE_ENDPOINT_BY_KIND = { + "intake": "/intakes", + "decision": "/decisions", +} + + +def _classify_work_record_kind(record_id: str, kind_registry: list[dict]) -> str | None: + for k in kind_registry: + if any(p.match(record_id) for p in k["patterns"]): + return k["kind"] + return None + + +def _inject_yaml_block_field( + file_path: Path, field_name: str, field_value: str, match_id: str +) -> bool: + """Inject a field into the ```yaml``` block whose id == match_id. + Mirrors _inject_task_id_into_block, adapted for fenced yaml blocks + (intake/decision/engagement records live as ```yaml, not ```task).""" + text = file_path.read_text(encoding="utf-8") + + def _replace(m: re.Match) -> str: + block_content = m.group(1) + meta = _parse_yaml_block(block_content.strip()) + if not isinstance(meta, dict) or str(meta.get("id", "")) != match_id: + return m.group(0) + existing_val = meta.get(field_name) + if existing_val is not None and str(existing_val).strip() not in ("", "~", "null", "None", "none"): + return m.group(0) + new_content = re.sub( + rf"^{re.escape(field_name)}:.*$", + f'{field_name}: "{field_value}"', + block_content, + flags=re.MULTILINE, + ) + if new_content == block_content: + new_content = block_content.rstrip() + f"\n{field_name}: \"{field_value}\"" + return f"```yaml\n{new_content}\n```" + + new_text = _YAML_ONLY_FENCE_RE.sub(_replace, text) + if new_text != text: + file_path.write_text(new_text, encoding="utf-8") + return True + return False + + +def _check_work_record_registration( + repo_dir: Path, + report: "ConsistencyReport", + api_base: str = "http://127.0.0.1:8000", + bootstrap_empty_projection: bool = False, +) -> None: + """C-32: register kind: intake / kind: decision YAML blocks found + anywhere in the repo against the hub (CUST-WP-0061-T02, work-record + stage 3) — writes state_hub_intake_id / state_hub_decision_id back into + the source block, same write-back pattern as C-06 for workplans. + + kind: engagement has no hub entity yet (deferred; reported INFO, not + fixable) — building one is a separate stage-3 follow-on, not this task. + """ + kind_registry = _load_work_record_kind_registry() + if kind_registry is None: + return + for md in sorted(repo_dir.rglob("*.md")): + if any(part in _WORK_RECORD_SKIP_DIRS for part in md.parts): + continue + try: + text = md.read_text(errors="replace") + except OSError: + continue + for block in _YAML_ONLY_FENCE_RE.findall(text): + meta = _parse_yaml_block(block.strip()) + if not isinstance(meta, dict) or meta.get("_parse_error"): + continue + rid = str(meta.get("id", "")).strip() + if not rid or "NNN" in rid: + continue + kind = _classify_work_record_kind(rid, kind_registry) + if kind not in _WORK_RECORD_ID_FIELD_BY_KIND: + continue + + id_field = _WORK_RECORD_ID_FIELD_BY_KIND[kind] + existing = str(meta.get(id_field, "")).strip().strip('"') + if existing and existing not in ("~", "null", "None", "none"): + if not bootstrap_empty_projection: + continue + endpoint = _WORK_RECORD_CREATE_ENDPOINT_BY_KIND.get(kind) + if endpoint and _api_get(api_base, f"{endpoint}/{existing}") is not None: + continue + + rel = md.relative_to(repo_dir) + if kind not in _WORK_RECORD_CREATE_ENDPOINT_BY_KIND: + report.add( + severity="INFO", check_id="C-32", + message=( + f"[{rel}] engagement '{rid}' has no hub entity yet — " + f"registration deferred (CUST-WP-0061 stage-3 seed)" + ), + fixable=False, + ) + continue + + report.add( + severity="WARN", check_id="C-32", + message=( + f"[{rel}] {kind} '{rid}' is absent from the explicitly empty projection" + if existing + else f"[{rel}] {kind} '{rid}' has no {id_field} — not indexed in DB" + ), + file_path=str(rel), + file_value=rid, + fixable=True, + _fix_context={ + "md_path": md, + "kind": kind, + "rid": rid, + "meta": meta, + "desired_record_id": existing or None, + }, + ) + + +_WORK_RECORD_INDEX_NAME = "WORK-RECORDS.md" +_WORK_RECORD_INDEX_KIND_ORDER = { + "workplan": 0, "task": 1, "intake": 2, "decision": 3, "engagement": 4, +} + + +def _generate_work_record_index(repo_dir: Path, repo_slug: str) -> str | None: + """CUST-WP-0061-T04: the generated per-repo work-record index — every + registered work record (workplan, task, intake, decision, engagement) + with kind/id/status/lane/source, derivable purely from repo files (no + hub query — matches the ADR-001 rebuild principle). Full transclusion + rendering (markitect integration) is a follow-on; this is the flat + first cut. Returns None (no file written) when the repo has no work + records at all, to avoid clutter in small repos. + """ + rows: list[tuple[str, str, str, str, str]] = [] # kind, id, status, lane, source + + workplans_dir = repo_dir / "workplans" + if workplans_dir.is_dir(): + for wp_file in iter_workplan_files(workplans_dir, include_archived=False): + try: + text = wp_file.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + meta, body = parse_frontmatter(text) + wp_id = str(meta.get("id", "")).strip() + if not wp_id: + continue + source = str(wp_file.relative_to(repo_dir)) + rows.append(("workplan", wp_id, str(meta.get("status", "")).strip() or "—", "—", source)) + for task in get_tasks_from_workplan(meta, body): + if task.get("_parse_error"): + continue + t_id = str(task.get("id", "")).strip() + if not t_id: + continue + rows.append(("task", t_id, str(task.get("status", "")).strip() or "—", "—", source)) + + kind_registry = _load_work_record_kind_registry() + if kind_registry: + for md in sorted(repo_dir.rglob("*.md")): + if any(part in _WORK_RECORD_SKIP_DIRS for part in md.parts): + continue + if md.name == _WORK_RECORD_INDEX_NAME: + continue + try: + text = md.read_text(errors="replace") + except OSError: + continue + for block in _YAML_ONLY_FENCE_RE.findall(text): + meta = _parse_yaml_block(block.strip()) + if not isinstance(meta, dict) or meta.get("_parse_error"): + continue + rid = str(meta.get("id", "")).strip() + if not rid or "NNN" in rid: + continue + kind = _classify_work_record_kind(rid, kind_registry) + if kind not in ("intake", "decision", "engagement"): + continue + status = str(meta.get("status", "")).strip() or "—" + lane = str(meta.get("lane", "")).strip() or "—" + source = str(md.relative_to(repo_dir)) + rows.append((kind, rid, status, lane, source)) + + if not rows: + return None + + rows.sort(key=lambda r: (_WORK_RECORD_INDEX_KIND_ORDER.get(r[0], 99), r[4], r[1])) + return _work_record_index_document(repo_slug, rows) + + +def _work_record_index_document( + repo_slug: str, rows: list[tuple[str, str, str, str, str]] +) -> str: + """Render WORK-RECORDS.md. An empty row list still produces the header + and table so a leftover index can be rewritten after every live record + is archived or removed. + """ + lines = [ + f"# Work Records — {repo_slug}", + "", + "> Generated by `statehub fix-consistency` (CUST-WP-0061-T04, work-record", + "> stage 3). Do not edit by hand — edit the source file/block listed for", + "> each record and re-run fix-consistency to refresh this index. Archived", + "> workplans are omitted; closed decisions/intakes/engagements stay listed", + "> so recently-resolved work is still visible. [auto]", + "", + "| Kind | ID | Status | Lane | Source |", + "| --- | --- | --- | --- | --- |", + ] + lines.extend( + f"| {kind} | {rid} | {status} | {lane} | {source} |" + for kind, rid, status, lane, source in rows + ) + lines.append("") + return "\n".join(lines) + + +def _check_work_record_index_freshness( + repo_dir: Path, repo_slug: str, report: "ConsistencyReport" +) -> None: + """C-33: WORK-RECORDS.md missing or stale relative to the current + file-derived work-record set.""" + content = _generate_work_record_index(repo_dir, repo_slug) + index_file = repo_dir / _WORK_RECORD_INDEX_NAME + existing = index_file.read_text(encoding="utf-8") if index_file.is_file() else None + if content is None: + if existing is None: + return + empty = _work_record_index_document(repo_slug, []) + if existing == empty: + return + content = empty + elif existing == content: + return + report.add( + severity="WARN", check_id="C-33", + message=( + f"{_WORK_RECORD_INDEX_NAME} is missing or stale — regenerate via " + f"fix-consistency (CUST-WP-0061-T04 generated work-record index)" + ), + file_path=_WORK_RECORD_INDEX_NAME, + fixable=True, + _fix_context={"index_file": index_file, "content": content}, + ) + + +def check_repo( + api_base: str, + repo_slug: str, + repo_path_override: str | None = None, + bootstrap_empty_projection: bool = False, +) -> ConsistencyReport: + """Run all consistency checks for a registered repo.""" + repo = _api_get(api_base, f"/repos/{repo_slug}", return_error=True) + if isinstance(repo, dict) and "_error" in repo: + report = ConsistencyReport(repo_slug=repo_slug, repo_path=repo_path_override or "(unknown)") + report.add( + severity="FAIL", check_id="C-00", + message=( + f"Could not query State Hub API at {api_base}: {repo['_error']}" + ), + fixable=False, + ) + return report + if repo is None: + report = ConsistencyReport(repo_slug=repo_slug, repo_path="(unknown)") + report.add( + severity="FAIL", check_id="C-00", + message=f"Repo '{repo_slug}' not found in state-hub DB", + fixable=False, + ) + return report + + # Repository aliases are accepted at the API boundary, but every new + # binding, inbox write, and generated file must use the current identity. + repo_slug = str(repo.get("canonical_slug") or repo.get("slug") or repo_slug) + repo_id: str = repo["id"] + repo_path: str = resolve_repo_path(repo, repo_path_override) + report = ConsistencyReport(repo_slug=repo_slug, repo_path=repo_path) + + if bootstrap_empty_projection: + projected = _api_get(api_base, "/workplans", {"repo_id": repo_id}, return_error=True) + if not isinstance(projected, list): + report.add( + severity="FAIL", + check_id="C-36", + message="Could not prove that the repository projection is empty", + fixable=False, + ) + return report + if projected: + report.add( + severity="FAIL", + check_id="C-36", + message=( + "Empty-projection bootstrap refused: repository already has " + f"{len(projected)} projected workplan(s)" + ), + fixable=False, + ) + return report + + if not repo_path: + report.add( + severity="FAIL", check_id="C-00", + message=f"Repo '{repo_slug}' has no local_path — cannot check workplan files", + fixable=False, + ) + _check_orphan_db(api_base, repo_id, set(), report) + return report + + repo_dir = Path(repo_path) + workplans_dir = repo_dir / "workplans" + repo_market_domain = str(repo.get("domain_slug") or "").strip() + + # C-24: repo classification file missing or invalid (always WARN — migration rows too) + class_data, class_errors, class_warnings = load_classification_file(repo_dir) + if class_data is None: + classified_by = str(repo.get("classified_by") or "").strip() + if class_errors: + detail = "; ".join(class_errors) + else: + detail = f"{CLASSIFICATION_FILENAME} missing on disk" + if classified_by == "migration": + detail = f"{detail} (DB row is migration-derived — commit a human-reviewed file when ready)" + report.add( + severity="WARN", + check_id="C-24", + message=f"Repo classification gap: {detail}", + fixable=False, + ) + for warning in class_warnings: + report.add( + severity="WARN", + check_id="C-24", + message=f"Repo classification advisory: {warning}", + fixable=False, + ) + + # C-35: consume the repository-standard answer from Repo Manager. + _check_repo_manager_conformance(repo_dir, report) + + # C-31: work-record sidetrack detector (canon work-record-types registry) + _check_unregistered_work_records(repo_dir, report) + + # C-32: intake/decision work-record registration (CUST-WP-0061-T02) + _check_work_record_registration( + repo_dir, + report, + api_base=api_base, + bootstrap_empty_projection=bootstrap_empty_projection, + ) + + # C-33: generated per-repo work-record index (CUST-WP-0061-T04) + _check_work_record_index_freshness(repo_dir, repo_slug, report) + + # C-01: workplans/ directory missing + if not workplans_dir.is_dir(): + report.add( + severity="FAIL", check_id="C-01", + message=( + "workplans/ directory missing — ADR-001 requires workplan files " + "at /workplans/-.md" + ), + fixable=False, + ) + _check_orphan_db(api_base, repo_id, set(), report) + return report + + # Bind the scan to the commit it reads; re-verified after C-25 below. + report.source_revision, report.source_revision_reason = scan_source_revision(repo_dir) + + # Parse workplan files + workplan_infos: list[tuple[Path, dict, str]] = [] + file_ws_ids: dict[str, tuple[Path, dict, str]] = {} # ws_id → (file, meta, body) + active_file_ws_ids: set[str] = set() + + for wp_file in iter_workplan_files(workplans_dir): + try: + text = wp_file.read_text(encoding="utf-8") + except OSError as e: + report.add(severity="FAIL", check_id="C-02", + message=f"Cannot read file: {e}", file_path=workplan_display_path(repo_dir, wp_file)) + continue + if not text.startswith("---"): + report.add(severity="FAIL", check_id="C-02", + message="No YAML frontmatter found", file_path=workplan_display_path(repo_dir, wp_file)) + continue + meta, body = parse_frontmatter(text) + if not meta or meta.get("_parse_error"): + report.add(severity="FAIL", check_id="C-02", + message="YAML frontmatter parse error", file_path=workplan_display_path(repo_dir, wp_file)) + continue + workplan_infos.append((wp_file, meta, body)) + ws_id = str(meta.get("state_hub_workstream_id", "")).strip().strip('"') + if ws_id: + file_ws_ids[ws_id] = (wp_file, meta, body) + if wp_file.parent == workplans_dir: + active_file_ws_ids.add(ws_id) + + workplan_id_to_ws_id: dict[str, str] = {} + task_file_id_to_sh_id: dict[str, str] = {} + for _wp_file, meta, body in workplan_infos: + mapped_ws_id = str(meta.get("state_hub_workstream_id", "")).strip().strip('"') + wp_id = str(meta.get("id", "")).strip() + if wp_id and mapped_ws_id: + workplan_id_to_ws_id[wp_id] = mapped_ws_id + for task in get_tasks_from_workplan(meta, body): + if task.get("_parse_error"): + continue + task_file_id = str(task.get("id", "")).strip() + raw_sh = task.get("state_hub_task_id") + task_sh_id = "" if raw_sh is None else str(raw_sh).strip().strip('"') + if task_file_id and task_sh_id and task_sh_id not in ("~", "null", "None", "none"): + task_file_id_to_sh_id[task_file_id] = task_sh_id + + # Modern hubs expose the entire repo-scoped read model in one response. + # Keep the older per-record calls as a rolling-deploy compatibility path. + projection_snapshot = _api_get( + api_base, + f"/repos/{repo_slug}/work-record-projection/snapshot", + return_error=True, + ) + snapshot_available = ( + isinstance(projection_snapshot, dict) + and projection_snapshot.get("schema") + == "state-hub.repository-projection-snapshot.v1" + and str(projection_snapshot.get("repo_id")) == repo_id + ) + snapshot_workplans_by_id: dict[str, dict] = {} + snapshot_tasks_by_workplan: dict[str, list[dict]] = {} + snapshot_dependencies_by_workplan: dict[str, list[dict]] = {} + if snapshot_available: + for row in projection_snapshot.get("workplans", []): + if isinstance(row, dict) and row.get("id"): + snapshot_workplans_by_id[str(row["id"])] = row + for row in projection_snapshot.get("tasks", []): + if isinstance(row, dict) and row.get("workplan_id"): + snapshot_tasks_by_workplan.setdefault( + str(row["workplan_id"]), [] + ).append(row) + for row in projection_snapshot.get("dependencies", []): + if isinstance(row, dict) and row.get("from_workplan_id"): + snapshot_dependencies_by_workplan.setdefault( + str(row["from_workplan_id"]), [] + ).append(row) + snapshot_tasks_by_id: dict[str, dict] = { + str(row["id"]): row + for rows in snapshot_tasks_by_workplan.values() + for row in rows + if row.get("id") + } + + # Task-block depends_on targets (CUST-WP-0074) may live in other repos, so + # resolve them by the file mapping first and the ADR-007 derived uuid second. + dependency_target_cache: dict[str, tuple[str | None, str | None]] = {} + + def _resolve_dependency_target(record_id: str) -> tuple[str | None, str | None]: + """Return (hub uuid, hub status) for a workplan/task record id, or (None, None).""" + if record_id in dependency_target_cache: + return dependency_target_cache[record_id] + is_task = bool(_TASK_RECORD_ID_RE.search(record_id)) + mapped = (task_file_id_to_sh_id if is_task else workplan_id_to_ws_id).get(record_id) + hub_id = mapped or _derived_work_record_uuid(record_id) + row = (snapshot_tasks_by_id if is_task else snapshot_workplans_by_id).get(hub_id) + if row is None: + row = _api_get(api_base, f"/{'tasks' if is_task else 'workplans'}/{hub_id}") + result: tuple[str | None, str | None] = ( + (hub_id, str(row.get("status") or "")) if isinstance(row, dict) else (None, None) + ) + dependency_target_cache[record_id] = result + return result + + decision_hub_ids: dict[str, str] | None = None + + def _resolve_decision(decision_id: str) -> dict | None: + nonlocal decision_hub_ids + if decision_hub_ids is None: + decision_hub_ids = _repo_decision_hub_ids(repo_dir) + hub_id = ( + decision_id + if _UUID_RE.match(decision_id) + else decision_hub_ids.get(decision_id) or _derived_work_record_uuid(decision_id) + ) + row = _api_get(api_base, f"/decisions/{hub_id}") + return row if isinstance(row, dict) else None + + # Per-workplan checks + for wp_file, meta, body in workplan_infos: + fname = workplan_display_path(repo_dir, wp_file) + archived_file = wp_file.parent.name == "archived" + ws_id = str(meta.get("state_hub_workstream_id", "")).strip().strip('"') + file_status = str(meta.get("status", "")).strip() + file_title = str(meta.get("title", "")).strip() + file_domain = str(meta.get("domain", "")).strip() + + normalised_file_status = normalise_workstream_status(file_status) + if archived_file and normalised_file_status not in CLOSED_WORKSTREAM_STATUSES: + report.add( + severity="FAIL", check_id="C-18", + message="Archived workplan file has an open or planning status", + file_path=fname, + file_value=file_status, + fixable=False, + ) + + if not ws_id: + # C-06: workplan not linked to any DB workstream + report.add( + severity="WARN", check_id="C-06", + message="Workplan has no state_hub_workstream_id — not indexed in DB", + file_path=fname, + file_value=file_title or fname, + fixable=True, + _fix_context={ + "wp_file": str(wp_file), + "meta": meta, + "body": body, + "repo_id": repo_id, + "domain": file_domain, + "repo_market_domain": repo_market_domain, + "repo_slug": repo_slug, + }, + ) + continue + + ws = ( + snapshot_workplans_by_id.get(ws_id) + if snapshot_available + else _api_get(api_base, f"/workplans/{ws_id}") + ) + if ws is None: + wp_id = str(meta.get("id", "")).strip() + if wp_id and ( + ws_id == _derived_work_record_uuid(wp_id) or bootstrap_empty_projection + ): + # A deterministic file identifier missing from a replaceable + # projection is registration work, not a stale reference. + report.add( + severity="WARN", + check_id="C-06", + message=( + f"Authoritative workplan {ws_id[:8]}… is absent from this " + + ( + "explicitly empty projection" + if bootstrap_empty_projection + else "projection" + ) + ), + file_path=fname, + db_id=ws_id, + fixable=True, + _fix_context={ + "wp_file": str(wp_file), + "meta": meta, + "body": body, + "repo_id": repo_id, + "domain": file_domain, + "repo_market_domain": repo_market_domain, + "repo_slug": repo_slug, + "desired_ws_id": ws_id, + }, + ) + else: + # A non-derived missing identifier may be genuine stale state; + # preserve the conservative manual-review behavior. + report.add( + severity="FAIL", check_id="C-03", + message=f"state_hub_workstream_id {ws_id[:8]}… not found in DB (stale reference)", + file_path=fname, + db_id=ws_id, + fixable=False, + ) + continue + + # C-09: repo mismatch — file is here but DB says different repo + db_repo_id: str = ws.get("repo_id") or "" + if db_repo_id != repo_id: + report.add( + severity="FAIL", check_id="C-09", + message=( + f"Workstream '{ws.get('slug')}' repo_id in DB is " + f"{db_repo_id[:8] if db_repo_id else 'None'} " + f"but backing file lives in {repo_slug} ({repo_id[:8]}…)" + ), + file_path=fname, + db_id=ws_id, + file_value=repo_id, + db_value=db_repo_id, + fixable=True, + _fix_context={"ws_id": ws_id, "correct_repo_id": repo_id}, + ) + # Continue to check drift even with mismatched repo + + tasks = get_tasks_from_workplan(meta, body) + db_tasks = ( + snapshot_tasks_by_workplan.get(ws_id, []) + if snapshot_available + else _api_get(api_base, "/tasks", {"workplan_id": ws_id}) + ) + db_tasks_by_id = { + str(row.get("id")): row + for row in db_tasks + if isinstance(row, dict) and row.get("id") + } if isinstance(db_tasks, list) else {} + file_task_statuses = [ + str(task.get("status", "")).strip() + for task in tasks + if not task.get("_parse_error") + ] + db_task_statuses = [ + str(task.get("status", "")).strip() + for task in db_tasks + if isinstance(db_tasks, list) + ] if isinstance(db_tasks, list) else [] + active_task_requires_activation = should_activate_parent_for_active_tasks( + parent_workstream_status=file_status, + task_statuses=[*file_task_statuses, *db_task_statuses], + ) + if active_task_requires_activation: + # ADR-007 decision 3 / RMGR-WP-0005-T06: automation may report + # that a proposed workplan has started work, but must not promote + # proposed → active. proposed is a human review gate. + proposed_gate = file_status == "proposed" + report.add( + severity="WARN", + check_id="C-23", + message=( + f"Lifecycle drift in '{ws.get('slug')}': workplan status " + f"{file_status!r} has an active task" + + ( + " — report only (ADR-007: do not auto-promote proposed)" + if proposed_gate + else " — repair to 'active'" + ) + ), + file_path=fname, + db_id=ws_id, + file_value=file_status, + db_value=ws.get("status", ""), + fixable=not proposed_gate, + _fix_context={ + "ws_id": ws_id, + "wp_file": str(wp_file), + "file_status": file_status, + "db_status": ws.get("status", ""), + "target_status": "active", + }, + ) + + # C-42: a blocked workplan must say what it waits for — at least one + # task in `wait` (which C-39 then requires to be qualified). Blocked + # with no wait task is a stale status, not a block (CUST-WP-0074-T06). + if file_status == "blocked" and not any( + normalise_task_status(s) == "wait" for s in file_task_statuses + ): + report.add( + severity="WARN", + check_id="C-42", + message=( + f"Blocked workplan '{ws.get('slug')}' has no task in status " + f"wait — nothing states what it waits for; qualify a wait " + f"task or re-status the workplan" + ), + file_path=fname, + db_id=ws_id, + file_value=file_status, + fixable=False, + ) + + # A bound historical split view shares its parent's row but supplies + # none of its fields or edges; only the bound parent file does. + historical_view = workplan_display_path(repo_dir, wp_file) in historical_view_paths(repo_slug) + + # C-04: status drift — normalise file value before comparing so that + # legacy file/API aliases are not treated as drift. Lifecycle repairs + # take precedence so a stale planning file cannot regress active work. + db_status = ws.get("status", "") + normalised_db_status = normalise_workstream_status(db_status) + if ( + not active_task_requires_activation + and file_status + and db_status + and normalised_file_status != normalised_db_status + and not historical_view + ): + report.add( + severity="WARN", check_id="C-04", + message=( + f"Status drift in '{ws.get('slug')}': " + f"file={file_status!r} db={db_status!r} (file wins)" + ), + file_path=fname, + db_id=ws_id, + file_value=file_status, + db_value=db_status, + fixable=True, + _fix_context={ + "ws_id": ws_id, + "field": "status", + "value": normalised_file_status, # always send DB-valid value + }, + ) + + if normalised_file_status == "ready": + review = ready_review_status( + repo_dir, + meta.get("reviewed_against_commit"), + meta.get("context_paths"), + ) + if review.needs_review: + detail = f"Ready workplan may be stale: {review.reason}" + if review.changed_paths: + preview = ", ".join(review.changed_paths[:5]) + extra = "" if len(review.changed_paths) <= 5 else ", ..." + detail = f"{detail}; changed paths: {preview}{extra}" + report.add( + severity="WARN", + check_id="C-21", + message=detail, + file_path=fname, + file_value=file_status, + db_value="needs_review", + fixable=False, + ) + # C-34: soft DoR quality debt (STATE-WP-0077) — never blocks + try: + from quality_assessment import assessment_from_mapping, is_ok + except ImportError: + sys.path.insert(0, str(Path(__file__).resolve().parent)) + from quality_assessment import assessment_from_mapping, is_ok # type: ignore + dor = assessment_from_mapping(meta, "DoR") + if not is_ok(dor): + report.add( + severity="WARN", + check_id="C-34", + message=( + "status=ready without quality_dor DoR-Ok — process claim " + "without DoR assessment (soft; see docs/work-record-quality-gates.md)" + ), + file_path=fname, + file_value=str(dor or "unassessed"), + fixable=False, + ) + + # C-05: title drift + db_title = ws.get("title", "") + if file_title and db_title and file_title != db_title: + report.add( + severity="WARN", check_id="C-05", + message=( + f"Title drift in '{ws.get('slug')}': " + f"file={file_title!r} db={db_title!r} (file wins)" + ), + file_path=fname, + db_id=ws_id, + file_value=file_title, + db_value=db_title, + fixable=True, + _fix_context={"ws_id": ws_id, "field": "title", "value": file_title}, + ) + + planning_priority = str(meta.get("planning_priority", "")).strip() or None + if planning_priority != (ws.get("planning_priority") or None): + report.add( + severity="WARN", check_id="C-19", + message=( + f"Planning priority drift in '{ws.get('slug')}': " + f"file={planning_priority!r} db={ws.get('planning_priority')!r} (file wins)" + ), + file_path=fname, + db_id=ws_id, + file_value=planning_priority, + db_value=ws.get("planning_priority"), + fixable=True, + _fix_context={"ws_id": ws_id, "field": "planning_priority", "value": planning_priority}, + ) + + planning_order = _as_int_or_none(meta.get("planning_order")) + if planning_order != ws.get("planning_order"): + report.add( + severity="WARN", check_id="C-19", + message=( + f"Planning order drift in '{ws.get('slug')}': " + f"file={planning_order!r} db={ws.get('planning_order')!r} (file wins)" + ), + file_path=fname, + db_id=ws_id, + file_value=planning_order, + db_value=ws.get("planning_order"), + fixable=True, + _fix_context={"ws_id": ws_id, "field": "planning_order", "value": planning_order}, + ) + + file_flavor = normalize_flavor(meta.get("flavor")) + if file_flavor is not None and file_flavor not in WORK_RECORD_FLAVORS: + report.add( + severity="WARN", + check_id="C-36", + message=( + f"Unknown work-record flavor {file_flavor!r} in '{ws.get('slug')}'; " + f"expected one of {', '.join(WORK_RECORD_FLAVORS)}" + ), + file_path=fname, + db_id=ws_id, + file_value=file_flavor, + fixable=False, + ) + file_flavor = None + db_flavor = normalize_flavor(ws.get("flavor")) + if file_flavor != db_flavor: + report.add( + severity="WARN", check_id="C-19", + message=( + f"Flavor drift in '{ws.get('slug')}': " + f"file={file_flavor!r} db={db_flavor!r} (file wins)" + ), + file_path=fname, + db_id=ws_id, + file_value=file_flavor, + db_value=db_flavor, + fixable=True, + _fix_context={"ws_id": ws_id, "field": "flavor", "value": file_flavor}, + ) + file_reason = normalize_promotion_reason(meta.get("flavor_promotion_reason")) + if file_reason is not None and file_reason not in FLAVOR_PROMOTION_REASONS: + report.add( + severity="WARN", + check_id="C-36", + message=( + f"Unknown flavor_promotion_reason {file_reason!r} in '{ws.get('slug')}'; " + f"expected one of {', '.join(FLAVOR_PROMOTION_REASONS)}" + ), + file_path=fname, + db_id=ws_id, + file_value=file_reason, + fixable=False, + ) + file_reason = None + db_reason = normalize_promotion_reason(ws.get("flavor_promotion_reason")) + if file_reason != db_reason: + report.add( + severity="WARN", check_id="C-19", + message=( + f"Flavor promotion reason drift in '{ws.get('slug')}': " + f"file={file_reason!r} db={db_reason!r} (file wins)" + ), + file_path=fname, + db_id=ws_id, + file_value=file_reason, + db_value=db_reason, + fixable=True, + _fix_context={ + "ws_id": ws_id, + "field": "flavor_promotion_reason", + "value": file_reason, + }, + ) + if is_residual_flavor(file_flavor): + origin = str(meta.get("origin") or "").strip() + origin_ref = str(meta.get("origin_ref") or "").strip() + if not origin and not origin_ref: + report.add( + severity="WARN", + check_id="C-37", + message=( + f"Residual workplan '{ws.get('slug')}' has no origin/origin_ref " + "provenance (not invented)" + ), + file_path=fname, + db_id=ws_id, + fixable=False, + ) + + # C-10, C-11, C-12: task-level checks + db_task_by_id: dict[str, dict] = {} + if isinstance(db_tasks, list): + for t in db_tasks: + db_task_by_id[t["id"]] = t + + existing_deps = ( + snapshot_dependencies_by_workplan.get(ws_id, []) + if snapshot_available + else _api_get(api_base, f"/workplans/{ws_id}/dependencies") or [] + ) + existing_dep_keys = set() + if isinstance(existing_deps, list): + for dep in existing_deps: + from_id = dep.get("from_workstream_id") or dep.get("from_workplan_id") + if from_id != ws_id: + continue + rel = dep.get("relationship_type") or "blocks" + # Frontmatter edges carry no from_task_id; task-block edges do. + from_task_id = dep.get("from_task_id") or None + to_workplan_id = dep.get("to_workstream_id") or dep.get("to_workplan_id") + if to_workplan_id: + existing_dep_keys.add((from_task_id, "workstream", to_workplan_id, rel)) + if dep.get("to_task_id"): + existing_dep_keys.add((from_task_id, "task", dep["to_task_id"], rel)) + + for target_wp_id in ([] if historical_view else _frontmatter_depends_on_workplans(meta)): + target_ws_id = workplan_id_to_ws_id.get(target_wp_id) + if not target_ws_id: + report.add( + severity="WARN", + check_id="C-20", + message=f"Workplan dependency target '{target_wp_id}' is not linked to State Hub", + file_path=fname, + file_value=target_wp_id, + fixable=False, + ) + continue + dep_key = (None, "workstream", target_ws_id, "blocks") + if dep_key not in existing_dep_keys: + report.add( + severity="WARN", + check_id="C-20", + message=f"Missing DB dependency edge: {ws_id[:8]}… depends on workplan {target_wp_id}", + file_path=fname, + db_id=ws_id, + file_value=target_wp_id, + fixable=True, + _fix_context={ + "from_workstream_id": ws_id, + "to_workstream_id": target_ws_id, + "relationship_type": "blocks", + }, + ) + + for target_task_id in _frontmatter_depends_on_tasks(meta): + target_sh_id = task_file_id_to_sh_id.get(target_task_id) + if not target_sh_id: + report.add( + severity="WARN", + check_id="C-20", + message=f"Task dependency target '{target_task_id}' is not linked to State Hub", + file_path=fname, + file_value=target_task_id, + fixable=False, + ) + continue + dep_key = (None, "task", target_sh_id, "starts_after") + if dep_key not in existing_dep_keys: + report.add( + severity="WARN", + check_id="C-20", + message=f"Missing DB dependency edge: {ws_id[:8]}… starts after task {target_task_id}", + file_path=fname, + db_id=ws_id, + file_value=target_task_id, + fixable=True, + _fix_context={ + "from_workstream_id": ws_id, + "to_task_id": target_sh_id, + "relationship_type": "starts_after", + }, + ) + + file_task_sh_ids: set[str] = set() + + for task in tasks: + if task.get("_parse_error"): + continue + t_id = str(task.get("id", "")).strip() + _raw_sh = task.get("state_hub_task_id") + t_sh_id = "" if _raw_sh is None else str(_raw_sh).strip().strip('"') + if t_sh_id in ("~", "null", "None", "none"): + t_sh_id = "" + t_status = normalise_task_status(task.get("status", "todo")) + + # CUST-WP-0074: task-block depends_on and wait qualifiers. + task_dep_targets = _frontmatter_depends_on_workplans(task) + _frontmatter_depends_on_tasks(task) + wait_fields = _task_wait_fields(task) + if t_status == "wait" and normalised_file_status not in CLOSED_WORKSTREAM_STATUSES: + # Rule 6: an owner-return wait with no workplan id yet is + # qualified by the C-25 vocabulary on the task block; a + # wait_until date is a window that clears by itself. + owner_return = bool(_BLOCKED_ON_RE.match(wait_fields["blocked_on"] or "")) + wait_until = wait_fields["wait_until"] + if ( + not task_dep_targets and not wait_fields["needs_human"] + and not owner_return and not wait_until + ): + report.add( + severity="WARN", check_id="C-39", + message=( + f"Wait task '{t_id}' in workplan '{meta.get('id', fname)}' has " + f"neither depends_on, needs_human: true, blocked_on: message-from:… " + f"nor wait_until — unqualified wait" + ), + file_path=f"{fname}#{t_id}", + file_value=t_status, + fixable=False, + ) + # CUST-WP-0075 P1: human gates belong in the decision queue. + if ( + wait_fields["needs_human"] and not wait_fields["decision_id"] + and not is_residual_flavor(meta.get("flavor")) + ): + report.add( + severity="WARN", check_id="C-39", + message=( + f"Human wait task '{t_id}' in workplan '{meta.get('id', fname)}' " + "has needs_human: true without decision_id — unrouted human gate" + ), + file_path=f"{fname}#{t_id}", + file_value="needs_human: true", + fixable=False, + ) + # C-40: rule 3 for a wait window — the date has passed (UTC today). + if wait_until and _date.fromisoformat(wait_until) < datetime.now(timezone.utc).date(): + report.add( + severity="WARN", check_id="C-40", + message=( + f"blocker satisfied, task still wait: '{t_id}' in workplan " + f"'{meta.get('id', fname)}' — wait_until {wait_until} has passed" + ), + file_path=f"{fname}#{t_id}", + file_value=wait_until, + fixable=False, + ) + # C-40: rule 3 — every depends_on target terminal. A target the + # hub cannot resolve is skipped, which also withholds the warning. + resolved_targets = [ + (target, _resolve_dependency_target(target)) for target in task_dep_targets + ] + if resolved_targets and all( + hub_status is not None + and _dependency_target_satisfied(target, hub_status) + for target, (_hub_id, hub_status) in resolved_targets + ): + report.add( + severity="WARN", check_id="C-40", + message=( + f"blocker satisfied, task still wait: '{t_id}' in workplan " + f"'{meta.get('id', fname)}' — every depends_on target is terminal " + f"({', '.join(task_dep_targets)})" + ), + file_path=f"{fname}#{t_id}", + file_value=", ".join(task_dep_targets), + fixable=False, + ) + # C-40: rule 4 — the tracked decision has been resolved. + if wait_fields["decision_id"]: + decision = _resolve_decision(wait_fields["decision_id"]) + if decision is not None and str(decision.get("status") or "") == "resolved": + report.add( + severity="WARN", check_id="C-40", + message=( + f"blocker satisfied, task still wait: '{t_id}' in workplan " + f"'{meta.get('id', fname)}' — decision " + f"{wait_fields['decision_id']} is resolved" + ), + file_path=f"{fname}#{t_id}", + file_value=wait_fields["decision_id"], + fixable=False, + ) + + if ( + t_sh_id + and task_dep_targets + and normalised_file_status not in CLOSED_WORKSTREAM_STATUSES + and t_status not in ("done", "cancel") + ): + # C-20 for task blocks: the waiting task is the from side. + # Closed workplans and closed tasks are not indexed — their + # ordering is history, not a live commitment. + for target in task_dep_targets: + target_hub_id, _target_status = _resolve_dependency_target(target) + is_task_target = bool(_TASK_RECORD_ID_RE.search(target)) + if not target_hub_id: + report.add( + severity="WARN", + check_id="C-20", + message=( + f"{'Task' if is_task_target else 'Workplan'} dependency target " + f"'{target}' of task '{t_id}' is not linked to State Hub" + ), + file_path=f"{fname}#{t_id}", + file_value=target, + fixable=False, + ) + continue + rel = "starts_after" if is_task_target else "blocks" + dep_key = (t_sh_id, "task" if is_task_target else "workstream", target_hub_id, rel) + if dep_key not in existing_dep_keys: + report.add( + severity="WARN", + check_id="C-20", + message=( + f"Missing DB dependency edge: task '{t_id}' " + f"{'starts after task' if is_task_target else 'depends on workplan'} {target}" + ), + file_path=f"{fname}#{t_id}", + db_id=ws_id, + file_value=target, + fixable=True, + _fix_context={ + "from_workstream_id": ws_id, + "from_task_id": t_sh_id, + "to_task_id" if is_task_target else "to_workstream_id": target_hub_id, + "relationship_type": rel, + "description": f"{t_id} depends_on {target}", + }, + ) + + if t_sh_id: + file_task_sh_ids.add(t_sh_id) + db_task = db_tasks_by_id.get(t_sh_id) + if db_task is None: + if t_id and t_sh_id == _derived_work_record_uuid(t_id): + report.add( + severity="WARN", + check_id="C-11", + message=f"Derived task '{t_id}' is absent from this projection", + file_path=f"{fname}#{t_id}", + db_id=t_sh_id, + fixable=True, + _fix_context={ + "ws_id": ws_id, + "ws_status": ws.get("status", ""), + "task": task, + "wp_file": str(wp_file), + "meta": meta, + "body": body, + "desired_task_id": t_sh_id, + }, + ) + else: + report.add( + severity="FAIL", check_id="C-03", + message=f"state_hub_task_id {t_sh_id[:8]}… not found in DB", + file_path=f"{fname}#{t_id}", + db_id=t_sh_id, + fixable=False, + ) + continue + # C-10 / C-15: task status drift. ADR-001: the file originates + # work. Same-rank wait/progress is not "DB ahead" — writing + # wait over progress/todo is the CFED/RMGR-WP-0004-T02 bug. + # C-15 remains a report-only review signal when the DB is + # terminal and the file is not. + db_t_status = normalise_task_status(db_task.get("status", "todo")) + if t_status and db_t_status and t_status != db_t_status: + db_rank = STATUS_ORDER.get(db_t_status, 0) + file_rank = STATUS_ORDER.get(t_status, 0) + db_terminal = db_t_status in {"done", "cancel"} + file_terminal = t_status in {"done", "cancel"} + if db_terminal and not file_terminal: + report.add( + severity="WARN", check_id="C-15", + message=( + f"DB task '{t_id}' is terminal " + f"(db={db_t_status!r}, file={t_status!r}) " + f"— report only; file wins (ADR-001 / RMGR-WP-0005-T06)" + ), + file_path=f"{fname}#{t_id}", + db_id=t_sh_id, + file_value=t_status, + db_value=db_t_status, + fixable=False, + ) + # File always wins, including same-rank wait ↔ progress. + report.add( + severity="WARN", check_id="C-10", + message=( + f"Task status drift '{t_id}': " + f"file={t_status!r} db={db_t_status!r} (file wins)" + ), + file_path=f"{fname}#{t_id}", + db_id=t_sh_id, + file_value=t_status, + db_value=db_t_status, + fixable=True, + _fix_context={ + "task_id": t_sh_id, + "status": t_status, + # A wait needs its qualifiers in the same PATCH. + "wait_fields": wait_fields if t_status == "wait" else {}, + }, + ) + file_description = task.get("description") + if isinstance(file_description, str): + file_description = file_description.strip() or None + else: + file_description = None + db_description = db_task.get("description") + if isinstance(db_description, str): + db_description = db_description.strip() or None + else: + db_description = None + if file_description and file_description != db_description: + report.add( + severity="WARN", check_id="C-22", + message=f"Task description drift '{t_id}': file content differs from DB (file wins)", + file_path=f"{fname}#{t_id}", + db_id=t_sh_id, + file_value=file_description[:120], + db_value=(db_description or "")[:120], + fixable=True, + _fix_context={"task_id": t_sh_id, "description": file_description}, + ) + file_task_flavor = normalize_flavor(task.get("flavor")) + if file_task_flavor is not None and file_task_flavor not in WORK_RECORD_FLAVORS: + report.add( + severity="WARN", + check_id="C-36", + message=( + f"Unknown work-record flavor {file_task_flavor!r} on task '{t_id}'" + ), + file_path=f"{fname}#{t_id}", + db_id=t_sh_id, + file_value=file_task_flavor, + fixable=False, + ) + file_task_flavor = None + db_task_flavor = normalize_flavor(db_task.get("flavor")) + if file_task_flavor != db_task_flavor: + report.add( + severity="WARN", + check_id="C-38", + message=( + f"Task flavor drift '{t_id}': " + f"file={file_task_flavor!r} db={db_task_flavor!r} (file wins)" + ), + file_path=f"{fname}#{t_id}", + db_id=t_sh_id, + file_value=file_task_flavor, + db_value=db_task_flavor, + fixable=True, + _fix_context={"task_id": t_sh_id, "flavor": file_task_flavor}, + ) + # C-41: wait qualifiers — only where the file speaks, so hub-set + # flags on blocks that never carried the keys are left alone. + if any(key in task for key in _TASK_WAIT_FIELD_KEYS): + db_wait_fields = { + "needs_human": bool(db_task.get("needs_human")), + "blocking_reason": str(db_task.get("blocking_reason") or "").strip() or None, + "decision_id": str(db_task.get("decision_id") or "").strip() or None, + "blocked_on": str(db_task.get("blocked_on") or "").strip() or None, + "wait_until": str(db_task.get("wait_until") or "").strip() or None, + } + file_wait_fields = {key: wait_fields[key] for key in _TASK_WAIT_FIELD_KEYS} + if file_wait_fields != db_wait_fields: + report.add( + severity="WARN", check_id="C-41", + message=( + f"Task wait-qualifier drift '{t_id}': " + f"file={file_wait_fields!r} db={db_wait_fields!r} (file wins)" + ), + file_path=f"{fname}#{t_id}", + db_id=t_sh_id, + file_value=json.dumps(file_wait_fields, sort_keys=True), + db_value=json.dumps(db_wait_fields, sort_keys=True), + fixable=True, + _fix_context={"task_id": t_sh_id, "wait_fields": wait_fields}, + ) + elif t_id: + # C-11: task exists in file but not linked to DB + ws_status = ws.get("status", "") + report.add( + severity="WARN", check_id="C-11", + message=f"Task '{t_id}' has no state_hub_task_id", + file_path=f"{fname}#{t_id}", + fixable=True, + _fix_context={ + "ws_id": ws_id, + "ws_status": ws_status, + "task": task, + "wp_file": str(wp_file), + "meta": meta, + "body": body, + }, + ) + + # C-12: DB tasks with no file backing + if isinstance(db_tasks, list): + ws_status = ws.get("status", "") + ws_finished = normalise_workstream_status(ws_status) in CLOSED_WORKSTREAM_STATUSES + for db_t in db_tasks: + if db_t["id"] not in file_task_sh_ids: + db_t_status = normalise_task_status(db_t.get("status", "todo")) + open_task = db_t_status not in TERMINAL_TASK_STATUSES + # Terminal orphans (cancel/done) are historical duplicates after + # file linkage or explicit retirement. The public task DELETE route + # is a cancel operation, so these are not further actionable. + if not open_task: + continue + # Auto-cancel fixable when workstream is finished and task is open. + fixable = ws_finished and open_task + report.add( + severity="WARN", check_id="C-12", + message=( + f"DB task '{db_t.get('title', '')}' " + f"(id={db_t['id'][:8]}…, status={db_t_status}) " + f"in workstream '{ws.get('slug')}' has no file backing" + ), + db_id=db_t["id"], + fixable=fixable, + _fix_context={ + "task_id": db_t["id"], + "ws_finished": ws_finished, + }, + ) + + # C-13: all DB tasks done but workstream still active — worker forgot to close + db_status = ws.get("status", "") + if normalise_workstream_status(db_status) == "active" and isinstance(db_tasks, list) and db_tasks: + non_terminal = [ + t for t in db_tasks + if normalise_task_status(t.get("status", "todo")) not in TERMINAL_TASK_STATUSES + ] + if not non_terminal: + report.add( + severity="WARN", check_id="C-13", + message=( + f"All {len(db_tasks)} DB tasks for '{ws.get('slug')}' are " + f"done/cancel but workstream status is still 'active' — " + f"worker likely forgot update_workstream_status()" + ), + file_path=fname, + db_id=ws_id, + db_value="active", + fixable=True, + _fix_context={ + "ws_id": ws_id, + "field": "status", + "value": "finished", + }, + ) + + # C-07 / C-08: orphan DB workstreams (have repo_id=this_repo but no backing file) + _check_orphan_db(api_base, repo_id, set(file_ws_ids.keys()), report, active_file_ws_ids) + + # C-14: ghost duplicate — active workstream on same topic with no repo_id whose + # title matches a file-backed workstream. Root cause: create_workstream() called + # before the workplan file was written; fix-consistency then created a second + # workstream from the file, leaving the first as an invisible orphan. + _check_ghost_duplicates(api_base, workplan_infos, file_ws_ids, report) + + _check_blocked_unblock_sweep(api_base, repo_slug, repo_dir, workplan_infos, report) + if report.source_revision is not None: + # The tree must not have moved while the files were read and C-25 ran. + if scan_source_revision(repo_dir) != (report.source_revision, None): + report.source_revision, report.source_revision_reason = None, "changed_during_scan" + _check_inbox_hygiene(api_base, repo_slug, report) + _check_unlinked_supersession(api_base, set(file_ws_ids), report) + _check_workplan_prefixes(repo_dir, repo_slug, workplan_infos, report) + _check_workplan_id_collisions(api_base, repo_slug, repo_dir, workplan_infos, report) + _check_scope_freshness(repo_dir, workplan_infos, report) + + if _refuse_frozen_fix_batch(report, repo_slug): + return report + _sync_workplan_bindings(api_base, repo_slug, workplan_infos, repo_dir, report) + + return report + + +def _sync_workplan_bindings( + api_base: str, + repo_slug: str, + workplan_infos: list[tuple[Path, dict, str]], + repo_dir: Path, + report: ConsistencyReport, +) -> None: + if repo_slug == "railiance-cluster": + scanned_paths = {workplan_display_path(repo_dir, path) for path, _, _ in workplan_infos} + missing = [entry["path"] for entry in FROZEN_CLUSTER_BINDINGS + if entry["path"] not in scanned_paths] + if missing: + report.add(severity="FAIL", check_id="C-44", + message="Frozen binding batch refused: bound source missing from scan", + fixable=False) + return + bindings: list[dict[str, Any]] = [] + for wp_file, meta, _ in workplan_infos: + ws_id = str(meta.get("state_hub_workstream_id", "")).strip().strip('"') + archived = wp_file.parent.name == "archived" + file_status = normalise_workstream_status(str(meta.get("status", "")).strip()) + binding = { + "workplan_id": ws_id, "filename": wp_file.name, + "relative_path": workplan_display_path(repo_dir, wp_file), + "repo_slug": repo_slug, "archived": archived, + "status": file_status or None, + } + try: + frozen = frozen_cluster_binding(binding) + if frozen is not None: + # Verify exact source before omitting the immutable binding. + # This is safe even against a receiver predating the API guard. + from hashlib import sha256 + if (wp_file.is_symlink() or not wp_file.is_file() + or sha256(wp_file.read_bytes()).hexdigest() != frozen["sha256"]): + raise ValueError("frozen historical source missing or changed") + continue + except ValueError as exc: + report.add(severity="FAIL", check_id="C-44", + message=f"Frozen binding batch refused: {exc}", + file_path=str(wp_file), fixable=False) + return # Never send a partial batch after a frozen-source refusal. + if not ws_id: + continue + bindings.append(binding) + if not bindings: + return + result = _api_put(api_base, "/workplans/index/bindings", {"bindings": bindings}) + if isinstance(result, dict) and "_error" in result: + report.fixes_applied.append(f"bindings WARN: {result['_error']}") + elif isinstance(result, dict): + report.fixes_applied.append( + f"bindings: synced {result.get('updated', 0)}/{result.get('received', len(bindings))}" + ) + + +RETIRED_SLUG_MARK = "@retired-" # mirrors api.services.forge_projection + + +def _is_projection_retired(ws: dict) -> bool: + """True for a row forge projection retired (field, or the slug stamp on older APIs).""" + return bool(ws.get("projection_retired_at")) or RETIRED_SLUG_MARK in (ws.get("slug") or "") + + +def _check_orphan_db( + api_base: str, + repo_id: str, + file_ws_ids: set[str], + report: ConsistencyReport, + active_file_ws_ids: set[str] | None = None, +) -> None: + """Flag DB workstreams with repo_id=this_repo that have no backing workplan file. + + A row retired by forge projection is a tombstone: retirement stamps the + slug with ``@retired-YYYYMMDD`` and sets ``projection_retired_at`` but + deliberately leaves status alone, so a row retired while blocked stays + blocked. Its canonical successor is file-backed under a new id. Such rows + are reported as C-08 INFO, never C-07 FAIL — the repo has nothing to fix, + and closing them by hand would rewrite history the retirement preserved. + """ + active_file_ws_ids = active_file_ws_ids or file_ws_ids + all_ws = _api_get(api_base, "/workplans", {"repo_id": repo_id}) + if not isinstance(all_ws, list): + return + for ws in all_ws: + ws_id = ws["id"] + ws_status = ws.get("status", "") + normalised_status = normalise_workstream_status(ws_status) + if normalised_status not in CLOSED_WORKSTREAM_STATUSES and ws_id in active_file_ws_ids: + continue + if normalised_status in CLOSED_WORKSTREAM_STATUSES and ws_id in file_ws_ids: + continue + ws_slug = ws.get("slug", "") + if _is_projection_retired(ws) and ws_id not in file_ws_ids: + report.add( + severity="INFO", check_id="C-08", + message=( + f"Retired DB workstream '{ws_slug}' " + f"(id={ws_id[:8]}…, status={ws_status}) has no backing workplan file " + f"— projection tombstone, not live work" + ), + db_id=ws_id, + fixable=False, + ) + continue + if normalised_status not in CLOSED_WORKSTREAM_STATUSES: + report.add( + severity="FAIL", check_id="C-07", + message=( + f"Non-closed DB workstream '{ws_slug}' (id={ws_id[:8]}…) " + f"has no backing workplan file — ADR-001 violation" + ), + db_id=ws_id, + fixable=False, + ) + elif normalised_status in CLOSED_WORKSTREAM_STATUSES: + report.add( + severity="INFO", check_id="C-08", + message=( + f"Closed DB workstream '{ws_slug}' " + f"(id={ws_id[:8]}…, status={ws_status}) has no backing workplan file" + ), + db_id=ws_id, + fixable=False, + ) + + +def _check_ghost_duplicates( + api_base: str, + workplan_infos: list[tuple], + file_ws_ids: dict[str, tuple], + report: ConsistencyReport, +) -> None: + """C-14: detect active workstreams with repo_id=null whose title matches a + file-backed workstream — these are ghosts created by premature create_workstream() + calls before the workplan file existed. + """ + # Build lookup: normalised title → file-backed workstream id + file_titles: dict[str, str] = {} + for _, meta, _ in workplan_infos: + ws_id = str(meta.get("state_hub_workstream_id", "")).strip().strip('"') + title = str(meta.get("title", "")).strip().lower() + if title and ws_id: + file_titles[title] = ws_id + + if not file_titles: + return + + # Gather topic_ids from all file-backed workstreams so we can query by topic + topic_ids: set[str] = set() + for ws_id in file_ws_ids: + ws = _api_get(api_base, f"/workplans/{ws_id}") + if ws and ws.get("topic_id"): + topic_ids.add(ws["topic_id"]) + + for topic_id in topic_ids: + topic_ws: list[dict] = [] + for status in OPEN_WORKSTREAM_STATUSES: + status_rows = _api_get(api_base, "/workplans", {"topic_id": topic_id, "status": status}) + if isinstance(status_rows, list): + topic_ws.extend(status_rows) + for ws in topic_ws: + ws_id = ws["id"] + if ws_id in file_ws_ids: + continue # legitimately linked + if ws.get("repo_id"): + continue # C-07 covers repo-scoped orphans + ws_title = ws.get("title", "").strip().lower() + if ws_title in file_titles: + file_backed_id = file_titles[ws_title] + report.add( + severity="WARN", + check_id="C-14", + message=( + f"Ghost duplicate: active workstream '{ws.get('slug')}' " + f"(id={ws_id[:8]}…, repo_id=null) has same title as " + f"file-backed workstream {file_backed_id[:8]}… — " + f"likely created via create_workstream() before workplan file existed; " + f"archive it" + ), + db_id=ws_id, + fixable=False, + ) + + +# --------------------------------------------------------------------------- +# Git sync (T02–T04: pull gate, writeback, push seal) — see repo_sync.py +# --------------------------------------------------------------------------- +# repo_sync.py owns the push-seal invariant and all git lifecycle primitives. +# The aliases below keep internal call sites and existing tests unchanged. + +from repo_sync import ( # noqa: E402 (import after top-level imports intentional) + count_local_ahead as _detect_ahead_of_remote, + count_remote_ahead as _count_remote_ahead, + pull_ff as _git_pull, + push_ff as _git_push, +) + + +def _detect_behind_remote(repo_path: str) -> bool: + """True if remote has commits the local repo lacks (C-16 predicate). + + Delegates to repo_sync.count_remote_ahead, which fetches before counting. + Returns False on any error so C-16 is never spuriously triggered. + """ + return _count_remote_ahead(repo_path) > 0 + + +def _patch_task_status_in_file( + file_path: Path, task_block_id: str, new_status: str +) -> bool: + """Update the ``status:`` field inside a task block identified by its id. + + Only modifies lines inside a ```task … ``` fenced block whose ``id:`` + matches *task_block_id*. Returns True if the file was changed. + """ + text = file_path.read_text(encoding="utf-8") + + def _replace(m: re.Match) -> str: + # m.group(0) is the full ```task...``` block including fences. + # m.group(1) is the inner YAML content (no fences). + full_block = m.group(0) + inner = m.group(1).strip() + task_meta = _parse_yaml_block(inner) + if str(task_meta.get("id", "")).strip() != task_block_id: + return full_block + # Replace the status line only, leave everything else untouched. + return re.sub( + r"^(status:\s*)\S+", + rf"\g<1>{new_status}", + full_block, + flags=re.MULTILINE, + ) + + new_text = _TASK_BLOCK_RE.sub(_replace, text) + if new_text != text: + file_path.write_text(new_text, encoding="utf-8") + return True + return False + + +def _git_identity_config() -> list[str]: + import os as _os + + name = _os.environ.get("GIT_SYNC_USER_NAME", "custodian-sync") + email = _os.environ.get("GIT_SYNC_USER_EMAIL", "custodian-sync@railiance.local") + return ["-c", f"user.name={name}", "-c", f"user.email={email}"] + + +def _git_commit_writeback( + repo_path: str, + file_path: Path, + changes: list[str], + *, + subject: str = "chore(consistency): sync task status from DB [auto]", +) -> bool: + """Stage *file_path* and commit with a standard writeback message. + + Returns True on success, False on any error (errors are logged to stderr + but do not abort the consistency run). + """ + from datetime import date as _date + summary = "\n".join(f" - {c}" for c in changes) + msg = ( + f"{subject}\n\n" + f"Updated by fix-consistency on {_date.today().isoformat()}:\n" + f"{summary}" + ) + import os as _os + # Pass GIT_CUSTODIAN_SYNC=1 so the post-commit hook can detect it is + # running from within a sync pass and exit early, preventing re-entrancy. + sync_env = {**_os.environ, "GIT_CUSTODIAN_SYNC": "1"} + try: + subprocess.run( + ["git", "-C", repo_path, "add", str(file_path)], + check=True, capture_output=True, env=sync_env, + ) + subprocess.run( + ["git", "-C", repo_path, *_git_identity_config(), "commit", "-m", msg], + check=True, capture_output=True, env=sync_env, + ) + return True + except subprocess.CalledProcessError as e: + print( + f"WARN: git commit failed for writeback: {e.stderr.decode().strip()}", + file=sys.stderr, + ) + return False + + +# --------------------------------------------------------------------------- +# Worker orientation brief (.custodian-brief.md) +# --------------------------------------------------------------------------- + +def default_wp_prefix(repo_slug: str) -> str: + first = repo_slug.split("-", 1)[0].upper() + return f"{first}-WP" + + +def infer_wp_prefix(repo_path: Path, repo_slug: str) -> str: + """Prefer established on-disk workplan prefixes over first-token derivation.""" + counts: Counter[str] = Counter() + workplans_dir = repo_path / "workplans" + if workplans_dir.is_dir(): + for workplan in iter_workplan_files(workplans_dir): + if workplan.name.startswith("ADHOC"): + continue + try: + text = workplan.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + id_match = _WP_ID_PREFIX_RE.search(text) or _WP_ID_BARE_RE.search(text) + if id_match: + counts[id_match.group(1).upper()] += 1 + continue + file_match = _WP_FILE_PREFIX_RE.match(workplan.name) or _WP_FILE_BARE_RE.match(workplan.name) + if file_match: + counts[file_match.group(1).upper()] += 1 + if not counts: + return default_wp_prefix(repo_slug) + return counts.most_common(1)[0][0] + + +def parse_blocked_on(value: str) -> str | None: + if not value: + return None + match = _BLOCKED_ON_RE.match(value.strip()) + return match.group("agent").strip() if match else None + + +def _parse_message_timestamp(value: Any) -> datetime | None: + if not value: + return None + if isinstance(value, datetime): + return value if value.tzinfo else value.replace(tzinfo=timezone.utc) + try: + parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except ValueError: + return None + return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) + + +def _summarise_inbox_message(msg: dict[str, Any], *, now: datetime) -> dict[str, Any]: + created = _parse_message_timestamp(msg.get("created_at")) + age_days = (now - created).days if created else None + return { + "id": str(msg.get("id", ""))[:8], + "from_agent": msg.get("from_agent", ""), + "subject": msg.get("subject", ""), + "age_days": age_days, + } + + +def collect_inbox_hygiene(api_base: str, repo_slug: str) -> dict[str, Any]: + messages = _api_get( + api_base, + "/messages", + {"to_agent": repo_slug, "unread_only": True, "limit": 100}, + ) or [] + if not isinstance(messages, list): + return { + "stale_unread": [], + "missing_thread": [], + "work_requests_unpromoted": [], + "stale_unread_count": 0, + } + + now = datetime.now(timezone.utc) + stale_cutoff = now - timedelta(days=STALE_UNREAD_DAYS) + thread_cutoff = now - timedelta(days=1) + + stale_unread: list[dict[str, Any]] = [] + missing_thread: list[dict[str, Any]] = [] + work_requests_unpromoted: list[dict[str, Any]] = [] + + for msg in messages: + created = _parse_message_timestamp(msg.get("created_at")) + if created is None: + continue + summary = _summarise_inbox_message(msg, now=now) + if created < stale_cutoff: + stale_unread.append(summary) + if not msg.get("thread_id") and created < thread_cutoff: + missing_thread.append(summary) + body = f"{msg.get('subject', '')} {msg.get('body', '')}" + if _WORK_REQUEST_RE.search(body) and created < stale_cutoff: + work_requests_unpromoted.append(summary) + + return { + "stale_unread": stale_unread, + "missing_thread": missing_thread, + "work_requests_unpromoted": work_requests_unpromoted, + "stale_unread_count": len(stale_unread), + } + + +_FULL_SHA = re.compile(r"^[0-9a-f]{40}$") + + +def scan_source_revision(repo_dir: Path) -> tuple[str | None, str | None]: + """Return ``(sha, None)`` for a clean ``workplans/`` tree, else ``(None, reason)``. + + ``sha`` is the full 40-hex HEAD commit. Any change under ``workplans/`` + (modified, staged, untracked or ignored) means the scanned files are not + that commit, so no revision is claimed. Reasons: ``not_a_git_tree``, + ``no_head_commit``, ``unsupported_object_format``, ``dirty_workplans``, + ``git_status_failed``. + """ + def git(*args: str) -> str | None: + try: + return subprocess.run( + ["git", *args], cwd=repo_dir, capture_output=True, text=True, + timeout=30, check=True, + ).stdout + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError): + return None + + if not Path(repo_dir).is_dir() or git("rev-parse", "--is-inside-work-tree") is None: + return None, "not_a_git_tree" + head = git("rev-parse", "--verify", "--quiet", "HEAD^{commit}") + if head is None or not head.strip(): + return None, "no_head_commit" + sha = head.strip() + if not _FULL_SHA.fullmatch(sha): + return None, "unsupported_object_format" + status = git("status", "--porcelain", "--untracked-files=all", "--ignored", + "--", "workplans") + if status is None: + return None, "git_status_failed" + if status.strip(): + return None, "dirty_workplans" + return sha, None + + +C25_PAGE_SIZE = 200 +C25_MAX_PAGES = 50 + + +def _collect_counterpart_messages( + api_base: str, repo_slug: str, counterpart: str +) -> tuple[list[dict], bool]: + """Page through every reply from ``counterpart`` (STATE-WP-0094-T03). + + Returns ``(messages, complete)``. Messages are deduplicated by id, so a + reply shifting between pages while new mail arrives is counted once and the + finding's message ids stay stable. ``complete`` is False when the hub + ignored ``offset`` (a page repeated only known ids) or the page bound was + hit; the caller reports that instead of silently capping. + """ + seen: dict[str, dict] = {} + anonymous: list[dict] = [] + for page in range(C25_MAX_PAGES): + rows = _api_get(api_base, "/messages", { + "to_agent": repo_slug, "from_agent": counterpart, + "unread_only": False, "limit": C25_PAGE_SIZE, "offset": page * C25_PAGE_SIZE, + }) or [] + if not isinstance(rows, list): + rows = [] + new = 0 + for row in rows: + if not isinstance(row, dict): + continue + message_id = str(row.get("id") or "") + if not message_id: + anonymous.append(row) + new += 1 + elif message_id not in seen: + seen[message_id] = row + new += 1 + if len(rows) < C25_PAGE_SIZE: + return list(seen.values()) + anonymous, True + if new == 0: + return list(seen.values()) + anonymous, False + return list(seen.values()) + anonymous, False + + +def _check_blocked_unblock_sweep( + api_base: str, + repo_slug: str, + repo_dir: Path, + workplan_infos: list[tuple[Path, dict, str]], + report: ConsistencyReport, +) -> None: + for wp_file, meta, _ in workplan_infos: + file_status = normalise_workstream_status(str(meta.get("status", "")).strip()) + if file_status != "blocked": + continue + blocked_on = str(meta.get("blocked_on", "")).strip() + counterpart = parse_blocked_on(blocked_on) + if not counterpart: + continue + messages = _api_get( + api_base, + "/messages", + { + "to_agent": repo_slug, + "from_agent": counterpart, + "unread_only": True, + "limit": 20, + }, + ) or [] + if not isinstance(messages, list) or not messages: + continue + fname = workplan_display_path(repo_dir, wp_file) + wp_id = str(meta.get("id", wp_file.stem)).strip() + report.add( + severity="WARN", + check_id="C-25", + message=( + f"Blocked workplan '{wp_id}' waiting on {blocked_on!r} has " + f"{len(messages)} unread message(s) from {counterpart} — " + "blocker may have cleared" + ), + file_path=fname, + file_value=blocked_on, + fixable=False, + ) + + + # Task-level returns can clear a wait even after somebody read the reply. + # Preserve the legacy workplan check above; task findings carry stable IDs + # for an owner runner to deduplicate without changing task status here. + messages_by_agent: dict[str, list[dict]] = {} + for wp_file, meta, body in workplan_infos: + if ( + normalise_workstream_status(str(meta.get("status", ""))) in CLOSED_WORKSTREAM_STATUSES + or is_residual_flavor(meta.get("flavor")) + ): + continue + for task in get_tasks_from_workplan(meta, body): + if normalise_task_status(task.get("status", "todo")) != "wait": + continue + counterpart = parse_blocked_on(str(task.get("blocked_on") or "")) + if not counterpart: + continue + if counterpart not in messages_by_agent: + rows, complete = _collect_counterpart_messages(api_base, repo_slug, counterpart) + messages_by_agent[counterpart] = rows + if not complete: + report.add( + severity="WARN", check_id="C-25", + message=(f"Owner-return reply collection from {counterpart} is incomplete " + f"({len(rows)} message(s) read; hub ignored paging or the " + f"{C25_MAX_PAGES}-page bound was reached) — returns may be missing"), + file_path=workplan_display_path(repo_dir, wp_file), + file_value=f"message-from:{counterpart}", + fixable=False, + ) + # Dated notes are the available acknowledgement watermark. Date-only + # notes cover the whole UTC day, avoiding same-day re-notification. + note_dates = [] + for value in re.findall(r"(?m)^\s*(?:[-*]\s+)?(?:\*\*)?(\d{4}-\d{2}-\d{2})\b", str(task.get("description") or "")): + parsed = _parse_message_timestamp(value) + if parsed: + note_dates.append(parsed + timedelta(days=1)) + explicit_updated = _parse_message_timestamp(task.get("updated")) + cutoff = max(note_dates + ([explicit_updated] if explicit_updated else []), default=None) + hits = [] + for message in messages_by_agent[counterpart]: + if message.get("from_agent") != counterpart or message.get("archived_at"): + continue + created = _parse_message_timestamp(message.get("created_at")) + if created and (created >= cutoff if cutoff else not message.get("read_at")): + hits.append(message) + if not hits: + continue + hits.sort(key=lambda message: str(message.get("created_at", ""))) + task_id = str(task.get("id", "")).strip() + message_ids = [str(message["id"]) for message in hits if message.get("id")] + report.add( + severity="WARN", check_id="C-25", + message=(f"Owner-return task '{task_id}' waiting on {counterpart} has " + f"{len(hits)} reply/replies after its last dated note " + f"(or unread when undated) — owner action needed: {', '.join(message_ids)}"), + file_path=f"{workplan_display_path(repo_dir, wp_file)}#{task_id}", + file_value=str(task.get("blocked_on")), + fixable=False, + _fix_context={"task_record_id": task_id, "message_ids": message_ids}, + ) + + +_SUPERSEDES_RE = re.compile( + r"\bsupersedes\s+(?:decision\s+)?([0-9a-f]{8}(?:-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})?)\b", + re.IGNORECASE, +) + + +def find_unlinked_supersessions( + decisions: list[dict], workplan_ids: set[str] +) -> list[tuple[dict, dict]]: + """(citing, cited) pairs where a resolved decision of these workplans says + "supersedes " but the cited decision is not superseded_by it. "Superseded + by" is the other direction and is not matched; ambiguous prefixes are skipped.""" + pairs: list[tuple[dict, dict]] = [] + for citing in decisions: + if str(citing.get("status") or "") != "resolved": + continue + if str(citing.get("workplan_id") or "") not in workplan_ids: + continue + text = " ".join(str(citing.get(k) or "") for k in ("title", "rationale", "description")) + for ref in dict.fromkeys(m.lower() for m in _SUPERSEDES_RE.findall(text)): + matches = [d for d in decisions if str(d.get("id") or "").lower().startswith(ref)] + if len(matches) != 1 or matches[0] is citing: + continue + cited = matches[0] + if ( + str(cited.get("status") or "") != "superseded" + or str(cited.get("superseded_by") or "") != str(citing["id"]) + ): + pairs.append((citing, cited)) + return pairs + + +def _check_unlinked_supersession( + api_base: str, workplan_ids: set[str], report: ConsistencyReport +) -> None: + workplan_ids = {str(w) for w in workplan_ids if w} + if not workplan_ids: + return + decisions = _api_get(api_base, "/decisions/") + if not isinstance(decisions, list): + return + for citing, cited in find_unlinked_supersessions(decisions, workplan_ids): + report.add( + severity="WARN", + check_id="C-43", + message=( + f"Decision {citing['id']} supersedes {cited['id']}, but the cited " + f"decision is status={cited.get('status')!r} " + f"superseded_by={cited.get('superseded_by')!r}; the decider should " + f"set status=superseded and superseded_by" + ), + db_id=str(cited["id"]), + fixable=False, + ) + + +def _check_inbox_hygiene(api_base: str, repo_slug: str, report: ConsistencyReport) -> None: + hygiene = collect_inbox_hygiene(api_base, repo_slug) + if hygiene["stale_unread_count"]: + preview = ", ".join( + f"{m['from_agent']}:{m['id']}" for m in hygiene["stale_unread"][:5] + ) + extra = "" if hygiene["stale_unread_count"] <= 5 else ", ..." + report.add( + severity="WARN", + check_id="C-28", + message=( + f"{hygiene['stale_unread_count']} unread inbox message(s) older than " + f"{STALE_UNREAD_DAYS} day(s): {preview}{extra}" + ), + fixable=False, + ) + if hygiene["missing_thread"]: + preview = ", ".join( + f"{m['from_agent']}:{m['id']}" for m in hygiene["missing_thread"][:5] + ) + report.add( + severity="WARN", + check_id="C-28", + message=( + f"{len(hygiene['missing_thread'])} unread message(s) lack thread_id " + f"for supersession tracking: {preview}" + ), + fixable=False, + ) + for msg in hygiene["work_requests_unpromoted"]: + report.add( + severity="WARN", + check_id="C-29", + message=( + f"Unread work request from {msg['from_agent']} ({msg['id']}) may need " + f"a workplan file: {msg['subject'][:120]}" + ), + fixable=False, + ) + + +def _check_workplan_prefixes( + repo_dir: Path, + repo_slug: str, + workplan_infos: list[tuple[Path, dict, str]], + report: ConsistencyReport, +) -> None: + canonical = infer_wp_prefix(repo_dir, repo_slug) + for wp_file, meta, _ in workplan_infos: + if wp_file.name.startswith("ADHOC"): + continue + wp_id = str(meta.get("id", "")).strip() + if not wp_id: + continue + match = _WP_ID_PREFIX_RE.match(wp_id) or _WP_ID_BARE_RE.match(wp_id) + if not match: + continue + prefix = match.group(1).upper() + if prefix == canonical.upper(): + continue + report.add( + severity="WARN", + check_id="C-26", + message=( + f"Workplan id '{wp_id}' uses prefix '{prefix}' but repo canonical " + f"prefix is '{canonical}' — new plans must conform" + ), + file_path=workplan_display_path(repo_dir, wp_file), + file_value=wp_id, + db_value=canonical, + fixable=False, + ) + + +def _scan_repo_workplan_ids(repos: list[dict[str, Any]]) -> dict[str, list[tuple[str, str]]]: + id_map: dict[str, list[tuple[str, str]]] = {} + for repo in repos: + slug = repo["slug"] + path = resolve_repo_path(repo) + if not path or not Path(path).is_dir(): + continue + workplans_dir = Path(path) / "workplans" + if not workplans_dir.is_dir(): + continue + for wp_file in iter_workplan_files(workplans_dir): + if wp_file.name.startswith("ADHOC"): + continue + try: + text = wp_file.read_text(encoding="utf-8") + except OSError: + continue + if not text.startswith("---"): + continue + meta, _ = parse_frontmatter(text) + if not meta or meta.get("_parse_error"): + continue + wp_id = str(meta.get("id", "")).strip() + if wp_id: + id_map.setdefault(wp_id, []).append((slug, wp_file.name)) + return id_map + + +def _check_workplan_id_collisions( + api_base: str, + repo_slug: str, + repo_dir: Path, + workplan_infos: list[tuple[Path, dict, str]], + report: ConsistencyReport, +) -> None: + repos = _api_get(api_base, "/repos") or [] + if not isinstance(repos, list): + return + id_map = _scan_repo_workplan_ids(repos) + for wp_file, meta, _ in workplan_infos: + wp_id = str(meta.get("id", "")).strip() + if not wp_id: + continue + locations = id_map.get(wp_id, []) + if len(locations) <= 1: + continue + report.add( + severity="WARN", + check_id="C-27", + message=( + f"Workplan id '{wp_id}' collides across repos: " + + ", ".join(f"{slug}/{fname}" for slug, fname in locations) + ), + file_path=workplan_display_path(repo_dir, wp_file), + file_value=wp_id, + fixable=False, + ) + + +def _scope_current_state_lines(repo_dir: Path) -> dict[str, str]: + scope_path = repo_dir / "SCOPE.md" + if not scope_path.exists(): + return {} + text = scope_path.read_text(encoding="utf-8", errors="replace") + match = re.search(r"## Current State\s*\n(.*?)(?:\n## |\Z)", text, re.DOTALL) + if not match: + return {} + result: dict[str, str] = {} + for line in match.group(1).splitlines(): + stripped = line.strip() + if not stripped.startswith("-"): + continue + parts = stripped[1:].split(":", 1) + if len(parts) != 2: + continue + result[parts[0].strip().lower()] = parts[1].strip() + return result + + +def _repo_has_open_workplans(workplan_infos: list[tuple[Path, dict, str]]) -> bool: + for wp_file, meta, _ in workplan_infos: + if wp_file.parent.name == "archived": + continue + status = normalise_workstream_status(str(meta.get("status", "")).strip()) + if status in _OPEN_WORKPLAN_STATUSES: + return True + return False + + +def _check_scope_freshness( + repo_dir: Path, + workplan_infos: list[tuple[Path, dict, str]], + report: ConsistencyReport, +) -> None: + scope = _scope_current_state_lines(repo_dir) + if not scope: + return + has_open_workplans = _repo_has_open_workplans(workplan_infos) + scope_status = scope.get("status", "").lower() + warnings: list[str] = [] + if "active" in scope_status and not has_open_workplans: + warnings.append( + "SCOPE.md Current State says active but no open workplans remain — may be stale" + ) + if has_open_workplans and any( + token in scope_status for token in ("finished", "archived", "idle", "dormant") + ): + warnings.append( + "SCOPE.md Current State does not reflect active workplans — may be stale" + ) + implementation = scope.get("implementation", "").lower() + if has_open_workplans and implementation and "not yet started" in implementation: + warnings.append( + "SCOPE.md mentions work not yet started but active workplans exist — may be stale" + ) + for warning in warnings: + report.add( + severity="WARN", + check_id="C-30", + message=warning, + file_path="SCOPE.md", + fixable=False, + ) + + +_BRIEF_HEADER = "" +_TASK_STATUS_ICON = {"done": "✓", "cancel": "✗", "progress": "►", "wait": "!", "todo": "·"} +_OPEN_STATUSES = set(OPEN_TASK_STATUSES) + + +def _commit_working_memory_notes(repo_slug: str, repo_path: str) -> bool: + """Stage and commit activity-core daily-triage working-memory notes. + + Only the-custodian carries the fleet working-memory sink. Notes are + repo-relative under memory/working/ and are written by activity-core on + Railiance via hostPath into the clone checkout. + """ + if repo_slug != "the-custodian": + return False + + wm_dir = Path(repo_path) / "memory" / "working" + if not wm_dir.is_dir(): + return False + + notes = sorted(wm_dir.glob("daily-triage-*.md")) + if not notes: + return False + + import os as _os + + sync_env = {**_os.environ, "GIT_CUSTODIAN_SYNC": "1"} + rel_paths = [str(note.relative_to(repo_path)) for note in notes] + try: + subprocess.run( + ["git", "-C", repo_path, "add", "--", *rel_paths], + check=True, + capture_output=True, + env=sync_env, + ) + staged = subprocess.run( + ["git", "-C", repo_path, "diff", "--cached", "--quiet"], + capture_output=True, + env=sync_env, + ) + if staged.returncode == 0: + return False + from datetime import date as _date + + msg = ( + "chore(working-memory): sync daily-triage notes from activity-core " + f"[auto {_date.today().isoformat()}]" + ) + subprocess.run( + ["git", "-C", repo_path, *_git_identity_config(), "commit", "-m", msg], + check=True, + capture_output=True, + env=sync_env, + ) + return True + except subprocess.CalledProcessError as e: + print( + f"WARN: working-memory commit failed: {e.stderr.decode().strip()}", + file=sys.stderr, + ) + return False + + +def _write_custodian_brief(api_base: str, repo_slug: str, repo_path: str) -> bool: + """Generate .custodian-brief.md at the repo root and git-commit if changed. + + The brief gives any agent — including subagents without MCP access and + workers on remote machines — instant orientation without a live hub + connection. Returns True if the file was written (content changed). + """ + import datetime as _dt + from datetime import timezone as _tz + + repo = _api_get(api_base, f"/repos/{repo_slug}") + if not repo: + return False + + repo_id: str = repo.get("id", "") + domain_slug = str(repo.get("domain_slug") or "") + + # The repository projection owns domain identity. Retain the workplan/topic + # lookup only for compatibility with older API responses that omitted it. + workstreams: list[dict] = [] + for status in OPEN_WORKSTREAM_STATUSES: + rows = _api_get(api_base, "/workplans", {"repo_id": repo_id, "status": status}) or [] + if isinstance(rows, list): + workstreams.extend(rows) + _ws_for_domain = workstreams if workstreams else [] + if not _ws_for_domain: + all_ws = _api_get(api_base, "/workplans", {"repo_id": repo_id}) or [] + _ws_for_domain = all_ws if isinstance(all_ws, list) else [] + if not domain_slug and _ws_for_domain: + topic = _api_get(api_base, f"/topics/{_ws_for_domain[0].get('topic_id', '')}") + if topic: + domain_slug = topic.get("domain_slug", "") + + # Active repo goal (first active one if multiple) + goal_text = "" + goals = _api_get(api_base, "/repo-goals", {"repo_slug": repo_slug}) or [] + if isinstance(goals, list): + active_goals = [g for g in goals if g.get("status") == "active"] + if active_goals: + g = active_goals[0] + goal_text = g.get("title", "") or g.get("description", "") + + now_utc = _dt.datetime.now(_tz.utc) + ts = now_utc.strftime("%Y-%m-%d %H:%M UTC") + + lines = [ + _BRIEF_HEADER, + f"# Custodian Brief — {repo_slug}", + "", + f"**Domain:** {domain_slug or '(unknown)'} ", + f"**Last synced:** {ts} ", + "**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*", + "", + ] + + if goal_text: + lines += ["## Current Goal", "", goal_text, ""] + + if isinstance(workstreams, list) and workstreams: + lines.append("## Active Workstreams") + for ws in workstreams: + ws_title = ws.get("title", ws.get("slug", "?")) + ws_id = ws["id"] + tasks = _api_get(api_base, "/tasks", {"workplan_id": ws_id}) or [] + if not isinstance(tasks, list): + tasks = [] + + done = sum( + 1 for t in tasks + if normalise_task_status(t.get("status", "todo")) in TERMINAL_TASK_STATUSES + ) + total = len(tasks) + pct = f"{done}/{total}" if total else "no tasks" + + open_tasks = [ + t for t in tasks + if normalise_task_status(t.get("status", "todo")) in _OPEN_STATUSES + ] + # Show wait first, then progress, then todo (cap at 5). + priority_order = {"wait": 0, "progress": 1, "todo": 2} + open_tasks.sort( + key=lambda t: priority_order.get( + normalise_task_status(t.get("status", "todo")), 9 + ) + ) + + lines += [ + "", + f"### {ws_title}", + f"Progress: {pct} done | workplan_id: `{ws_id}`", + ] + + if open_tasks: + lines.append("") + lines.append("**Open tasks:**") + for t in open_tasks[:7]: + status = normalise_task_status(t.get("status", "todo")) + icon = _TASK_STATUS_ICON.get(status, "·") + title = t.get("title", t["id"]) + tid = t["id"] + blocker = t.get("blocking_reason", "") + task_line = f"- {icon} {title} `{tid[:8]}`" + if status == "wait" and blocker: + task_line += f"\n *(wait: {blocker})*" + lines.append(task_line) + if len(open_tasks) > 7: + lines.append(f"- … and {len(open_tasks) - 7} more open tasks") + else: + lines += ["## Active Workstreams", "", "*(none — repo may need first-session setup)*"] + + hygiene = collect_inbox_hygiene(api_base, repo_slug) + if hygiene["stale_unread_count"] or hygiene["missing_thread"] or hygiene["work_requests_unpromoted"]: + lines += ["", "## Inbox Hygiene", ""] + if hygiene["stale_unread_count"]: + lines.append( + f"**Stale unread:** {hygiene['stale_unread_count']} message(s) older than " + f"{STALE_UNREAD_DAYS} day(s) — triage at session start." + ) + if hygiene["missing_thread"]: + lines.append( + f"**Missing thread_id:** {len(hygiene['missing_thread'])} unread message(s) " + "lack supersession chains." + ) + for msg in hygiene["work_requests_unpromoted"][:3]: + lines.append( + f"- ! {msg['from_agent']}: {msg['subject'][:100]} `{msg['id']}`" + ) + + blocked_warnings: list[str] = [] + workplans_dir = Path(repo_path) / "workplans" + if workplans_dir.is_dir(): + for wp_file in iter_workplan_files(workplans_dir): + try: + text = wp_file.read_text(encoding="utf-8") + except OSError: + continue + if not text.startswith("---"): + continue + meta, _ = parse_frontmatter(text) + if not meta or meta.get("_parse_error"): + continue + if normalise_workstream_status(str(meta.get("status", "")).strip()) != "blocked": + continue + blocked_on = str(meta.get("blocked_on", "")).strip() + counterpart = parse_blocked_on(blocked_on) + if not counterpart: + continue + messages = _api_get( + api_base, + "/messages", + { + "to_agent": repo_slug, + "from_agent": counterpart, + "unread_only": True, + "limit": 5, + }, + ) or [] + if isinstance(messages, list) and messages: + wp_id = str(meta.get("id", wp_file.stem)).strip() + blocked_warnings.append( + f"- ! **{wp_id}** — blocker may have cleared: " + f"{len(messages)} unread message(s) from `{counterpart}`" + ) + if blocked_warnings: + lines += ["", "## Blocked Workplans", ""] + lines.extend(blocked_warnings) + + scope_warnings: list[str] = [] + scope = _scope_current_state_lines(Path(repo_path)) + has_open_workplans = False + if workplans_dir.is_dir(): + for wp_file in iter_workplan_files(workplans_dir): + try: + text = wp_file.read_text(encoding="utf-8") + except OSError: + continue + if not text.startswith("---"): + continue + meta, _ = parse_frontmatter(text) + if not meta or meta.get("_parse_error"): + continue + if wp_file.parent.name == "archived": + continue + status = normalise_workstream_status(str(meta.get("status", "")).strip()) + if status in _OPEN_WORKPLAN_STATUSES: + has_open_workplans = True + break + if scope: + scope_status = scope.get("status", "").lower() + if "active" in scope_status and not has_open_workplans: + scope_warnings.append( + "SCOPE.md says active but no open workplans remain — refresh Current State." + ) + if has_open_workplans and any( + token in scope_status for token in ("finished", "archived", "idle", "dormant") + ): + scope_warnings.append( + "SCOPE.md Current State does not reflect active workplans — may be stale." + ) + if scope_warnings: + lines += ["", "## SCOPE Freshness", ""] + lines.extend(f"- {warning}" for warning in scope_warnings) + + lines += [ + "", + "---", + "## MCP Orientation (when available)", + "", + "If the state-hub MCP server is reachable, call:", + f"`get_domain_summary(\"{domain_slug}\")`", + "This provides richer cross-domain context.", + "If the MCP call fails, use this file as your orientation source.", + ] + + content = "\n".join(lines) + "\n" + + brief_path = Path(repo_path) / ".custodian-brief.md" + existing = brief_path.read_text(encoding="utf-8") if brief_path.exists() else "" + + # Strip the timestamp line before comparing to avoid spurious writes + def _strip_ts(text: str) -> str: + return "\n".join( + ln for ln in text.splitlines() + if not ln.startswith("**Last synced:**") + ) + + if _strip_ts(content) == _strip_ts(existing): + return False # no meaningful change + + brief_path.write_text(content, encoding="utf-8") + + # Commit the brief so remote workers can pull it + _git_commit_writeback( + repo_path, + brief_path, + [f"update .custodian-brief.md for {repo_slug}"], + ) + return True + + +# --------------------------------------------------------------------------- +# Fix engine +# --------------------------------------------------------------------------- + +def _refuse_frozen_fix_batch(report: ConsistencyReport, repo_slug: str) -> bool: + """Refuse a complete repair batch touching the exact bound histories.""" + if any(issue.check_id == "C-44" and issue.severity == "FAIL" for issue in report.issues): + return True + protected_ids = {entry["uuid"] for entry in FROZEN_CLUSTER_BINDINGS} + protected_ids.update(task["uuid"] for entry in FROZEN_CLUSTER_BINDINGS + for task in entry["tasks"]) + paths = {entry["path"] for entry in FROZEN_CLUSTER_BINDINGS} + root = Path(report.repo_path).absolute() if report.repo_path else None + + def protected(value, key=""): + if isinstance(value, dict): + return any(protected(item, name) for name, item in value.items()) + if isinstance(value, (list, tuple)): + return any(protected(item, key) for item in value) + text = str(value) + if key in {"db_id", "ws_id", "task_id", "from_workstream_id", + "from_workplan_id", "from_task_id", "desired_ws_id", + "desired_task_id", "desired_record_id", "state_hub_workstream_id", "state_hub_task_id"}: + if text in protected_ids: + return True + if key in {"file_path", "wp_file", "md_path", "path"} and text: + candidate = Path(text) + if candidate.is_absolute(): + return root is not None and any(candidate.absolute() == root / path for path in paths) + return repo_slug == "railiance-cluster" and candidate.as_posix() in paths + return False + + for issue in report.issues: + if issue.fixable and (protected(issue.db_id, "db_id") + or protected(issue.file_path, "file_path") + or protected(issue._fix_context)): + report.add(severity="FAIL", check_id="C-44", + message="Frozen historical repair batch refused; owner review required", + fixable=False) + return True + return False + + +def fix_repo( + api_base: str, + repo_slug: str, + repo_path_override: str | None = None, + no_writeback: bool = False, + bootstrap_empty_projection: bool = False, +) -> ConsistencyReport: + """Run checks then apply all auto-fixable issues. Returns updated report.""" + report = check_repo( + api_base, + repo_slug, + repo_path_override, + bootstrap_empty_projection=bootstrap_empty_projection, + ) + if _refuse_frozen_fix_batch(report, repo_slug): + return report + if any(i.check_id in {"C-00", "C-36", "C-44"} for i in report.failures): + return report + + # RMGR-WP-0002: optional repo-manager reconcile proxy (index rebuild) for pilots + try: + from api.services.repo_manager_dual_run import reconcile_for_repo, rm_reconcile + + if reconcile_for_repo(repo_slug) and report.repo_path: + rm_rec = rm_reconcile(repo_path=report.repo_path, repo_slug=repo_slug) + if rm_rec.get("ok") or rm_rec.get("exit_code") == 0: + report.fixes_applied.append( + f"RM dual-run reconcile ok: index={rm_rec.get('index_path') or 'written'}" + ) + else: + report.fixes_applied.append( + f"RM dual-run reconcile note: {rm_rec.get('error') or rm_rec}" + ) + except Exception as rec_exc: # noqa: BLE001 + report.fixes_applied.append(f"RM dual-run reconcile skipped: {rec_exc}") + + # Auto-register this machine's path in host_paths so future runs work + # without --repo-path. Idempotent: skipped when already correct. + repo_path = report.repo_path + if repo_path: + repo_record = _api_get(api_base, f"/repos/{repo_slug}") + if repo_record: + hostname = _sweep_hostname() + if (repo_record.get("host_paths") or {}).get(hostname) != repo_path: + result = _api_post( + api_base, f"/repos/{repo_slug}/paths", + {"host": hostname, "path": repo_path}, + ) + if result and "_error" not in result: + report.fixes_applied.append( + f"host_paths[{hostname}] → {repo_path}" + ) + + # T02 — pull gate: warn and skip all write operations when local repo is + # behind its remote tracking branch. + if repo_path and _detect_behind_remote(repo_path): + report.add( + severity="WARN", check_id="C-16", + message=( + f"Repo '{repo_slug}' is behind its remote tracking branch — " + f"pull before fixing to avoid clobbering remote progress. " + f"Run: git -C {repo_path} pull --ff-only" + ), + fixable=False, + ) + report.fixes_applied.append( + "C-16: all write operations skipped — local repo is behind remote" + ) + return report + + # C-17: backlog guard — if local has unpushed commits from a prior failed push, + # try to push them before making more. Skipping writes prevents runaway divergence. + if repo_path: + ahead = _detect_ahead_of_remote(repo_path) + if ahead > 0: + push_ok, push_msg = _git_push(repo_path) + if not push_ok: + report.add( + severity="WARN", check_id="C-17", + message=( + f"Repo '{repo_slug}' has {ahead} unpushed commit(s) and push " + f"failed ({push_msg}) — skipping writes to prevent runaway divergence" + ), + fixable=False, + ) + report.fixes_applied.append( + "C-17: all write operations skipped — unpushed commits, push failed" + ) + return report + # Push succeeded — local is now in sync; proceed normally + report.fixes_applied.append(f"C-17 cleared: pushed {ahead} backlogged commit(s)") + + fixable = [i for i in report.issues if i.fixable] + + for issue in fixable: + ctx = issue._fix_context + try: + if issue.check_id in ("C-04", "C-05", "C-13", "C-19"): + ws_id = ctx["ws_id"] + result = _api_patch(api_base, f"/workplans/{ws_id}", + {ctx["field"]: ctx["value"]}) + if result is not None and "_error" not in result: + report.fixes_applied.append( + f"{issue.check_id} fixed: workstream {ws_id[:8]}… " + f"{ctx['field']} → {ctx['value']!r}" + ) + elif result is not None: + report.fixes_applied.append( + f"{issue.check_id} FAILED: workstream {ws_id[:8]}… " + f"{ctx['field']} → {ctx['value']!r}: {result['_error']}" + ) + + elif issue.check_id == "C-23": + ws_id = ctx["ws_id"] + target_status = ctx["target_status"] + result = _api_patch(api_base, f"/workplans/{ws_id}", {"status": target_status}) + if result is not None and "_error" not in result: + report.fixes_applied.append( + f"C-23 fixed: workstream {ws_id[:8]}… status → {target_status!r}" + ) + elif result is not None: + report.fixes_applied.append( + f"C-23 FAILED: workstream {ws_id[:8]}… " + f"status → {target_status!r}: {result['_error']}" + ) + + if no_writeback: + report.fixes_applied.append( + f"C-23 skipped file repair (--no-writeback): {Path(ctx['wp_file']).name}" + ) + else: + wp_file = Path(ctx["wp_file"]) + old_status = ctx["file_status"] + if _patch_frontmatter_field(wp_file, "status", target_status): + committed = _git_commit_writeback( + repo_path, + wp_file, + [f"workplan status: {old_status} → {target_status}"], + subject="chore(consistency): renormalize lifecycle state [auto]", + ) + suffix = " (committed)" if committed else " (file patched, commit failed)" + report.fixes_applied.append( + f"C-23 fixed: {wp_file.name} status " + f"{old_status} → {target_status}{suffix}" + ) + else: + report.fixes_applied.append( + f"C-23 SKIP: {wp_file.name} already has status {target_status!r}" + ) + + elif issue.check_id == "C-06": + wp_file = Path(ctx["wp_file"]) + meta = ctx["meta"] + domain = ctx["domain"] + repo_market_domain = str(ctx.get("repo_market_domain") or "").strip() + repo_id_val = ctx["repo_id"] + body = ctx.get("body", "") + wp_id = str(meta.get("id", "")).strip() + desired_ws_id = str( + ctx.get("desired_ws_id") or _derived_work_record_uuid(wp_id) + ) + title = str(meta.get("title", "")).strip() + status = str(meta.get("status", "active")).strip() + status = normalise_workstream_status(status) + if status not in VALID_WP_STATUSES: + status = "active" + + # Find topic_id — workplan frontmatter may still use legacy + # coordination slugs (e.g. custodian); map to market domain first. + topic_domain = resolve_topic_domain_slug( + domain, + repo_market_domain=repo_market_domain or None, + ) + topics = _api_get(api_base, "/topics") + topic_id = None + if isinstance(topics, list): + for t in topics: + if t.get("domain_slug") == topic_domain: + topic_id = t["id"] + break + if topic_id is None: + report.fixes_applied.append( + f"C-06 SKIP {wp_id}: no topic found for domain " + f"'{topic_domain}' (workplan domain={domain!r})" + ) + continue + + base_slug = re.sub(r"[^a-z0-9-]", "-", wp_id.lower()).strip("-") or "workplan" + repo_slug_part = re.sub( + r"[^a-z0-9-]", "-", str(ctx.get("repo_slug") or "").lower() + ).strip("-") + slug_candidates = [base_slug] + repo_qualified_slug = base_slug + if repo_slug_part and not base_slug.startswith(f"{repo_slug_part}-"): + repo_qualified_slug = f"{repo_slug_part}-{base_slug}" + slug_candidates.append(repo_qualified_slug) + for suffix in range(2, 21): + slug_candidates.append(f"{repo_qualified_slug}-{suffix}") + + ws_data = None + last_error = None + for slug in slug_candidates: + existing = _api_get(api_base, "/workplans", {"slug": slug}, return_error=True) + if isinstance(existing, dict) and "_error" in existing: + last_error = existing["_error"] + continue + if isinstance(existing, list) and existing: + existing_same_repo = next( + (w for w in existing if w.get("repo_id") == repo_id_val), + None, + ) + if ( + existing_same_repo + and existing_same_repo.get("title") == (title or wp_id) + and existing_same_repo.get("id") == desired_ws_id + ): + ws_data = existing_same_repo + report.fixes_applied.append( + f"C-06 reusing existing workstream {ws_data['id'][:8]}... for {wp_id}" + ) + break + last_error = ( + f"slug {slug!r} already belongs to a different workplan identity" + ) + continue + + create_flavor = normalize_flavor(meta.get("flavor")) + if create_flavor is not None and create_flavor not in WORK_RECORD_FLAVORS: + create_flavor = None + create_reason = normalize_promotion_reason(meta.get("flavor_promotion_reason")) + if create_reason is not None and create_reason not in FLAVOR_PROMOTION_REASONS: + create_reason = None + create_from = normalize_flavor(meta.get("flavor_promoted_from")) + if create_from is not None and create_from not in WORK_RECORD_FLAVORS: + create_from = None + ws_data = _api_post(api_base, "/workplans", { + "id": desired_ws_id, + "topic_id": topic_id, + "repo_id": repo_id_val, + "slug": slug, + "title": title or wp_id, + "status": status, + "owner": str(meta.get("owner", "")).strip() or None, + "planning_priority": str(meta.get("planning_priority", "")).strip() or None, + "planning_order": _as_int_or_none(meta.get("planning_order")), + "flavor": create_flavor, + "flavor_promotion_reason": create_reason, + "flavor_promoted_from": create_from, + }) + if ws_data is None or (isinstance(ws_data, dict) and "_error" in ws_data): + last_error = ws_data.get("_error") if isinstance(ws_data, dict) else "no response" + ws_data = None + continue + break + + if ws_data is None: + report.fixes_applied.append( + f"C-06 FAIL {wp_id}: could not create workstream in DB: {last_error or 'no usable slug'}" + ) + continue + + new_ws_id = ws_data["id"] + if new_ws_id != desired_ws_id: + report.fixes_applied.append( + f"C-06 FAIL {wp_id}: projection returned unexpected UUID {new_ws_id}" + ) + continue + if not meta.get("state_hub_workstream_id"): + _add_frontmatter_field(wp_file, "state_hub_workstream_id", new_ws_id) + report.fixes_applied.append( + f"C-06 fixed: created workstream {new_ws_id[:8]}… " + f"for {wp_id}, wrote ID to {wp_file.name}" + ) + + # Create tasks and inject IDs + tasks = get_tasks_from_workplan(meta, body) + for task in tasks: + if task.get("_parse_error"): + continue + t_id = str(task.get("id", "")).strip() + if not t_id: + continue + t_status = normalise_task_status(task.get("status", "todo")) + t_priority = str(task.get("priority", "medium")).strip() + if t_priority not in VALID_TASK_PRIORITIES: + t_priority = "medium" + raw_task_id = task.get("state_hub_task_id") + desired_task_id = ( + str(raw_task_id).strip().strip('"') + if raw_task_id not in (None, "", "~", "null", "None", "none") + else _derived_work_record_uuid(t_id) + ) + task_flavor = normalize_flavor(task.get("flavor")) + if task_flavor is not None and task_flavor not in WORK_RECORD_FLAVORS: + task_flavor = None + t_data = _api_post(api_base, "/tasks", { + "id": desired_task_id, + "workplan_id": new_ws_id, + "title": str(task.get("title", t_id)).strip() or t_id, + "description": task.get("description") or None, + "status": t_status, + "priority": t_priority, + "assignee": task.get("assignee") or None, + "flavor": task_flavor, + **_task_wait_fields(task), + }) + if t_data and "_error" not in t_data: + t_db_id = t_data["id"] + if t_db_id != desired_task_id: + report.fixes_applied.append( + f" ! task {t_id} returned unexpected UUID {t_db_id}" + ) + continue + if not raw_task_id: + injected = _inject_task_id_into_block( + wp_file, "state_hub_task_id", t_db_id, t_id + ) + if not injected: + _inject_task_id_frontmatter_list(wp_file, t_db_id, t_id) + report.fixes_applied.append(f" + task {t_id} → {t_db_id[:8]}…") + elif t_data: + report.fixes_applied.append( + f" ! task {t_id} not created: {t_data.get('_error', t_data)}" + ) + + elif issue.check_id == "C-32": + md_path = ctx["md_path"] + kind = ctx["kind"] + rid = ctx["rid"] + meta = ctx["meta"] + id_field = _WORK_RECORD_ID_FIELD_BY_KIND[kind] + title = str(meta.get("title") or rid) + desired_record_id = ctx.get("desired_record_id") + + repo_record = _api_get(api_base, f"/repos/{repo_slug}") + if not repo_record: + report.fixes_applied.append( + f"C-32 SKIP {rid}: could not look up repo '{repo_slug}'" + ) + continue + created = None + if kind == "intake": + payload = { + "id": desired_record_id, + "title": title, + "repo_id": repo_record.get("id"), + "description": meta.get("description"), + "lane": meta.get("lane", "green"), + "origin": meta.get("origin"), + "origin_ref": meta.get("origin_ref"), + "source_repo_path": str(md_path.relative_to(Path(report.repo_path))), + } + created = _api_post(api_base, "/intakes", payload) + elif kind == "decision": + repo_market_domain = str(repo_record.get("domain_slug") or "").strip() + topic_domain = resolve_topic_domain_slug( + repo_market_domain, repo_market_domain=repo_market_domain or None + ) + topics = _api_get(api_base, "/topics") + topic_id = None + if isinstance(topics, list): + for t in topics: + if t.get("domain_slug") == topic_domain: + topic_id = t["id"] + break + if topic_id is None: + report.fixes_applied.append( + f"C-32 SKIP {rid}: no topic found for domain '{topic_domain}'" + ) + continue + payload = { + "id": desired_record_id, + "title": title, + "topic_id": topic_id, + "decision_type": normalise_decision_type(meta), + "description": meta.get("description"), + "rationale": meta.get("agent_recommendation") or meta.get("rationale"), + } + created = _api_post(api_base, "/decisions", payload) + + if not created or "_error" in created: + report.fixes_applied.append( + f"C-32 FAIL {rid}: could not create {kind} in DB: " + f"{(created or {}).get('_error', 'no response')}" + ) + continue + + new_id = created["id"] + if desired_record_id and new_id != desired_record_id: + report.fixes_applied.append( + f"C-32 FAIL {rid}: projection returned unexpected UUID {new_id}" + ) + continue + if desired_record_id: + report.fixes_applied.append( + f"C-32 fixed: created {kind} {new_id[:8]}… for {rid} " + "with the authoritative ID" + ) + continue + if _inject_yaml_block_field(md_path, id_field, new_id, rid): + report.fixes_applied.append( + f"C-32 fixed: created {kind} {new_id[:8]}… for {rid}, " + f"wrote {id_field} to {md_path.name}" + ) + else: + report.fixes_applied.append( + f"C-32 WARN {rid}: created {kind} {new_id[:8]}… but " + f"failed to write {id_field} back to {md_path.name}" + ) + + elif issue.check_id == "C-33": + ctx["index_file"].write_text(ctx["content"], encoding="utf-8") + report.fixes_applied.append( + f"C-33 fixed: regenerated {ctx['index_file'].name}" + ) + + elif issue.check_id == "C-09": + ws_id = ctx["ws_id"] + correct_repo_id = ctx["correct_repo_id"] + result = _api_patch(api_base, f"/workplans/{ws_id}", + {"repo_id": correct_repo_id}) + if result is not None: + report.fixes_applied.append( + f"C-09 fixed: workstream {ws_id[:8]}… " + f"repo_id → {correct_repo_id[:8]}…" + ) + + elif issue.check_id == "C-20": + from_workstream_id = ctx["from_workstream_id"] + body = { + "to_workstream_id": ctx.get("to_workstream_id"), + "to_task_id": ctx.get("to_task_id"), + "relationship_type": ctx["relationship_type"], + } + if ctx.get("from_task_id"): + # A hub without migration e8f9a0b1c2d3 ignores the field + # and would store a workplan-level edge that later + # duplicates the task-scoped one. Skip until deployed. + if not _hub_accepts_from_task_id(api_base): + report.fixes_applied.append( + f"C-20 SKIP: hub at {api_base} does not accept " + f"from_task_id yet (task '{ctx.get('description', '')}')" + ) + continue + body["from_task_id"] = ctx["from_task_id"] + body["description"] = ctx.get("description") + result = _api_post(api_base, f"/workplans/{from_workstream_id}/dependencies", body) + if result is not None and "_error" not in result: + target = ctx.get("to_workstream_id") or ctx.get("to_task_id") + report.fixes_applied.append( + f"C-20 fixed: dependency {from_workstream_id[:8]}… " + f"{ctx['relationship_type']} → {target[:8]}…" + ) + elif result is not None: + report.fixes_applied.append( + f"C-20 FAILED: {result['_error']}" + ) + + elif issue.check_id == "C-10": + task_id = ctx["task_id"] + status = ctx["status"] + result = _api_patch(api_base, f"/tasks/{task_id}", + {"status": status, "suppress_token_event": True, + **ctx.get("wait_fields", {})}) + if result is not None and "_error" not in result: + report.fixes_applied.append( + f"C-10 fixed: task {task_id[:8]}… status → {status!r}" + ) + elif result is not None: + report.fixes_applied.append( + f"C-10 FAILED: task {task_id[:8]}… status → {status!r}: {result['_error']}" + ) + + elif issue.check_id == "C-11": + ws_id = ctx["ws_id"] + ws_status = ctx.get("ws_status", "") + task = ctx["task"] + wp_file = Path(ctx["wp_file"]) + meta = ctx["meta"] + body = ctx.get("body", "") + t_id = str(task.get("id", "")).strip() + # Skip creating tasks for finished workstreams — the workstream is + # done/archived so unlinked tasks are stale file artefacts, not gaps. + if normalise_workstream_status(ws_status) in CLOSED_WORKSTREAM_STATUSES: + report.fixes_applied.append( + f"C-11 skipped: task '{t_id}' in {ws_status} workstream — not created" + ) + else: + t_status = normalise_task_status(task.get("status", "todo")) + t_priority = str(task.get("priority", "medium")).strip() + if t_priority not in VALID_TASK_PRIORITIES: + t_priority = "medium" + desired_task_id = str( + ctx.get("desired_task_id") or _derived_work_record_uuid(t_id) + ) + t_data = _api_post(api_base, "/tasks", { + "id": desired_task_id, + "workplan_id": ws_id, + "title": str(task.get("title", t_id)).strip() or t_id, + "description": task.get("description") or None, + "status": t_status, + "priority": t_priority, + "assignee": task.get("assignee") or None, + **_task_wait_fields(task), + }) + if t_data: + t_db_id = t_data["id"] + if t_db_id != desired_task_id: + report.fixes_applied.append( + f"C-11 FAIL: task '{t_id}' returned unexpected UUID {t_db_id}" + ) + continue + if not task.get("state_hub_task_id"): + injected = _inject_task_id_into_block( + wp_file, "state_hub_task_id", t_db_id, t_id + ) + if not injected: + _inject_task_id_frontmatter_list(wp_file, t_db_id, t_id) + report.fixes_applied.append( + f"C-11 fixed: task '{t_id}' → {t_db_id[:8]}…" + ) + + elif issue.check_id == "C-12": + task_id = ctx["task_id"] + if ctx.get("ws_finished"): + result = _api_patch(api_base, f"/tasks/{task_id}", {"status": "cancel"}) + if result is not None: + report.fixes_applied.append( + f"C-12 fixed: orphan task {task_id[:8]}… canceled (workstream finished)" + ) + + elif issue.check_id == "C-22": + task_id = ctx["task_id"] + description = ctx["description"] + result = _api_patch(api_base, f"/tasks/{task_id}", {"description": description}) + if result is not None and "_error" not in result: + report.fixes_applied.append( + f"C-22 fixed: task {task_id[:8]}… description updated" + ) + elif result is not None: + report.fixes_applied.append( + f"C-22 FAILED: task {task_id[:8]}… description update: {result['_error']}" + ) + + elif issue.check_id == "C-38": + task_id = ctx["task_id"] + flavor = ctx.get("flavor") + result = _api_patch(api_base, f"/tasks/{task_id}", {"flavor": flavor}) + if result is not None and "_error" not in result: + report.fixes_applied.append( + f"C-38 fixed: task {task_id[:8]}… flavor → {flavor!r}" + ) + elif result is not None: + report.fixes_applied.append( + f"C-38 FAILED: task {task_id[:8]}… flavor → {flavor!r}: {result['_error']}" + ) + + elif issue.check_id == "C-41": + task_id = ctx["task_id"] + wait_fields = ctx["wait_fields"] + result = _api_patch(api_base, f"/tasks/{task_id}", wait_fields) + if result is not None and "_error" not in result: + report.fixes_applied.append( + f"C-41 fixed: task {task_id[:8]}… wait qualifiers synced" + ) + elif result is not None: + report.fixes_applied.append( + f"C-41 FAILED: task {task_id[:8]}… wait qualifiers: {result['_error']}" + ) + + elif issue.check_id == "C-15": + # T03 — writeback: DB is ahead of file — patch file to match DB. + if no_writeback: + report.fixes_applied.append( + f"C-15 skipped (--no-writeback): task '{ctx['task_block_id']}' " + f"db={ctx['db_status']!r}" + ) + else: + wp_file = Path(ctx["wp_file"]) + task_block_id = ctx["task_block_id"] + db_status = ctx["db_status"] + old_status = issue.file_value + # RMGR-WP-0002 dual-run: prefer repo-manager writeback when flagged + rm_done = False + try: + from api.services.repo_manager_dual_run import ( + try_writeback_for_task, + writeback_for_repo, + ) + + if writeback_for_repo(repo_slug) and repo_path: + # Prefer hub UUID when available for robust matching + task_ref = ctx.get("task_id") or task_block_id + rm_result = try_writeback_for_task( + repo_path=repo_path, + repo_slug=repo_slug, + task_id=str(task_ref), + status=db_status, + reason="state-hub C-15 dual-run writeback", + ) + if rm_result and rm_result.get("status") == "applied": + git_sha = (rm_result.get("evidence") or {}).get("git_sha") + suffix = f" (repo-manager git_sha={git_sha})" if git_sha else " (repo-manager)" + report.fixes_applied.append( + f"C-15 fixed: task '{task_block_id}' " + f"{old_status} → {db_status}{suffix}" + ) + rm_done = True + except Exception as rm_exc: # noqa: BLE001 + report.fixes_applied.append( + f"C-15 dual-run fallback to native: {rm_exc}" + ) + if not rm_done: + if _patch_task_status_in_file(wp_file, task_block_id, db_status): + committed = _git_commit_writeback( + repo_path, + wp_file, + [f"{task_block_id}: {old_status} → {db_status}"], + ) + try: + from api.services.repo_manager_dual_run import record_mutation + + record_mutation( + source="state-hub", + kind="task_status_writeback_c15", + repo_slug=repo_slug, + detail={ + "task_block_id": task_block_id, + "status": db_status, + "committed": committed, + }, + ) + except Exception: + pass + suffix = " (committed)" if committed else " (file patched, commit failed)" + report.fixes_applied.append( + f"C-15 fixed: task '{task_block_id}' " + f"{old_status} → {db_status}{suffix}" + ) + else: + report.fixes_applied.append( + f"C-15 SKIP: could not locate task block '{task_block_id}' " + f"in {wp_file.name}" + ) + + except Exception as e: + report.fixes_applied.append(f"{issue.check_id} ERROR: {e}") + + # Record that a sync run happened for this repo + from datetime import timezone as _tz + import datetime as _dt + now_iso = _dt.datetime.now(_tz.utc).isoformat() + _api_patch(api_base, f"/repos/{repo_slug}", {"last_state_synced_at": now_iso}) + + # Write the worker orientation brief (.custodian-brief.md) + if repo_path: + brief_written = _write_custodian_brief(api_base, repo_slug, repo_path) + if brief_written: + report.fixes_applied.append("brief: .custodian-brief.md updated") + + if repo_path: + wm_committed = _commit_working_memory_notes(repo_slug, repo_path) + if wm_committed: + report.fixes_applied.append("working-memory: daily-triage notes committed") + + # Push all commits made this run (writebacks + brief) to close the loop. + # This ensures the next run starts with local == remote, making the + # timer idempotent and preventing commit pile-up. + if repo_path: + push_ok, push_msg = _git_push(repo_path) + if push_ok: + report.fixes_applied.append(f"push: {push_msg}") + else: + report.fixes_applied.append(f"push WARN: {push_msg}") + + return report + + +# Check IDs that are known-background noise in multi-machine setups: +# C-08 = finished/archived DB workstream with no file (pre-ADR-001 legacy) +# These alone do not warrant a pull+fix cycle. +_BACKGROUND_CHECKS: frozenset[str] = frozenset({"C-08"}) + + +def _report_needs_action( + report: ConsistencyReport, behind_remote: bool, ahead_of_remote: int = 0 +) -> bool: + """Return True if the repo warrants a pull+fix cycle. + + A repo is considered clean (no action needed) when: + - It is not behind its remote tracking branch, AND + - It has no unpushed local commits (from a prior failed push), AND + - It has no FAIL issues, AND + - Every WARN/INFO issue is in the background-noise set (C-08). + """ + if behind_remote or ahead_of_remote > 0: + return True + if report.assessment_failures or report.automation_errors: + return True + actionable_warns = [ + i for i in report.warnings + report.infos + if i.check_id not in _BACKGROUND_CHECKS + ] + return bool(actionable_warns) + + +def fix_all_remote( + api_base: str, + no_writeback: bool = False, + max_seconds: int = DEFAULT_REMOTE_ALL_MAX_SECONDS, +) -> list[ConsistencyReport]: + """Pull-then-fix all registered repos that need attention. + + For each repo with a resolvable local path on this machine: + 1. Skip if the path does not exist (repo lives on another machine). + 2. Run check_repo() and detect_behind_remote() — read-only. + 3. If the repo is clean (no actionable issues, not behind remote): skip. + 4. Otherwise: git pull --ff-only, then fix_repo(). + + Returns one ConsistencyReport per repo that was *not* skipped as clean. + Repos skipped as clean are printed to stdout but not included in the return value. + """ + repos = _api_get(api_base, "/repos") + if not isinstance(repos, list): + print("ERROR: Could not fetch repos from state-hub API", file=sys.stderr) + return None + + started = time.monotonic() + reports: list[ConsistencyReport] = [] + skipped_clean: list[str] = [] + skipped_missing: list[str] = [] + skipped_budget: list[str] = [] + + for repo in repos: + slug = repo["slug"] + if max_seconds > 0 and time.monotonic() - started > max_seconds: + skipped_budget.append(slug) + skipped_budget.extend(r.get("slug", "?") for r in repos[repos.index(repo) + 1:]) + break + + # Resolve path using the same priority as check_repo + path = resolve_repo_path(repo) + if not path or not Path(path).is_dir(): + skipped_missing.append(slug) + continue + + # Read-only pass: detect issues and remote staleness. + # _detect_behind_remote does a fetch, so _detect_ahead_of_remote + # after it sees an up-to-date tracking branch. + pre_report = check_repo(api_base, slug) + behind = _detect_behind_remote(path) + ahead = _detect_ahead_of_remote(path) + + if not _report_needs_action(pre_report, behind, ahead): + skipped_clean.append(slug) + continue + + # Pull before fixing + pull_ok, pull_msg = _git_pull(path) + pull_tag = f"pull: {pull_msg}" + if not pull_ok: + pull_tag = f"pull WARN: {pull_msg} — proceeding anyway" + + report = fix_repo(api_base, slug, no_writeback=no_writeback) + report.fixes_applied.insert(0, pull_tag) + reports.append(report) + + # Print clean/missing summary lines before the detailed reports + if skipped_clean: + print(f" CLEAN (skipped): {', '.join(skipped_clean)}") + if skipped_missing: + print(f" NOT ON THIS HOST (skipped): {', '.join(skipped_missing)}") + if skipped_budget: + print( + f" BUDGET EXHAUSTED after {max_seconds}s (skipped): " + f"{', '.join(skipped_budget)}" + ) + if skipped_clean or skipped_missing or skipped_budget: + print() + + return reports + + +def archive_closed_workplans( + repo_path: str, + completion_date: str | None = None, + workplan: str | None = None, +) -> list[str]: + """Move closed root workplans into workplans/archived/ with YYMMDD prefix. + + Only root-level files whose frontmatter status normalises to finished or + archived are moved. Files with any open task blocks are left in place. + """ + repo_dir = Path(repo_path) + workplans_dir = repo_dir / "workplans" + archived_dir = workplans_dir / "archived" + if not workplans_dir.is_dir(): + return [] + + date_prefix = completion_date or datetime.now().strftime("%y%m%d") + archived_dir.mkdir(exist_ok=True) + moved: list[str] = [] + + for wp_file in sorted(workplans_dir.glob("*.md")): + text = wp_file.read_text(encoding="utf-8") + if not text.startswith("---"): + continue + meta, body = parse_frontmatter(text) + if not meta or meta.get("_parse_error"): + continue + if workplan: + wanted = workplan.removesuffix(".md") + if wanted not in {str(meta.get("id", "")), wp_file.stem, wp_file.name}: + continue + status = normalise_workstream_status(str(meta.get("status", "")).strip()) + if status not in CLOSED_WORKSTREAM_STATUSES: + continue + tasks = get_tasks_from_workplan(meta, body) + open_tasks = [ + t for t in tasks + if normalise_task_status(t.get("status", "todo")) not in TERMINAL_TASK_STATUSES + ] + if open_tasks: + continue + + target = archived_dir / f"{date_prefix}-{canonical_workplan_filename(wp_file)}" + if target.exists(): + raise FileExistsError(f"Archived workplan already exists: {target}") + wp_file.rename(target) + moved.append(f"{wp_file.relative_to(repo_dir)} -> {target.relative_to(repo_dir)}") + + return moved + + +# --------------------------------------------------------------------------- +# Output / rendering +# --------------------------------------------------------------------------- + +def render_renormalization_guide() -> str: + lines = ["Lifecycle Renormalization Rules", "=" * 33] + for rule in RENORMALIZATION_RULES: + lines.extend( + [ + "", + f"{rule.check_id}: {rule.invariant}", + f" Detect: {rule.detection}", + f" Repair: {rule.repair}", + f" Tests: {rule.test_anchor}", + ] + ) + lines.extend(["", "Add The Next Guard"]) + for idx, item in enumerate(RENORMALIZATION_NEXT_GUARD_CHECKLIST, 1): + lines.append(f" {idx}. {item}") + return "\n".join(lines) + + +def render_text(report: ConsistencyReport, show_info: bool = True) -> str: + SEP = "=" * 66 + lines = [ + f"ADR-001 Consistency Report", + f"Repo: {report.repo_slug}", + f"Path: {report.repo_path}", + SEP, + ] + + error_section = report.automation_errors + fail_section = report.assessment_failures + if error_section: + lines.append(f"\n AUTOMATION ERRORS ({len(error_section)}):") + for i in error_section: + loc = f" [{i.file_path}]" if i.file_path else "" + lines.append(f" {i.check_id}{loc}") + lines.append(f" {i.message}") + + if fail_section: + lines.append(f"\n ASSESSMENT FAILURES ({len(fail_section)}):") + for i in fail_section: + loc = f" [{i.file_path}]" if i.file_path else "" + fix_tag = " [fixable]" if i.fixable else "" + lines.append(f" {i.check_id}{loc}{fix_tag}") + lines.append(f" {i.message}") + if i.file_value or i.db_value: + lines.append(f" file={i.file_value!r} db={i.db_value!r}") + + for sev in ("WARN", "INFO"): + section = [i for i in report.issues if i.severity == sev] + if not section or (sev == "INFO" and not show_info): + continue + lines.append(f"\n {sev}S ({len(section)}):") + for i in section: + loc = f" [{i.file_path}]" if i.file_path else "" + fix_tag = " [fixable]" if i.fixable else "" + lines.append(f" {i.check_id}{loc}{fix_tag}") + lines.append(f" {i.message}") + if i.file_value or i.db_value: + lines.append(f" file={i.file_value!r} db={i.db_value!r}") + + if report.fixes_applied: + lines.append(f"\n FIXES APPLIED ({len(report.fixes_applied)}):") + for f in report.fixes_applied: + lines.append(f" {f}") + + lines.append(f"\n{SEP}") + n_err = len(report.automation_errors) + n_fail = len(report.assessment_failures) + n_warn = len(report.warnings) + n_info = len(report.infos) + lines.append( + f" {n_err} automation-error | {n_fail} assessment-fail | " + f"{n_warn} warn | {n_info} info" + ) + if n_err: + lines.append(" RESULT: ✗ AUTOMATION ERROR") + elif n_fail: + lines.append(" RESULT: ✗ ASSESSMENT FAIL (follow-up needed)") + elif n_warn: + lines.append(" RESULT: ✓ PASS (with warnings)") + else: + lines.append(" RESULT: ✓ PASS") + lines.append(SEP) + return "\n".join(lines) + + +def report_to_dict(report: ConsistencyReport) -> dict: + revision = { + "source_revision": report.source_revision, + "source_revision_reason": report.source_revision_reason, + } + return { + "repo_slug": report.repo_slug, + "repo_path": report.repo_path, + **revision, + "issues": [ + { + "severity": i.severity, + "check_id": i.check_id, + "message": i.message, + "file_path": i.file_path, + "db_id": i.db_id, + "file_value": i.file_value, + "db_value": i.db_value, + "fixable": i.fixable, + **({"repo_slug": report.repo_slug, **revision} if i.check_id == "C-25" else {}), + **({"owner_return": i._fix_context} if i.check_id == "C-25" and i._fix_context.get("task_record_id") else {}), + } + for i in report.issues + ], + "fixes_applied": report.fixes_applied, + "summary": { + "fail": len(report.assessment_failures), + "automation_error": len(report.automation_errors), + "warn": len(report.warnings), + "info": len(report.infos), + }, + "result": ( + "error" if report.automation_errors else + "fail" if report.assessment_failures else + "warn" if report.warnings else + "pass" + ), + } + + +def consistency_exit_code(reports: list[ConsistencyReport], *, remote_all: bool = False) -> int: + """Return the strict CLI exit code for consistency reports.""" + any_automation_error = any(r.automation_errors for r in reports) + any_assessment_fail = any(r.assessment_failures for r in reports) + any_warn = any(r.warnings for r in reports) + if remote_all: + return 1 if any_automation_error else 0 + if any_automation_error or any_assessment_fail: + return 1 + return 2 if any_warn else 0 + + +# --------------------------------------------------------------------------- +# CLI entry point +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="ADR-001 consistency checker — bidirectional file↔DB validation", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--repo", metavar="SLUG", + help="Registered repo slug (e.g. the-custodian)") + group.add_argument("--all", action="store_true", + help="Run checks against all repos with a resolvable path on this host") + group.add_argument("--here", metavar="PATH", nargs="?", const="", + help="Infer repo slug from git remote URL at PATH (default: CWD)") + group.add_argument("--renormalization-guide", action="store_true", + help="Print lifecycle renormalization rules and the add-next-guard checklist") + parser.add_argument("--fix", action="store_true", + help="Apply auto-fixable issues (status drift, repo mismatch, etc.)") + parser.add_argument("--remote", action="store_true", + help="Pull each repo before fixing; when used with --all, skips repos " + "that are already clean (no actionable issues, not behind remote). " + "Implies --fix.") + parser.add_argument("--max-seconds", type=int, default=DEFAULT_REMOTE_ALL_MAX_SECONDS, + help="Wall-clock budget for --remote --all before remaining repos are skipped " + f"(default: {DEFAULT_REMOTE_ALL_MAX_SECONDS}; 0 disables)") + parser.add_argument("--no-writeback", action="store_true", dest="no_writeback", + help="Disable DB→file status writeback (C-15) while keeping other fixes") + parser.add_argument( + "--bootstrap-empty-projection", + action="store_true", + help=( + "Rebuild authoritative legacy UUIDs only after proving the target " + "repository has zero projected workplans" + ), + ) + parser.add_argument("--archive-closed", action="store_true", + help="Move closed root workplans to workplans/archived/YYMMDD-*.md") + parser.add_argument("--archive-workplan", metavar="ID_OR_FILE", default=None, + help="When archiving, only move the matching workplan id or filename") + parser.add_argument("--archive-date", metavar="YYMMDD", default=None, + help="Completion date prefix for --archive-closed (default: today)") + parser.add_argument("--repo-path", metavar="PATH", default=None, + help="Override the local repo path (useful when the DB has a different " + "machine's path). Takes priority over host_paths and local_path.") + parser.add_argument("--api-base", default="http://127.0.0.1:8000", + help="State Hub API base URL") + parser.add_argument("--json", action="store_true", dest="as_json", + help="Output JSON instead of human-readable text") + args = parser.parse_args() + + if args.renormalization_guide: + if args.as_json: + print(json.dumps({ + "rules": [ + { + "check_id": rule.check_id, + "invariant": rule.invariant, + "detection": rule.detection, + "repair": rule.repair, + "test_anchor": rule.test_anchor, + } + for rule in RENORMALIZATION_RULES + ], + "add_next_guard": list(RENORMALIZATION_NEXT_GUARD_CHECKLIST), + }, indent=2)) + else: + print(render_renormalization_guide()) + sys.exit(0) + + import os as _os + no_wb = getattr(args, "no_writeback", False) + do_fix = args.fix or args.remote + if args.bootstrap_empty_projection and (not do_fix or args.all or args.remote): + parser.error( + "--bootstrap-empty-projection requires --fix for one local repository " + "and cannot be combined with --all or --remote" + ) + + # --here: infer slug from git remote URL, then run as single-repo check/fix + if args.here is not None: + search_path = args.here or _os.getcwd() + inferred = _infer_slug_from_path(args.api_base, search_path) + if inferred is None: + print( + f"ERROR: No registered repo with a matching remote_url found at '{search_path}'.", + file=sys.stderr, + ) + print( + " Register the repo first: POST /repos/ with remote_url set.", + file=sys.stderr, + ) + sys.exit(1) + inferred_slug, git_root = inferred + print(f" Detected: {inferred_slug} ({git_root})") + if do_fix: + reports = [ + fix_repo( + args.api_base, + inferred_slug, + git_root, + no_writeback=no_wb, + bootstrap_empty_projection=args.bootstrap_empty_projection, + ) + ] + else: + reports = [check_repo(args.api_base, inferred_slug, git_root)] + if args.archive_closed: + moved = archive_closed_workplans(git_root, args.archive_date, args.archive_workplan) + reports[0].fixes_applied.extend(f"archive: {m}" for m in moved) + # --remote --all: smart pull+fix across all repos + elif args.remote and args.all: + with run_lock("consistency-remote-all") as acquired: + if not acquired: + print( + "SKIP: another fix-consistency-remote --all run is already active", + file=sys.stderr, + ) + sys.exit(0) + reports = fix_all_remote( + args.api_base, + no_writeback=no_wb, + max_seconds=args.max_seconds, + ) + if reports is None: + sys.exit(1) + if not reports: + sys.exit(0) + else: + # Resolve repo list + hostname = _sweep_hostname() + repo_slugs: list[str] = [] + if args.all: + repos = _api_get(args.api_base, "/repos") + if not isinstance(repos, list): + print("ERROR: Could not fetch repos from state-hub API", file=sys.stderr) + sys.exit(1) + repo_slugs = [ + r["slug"] for r in repos + if r.get("local_path") or (r.get("host_paths") or {}).get(hostname) + ] + if not repo_slugs: + print( + f"No repos with a path registered for host '{hostname}'.", + file=sys.stderr, + ) + sys.exit(0) + else: + repo_slugs = [args.repo] + + # --repo-path only applies to single-repo runs; silently ignored with --all + path_override = args.repo_path if not args.all else None + + if args.remote: + # Single-repo remote: pull first, then fix + slug = repo_slugs[0] + repo = _api_get(args.api_base, f"/repos/{slug}") + path = resolve_repo_path(repo or {}, path_override) if repo else (path_override or "") + if path: + pull_ok, pull_msg = _git_pull(path) + prefix = "pull" if pull_ok else "pull WARN" + print(f" {prefix}: {pull_msg}") + report = fix_repo(args.api_base, slug, path_override, no_writeback=no_wb) + reports = [report] + elif do_fix: + reports = [ + fix_repo( + args.api_base, + slug, + path_override, + no_writeback=no_wb, + bootstrap_empty_projection=args.bootstrap_empty_projection, + ) + for slug in repo_slugs + ] + else: + reports = [check_repo(args.api_base, slug, path_override) for slug in repo_slugs] + + if args.archive_closed: + for report in reports: + moved = archive_closed_workplans(report.repo_path, args.archive_date, args.archive_workplan) + report.fixes_applied.extend(f"archive: {m}" for m in moved) + + if args.as_json: + output = ( + report_to_dict(reports[0]) + if len(reports) == 1 + else [report_to_dict(r) for r in reports] + ) + print(json.dumps(output, indent=2)) + else: + for report in reports: + print(render_text(report)) + print() + + sys.exit(consistency_exit_code(reports, remote_all=args.remote and args.all)) + + +if __name__ == "__main__": + main() diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r5/state-hub/scripts/sync_classification_allowed.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r5/state-hub/scripts/sync_classification_allowed.py new file mode 100644 index 00000000..322770f5 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r5/state-hub/scripts/sync_classification_allowed.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Sync the canon classification allowed-values into the chart (CUST-WP-0067-T09). + +The State Hub validates repository classification against +``the-custodian/canon/standards/repo-classification.allowed.yaml``. That file is +canon and must stay authoritative, but a container has no the-custodian +checkout, so a copy has to travel with the release. + +A copy that nobody checks is a copy that silently drifts. This script owns the +copy: ``--check`` fails when it diverges from canon, so the release can refuse +to ship a stale vocabulary rather than validate against yesterday's rules. + +Usage: + python scripts/sync_classification_allowed.py # write the copy + python scripts/sync_classification_allowed.py --check # verify, exit 1 on drift +""" +from __future__ import annotations + +import argparse +import difflib +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +CHART_COPY = ( + REPO_ROOT + / "deploy/railiance/apps/charts/state-hub/files/repo-classification.allowed.yaml" +) +CANON_CANDIDATES = ( + Path("/home/worsch/the-custodian/canon/standards/repo-classification.allowed.yaml"), + Path("/home/tegwick/the-custodian/canon/standards/repo-classification.allowed.yaml"), + REPO_ROOT.parent / "the-custodian/canon/standards/repo-classification.allowed.yaml", +) + +HEADER = ( + "# GENERATED — do not edit.\n" + "# Synced from the-custodian/canon/standards/repo-classification.allowed.yaml\n" + "# by scripts/sync_classification_allowed.py (CUST-WP-0067-T09).\n" + "# Canon is authoritative; this copy exists only so the container has one.\n" +) + + +def find_canon() -> Path: + for candidate in CANON_CANDIDATES: + if candidate.is_file(): + return candidate + raise SystemExit( + "ERROR: canon allowed-values not found. Looked in:\n " + + "\n ".join(str(c) for c in CANON_CANDIDATES) + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--check", + action="store_true", + help="Verify the chart copy matches canon; exit 1 on drift", + ) + args = parser.parse_args() + + canon = find_canon() + desired = HEADER + canon.read_text(encoding="utf-8") + + if args.check: + if not CHART_COPY.is_file(): + print(f"DRIFT: {CHART_COPY} is missing; run without --check to create it") + return 1 + current = CHART_COPY.read_text(encoding="utf-8") + if current != desired: + print(f"DRIFT: chart copy differs from canon ({canon})") + sys.stdout.writelines( + difflib.unified_diff( + current.splitlines(keepends=True), + desired.splitlines(keepends=True), + fromfile="chart copy", + tofile="canon", + ) + ) + return 1 + print(f"OK: chart copy matches canon ({canon})") + return 0 + + CHART_COPY.parent.mkdir(parents=True, exist_ok=True) + CHART_COPY.write_text(desired, encoding="utf-8") + print(f"Synced {canon} -> {CHART_COPY}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r5/state-hub/tests/test_frozen_binding_guard_isolated.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r5/state-hub/tests/test_frozen_binding_guard_isolated.py new file mode 100644 index 00000000..2512d2f8 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r5/state-hub/tests/test_frozen_binding_guard_isolated.py @@ -0,0 +1,200 @@ +from pathlib import Path +import ast, asyncio, importlib.util, types, uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +import pytest + +ROOT = Path(__file__).resolve().parents[1] +spec=importlib.util.spec_from_file_location('historical',ROOT/'api/services/historical_projection.py') +h=importlib.util.module_from_spec(spec);spec.loader.exec_module(h) +B=h.FROZEN_CLUSTER_BINDINGS[0] +def binding(_bound=B,**changes): + B=_bound + d=dict(workplan_id=uuid.UUID(B['uuid']),repo_slug=B['repo'],filename=Path(B['path']).name,relative_path=B['path'],archived=True,status='finished');d.update(changes);return d +class Obj: + def __init__(self,**kw):self.__dict__.update(kw);self.writes=[] + def __setattr__(self,k,v): + if 'writes' in self.__dict__:self.writes.append(k) + object.__setattr__(self,k,v) +class Entry(Obj): + def model_dump(self):return {k:v for k,v in self.__dict__.items() if k!='writes'} +class Col: + def in_(self,x):return self + def __eq__(self,x):return self +class Query: + def __init__(self):self.locked=False;self.refreshed=False;self.ordered=False + def where(self,*a):return self + def order_by(self,*a):self.ordered=True;return self + def with_for_update(self):self.locked=True;return self + def execution_options(self,**kw):self.refreshed=kw.get("populate_existing") is True;return self +class Model: id=Col();slug=Col();workplan_id=Col() +class Result: + def __init__(self,values):self.values=values + def scalars(self):return self + def all(self):return self.values + def scalar_one_or_none(self):return self.values[0] if self.values else None +class Session: + def __init__(self,rows):self.results=iter(rows);self.commits=0;self.queries=[] + async def execute(self,q):self.queries.append(q);return Result(next(self.results)) + async def commit(self):self.commits+=1 +class HTTPException(Exception): + def __init__(self,status_code,detail):self.status_code=status_code;super().__init__(detail) +def extracted(path,name,extra): + tree=ast.parse((ROOT/path).read_text());node=next(n for n in tree.body if isinstance(n,(ast.FunctionDef,ast.AsyncFunctionDef)) and n.name==name) + node.decorator_list=[];node.returns=None + for a in node.args.args + node.args.kwonlyargs:a.annotation=None + node.args.defaults=[] + ns=dict(Path=Path,datetime=datetime,timezone=timezone,**extra) + exec(compile(ast.Module(body=[node],type_ignores=[]),str(path),'exec'),ns);return ns[name] +def actual_report(): + tree=ast.parse((ROOT/'scripts/consistency_check.py').read_text()) + nodes=[n for n in tree.body if (isinstance(n,ast.ClassDef) and n.name in {'Issue','ConsistencyReport'}) or (isinstance(n,ast.AnnAssign) and isinstance(n.target,ast.Name) and n.target.id=='_AUTOMATION_ERROR_CHECKS')] + ns=dict(dataclass=dataclass,field=field,_AUTOMATION_ERROR_CHECKS=frozenset({'C-00','C-44'})) + exec(compile(ast.Module(body=nodes,type_ignores=[]),'actual_report','exec'),ns) + return ns['ConsistencyReport']('railiance-cluster','/unused') + +def closure(B=B): + repo=Obj(id=uuid.uuid4());parent=Obj(id=uuid.UUID(B['uuid']),repo_id=repo.id,status='finished') + tasks=[Obj(id=uuid.UUID(t['uuid']),workplan_id=parent.id,status='done') for t in B['tasks']] + return repo,parent,tasks +async def run_api(entries,repo,parent,tasks,ordinary=None): + rows=([parent] if parent else [])+([ordinary] if ordinary else []) + session=Session([rows,[repo] if repo else [],tasks]) + fn=extracted('api/routers/workstreams.py','sync_workplan_bindings',dict(frozen_cluster_binding=h.frozen_cluster_binding,validate_frozen_cluster_rows=h.validate_frozen_cluster_rows,HTTPException=HTTPException,select=lambda *_:Query(),Workplan=Model,Task=Model,ManagedRepo=Model,_invalidate_workplan_index_cache=lambda:None)) + result=await fn(Obj(bindings=[Entry(**e) for e in entries]),session) + return result,session +@pytest.mark.parametrize('change',[dict(repo_slug='foreign'),dict(workplan_id=uuid.uuid4()),dict(relative_path='workplans/other.md'),dict(filename='other.md'),dict(archived=False),dict(status='active')]) +def test_metadata_refuses(change): + with pytest.raises(ValueError):h.frozen_cluster_binding(binding(**change)) +def test_unrelated_binding_unchanged():assert h.frozen_cluster_binding(binding(workplan_id=uuid.uuid4(),filename='ordinary.md',relative_path='workplans/ordinary.md',repo_slug='other')) is None +@pytest.mark.parametrize('fault',['parent_missing','foreign_repo','parent_active','task_missing','task_active','task_foreign','repo_missing']) +@pytest.mark.parametrize("bound",h.FROZEN_CLUSTER_BINDINGS) +def test_mixed_batch_refusal_precedes_all_writes(fault,bound): + repo,parent,tasks=closure(bound);ordinary=Obj(id=uuid.uuid4()) + if fault=='repo_missing':repo=None + if fault=='parent_missing':parent=None + if fault=='foreign_repo':parent.repo_id=uuid.uuid4() + if fault=='parent_active':parent.status='active' + if fault=='task_missing':tasks.pop() + if fault=='task_active':tasks[0].status='progress' + if fault=='task_foreign':tasks[0].workplan_id=uuid.uuid4() + if parent:parent.writes.clear() + for t in tasks:t.writes.clear() + normal=binding(workplan_id=ordinary.id,repo_slug='ordinary',filename='ordinary.md',relative_path='workplans/ordinary.md') + with pytest.raises(HTTPException):asyncio.run(run_api([normal,binding(_bound=bound)],repo,parent,tasks,ordinary)) + assert ordinary.writes==[] + if parent:assert parent.writes==[] + assert all(t.writes==[] for t in tasks) +@pytest.mark.parametrize("bound",h.FROZEN_CLUSTER_BINDINGS) +def test_exact_frozen_no_setters_or_commit(bound): + repo,parent,tasks=closure(bound);result,session=asyncio.run(run_api([binding(_bound=bound)],repo,parent,tasks)) + assert result==dict(updated=0,received=1,preserved=1);assert session.commits==0;assert parent.writes==[] + assert all(q.locked and q.refreshed and q.ordered for q in (session.queries[0],session.queries[2])) +@pytest.mark.parametrize("bound",h.FROZEN_CLUSTER_BINDINGS) +def test_mixed_valid_preserves_frozen_and_updates_ordinary(bound): + repo,parent,tasks=closure(bound);ordinary=Obj(id=uuid.uuid4()) + normal=binding(workplan_id=ordinary.id,repo_slug='ordinary',filename='ordinary.md',relative_path='workplans/ordinary.md') + result,session=asyncio.run(run_api([normal,binding(_bound=bound)],repo,parent,tasks,ordinary)) + assert result==dict(updated=1,received=2,preserved=1);assert session.commits==1;assert parent.writes==[] + assert ordinary.writes==['backing_filename','backing_relative_path','backing_archived','backing_synced_at'] +@pytest.mark.parametrize('tamper',['metadata','bytes','symlink','missing_scan','missing_uuid','valid']) +@pytest.mark.parametrize("bound_index", range(3)) +def test_cli_exact_source_omission_or_whole_batch_refusal(tmp_path,tamper,bound_index): + bound=h.FROZEN_CLUSTER_BINDINGS[bound_index] + import shutil + infos=[] + for entry in h.FROZEN_CLUSTER_BINDINGS: + target=tmp_path/entry['path'];target.parent.mkdir(parents=True,exist_ok=True) + shutil.copyfile(Path('/home/worsch/railiance-cluster')/entry['path'],target) + infos.append((target,dict(state_hub_workstream_id=entry['uuid'],status='finished'),'')) + path=tmp_path/bound['path'] + meta=dict(state_hub_workstream_id=bound['uuid'],status='finished') + if tamper=='metadata':meta['status']='active' + if tamper=='missing_uuid':meta.pop('state_hub_workstream_id') + if tamper=='bytes':path.write_text(path.read_text()+'\n') + if tamper=='symlink':path.unlink();path.symlink_to(Path('/home/worsch/railiance-cluster')/bound['path']) + ordinary=tmp_path/'workplans/ordinary.md';ordinary.write_text('ordinary') + if tamper=='missing_scan':infos.pop(bound_index) + else:infos[bound_index]=(path,meta,'') + calls=[];report=actual_report() + fn=extracted('scripts/consistency_check.py','_sync_workplan_bindings',dict(FROZEN_CLUSTER_BINDINGS=h.FROZEN_CLUSTER_BINDINGS,frozen_cluster_binding=h.frozen_cluster_binding,normalise_workstream_status=lambda s:s,workplan_display_path=lambda root,p:p.relative_to(root).as_posix(),_api_put=lambda *a:calls.append(a) or dict(updated=1,received=1))) + fn('http://unused','railiance-cluster',[(ordinary,dict(state_hub_workstream_id=str(uuid.uuid4()),status='active'),''),*infos],tmp_path,report) + if tamper=='valid':assert len(calls)==1 and len(calls[0][2]['bindings'])==1 + else:assert calls==[] and report.failures[0].check_id=='C-44' and report.automation_errors + + +def test_api_tampered_mixed_batch_refuses_before_queries_or_assignments(): + repo,parent,tasks=closure();ordinary=Obj(id=uuid.uuid4()) + with pytest.raises(HTTPException): + asyncio.run(run_api([binding(workplan_id=ordinary.id,repo_slug='ordinary',filename='ordinary.md',relative_path='workplans/ordinary.md'),binding(archived=False)],repo,parent,tasks,ordinary)) + assert ordinary.writes==[] and parent.writes==[] + + +def test_foreign_repo_same_filename_path_is_ordinary(): + assert h.frozen_cluster_binding(binding(workplan_id=uuid.uuid4(),repo_slug='foreign')) is None + + +@pytest.mark.parametrize('entry',h.FROZEN_CLUSTER_BINDINGS) +def test_all_three_registry_bindings_are_guarded(entry): + good=dict(workplan_id=entry['uuid'],repo_slug=entry['repo'],filename=Path(entry['path']).name,relative_path=entry['path'],archived=True,status='finished') + assert h.frozen_cluster_binding(good) is entry + with pytest.raises(ValueError):h.frozen_cluster_binding(dict(good,status='active')) + + +def test_real_cli_exit_criterion_is_nonzero_including_remote_all(): + report=actual_report();report.add(severity='FAIL',check_id='C-44',message='refused') + fn=extracted('scripts/consistency_check.py','consistency_exit_code',{}) + assert fn([report])==1 and fn([report],remote_all=True)==1 + + +@pytest.mark.parametrize('check,key,protected_kind',[ + ('C-04','ws_id','parent'),('C-10','task_id','task'),('C-22','task_id','task'), + ('C-09','db_id','parent'),('C-23','wp_file','path'), +]) +@pytest.mark.parametrize('bound',h.FROZEN_CLUSTER_BINDINGS) +def test_complete_fix_batch_preflight_before_ordinary_writes(check,key,protected_kind,bound): + report=actual_report();report.repo_path='/tmp/fixture-cluster' + report.add(severity='WARN',check_id='C-05',message='ordinary first',fixable=True,_fix_context={'ws_id':str(uuid.uuid4()),'field':'title','value':'ordinary'}) + value=bound['uuid'] if protected_kind=='parent' else bound['tasks'][0]['uuid'] if protected_kind=='task' else str(Path(report.repo_path)/bound['path']) + report.add(severity='WARN',check_id=check,message='protected later',fixable=True,db_id=value if key=='db_id' else '',_fix_context={key:value}) + guard=extracted('scripts/consistency_check.py','_refuse_frozen_fix_batch',dict(FROZEN_CLUSTER_BINDINGS=h.FROZEN_CLUSTER_BINDINGS)) + assert guard(report,'railiance-cluster') and report.automation_errors + calls=[] + fn=extracted('scripts/consistency_check.py','fix_repo',dict(check_repo=lambda *a,**kw:report,_refuse_frozen_fix_batch=guard,_api_post=lambda *a:calls.append(a),_api_patch=lambda *a:calls.append(a))) + assert fn('http://unused','railiance-cluster',None,False,False) is report and calls==[] + +def test_unrelated_fix_context_not_refused(): + report=actual_report();report.add(severity='WARN',check_id='C-05',message='ordinary',fixable=True,_fix_context={'ws_id':str(uuid.uuid4())}) + guard=extracted('scripts/consistency_check.py','_refuse_frozen_fix_batch',dict(FROZEN_CLUSTER_BINDINGS=h.FROZEN_CLUSTER_BINDINGS)) + assert not guard(report,'other') + + +def test_check_repo_guard_precedes_binding_sync_and_does_not_duplicate(): + tree=ast.parse((ROOT/'scripts/consistency_check.py').read_text()) + node=next(n for n in tree.body if isinstance(n,ast.FunctionDef) and n.name=='check_repo') + guard_lines=[n.lineno for n in ast.walk(node) if isinstance(n,ast.Call) and isinstance(n.func,ast.Name) and n.func.id=='_refuse_frozen_fix_batch'] + sync_lines=[n.lineno for n in ast.walk(node) if isinstance(n,ast.Call) and isinstance(n.func,ast.Name) and n.func.id=='_sync_workplan_bindings'] + assert len(guard_lines)==len(sync_lines)==1 and guard_lines[0], wait_until: YYYY-MM-DD (CUST-WP-0074 rules 1/6) + C-40 task-wait-blocker-satisfied WARN No wait task whose depends_on targets are all terminal, whose decision_id is resolved, or whose wait_until has passed (CUST-WP-0074 rules 3/4) + C-41 task-wait-qualifier-drift WARN Yes needs_human/blocking_reason/decision_id/blocked_on/wait_until differ between file and DB (file wins) + C-42 workplan-blocked-no-wait WARN No blocked workplan has no task in status wait (CUST-WP-0074-T06) + C-43 decision-supersession-unlinked WARN No resolved decision of this repo says "supersedes " but the cited decision is not superseded_by it (STATE-WP-0097) + (C-20 also indexes task-block depends_on as workplan_dependencies rows with from_task_id set) + (finished¬DoD-Ok is listed by `statehub quality-debt`, not per-file C-warn — avoids historical flood) + +Usage: + python scripts/consistency_check.py --repo SLUG [--fix] [--no-writeback] [--json] [--api-base URL] + python scripts/consistency_check.py --all [--fix] [--no-writeback] [--json] [--api-base URL] + python scripts/consistency_check.py --here [PATH] [--fix] [--no-writeback] [--json] [--api-base URL] + +Identifiers (ADR-007 / RMGR-WP-0005): + C-06 / C-11 / C-32 derive UUIDv5 identifiers from the fleet namespace and + canonical record id. Any instance may reconcile; independent instances + produce the same identifier and byte-identical writeback. + +Exit codes (single-repo / local CLI): + 0 — clean (no FAILs or WARNs; INFOs are allowed) + 1 — one or more assessment FAILs or automation ERRORs (C-00) present + 2 — warnings-only strict CLI result (no FAILs, but WARNs present) + +Exit codes (--remote --all scheduled sweep): + 0 — automation completed and documented results (assessment failures OK) + 1 — automation error: API unreachable, repo list fetch failed, C-00 on + any repo, or other infrastructure fault that prevented a full run + +Assessment failures (C-01..C-24 except C-00) are repo hygiene gaps recorded +in the sweep report for later improvement. They do not fail the scheduler. + +Agent/operator Make wrappers normalize exit code 2 to shell success while +preserving visible warning output. Use the direct script when a machine caller +needs to distinguish clean from warnings-only. +""" +from __future__ import annotations + +import argparse +import os +import json +import re +import shutil +import socket +import subprocess +import sys +import time +import uuid +from contextlib import contextmanager +from dataclasses import dataclass, field +from collections import Counter +from datetime import date as _date, datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +_REPO_ROOT = Path(__file__).resolve().parent.parent +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) + +def _sweep_hostname() -> str: + """Hostname for host_paths resolution; override when pod hostname != fleet FQDN.""" + return os.environ.get("STATE_HUB_SWEEP_HOSTNAME", "").strip() or socket.gethostname() + + +from api.workplan_status import ( # noqa: E402 + CANONICAL_WORKSTREAM_STATUSES, + CLOSED_WORKSTREAM_STATUSES, + LEGACY_WORKSTREAM_STATUS_ALIASES, + OPEN_WORKSTREAM_STATUSES, + SUPPORTED_WORKSTREAM_STATUSES, + normalize_workstream_status as _normalize_workstream_status, + ready_review_status, +) +from api.classification import ( # noqa: E402 + CLASSIFICATION_FILENAME, + load_classification_file, + resolve_topic_domain_slug, +) +from api.services.lifecycle import should_activate_parent_for_active_tasks # noqa: E402 +from api.services.historical_projection import ( # noqa: E402 + BINDINGS as _HISTORICAL_BINDINGS, FROZEN_CLUSTER_BINDINGS, frozen_cluster_binding, +) +from api.task_status import ( # noqa: E402 + CANONICAL_TASK_STATUSES, + OPEN_TASK_STATUSES, + TASK_STATUS_ORDER, + TERMINAL_TASK_STATUSES, + normalize_task_status, +) +from api.work_record_flavor import ( # noqa: E402 + FLAVOR_PROMOTION_REASONS, + WORK_RECORD_FLAVORS, + is_residual_flavor, + normalize_flavor, + normalize_promotion_reason, +) + +_WORK_RECORD_NAMESPACE_UUID = uuid.UUID("a4058507-5c4a-5a00-ab06-fffa4fb46009") + + +def _derived_work_record_uuid(record_id: str) -> str: + return str(uuid.uuid5(_WORK_RECORD_NAMESPACE_UUID, f"helixforge\n{record_id}")) + +try: + import yaml as _yaml + _HAS_YAML = True +except ImportError: + _HAS_YAML = False + +try: + import httpx as _httpx + _HAS_HTTPX = True +except ImportError: + _HAS_HTTPX = False + + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_LEGACY_METER_COMPONENT = "state-hub.fix-consistency" + + +def _legacy_meter_headers() -> dict[str, str]: + return {"X-StateHub-Component": _LEGACY_METER_COMPONENT} + + +_TASK_BLOCK_RE = re.compile(r"```task\s*\n(.*?)\n```", re.DOTALL) +_HEADING_RE = re.compile(r"^(#{1,4})\s+(.+?)$", re.MULTILINE) +_ARCHIVED_WP_RE = re.compile(r"^\d{6}-(.+\.md)$") +VALID_WP_STATUSES = set(CANONICAL_WORKSTREAM_STATUSES) +SUPPORTED_WP_STATUSES = set(SUPPORTED_WORKSTREAM_STATUSES) +VALID_TASK_STATUSES = set(CANONICAL_TASK_STATUSES) +VALID_TASK_PRIORITIES = {"low", "medium", "high", "critical"} +VALID_DEP_RELATIONSHIPS = {"blocks", "starts_after", "informs", "soft_dependency"} +DEFAULT_REMOTE_ALL_MAX_SECONDS = int(os.environ.get("CONSISTENCY_REMOTE_ALL_MAX_SECONDS", "300")) +STALE_UNREAD_DAYS = int(os.environ.get("INBOX_STALE_DAYS", "3")) +_API_GET_RETRIES = int(os.environ.get("CONSISTENCY_API_GET_RETRIES", "3")) +_API_GET_RETRY_BASE_DELAY = float(os.environ.get("CONSISTENCY_API_GET_RETRY_DELAY", "0.5")) + +_WP_FILE_PREFIX_RE = re.compile(r"^([A-Za-z][A-Za-z0-9-]*-WP)-\d+", re.IGNORECASE) +_WP_FILE_BARE_RE = re.compile(r"^(WP)-\d+", re.IGNORECASE) +_WP_ID_PREFIX_RE = re.compile(r"^([A-Z][A-Z0-9-]*-WP)-\d+", re.IGNORECASE) +_WP_ID_BARE_RE = re.compile(r"^(WP)-\d+", re.IGNORECASE) +_BLOCKED_ON_RE = re.compile(r"^message-from:(?P.+)$") +_WAIT_UNTIL_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") +_WORK_REQUEST_RE = re.compile( + r"\b(implement|workplan|multi-?step|please\s+(implement|add|create|fix|build))\b", + re.IGNORECASE, +) +_OPEN_WORKPLAN_STATUSES = {"proposed", "ready", "active", "blocked", "backlog"} + + +def _check_repo_manager_conformance(repo_dir: Path, report: "ConsistencyReport") -> None: + """C-35: consume Repo Manager's conformance contract without reimplementing it.""" + configured = os.environ.get("RMGR_BIN", "").strip() + candidates = [configured] if configured else [] + candidates.extend( + [ + str(Path.home() / "repo-manager" / ".venv" / "bin" / "rmgr"), + shutil.which("rmgr") or "", + ] + ) + executable = next((item for item in candidates if item and Path(item).is_file()), None) + if executable is None: + report.add( + severity="INFO", + check_id="C-35", + message="Repo Manager conformance adapter unavailable; set RMGR_BIN or install rmgr", + fixable=False, + ) + return + try: + completed = subprocess.run( + [executable, "conform", "--path", str(repo_dir)], + capture_output=True, + text=True, + check=False, + timeout=30, + ) + payload = json.loads(completed.stdout) + except (OSError, subprocess.TimeoutExpired, json.JSONDecodeError) as exc: + report.add( + severity="INFO", + check_id="C-35", + message=f"Repo Manager conformance adapter could not run: {exc}", + fixable=False, + ) + return + for finding in payload.get("findings") or []: + severity = str(finding.get("severity") or "warning") + report.add( + severity="WARN" if severity in {"missing", "contradictory", "warning"} else "INFO", + check_id="C-35", + message=( + f"Repo Manager {severity}: {finding.get('path') or '(repository)'}: " + f"{finding.get('message') or finding.get('code') or 'conformance finding'}" + ), + file_path=finding.get("path"), + fixable=False, + ) + +# Legacy file/API aliases translated before comparison and PATCHing. +FILE_TO_DB_WORKSTREAM_STATUS: dict[str, str] = dict(LEGACY_WORKSTREAM_STATUS_ALIASES) + +# Ordinal ranking for task statuses used by the no-regress rule (T01/C-15). +STATUS_ORDER: dict[str, int] = dict(TASK_STATUS_ORDER) + + +def normalise_decision_type(meta: dict) -> str: + """Map a file's decision_type onto the hub's two values, made and pending. + + Repos record decided outcomes with words like ``approved``; the hub rejects + anything else with a 422, which left those decisions unindexed. + """ + value = str(meta.get("decision_type") or "").strip().lower() + if value in {"made", "pending"}: + return value + status = str(meta.get("status") or "").strip().lower() + if status == "resolved" or value in { + "approved", "accepted", "decided", "rejected", "declined", "resolved", + }: + return "made" + return "pending" + + +def historical_view_paths(repo_slug: str) -> set[str]: + """Repo-relative paths of bound historical views that share a parent row. + + A split view carries its parent's UUID but does not supply the parent's + fields; only the bound parent file does (see historical_projection). Letting + each view write the shared row made its status flip on every run. + """ + return { + entry["path"] + for entry in _HISTORICAL_BINDINGS + if entry["repo"] == repo_slug and entry["record_id"] != entry["parent_record_id"] + } + + +def normalise_workstream_status(status: str, *, has_started: bool | None = None) -> str: + """Translate a workplan file status value to its DB-canonical equivalent.""" + return _normalize_workstream_status(status, has_started=has_started) + + +def normalise_task_status(status: Any, *, default: str = "todo") -> str: + try: + return normalize_task_status(status, default=default) + except ValueError: + return default + + +def canonical_workplan_filename(path: Path) -> str: + """Return the workplan filename without an archive completion-date prefix.""" + return _ARCHIVED_WP_RE.sub(r"\1", path.name) + + +def workplan_display_path(repo_dir: Path, path: Path) -> str: + """Stable relative path for reports, including archived/ when applicable.""" + try: + return str(path.relative_to(repo_dir)) + except ValueError: + return path.name + + +def iter_workplan_files(workplans_dir: Path, include_archived: bool = True) -> list[Path]: + """Return active root workplans plus archived workplans when requested.""" + files = [ + path for path in sorted(workplans_dir.glob("*.md")) + if path.name not in _NON_WORKPLAN_WORKPLAN_FILES + ] + archived_dir = workplans_dir / "archived" + if include_archived and archived_dir.is_dir(): + files.extend(sorted(archived_dir.glob("*.md"))) + return files + + +# C-00 marks infrastructure/automation faults (API down, repo missing in DB). +# All other FAIL severities are assessment findings for follow-up. +_AUTOMATION_ERROR_CHECKS: frozenset[str] = frozenset({"C-00", "C-44"}) +_NON_WORKPLAN_WORKPLAN_FILES: frozenset[str] = frozenset({"README.md"}) + + +# --------------------------------------------------------------------------- +# Data types +# --------------------------------------------------------------------------- + +@dataclass +class Issue: + severity: str # FAIL | WARN | INFO + check_id: str # C-01 through C-12 + message: str + file_path: str = "" + db_id: str = "" + file_value: str = "" + db_value: str = "" + fixable: bool = False + _fix_context: dict = field(default_factory=dict, repr=False) + + +@dataclass +class ConsistencyReport: + repo_slug: str + repo_path: str + issues: list[Issue] = field(default_factory=list) + fixes_applied: list[str] = field(default_factory=list) + # Commit the workplans/ scan read (STATE-WP-0094): a full 40-hex SHA, or + # None with a machine-readable reason. Never a branch name or a guess. + source_revision: str | None = None + source_revision_reason: str | None = "not_scanned" + + def add(self, **kwargs) -> Issue: + issue = Issue(**kwargs) + self.issues.append(issue) + return issue + + @property + def failures(self) -> list[Issue]: + return [i for i in self.issues if i.severity == "FAIL"] + + @property + def automation_errors(self) -> list[Issue]: + return [ + i for i in self.issues + if i.severity == "FAIL" and i.check_id in _AUTOMATION_ERROR_CHECKS + ] + + @property + def assessment_failures(self) -> list[Issue]: + return [ + i for i in self.issues + if i.severity == "FAIL" and i.check_id not in _AUTOMATION_ERROR_CHECKS + ] + + @property + def warnings(self) -> list[Issue]: + return [i for i in self.issues if i.severity == "WARN"] + + @property + def infos(self) -> list[Issue]: + return [i for i in self.issues if i.severity == "INFO"] + + +@dataclass(frozen=True) +class RenormalizationRule: + check_id: str + invariant: str + detection: str + repair: str + test_anchor: str + + +RENORMALIZATION_RULES: tuple[RenormalizationRule, ...] = ( + RenormalizationRule( + check_id="C-23", + invariant="Planning-state workplans cannot contain active task work.", + detection=( + "Workplan status is proposed, ready, or backlog while a linked " + "task is progress or wait." + ), + repair=( + "ready/backlog: patch DB and file to active. " + "proposed: report only (ADR-007 decision 3)." + ), + test_anchor="tests/test_consistency_check.py::TestLifecycleRenormalization", + ), +) + + +RENORMALIZATION_NEXT_GUARD_CHECKLIST: tuple[str, ...] = ( + "Name the invariant and assign the next C-id.", + "Add rule metadata to RENORMALIZATION_RULES.", + "Add detection in check_repo before generic drift rules that could fight it.", + "Add the repair branch in fix_repo, using file writes only through helpers.", + "Add detection and repair tests under TestLifecycleRenormalization.", +) + + +@contextmanager +def run_lock(name: str): + """Hold a nonblocking process lock for long-running consistency modes.""" + try: + import fcntl + except ImportError: + yield True + return + + lock_path = Path(os.environ.get("CONSISTENCY_LOCK_DIR", "/tmp")) / f"custodian-{name}.lock" + lock_path.parent.mkdir(parents=True, exist_ok=True) + handle = lock_path.open("w", encoding="utf-8") + try: + try: + fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + yield False + return + handle.seek(0) + handle.truncate() + handle.write(f"{os.getpid()} {datetime.utcnow().isoformat()}Z\n") + handle.flush() + yield True + finally: + try: + fcntl.flock(handle, fcntl.LOCK_UN) + except OSError: + pass + handle.close() + + +# --------------------------------------------------------------------------- +# YAML / frontmatter parsing +# --------------------------------------------------------------------------- + +def _parse_yaml_block(raw: str) -> dict: + """Parse a YAML string; fallback to simple key:value if pyyaml unavailable.""" + if _HAS_YAML: + try: + return _yaml.safe_load(raw) or {} + except _yaml.YAMLError: + return {"_parse_error": True} + result: dict = {} + for line in raw.splitlines(): + if ":" in line and not line.startswith(" "): + k, _, v = line.partition(":") + result[k.strip()] = v.strip().strip('"').strip("'") + return result + + +def parse_frontmatter(text: str) -> tuple[dict, str]: + """Split YAML frontmatter from body. Returns ({}, text) if no frontmatter.""" + if not text.startswith("---"): + return {}, text + parts = text.split("---", 2) + if len(parts) < 3: + return {}, text + meta = _parse_yaml_block(parts[1].strip()) + return meta, parts[2] + + +def _clean_task_description(raw: str) -> str | None: + """Trim section chrome while preserving task markdown content.""" + lines = raw.splitlines() + while lines and not lines[0].strip(): + lines.pop(0) + while lines and not lines[-1].strip(): + lines.pop() + while lines and lines[-1].strip() in {"---", "***", "___"}: + lines.pop() + while lines and not lines[-1].strip(): + lines.pop() + while lines and lines[0].strip() in {"---", "***", "___"}: + lines.pop(0) + while lines and not lines[0].strip(): + lines.pop(0) + text = "\n".join(lines).strip() + return text or None + + +def parse_task_blocks(body: str) -> list[dict]: + """Extract task blocks, injecting title and markdown content from the task section.""" + headings = [ + (m.start(), len(m.group(1)), m.group(2).strip()) + for m in _HEADING_RE.finditer(body) + ] + task_matches = list(_TASK_BLOCK_RE.finditer(body)) + results = [] + for idx, m in enumerate(task_matches): + task = _parse_yaml_block(m.group(1).strip()) + prev = [(pos, level, text) for pos, level, text in headings if pos < m.start()] + prev_heading = prev[-1] if prev else None + if "title" not in task: + if prev_heading: + task["title"] = prev_heading[2] + + content_end = len(body) + if idx + 1 < len(task_matches): + content_end = min(content_end, task_matches[idx + 1].start()) + if prev_heading: + current_level = prev_heading[1] + next_peer_heading = next( + ( + pos + for pos, level, _text in headings + if pos > m.end() and level <= current_level + ), + None, + ) + if next_peer_heading is not None: + content_end = min(content_end, next_peer_heading) + else: + next_heading = next((pos for pos, _level, _text in headings if pos > m.end()), None) + if next_heading is not None: + content_end = min(content_end, next_heading) + + description = _clean_task_description(body[m.end():content_end]) + if description: + task["description"] = description + results.append(task) + return results + + +def get_tasks_from_workplan(meta: dict, body: str) -> list[dict]: + """Get tasks from workplan — handles both ```task``` blocks and tasks: YAML list.""" + blocks = parse_task_blocks(body) + if blocks: + return blocks + # Fallback: tasks embedded as a YAML list in frontmatter (activity-core style) + tasks = meta.get("tasks") + if isinstance(tasks, list): + return tasks + return [] + + +def _as_list(value: Any) -> list[str]: + if value is None: + return [] + if isinstance(value, list): + return [str(item).strip().strip('"') for item in value if str(item).strip()] + if isinstance(value, str): + return [item.strip().strip('"') for item in value.split(",") if item.strip()] + return [str(value).strip().strip('"')] + + +_TASK_RECORD_ID_RE = re.compile(r"-T\d{2,3}$") + + +def _dedupe_preserve(items: list[str]) -> list[str]: + seen: set[str] = set() + out: list[str] = [] + for item in items: + if item in seen: + continue + seen.add(item) + out.append(item) + return out + + +def _frontmatter_depends_on_workplans(meta: dict) -> list[str]: + """Canonical `depends_on` plus legacy `depends_on_workplans` alias.""" + combined = _as_list(meta.get("depends_on")) + _as_list(meta.get("depends_on_workplans")) + return _dedupe_preserve( + [item for item in combined if item and not _TASK_RECORD_ID_RE.search(item)] + ) + + +def _frontmatter_depends_on_tasks(meta: dict) -> list[str]: + from_alias = _as_list(meta.get("depends_on_tasks")) + from_depends_on = [ + item for item in _as_list(meta.get("depends_on")) if _TASK_RECORD_ID_RE.search(item) + ] + return _dedupe_preserve(from_alias + from_depends_on) + + +_UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I) + + +def _as_bool(value: Any) -> bool: + if isinstance(value, bool): + return value + return str(value).strip().lower() in {"true", "yes", "1", "on"} + + +def _task_wait_until(task: dict) -> str | None: + """``wait_until`` as an ISO date string, or None when absent or not a + ``YYYY-MM-DD`` date (PyYAML hands a bare date over as ``datetime.date``).""" + raw = task.get("wait_until") + if raw in (None, ""): + return None + if isinstance(raw, datetime): + raw = raw.date() + if isinstance(raw, _date): + return raw.isoformat() + text = str(raw).strip().strip('"').strip("'") + if not _WAIT_UNTIL_RE.match(text): + return None + try: + return _date.fromisoformat(text).isoformat() + except ValueError: + return None + + +def _task_wait_fields(task: dict) -> dict[str, Any]: + """File → hub fields that qualify a wait (CUST-WP-0074): needs_human, + blocking_reason, decision_id, blocked_on, wait_until. TaskCreate/TaskUpdate + require an intervention_note whenever needs_human is set; the blocking + reason is by rule 2 what the human must do, so it stands in when the block + has none.""" + needs_human = _as_bool(task.get("needs_human")) + blocking_reason = str(task.get("blocking_reason") or "").strip() or None + decision_id = str(task.get("decision_id") or "").strip().strip('"') or None + blocked_on = str(task.get("blocked_on") or "").strip().strip('"') or None + fields: dict[str, Any] = { + "needs_human": needs_human, + "blocking_reason": blocking_reason, + "decision_id": decision_id, + "blocked_on": blocked_on, + "wait_until": _task_wait_until(task), + } + if needs_human: + fields["intervention_note"] = ( + str(task.get("intervention_note") or "").strip() or blocking_reason + ) + return fields + + +_TASK_WAIT_FIELD_KEYS = ("needs_human", "blocking_reason", "decision_id", "blocked_on", "wait_until") + + +def _dependency_target_satisfied(record_id: str, status: str) -> bool: + """Rule 3: task targets are satisfied at done/cancel, workplan targets at + finished/archived.""" + if _TASK_RECORD_ID_RE.search(record_id): + return normalise_task_status(status, default="") in {"done", "cancel"} + return normalise_workstream_status(status) in CLOSED_WORKSTREAM_STATUSES + + +def _repo_decision_hub_ids(repo_dir: Path) -> dict[str, str]: + """Map decision record ids (e.g. CCR-2026-0004) to the hub uuid C-32 wrote + back into their YAML block.""" + out: dict[str, str] = {} + for md in sorted(repo_dir.rglob("*.md")): + if any(part in _WORK_RECORD_SKIP_DIRS for part in md.parts): + continue + try: + text = md.read_text(errors="replace") + except OSError: + continue + if "state_hub_decision_id" not in text: + continue + for block in _YAML_ONLY_FENCE_RE.findall(text): + meta = _parse_yaml_block(block.strip()) + if not isinstance(meta, dict) or meta.get("_parse_error"): + continue + rid = str(meta.get("id", "")).strip() + hub_id = str(meta.get("state_hub_decision_id", "")).strip().strip('"') + if rid and hub_id and hub_id not in ("~", "null", "None", "none"): + out[rid] = hub_id + return out + + +def _as_int_or_none(value: Any) -> int | None: + if value in (None, "", "~", "null", "None", "none"): + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +# --------------------------------------------------------------------------- +# File update helpers +# --------------------------------------------------------------------------- + +def _add_frontmatter_field(file_path: Path, key: str, value: str) -> None: + """Insert key: "value" into frontmatter before the closing --- line.""" + text = file_path.read_text(encoding="utf-8") + lines = text.split("\n") + close_idx = None + for i, line in enumerate(lines[1:], 1): + if line.strip() == "---": + close_idx = i + break + if close_idx is None: + return + lines.insert(close_idx, f'{key}: "{value}"') + file_path.write_text("\n".join(lines), encoding="utf-8") + + +def _patch_frontmatter_field(file_path: Path, key: str, value: str) -> bool: + """Update or insert a scalar frontmatter field without rewriting the file.""" + text = file_path.read_text(encoding="utf-8") + if not text.startswith("---"): + return False + lines = text.split("\n") + close_idx = None + for i, line in enumerate(lines[1:], 1): + if line.strip() == "---": + close_idx = i + break + if close_idx is None: + return False + + new_line = f"{key}: {value}" + for i in range(1, close_idx): + if re.match(rf"^\s*{re.escape(key)}\s*:", lines[i]): + if lines[i] == new_line: + return False + lines[i] = new_line + file_path.write_text("\n".join(lines), encoding="utf-8") + return True + + lines.insert(close_idx, new_line) + file_path.write_text("\n".join(lines), encoding="utf-8") + return True + + +def _inject_task_id_into_block( + file_path: Path, field_name: str, field_value: str, match_id: str +) -> bool: + """Inject state_hub_task_id into the ```task``` block whose id == match_id.""" + text = file_path.read_text(encoding="utf-8") + + def _replace(m: re.Match) -> str: + block_content = m.group(1) + task_meta = _parse_yaml_block(block_content.strip()) + if str(task_meta.get("id", "")) != match_id: + return m.group(0) + existing_val = task_meta.get(field_name) + if existing_val is not None and str(existing_val).strip() not in ("", "~", "null", "None", "none"): + return m.group(0) + # Replace existing null/~ line if present, otherwise append + new_content = re.sub( + rf"^{re.escape(field_name)}:.*$", + f'{field_name}: "{field_value}"', + block_content, + flags=re.MULTILINE, + ) + if new_content == block_content: + new_content = block_content.rstrip() + f"\n{field_name}: \"{field_value}\"" + return f"```task\n{new_content}\n```" + + new_text = _TASK_BLOCK_RE.sub(_replace, text) + if new_text != text: + file_path.write_text(new_text, encoding="utf-8") + return True + return False + + +def _inject_task_id_frontmatter_list( + file_path: Path, field_value: str, match_id: str +) -> bool: + """Inject state_hub_task_id into a task entry in frontmatter tasks: list.""" + if not _HAS_YAML: + return False + import yaml + text = file_path.read_text(encoding="utf-8") + meta, body = parse_frontmatter(text) + tasks = meta.get("tasks", []) + changed = False + for t in tasks: + if str(t.get("id", "")) == match_id and "state_hub_task_id" not in t: + t["state_hub_task_id"] = field_value + changed = True + if not changed: + return False + try: + new_meta_str = yaml.dump(meta, allow_unicode=True, default_flow_style=False) + parts = text.split("---", 2) + if len(parts) < 3: + return False + new_text = f"---\n{new_meta_str}---{parts[2]}" + file_path.write_text(new_text, encoding="utf-8") + return True + except Exception: + return False + + +# --------------------------------------------------------------------------- +# API helpers +# --------------------------------------------------------------------------- + +_API_CLIENTS: dict[tuple[str, object], Any] = {} + + +def _api_client(api_base: str) -> Any: + """Reuse one keep-alive pool per API/client implementation for this run.""" + key = (api_base.rstrip("/"), _httpx.Client) + client = _API_CLIENTS.get(key) + if client is None: + client = _httpx.Client( + base_url=api_base, + timeout=10.0, + follow_redirects=True, + headers=_legacy_meter_headers(), + ) + _API_CLIENTS[key] = client + return client + + +def _api_get( + api_base: str, + path: str, + params: dict | None = None, + *, + return_error: bool = False, +) -> Any: + if not _HAS_HTTPX: + return {"_error": "httpx is not installed"} if return_error else None + # Only append trailing slash to the path component, not to query strings + if "?" not in path and not path.endswith("/"): + path += "/" + filtered = {k: v for k, v in (params or {}).items() if v is not None} + last_error: Exception | None = None + for attempt in range(_API_GET_RETRIES): + try: + r = _api_client(api_base).get(path, params=filtered if filtered else None) + r.raise_for_status() + return r.json() + except _httpx.HTTPStatusError as exc: + if exc.response.status_code == 404: + return None + last_error = exc + if exc.response.status_code >= 500 and attempt < _API_GET_RETRIES - 1: + time.sleep(_API_GET_RETRY_BASE_DELAY * (attempt + 1)) + continue + if return_error: + return {"_error": str(exc)} + return None + except (_httpx.TimeoutException, _httpx.ConnectError, _httpx.NetworkError) as exc: + last_error = exc + if attempt < _API_GET_RETRIES - 1: + time.sleep(_API_GET_RETRY_BASE_DELAY * (attempt + 1)) + continue + if return_error: + return {"_error": str(exc)} + return None + except Exception as exc: + last_error = exc + if return_error: + return {"_error": str(exc)} + return None + if return_error and last_error is not None: + return {"_error": str(last_error)} + return None + + +def _api_patch(api_base: str, path: str, body: dict) -> Any: + if not _HAS_HTTPX: + return None + if not path.endswith("/"): + path += "/" + try: + r = _api_client(api_base).patch(path, json=body) + r.raise_for_status() + return r.json() + except Exception as exc: + # Return a sentinel dict so callers can distinguish "API error" from "success" + # and report it rather than silently dropping the fix. + return {"_error": str(exc)} + + +def _api_put(api_base: str, path: str, body: dict) -> Any: + if not _HAS_HTTPX: + return {"_error": "httpx is not installed"} + if not path.endswith("/"): + path += "/" + try: + r = _api_client(api_base).put(path, json=body, timeout=30.0) + r.raise_for_status() + return r.json() + except Exception as exc: + return {"_error": str(exc)} + + +_FROM_TASK_ID_SUPPORT: dict[str, bool] = {} + + +def _hub_accepts_from_task_id(api_base: str) -> bool: + """True when the hub's dependency-create schema declares from_task_id. + + Cached per api_base. An unreadable schema counts as unsupported so the + fixer never writes a task-scoped edge into a hub that would drop the + attribution silently. + """ + if api_base not in _FROM_TASK_ID_SUPPORT: + # Not via _api_get: it appends a trailing slash, which /openapi.json rejects. + try: + r = _api_client(api_base).get("/openapi.json") + spec = r.json() if r.status_code == 200 else {} + except Exception: + spec = {} + schemas = spec.get("components", {}).get("schemas", {}) if isinstance(spec, dict) else {} + _FROM_TASK_ID_SUPPORT[api_base] = any( + "from_task_id" in (schemas.get(name) or {}).get("properties", {}) + for name in ("WorkplanDependencyCreate", "WorkstreamDependencyCreate") + ) + return _FROM_TASK_ID_SUPPORT[api_base] + + +def _api_post(api_base: str, path: str, body: dict) -> Any: + if not _HAS_HTTPX: + return {"_error": "httpx is not installed"} + if not path.endswith("/"): + path += "/" + try: + r = _api_client(api_base).post(path, json=body) + r.raise_for_status() + return r.json() + except _httpx.HTTPStatusError as exc: + detail = exc.response.text + if len(detail) > 500: + detail = detail[:497] + "..." + return {"_error": f"{exc.response.status_code} {exc.response.reason_phrase}: {detail}"} + except Exception as exc: + return {"_error": str(exc)} + + +# --------------------------------------------------------------------------- +# Core check engine +# --------------------------------------------------------------------------- + +def resolve_repo_path(repo: dict, override: str | None = None) -> str: + """Resolve the local filesystem path for a repo on the current machine. + + Priority: + 1. Explicit --repo-path CLI override + 2. host_paths[current_hostname] — per-machine path registered via POST /repos/{slug}/paths/ + 3. local_path — legacy single-path field (backward compat) + """ + if override: + return override + hostname = _sweep_hostname() + host_paths = repo.get("host_paths") or {} + return host_paths.get(hostname) or repo.get("local_path") or "" + + +def _infer_slug_from_path(api_base: str, path: str) -> "tuple[str, str] | None": + """Identify a registered repo from a local checkout path. + + Strategy (in order): + 1. Root-commit fingerprint — ``git rev-list --max-parents=0 HEAD`` produces + the same SHA-1 on every clone, independent of remote URL or checkout path. + Looked up via ``GET /repos/by-fingerprint?hash=``. + 2. Remote URL fallback — exact string match against ``remote_url`` field. + Less reliable across machines (SSH aliases, HTTP vs HTTPS, etc.) but + useful when fingerprint is not yet stored. + + Returns ``(slug, git_root)`` on success, ``None`` if no match found. + """ + try: + git_root = subprocess.check_output( + ["git", "rev-parse", "--show-toplevel"], + cwd=path, stderr=subprocess.DEVNULL, text=True, + ).strip() + except (subprocess.CalledProcessError, FileNotFoundError, OSError): + return None + + # Strategy 1: fingerprint lookup (most reliable) + try: + fingerprint = subprocess.check_output( + ["git", "rev-list", "--max-parents=0", "HEAD"], + cwd=git_root, stderr=subprocess.DEVNULL, text=True, + ).strip() + except (subprocess.CalledProcessError, FileNotFoundError, OSError): + fingerprint = "" + + # Get local remote URL once — used for both disambiguation and fallback + try: + remote_url = subprocess.check_output( + ["git", "remote", "get-url", "origin"], + cwd=git_root, stderr=subprocess.DEVNULL, text=True, + ).strip() + except (subprocess.CalledProcessError, FileNotFoundError, OSError): + remote_url = "" + + if fingerprint: + # Try fingerprint + remote URL for precise match + if remote_url: + import urllib.parse as _up + candidates = _api_get( + api_base, + f"/repos/by-fingerprint?hash={fingerprint}&remote_url={_up.quote(remote_url, safe='')}", + ) + if isinstance(candidates, list) and len(candidates) == 1: + return candidates[0]["slug"], git_root + + # Fingerprint alone (works when repos don't share ancestry) + candidates = _api_get(api_base, f"/repos/by-fingerprint?hash={fingerprint}") + if isinstance(candidates, list): + if len(candidates) == 1: + return candidates[0]["slug"], git_root + if len(candidates) > 1: + # Disambiguate: prefer the repo whose slug appears in the git_root path + for repo in candidates: + if repo["slug"] in git_root: + return repo["slug"], git_root + # Can't disambiguate — return first match with a warning + print( + f" WARNING: {len(candidates)} repos share fingerprint {fingerprint[:12]}… " + f"— using '{candidates[0]['slug']}'. " + "Set remote_url on each repo for accurate matching.", + file=sys.stderr, + ) + return candidates[0]["slug"], git_root + + # Strategy 2: remote URL exact match (fallback) + if remote_url: + import urllib.parse as _up + repo = _api_get(api_base, f"/repos/by-remote?url={_up.quote(remote_url, safe='')}") + if repo and isinstance(repo, dict) and "slug" in repo: + return repo["slug"], git_root + + return None + + +_WORK_RECORD_FENCE_RE = re.compile(r"```(?:yaml|task)\n(.*?)```", re.S) +_WORK_RECORD_ID_LINE_RE = re.compile(r"^id:\s*[\"']?([A-Za-z0-9_-]+)[\"']?\s*$", re.M) +_WORK_RECORD_ID_LIKE_RE = re.compile(r"^[A-Z][A-Z0-9]*(-[A-Z0-9]+)+$") +_WORK_RECORD_SKIP_DIRS = frozenset({".git", "node_modules", ".venv", "history"}) + + +def _load_work_record_kind_registry() -> list[dict] | None: + """Load [{kind, patterns}] from the canon work-record type registry. + + Location: $WORK_RECORD_REGISTRY, else ~/the-custodian/canon/standards/ + work-record-types.yaml. Returns None (checks skipped) when unavailable. + Kind-aware: shared by C-31 (flat sidetrack detection) and C-32 + (kind-aware registration — needs to know intake vs. decision vs. + engagement to pick the right hub entity). + """ + if not _HAS_YAML: + return None + candidates = [] + env = os.environ.get("WORK_RECORD_REGISTRY") + if env: + candidates.append(Path(env)) + candidates.append( + Path.home() / "the-custodian/canon/standards/work-record-types.yaml") + for path in candidates: + if not path.is_file(): + continue + try: + reg = _yaml.safe_load(path.read_text()) + kinds = [] + for kind in reg.get("kinds", []): + patterns = [re.compile(p) for p in kind.get("id_patterns", [])] + patterns += [re.compile(lp["pattern"]) + for lp in kind.get("legacy_patterns", [])] + kinds.append({"kind": kind["kind"], "patterns": patterns}) + return kinds + except Exception: + return None + return None + + +def _load_work_record_patterns() -> list[re.Pattern] | None: + """Flat id-pattern list across all kinds — used by C-31's sidetrack + detector, which only needs to know "is this id registered at all", + not which kind it belongs to.""" + kinds = _load_work_record_kind_registry() + if kinds is None: + return None + return [p for k in kinds for p in k["patterns"]] + + +def _check_unregistered_work_records(repo_dir: Path, report: "ConsistencyReport") -> None: + """C-31: warn on YAML-block ids matching no registered work-record kind. + + Detection only (CUST-WP-0060-T05); registration of non-workplan kinds is + a later stage. An unregistered id scheme is a coordination sidetrack that + will need manual reintegration — surface it the day it appears. + """ + patterns = _load_work_record_patterns() + if patterns is None: + return + seen: set[str] = set() + for md in sorted(repo_dir.rglob("*.md")): + if any(part in _WORK_RECORD_SKIP_DIRS for part in md.parts): + continue + try: + text = md.read_text(errors="replace") + except OSError: + continue + for body in _WORK_RECORD_FENCE_RE.findall(text): + for rid in _WORK_RECORD_ID_LINE_RE.findall(body): + if rid in seen or "NNN" in rid or rid.endswith("-TNN"): + continue + if not _WORK_RECORD_ID_LIKE_RE.match(rid): + continue + if any(p.match(rid) for p in patterns): + continue + seen.add(rid) + report.add( + severity="WARN", + check_id="C-31", + message=( + f"[{md.relative_to(repo_dir)}] id '{rid}' matches no " + f"kind in the canon work-record type registry — " + f"unregistered species become sidetracks needing " + f"manual reintegration (work-record-types_v0.1)" + ), + fixable=False, + ) + + +_YAML_ONLY_FENCE_RE = re.compile(r"```yaml\n(.*?)\n```", re.S) +_WORK_RECORD_ID_FIELD_BY_KIND = { + "intake": "state_hub_intake_id", + "decision": "state_hub_decision_id", + "engagement": "state_hub_engagement_id", +} +_WORK_RECORD_CREATE_ENDPOINT_BY_KIND = { + "intake": "/intakes", + "decision": "/decisions", +} + + +def _classify_work_record_kind(record_id: str, kind_registry: list[dict]) -> str | None: + for k in kind_registry: + if any(p.match(record_id) for p in k["patterns"]): + return k["kind"] + return None + + +def _inject_yaml_block_field( + file_path: Path, field_name: str, field_value: str, match_id: str +) -> bool: + """Inject a field into the ```yaml``` block whose id == match_id. + Mirrors _inject_task_id_into_block, adapted for fenced yaml blocks + (intake/decision/engagement records live as ```yaml, not ```task).""" + text = file_path.read_text(encoding="utf-8") + + def _replace(m: re.Match) -> str: + block_content = m.group(1) + meta = _parse_yaml_block(block_content.strip()) + if not isinstance(meta, dict) or str(meta.get("id", "")) != match_id: + return m.group(0) + existing_val = meta.get(field_name) + if existing_val is not None and str(existing_val).strip() not in ("", "~", "null", "None", "none"): + return m.group(0) + new_content = re.sub( + rf"^{re.escape(field_name)}:.*$", + f'{field_name}: "{field_value}"', + block_content, + flags=re.MULTILINE, + ) + if new_content == block_content: + new_content = block_content.rstrip() + f"\n{field_name}: \"{field_value}\"" + return f"```yaml\n{new_content}\n```" + + new_text = _YAML_ONLY_FENCE_RE.sub(_replace, text) + if new_text != text: + file_path.write_text(new_text, encoding="utf-8") + return True + return False + + +def _check_work_record_registration( + repo_dir: Path, + report: "ConsistencyReport", + api_base: str = "http://127.0.0.1:8000", + bootstrap_empty_projection: bool = False, +) -> None: + """C-32: register kind: intake / kind: decision YAML blocks found + anywhere in the repo against the hub (CUST-WP-0061-T02, work-record + stage 3) — writes state_hub_intake_id / state_hub_decision_id back into + the source block, same write-back pattern as C-06 for workplans. + + kind: engagement has no hub entity yet (deferred; reported INFO, not + fixable) — building one is a separate stage-3 follow-on, not this task. + """ + kind_registry = _load_work_record_kind_registry() + if kind_registry is None: + return + for md in sorted(repo_dir.rglob("*.md")): + if any(part in _WORK_RECORD_SKIP_DIRS for part in md.parts): + continue + try: + text = md.read_text(errors="replace") + except OSError: + continue + for block in _YAML_ONLY_FENCE_RE.findall(text): + meta = _parse_yaml_block(block.strip()) + if not isinstance(meta, dict) or meta.get("_parse_error"): + continue + rid = str(meta.get("id", "")).strip() + if not rid or "NNN" in rid: + continue + kind = _classify_work_record_kind(rid, kind_registry) + if kind not in _WORK_RECORD_ID_FIELD_BY_KIND: + continue + + id_field = _WORK_RECORD_ID_FIELD_BY_KIND[kind] + existing = str(meta.get(id_field, "")).strip().strip('"') + if existing and existing not in ("~", "null", "None", "none"): + if not bootstrap_empty_projection: + continue + endpoint = _WORK_RECORD_CREATE_ENDPOINT_BY_KIND.get(kind) + if endpoint and _api_get(api_base, f"{endpoint}/{existing}") is not None: + continue + + rel = md.relative_to(repo_dir) + if kind not in _WORK_RECORD_CREATE_ENDPOINT_BY_KIND: + report.add( + severity="INFO", check_id="C-32", + message=( + f"[{rel}] engagement '{rid}' has no hub entity yet — " + f"registration deferred (CUST-WP-0061 stage-3 seed)" + ), + fixable=False, + ) + continue + + report.add( + severity="WARN", check_id="C-32", + message=( + f"[{rel}] {kind} '{rid}' is absent from the explicitly empty projection" + if existing + else f"[{rel}] {kind} '{rid}' has no {id_field} — not indexed in DB" + ), + file_path=str(rel), + file_value=rid, + fixable=True, + _fix_context={ + "md_path": md, + "kind": kind, + "rid": rid, + "meta": meta, + "desired_record_id": existing or None, + }, + ) + + +_WORK_RECORD_INDEX_NAME = "WORK-RECORDS.md" +_WORK_RECORD_INDEX_KIND_ORDER = { + "workplan": 0, "task": 1, "intake": 2, "decision": 3, "engagement": 4, +} + + +def _generate_work_record_index(repo_dir: Path, repo_slug: str) -> str | None: + """CUST-WP-0061-T04: the generated per-repo work-record index — every + registered work record (workplan, task, intake, decision, engagement) + with kind/id/status/lane/source, derivable purely from repo files (no + hub query — matches the ADR-001 rebuild principle). Full transclusion + rendering (markitect integration) is a follow-on; this is the flat + first cut. Returns None (no file written) when the repo has no work + records at all, to avoid clutter in small repos. + """ + rows: list[tuple[str, str, str, str, str]] = [] # kind, id, status, lane, source + + workplans_dir = repo_dir / "workplans" + if workplans_dir.is_dir(): + for wp_file in iter_workplan_files(workplans_dir, include_archived=False): + try: + text = wp_file.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + meta, body = parse_frontmatter(text) + wp_id = str(meta.get("id", "")).strip() + if not wp_id: + continue + source = str(wp_file.relative_to(repo_dir)) + rows.append(("workplan", wp_id, str(meta.get("status", "")).strip() or "—", "—", source)) + for task in get_tasks_from_workplan(meta, body): + if task.get("_parse_error"): + continue + t_id = str(task.get("id", "")).strip() + if not t_id: + continue + rows.append(("task", t_id, str(task.get("status", "")).strip() or "—", "—", source)) + + kind_registry = _load_work_record_kind_registry() + if kind_registry: + for md in sorted(repo_dir.rglob("*.md")): + if any(part in _WORK_RECORD_SKIP_DIRS for part in md.parts): + continue + if md.name == _WORK_RECORD_INDEX_NAME: + continue + try: + text = md.read_text(errors="replace") + except OSError: + continue + for block in _YAML_ONLY_FENCE_RE.findall(text): + meta = _parse_yaml_block(block.strip()) + if not isinstance(meta, dict) or meta.get("_parse_error"): + continue + rid = str(meta.get("id", "")).strip() + if not rid or "NNN" in rid: + continue + kind = _classify_work_record_kind(rid, kind_registry) + if kind not in ("intake", "decision", "engagement"): + continue + status = str(meta.get("status", "")).strip() or "—" + lane = str(meta.get("lane", "")).strip() or "—" + source = str(md.relative_to(repo_dir)) + rows.append((kind, rid, status, lane, source)) + + if not rows: + return None + + rows.sort(key=lambda r: (_WORK_RECORD_INDEX_KIND_ORDER.get(r[0], 99), r[4], r[1])) + return _work_record_index_document(repo_slug, rows) + + +def _work_record_index_document( + repo_slug: str, rows: list[tuple[str, str, str, str, str]] +) -> str: + """Render WORK-RECORDS.md. An empty row list still produces the header + and table so a leftover index can be rewritten after every live record + is archived or removed. + """ + lines = [ + f"# Work Records — {repo_slug}", + "", + "> Generated by `statehub fix-consistency` (CUST-WP-0061-T04, work-record", + "> stage 3). Do not edit by hand — edit the source file/block listed for", + "> each record and re-run fix-consistency to refresh this index. Archived", + "> workplans are omitted; closed decisions/intakes/engagements stay listed", + "> so recently-resolved work is still visible. [auto]", + "", + "| Kind | ID | Status | Lane | Source |", + "| --- | --- | --- | --- | --- |", + ] + lines.extend( + f"| {kind} | {rid} | {status} | {lane} | {source} |" + for kind, rid, status, lane, source in rows + ) + lines.append("") + return "\n".join(lines) + + +def _check_work_record_index_freshness( + repo_dir: Path, repo_slug: str, report: "ConsistencyReport" +) -> None: + """C-33: WORK-RECORDS.md missing or stale relative to the current + file-derived work-record set.""" + content = _generate_work_record_index(repo_dir, repo_slug) + index_file = repo_dir / _WORK_RECORD_INDEX_NAME + existing = index_file.read_text(encoding="utf-8") if index_file.is_file() else None + if content is None: + if existing is None: + return + empty = _work_record_index_document(repo_slug, []) + if existing == empty: + return + content = empty + elif existing == content: + return + report.add( + severity="WARN", check_id="C-33", + message=( + f"{_WORK_RECORD_INDEX_NAME} is missing or stale — regenerate via " + f"fix-consistency (CUST-WP-0061-T04 generated work-record index)" + ), + file_path=_WORK_RECORD_INDEX_NAME, + fixable=True, + _fix_context={"index_file": index_file, "content": content}, + ) + + +def check_repo( + api_base: str, + repo_slug: str, + repo_path_override: str | None = None, + bootstrap_empty_projection: bool = False, +) -> ConsistencyReport: + """Run all consistency checks for a registered repo.""" + repo = _api_get(api_base, f"/repos/{repo_slug}", return_error=True) + if isinstance(repo, dict) and "_error" in repo: + report = ConsistencyReport(repo_slug=repo_slug, repo_path=repo_path_override or "(unknown)") + report.add( + severity="FAIL", check_id="C-00", + message=( + f"Could not query State Hub API at {api_base}: {repo['_error']}" + ), + fixable=False, + ) + return report + if repo is None: + report = ConsistencyReport(repo_slug=repo_slug, repo_path="(unknown)") + report.add( + severity="FAIL", check_id="C-00", + message=f"Repo '{repo_slug}' not found in state-hub DB", + fixable=False, + ) + return report + + # Repository aliases are accepted at the API boundary, but every new + # binding, inbox write, and generated file must use the current identity. + repo_slug = str(repo.get("canonical_slug") or repo.get("slug") or repo_slug) + repo_id: str = repo["id"] + repo_path: str = resolve_repo_path(repo, repo_path_override) + report = ConsistencyReport(repo_slug=repo_slug, repo_path=repo_path) + + if bootstrap_empty_projection: + projected = _api_get(api_base, "/workplans", {"repo_id": repo_id}, return_error=True) + if not isinstance(projected, list): + report.add( + severity="FAIL", + check_id="C-36", + message="Could not prove that the repository projection is empty", + fixable=False, + ) + return report + if projected: + report.add( + severity="FAIL", + check_id="C-36", + message=( + "Empty-projection bootstrap refused: repository already has " + f"{len(projected)} projected workplan(s)" + ), + fixable=False, + ) + return report + + if not repo_path: + report.add( + severity="FAIL", check_id="C-00", + message=f"Repo '{repo_slug}' has no local_path — cannot check workplan files", + fixable=False, + ) + _check_orphan_db(api_base, repo_id, set(), report) + return report + + repo_dir = Path(repo_path) + workplans_dir = repo_dir / "workplans" + repo_market_domain = str(repo.get("domain_slug") or "").strip() + + # C-24: repo classification file missing or invalid (always WARN — migration rows too) + class_data, class_errors, class_warnings = load_classification_file(repo_dir) + if class_data is None: + classified_by = str(repo.get("classified_by") or "").strip() + if class_errors: + detail = "; ".join(class_errors) + else: + detail = f"{CLASSIFICATION_FILENAME} missing on disk" + if classified_by == "migration": + detail = f"{detail} (DB row is migration-derived — commit a human-reviewed file when ready)" + report.add( + severity="WARN", + check_id="C-24", + message=f"Repo classification gap: {detail}", + fixable=False, + ) + for warning in class_warnings: + report.add( + severity="WARN", + check_id="C-24", + message=f"Repo classification advisory: {warning}", + fixable=False, + ) + + # C-35: consume the repository-standard answer from Repo Manager. + _check_repo_manager_conformance(repo_dir, report) + + # C-31: work-record sidetrack detector (canon work-record-types registry) + _check_unregistered_work_records(repo_dir, report) + + # C-32: intake/decision work-record registration (CUST-WP-0061-T02) + _check_work_record_registration( + repo_dir, + report, + api_base=api_base, + bootstrap_empty_projection=bootstrap_empty_projection, + ) + + # C-33: generated per-repo work-record index (CUST-WP-0061-T04) + _check_work_record_index_freshness(repo_dir, repo_slug, report) + + # C-01: workplans/ directory missing + if not workplans_dir.is_dir(): + report.add( + severity="FAIL", check_id="C-01", + message=( + "workplans/ directory missing — ADR-001 requires workplan files " + "at /workplans/-.md" + ), + fixable=False, + ) + _check_orphan_db(api_base, repo_id, set(), report) + return report + + # Bind the scan to the commit it reads; re-verified after C-25 below. + report.source_revision, report.source_revision_reason = scan_source_revision(repo_dir) + + # Parse workplan files + workplan_infos: list[tuple[Path, dict, str]] = [] + file_ws_ids: dict[str, tuple[Path, dict, str]] = {} # ws_id → (file, meta, body) + active_file_ws_ids: set[str] = set() + + for wp_file in iter_workplan_files(workplans_dir): + try: + text = wp_file.read_text(encoding="utf-8") + except OSError as e: + report.add(severity="FAIL", check_id="C-02", + message=f"Cannot read file: {e}", file_path=workplan_display_path(repo_dir, wp_file)) + continue + if not text.startswith("---"): + report.add(severity="FAIL", check_id="C-02", + message="No YAML frontmatter found", file_path=workplan_display_path(repo_dir, wp_file)) + continue + meta, body = parse_frontmatter(text) + if not meta or meta.get("_parse_error"): + report.add(severity="FAIL", check_id="C-02", + message="YAML frontmatter parse error", file_path=workplan_display_path(repo_dir, wp_file)) + continue + workplan_infos.append((wp_file, meta, body)) + ws_id = str(meta.get("state_hub_workstream_id", "")).strip().strip('"') + if ws_id: + file_ws_ids[ws_id] = (wp_file, meta, body) + if wp_file.parent == workplans_dir: + active_file_ws_ids.add(ws_id) + + workplan_id_to_ws_id: dict[str, str] = {} + task_file_id_to_sh_id: dict[str, str] = {} + for _wp_file, meta, body in workplan_infos: + mapped_ws_id = str(meta.get("state_hub_workstream_id", "")).strip().strip('"') + wp_id = str(meta.get("id", "")).strip() + if wp_id and mapped_ws_id: + workplan_id_to_ws_id[wp_id] = mapped_ws_id + for task in get_tasks_from_workplan(meta, body): + if task.get("_parse_error"): + continue + task_file_id = str(task.get("id", "")).strip() + raw_sh = task.get("state_hub_task_id") + task_sh_id = "" if raw_sh is None else str(raw_sh).strip().strip('"') + if task_file_id and task_sh_id and task_sh_id not in ("~", "null", "None", "none"): + task_file_id_to_sh_id[task_file_id] = task_sh_id + + # Modern hubs expose the entire repo-scoped read model in one response. + # Keep the older per-record calls as a rolling-deploy compatibility path. + projection_snapshot = _api_get( + api_base, + f"/repos/{repo_slug}/work-record-projection/snapshot", + return_error=True, + ) + snapshot_available = ( + isinstance(projection_snapshot, dict) + and projection_snapshot.get("schema") + == "state-hub.repository-projection-snapshot.v1" + and str(projection_snapshot.get("repo_id")) == repo_id + ) + snapshot_workplans_by_id: dict[str, dict] = {} + snapshot_tasks_by_workplan: dict[str, list[dict]] = {} + snapshot_dependencies_by_workplan: dict[str, list[dict]] = {} + if snapshot_available: + for row in projection_snapshot.get("workplans", []): + if isinstance(row, dict) and row.get("id"): + snapshot_workplans_by_id[str(row["id"])] = row + for row in projection_snapshot.get("tasks", []): + if isinstance(row, dict) and row.get("workplan_id"): + snapshot_tasks_by_workplan.setdefault( + str(row["workplan_id"]), [] + ).append(row) + for row in projection_snapshot.get("dependencies", []): + if isinstance(row, dict) and row.get("from_workplan_id"): + snapshot_dependencies_by_workplan.setdefault( + str(row["from_workplan_id"]), [] + ).append(row) + snapshot_tasks_by_id: dict[str, dict] = { + str(row["id"]): row + for rows in snapshot_tasks_by_workplan.values() + for row in rows + if row.get("id") + } + + # Task-block depends_on targets (CUST-WP-0074) may live in other repos, so + # resolve them by the file mapping first and the ADR-007 derived uuid second. + dependency_target_cache: dict[str, tuple[str | None, str | None]] = {} + + def _resolve_dependency_target(record_id: str) -> tuple[str | None, str | None]: + """Return (hub uuid, hub status) for a workplan/task record id, or (None, None).""" + if record_id in dependency_target_cache: + return dependency_target_cache[record_id] + is_task = bool(_TASK_RECORD_ID_RE.search(record_id)) + mapped = (task_file_id_to_sh_id if is_task else workplan_id_to_ws_id).get(record_id) + hub_id = mapped or _derived_work_record_uuid(record_id) + row = (snapshot_tasks_by_id if is_task else snapshot_workplans_by_id).get(hub_id) + if row is None: + row = _api_get(api_base, f"/{'tasks' if is_task else 'workplans'}/{hub_id}") + result: tuple[str | None, str | None] = ( + (hub_id, str(row.get("status") or "")) if isinstance(row, dict) else (None, None) + ) + dependency_target_cache[record_id] = result + return result + + decision_hub_ids: dict[str, str] | None = None + + def _resolve_decision(decision_id: str) -> dict | None: + nonlocal decision_hub_ids + if decision_hub_ids is None: + decision_hub_ids = _repo_decision_hub_ids(repo_dir) + hub_id = ( + decision_id + if _UUID_RE.match(decision_id) + else decision_hub_ids.get(decision_id) or _derived_work_record_uuid(decision_id) + ) + row = _api_get(api_base, f"/decisions/{hub_id}") + return row if isinstance(row, dict) else None + + # Per-workplan checks + for wp_file, meta, body in workplan_infos: + fname = workplan_display_path(repo_dir, wp_file) + archived_file = wp_file.parent.name == "archived" + ws_id = str(meta.get("state_hub_workstream_id", "")).strip().strip('"') + file_status = str(meta.get("status", "")).strip() + file_title = str(meta.get("title", "")).strip() + file_domain = str(meta.get("domain", "")).strip() + + normalised_file_status = normalise_workstream_status(file_status) + if archived_file and normalised_file_status not in CLOSED_WORKSTREAM_STATUSES: + report.add( + severity="FAIL", check_id="C-18", + message="Archived workplan file has an open or planning status", + file_path=fname, + file_value=file_status, + fixable=False, + ) + + if not ws_id: + # C-06: workplan not linked to any DB workstream + report.add( + severity="WARN", check_id="C-06", + message="Workplan has no state_hub_workstream_id — not indexed in DB", + file_path=fname, + file_value=file_title or fname, + fixable=True, + _fix_context={ + "wp_file": str(wp_file), + "meta": meta, + "body": body, + "repo_id": repo_id, + "domain": file_domain, + "repo_market_domain": repo_market_domain, + "repo_slug": repo_slug, + }, + ) + continue + + ws = ( + snapshot_workplans_by_id.get(ws_id) + if snapshot_available + else _api_get(api_base, f"/workplans/{ws_id}") + ) + if ws is None: + wp_id = str(meta.get("id", "")).strip() + if wp_id and ( + ws_id == _derived_work_record_uuid(wp_id) or bootstrap_empty_projection + ): + # A deterministic file identifier missing from a replaceable + # projection is registration work, not a stale reference. + report.add( + severity="WARN", + check_id="C-06", + message=( + f"Authoritative workplan {ws_id[:8]}… is absent from this " + + ( + "explicitly empty projection" + if bootstrap_empty_projection + else "projection" + ) + ), + file_path=fname, + db_id=ws_id, + fixable=True, + _fix_context={ + "wp_file": str(wp_file), + "meta": meta, + "body": body, + "repo_id": repo_id, + "domain": file_domain, + "repo_market_domain": repo_market_domain, + "repo_slug": repo_slug, + "desired_ws_id": ws_id, + }, + ) + else: + # A non-derived missing identifier may be genuine stale state; + # preserve the conservative manual-review behavior. + report.add( + severity="FAIL", check_id="C-03", + message=f"state_hub_workstream_id {ws_id[:8]}… not found in DB (stale reference)", + file_path=fname, + db_id=ws_id, + fixable=False, + ) + continue + + # C-09: repo mismatch — file is here but DB says different repo + db_repo_id: str = ws.get("repo_id") or "" + if db_repo_id != repo_id: + report.add( + severity="FAIL", check_id="C-09", + message=( + f"Workstream '{ws.get('slug')}' repo_id in DB is " + f"{db_repo_id[:8] if db_repo_id else 'None'} " + f"but backing file lives in {repo_slug} ({repo_id[:8]}…)" + ), + file_path=fname, + db_id=ws_id, + file_value=repo_id, + db_value=db_repo_id, + fixable=True, + _fix_context={"ws_id": ws_id, "correct_repo_id": repo_id}, + ) + # Continue to check drift even with mismatched repo + + tasks = get_tasks_from_workplan(meta, body) + db_tasks = ( + snapshot_tasks_by_workplan.get(ws_id, []) + if snapshot_available + else _api_get(api_base, "/tasks", {"workplan_id": ws_id}) + ) + db_tasks_by_id = { + str(row.get("id")): row + for row in db_tasks + if isinstance(row, dict) and row.get("id") + } if isinstance(db_tasks, list) else {} + file_task_statuses = [ + str(task.get("status", "")).strip() + for task in tasks + if not task.get("_parse_error") + ] + db_task_statuses = [ + str(task.get("status", "")).strip() + for task in db_tasks + if isinstance(db_tasks, list) + ] if isinstance(db_tasks, list) else [] + active_task_requires_activation = should_activate_parent_for_active_tasks( + parent_workstream_status=file_status, + task_statuses=[*file_task_statuses, *db_task_statuses], + ) + if active_task_requires_activation: + # ADR-007 decision 3 / RMGR-WP-0005-T06: automation may report + # that a proposed workplan has started work, but must not promote + # proposed → active. proposed is a human review gate. + proposed_gate = file_status == "proposed" + report.add( + severity="WARN", + check_id="C-23", + message=( + f"Lifecycle drift in '{ws.get('slug')}': workplan status " + f"{file_status!r} has an active task" + + ( + " — report only (ADR-007: do not auto-promote proposed)" + if proposed_gate + else " — repair to 'active'" + ) + ), + file_path=fname, + db_id=ws_id, + file_value=file_status, + db_value=ws.get("status", ""), + fixable=not proposed_gate, + _fix_context={ + "ws_id": ws_id, + "wp_file": str(wp_file), + "file_status": file_status, + "db_status": ws.get("status", ""), + "target_status": "active", + }, + ) + + # C-42: a blocked workplan must say what it waits for — at least one + # task in `wait` (which C-39 then requires to be qualified). Blocked + # with no wait task is a stale status, not a block (CUST-WP-0074-T06). + if file_status == "blocked" and not any( + normalise_task_status(s) == "wait" for s in file_task_statuses + ): + report.add( + severity="WARN", + check_id="C-42", + message=( + f"Blocked workplan '{ws.get('slug')}' has no task in status " + f"wait — nothing states what it waits for; qualify a wait " + f"task or re-status the workplan" + ), + file_path=fname, + db_id=ws_id, + file_value=file_status, + fixable=False, + ) + + # A bound historical split view shares its parent's row but supplies + # none of its fields or edges; only the bound parent file does. + historical_view = workplan_display_path(repo_dir, wp_file) in historical_view_paths(repo_slug) + + # C-04: status drift — normalise file value before comparing so that + # legacy file/API aliases are not treated as drift. Lifecycle repairs + # take precedence so a stale planning file cannot regress active work. + db_status = ws.get("status", "") + normalised_db_status = normalise_workstream_status(db_status) + if ( + not active_task_requires_activation + and file_status + and db_status + and normalised_file_status != normalised_db_status + and not historical_view + ): + report.add( + severity="WARN", check_id="C-04", + message=( + f"Status drift in '{ws.get('slug')}': " + f"file={file_status!r} db={db_status!r} (file wins)" + ), + file_path=fname, + db_id=ws_id, + file_value=file_status, + db_value=db_status, + fixable=True, + _fix_context={ + "ws_id": ws_id, + "field": "status", + "value": normalised_file_status, # always send DB-valid value + }, + ) + + if normalised_file_status == "ready": + review = ready_review_status( + repo_dir, + meta.get("reviewed_against_commit"), + meta.get("context_paths"), + ) + if review.needs_review: + detail = f"Ready workplan may be stale: {review.reason}" + if review.changed_paths: + preview = ", ".join(review.changed_paths[:5]) + extra = "" if len(review.changed_paths) <= 5 else ", ..." + detail = f"{detail}; changed paths: {preview}{extra}" + report.add( + severity="WARN", + check_id="C-21", + message=detail, + file_path=fname, + file_value=file_status, + db_value="needs_review", + fixable=False, + ) + # C-34: soft DoR quality debt (STATE-WP-0077) — never blocks + try: + from quality_assessment import assessment_from_mapping, is_ok + except ImportError: + sys.path.insert(0, str(Path(__file__).resolve().parent)) + from quality_assessment import assessment_from_mapping, is_ok # type: ignore + dor = assessment_from_mapping(meta, "DoR") + if not is_ok(dor): + report.add( + severity="WARN", + check_id="C-34", + message=( + "status=ready without quality_dor DoR-Ok — process claim " + "without DoR assessment (soft; see docs/work-record-quality-gates.md)" + ), + file_path=fname, + file_value=str(dor or "unassessed"), + fixable=False, + ) + + # C-05: title drift + db_title = ws.get("title", "") + if file_title and db_title and file_title != db_title: + report.add( + severity="WARN", check_id="C-05", + message=( + f"Title drift in '{ws.get('slug')}': " + f"file={file_title!r} db={db_title!r} (file wins)" + ), + file_path=fname, + db_id=ws_id, + file_value=file_title, + db_value=db_title, + fixable=True, + _fix_context={"ws_id": ws_id, "field": "title", "value": file_title}, + ) + + planning_priority = str(meta.get("planning_priority", "")).strip() or None + if planning_priority != (ws.get("planning_priority") or None): + report.add( + severity="WARN", check_id="C-19", + message=( + f"Planning priority drift in '{ws.get('slug')}': " + f"file={planning_priority!r} db={ws.get('planning_priority')!r} (file wins)" + ), + file_path=fname, + db_id=ws_id, + file_value=planning_priority, + db_value=ws.get("planning_priority"), + fixable=True, + _fix_context={"ws_id": ws_id, "field": "planning_priority", "value": planning_priority}, + ) + + planning_order = _as_int_or_none(meta.get("planning_order")) + if planning_order != ws.get("planning_order"): + report.add( + severity="WARN", check_id="C-19", + message=( + f"Planning order drift in '{ws.get('slug')}': " + f"file={planning_order!r} db={ws.get('planning_order')!r} (file wins)" + ), + file_path=fname, + db_id=ws_id, + file_value=planning_order, + db_value=ws.get("planning_order"), + fixable=True, + _fix_context={"ws_id": ws_id, "field": "planning_order", "value": planning_order}, + ) + + file_flavor = normalize_flavor(meta.get("flavor")) + if file_flavor is not None and file_flavor not in WORK_RECORD_FLAVORS: + report.add( + severity="WARN", + check_id="C-36", + message=( + f"Unknown work-record flavor {file_flavor!r} in '{ws.get('slug')}'; " + f"expected one of {', '.join(WORK_RECORD_FLAVORS)}" + ), + file_path=fname, + db_id=ws_id, + file_value=file_flavor, + fixable=False, + ) + file_flavor = None + db_flavor = normalize_flavor(ws.get("flavor")) + if file_flavor != db_flavor: + report.add( + severity="WARN", check_id="C-19", + message=( + f"Flavor drift in '{ws.get('slug')}': " + f"file={file_flavor!r} db={db_flavor!r} (file wins)" + ), + file_path=fname, + db_id=ws_id, + file_value=file_flavor, + db_value=db_flavor, + fixable=True, + _fix_context={"ws_id": ws_id, "field": "flavor", "value": file_flavor}, + ) + file_reason = normalize_promotion_reason(meta.get("flavor_promotion_reason")) + if file_reason is not None and file_reason not in FLAVOR_PROMOTION_REASONS: + report.add( + severity="WARN", + check_id="C-36", + message=( + f"Unknown flavor_promotion_reason {file_reason!r} in '{ws.get('slug')}'; " + f"expected one of {', '.join(FLAVOR_PROMOTION_REASONS)}" + ), + file_path=fname, + db_id=ws_id, + file_value=file_reason, + fixable=False, + ) + file_reason = None + db_reason = normalize_promotion_reason(ws.get("flavor_promotion_reason")) + if file_reason != db_reason: + report.add( + severity="WARN", check_id="C-19", + message=( + f"Flavor promotion reason drift in '{ws.get('slug')}': " + f"file={file_reason!r} db={db_reason!r} (file wins)" + ), + file_path=fname, + db_id=ws_id, + file_value=file_reason, + db_value=db_reason, + fixable=True, + _fix_context={ + "ws_id": ws_id, + "field": "flavor_promotion_reason", + "value": file_reason, + }, + ) + if is_residual_flavor(file_flavor): + origin = str(meta.get("origin") or "").strip() + origin_ref = str(meta.get("origin_ref") or "").strip() + if not origin and not origin_ref: + report.add( + severity="WARN", + check_id="C-37", + message=( + f"Residual workplan '{ws.get('slug')}' has no origin/origin_ref " + "provenance (not invented)" + ), + file_path=fname, + db_id=ws_id, + fixable=False, + ) + + # C-10, C-11, C-12: task-level checks + db_task_by_id: dict[str, dict] = {} + if isinstance(db_tasks, list): + for t in db_tasks: + db_task_by_id[t["id"]] = t + + existing_deps = ( + snapshot_dependencies_by_workplan.get(ws_id, []) + if snapshot_available + else _api_get(api_base, f"/workplans/{ws_id}/dependencies") or [] + ) + existing_dep_keys = set() + if isinstance(existing_deps, list): + for dep in existing_deps: + from_id = dep.get("from_workstream_id") or dep.get("from_workplan_id") + if from_id != ws_id: + continue + rel = dep.get("relationship_type") or "blocks" + # Frontmatter edges carry no from_task_id; task-block edges do. + from_task_id = dep.get("from_task_id") or None + to_workplan_id = dep.get("to_workstream_id") or dep.get("to_workplan_id") + if to_workplan_id: + existing_dep_keys.add((from_task_id, "workstream", to_workplan_id, rel)) + if dep.get("to_task_id"): + existing_dep_keys.add((from_task_id, "task", dep["to_task_id"], rel)) + + for target_wp_id in ([] if historical_view else _frontmatter_depends_on_workplans(meta)): + target_ws_id = workplan_id_to_ws_id.get(target_wp_id) + if not target_ws_id: + report.add( + severity="WARN", + check_id="C-20", + message=f"Workplan dependency target '{target_wp_id}' is not linked to State Hub", + file_path=fname, + file_value=target_wp_id, + fixable=False, + ) + continue + dep_key = (None, "workstream", target_ws_id, "blocks") + if dep_key not in existing_dep_keys: + report.add( + severity="WARN", + check_id="C-20", + message=f"Missing DB dependency edge: {ws_id[:8]}… depends on workplan {target_wp_id}", + file_path=fname, + db_id=ws_id, + file_value=target_wp_id, + fixable=True, + _fix_context={ + "from_workstream_id": ws_id, + "to_workstream_id": target_ws_id, + "relationship_type": "blocks", + }, + ) + + for target_task_id in _frontmatter_depends_on_tasks(meta): + target_sh_id = task_file_id_to_sh_id.get(target_task_id) + if not target_sh_id: + report.add( + severity="WARN", + check_id="C-20", + message=f"Task dependency target '{target_task_id}' is not linked to State Hub", + file_path=fname, + file_value=target_task_id, + fixable=False, + ) + continue + dep_key = (None, "task", target_sh_id, "starts_after") + if dep_key not in existing_dep_keys: + report.add( + severity="WARN", + check_id="C-20", + message=f"Missing DB dependency edge: {ws_id[:8]}… starts after task {target_task_id}", + file_path=fname, + db_id=ws_id, + file_value=target_task_id, + fixable=True, + _fix_context={ + "from_workstream_id": ws_id, + "to_task_id": target_sh_id, + "relationship_type": "starts_after", + }, + ) + + file_task_sh_ids: set[str] = set() + + for task in tasks: + if task.get("_parse_error"): + continue + t_id = str(task.get("id", "")).strip() + _raw_sh = task.get("state_hub_task_id") + t_sh_id = "" if _raw_sh is None else str(_raw_sh).strip().strip('"') + if t_sh_id in ("~", "null", "None", "none"): + t_sh_id = "" + t_status = normalise_task_status(task.get("status", "todo")) + + # CUST-WP-0074: task-block depends_on and wait qualifiers. + task_dep_targets = _frontmatter_depends_on_workplans(task) + _frontmatter_depends_on_tasks(task) + wait_fields = _task_wait_fields(task) + if t_status == "wait" and normalised_file_status not in CLOSED_WORKSTREAM_STATUSES: + # Rule 6: an owner-return wait with no workplan id yet is + # qualified by the C-25 vocabulary on the task block; a + # wait_until date is a window that clears by itself. + owner_return = bool(_BLOCKED_ON_RE.match(wait_fields["blocked_on"] or "")) + wait_until = wait_fields["wait_until"] + if ( + not task_dep_targets and not wait_fields["needs_human"] + and not owner_return and not wait_until + ): + report.add( + severity="WARN", check_id="C-39", + message=( + f"Wait task '{t_id}' in workplan '{meta.get('id', fname)}' has " + f"neither depends_on, needs_human: true, blocked_on: message-from:… " + f"nor wait_until — unqualified wait" + ), + file_path=f"{fname}#{t_id}", + file_value=t_status, + fixable=False, + ) + # CUST-WP-0075 P1: human gates belong in the decision queue. + if ( + wait_fields["needs_human"] and not wait_fields["decision_id"] + and not is_residual_flavor(meta.get("flavor")) + ): + report.add( + severity="WARN", check_id="C-39", + message=( + f"Human wait task '{t_id}' in workplan '{meta.get('id', fname)}' " + "has needs_human: true without decision_id — unrouted human gate" + ), + file_path=f"{fname}#{t_id}", + file_value="needs_human: true", + fixable=False, + ) + # C-40: rule 3 for a wait window — the date has passed (UTC today). + if wait_until and _date.fromisoformat(wait_until) < datetime.now(timezone.utc).date(): + report.add( + severity="WARN", check_id="C-40", + message=( + f"blocker satisfied, task still wait: '{t_id}' in workplan " + f"'{meta.get('id', fname)}' — wait_until {wait_until} has passed" + ), + file_path=f"{fname}#{t_id}", + file_value=wait_until, + fixable=False, + ) + # C-40: rule 3 — every depends_on target terminal. A target the + # hub cannot resolve is skipped, which also withholds the warning. + resolved_targets = [ + (target, _resolve_dependency_target(target)) for target in task_dep_targets + ] + if resolved_targets and all( + hub_status is not None + and _dependency_target_satisfied(target, hub_status) + for target, (_hub_id, hub_status) in resolved_targets + ): + report.add( + severity="WARN", check_id="C-40", + message=( + f"blocker satisfied, task still wait: '{t_id}' in workplan " + f"'{meta.get('id', fname)}' — every depends_on target is terminal " + f"({', '.join(task_dep_targets)})" + ), + file_path=f"{fname}#{t_id}", + file_value=", ".join(task_dep_targets), + fixable=False, + ) + # C-40: rule 4 — the tracked decision has been resolved. + if wait_fields["decision_id"]: + decision = _resolve_decision(wait_fields["decision_id"]) + if decision is not None and str(decision.get("status") or "") == "resolved": + report.add( + severity="WARN", check_id="C-40", + message=( + f"blocker satisfied, task still wait: '{t_id}' in workplan " + f"'{meta.get('id', fname)}' — decision " + f"{wait_fields['decision_id']} is resolved" + ), + file_path=f"{fname}#{t_id}", + file_value=wait_fields["decision_id"], + fixable=False, + ) + + if ( + t_sh_id + and task_dep_targets + and normalised_file_status not in CLOSED_WORKSTREAM_STATUSES + and t_status not in ("done", "cancel") + ): + # C-20 for task blocks: the waiting task is the from side. + # Closed workplans and closed tasks are not indexed — their + # ordering is history, not a live commitment. + for target in task_dep_targets: + target_hub_id, _target_status = _resolve_dependency_target(target) + is_task_target = bool(_TASK_RECORD_ID_RE.search(target)) + if not target_hub_id: + report.add( + severity="WARN", + check_id="C-20", + message=( + f"{'Task' if is_task_target else 'Workplan'} dependency target " + f"'{target}' of task '{t_id}' is not linked to State Hub" + ), + file_path=f"{fname}#{t_id}", + file_value=target, + fixable=False, + ) + continue + rel = "starts_after" if is_task_target else "blocks" + dep_key = (t_sh_id, "task" if is_task_target else "workstream", target_hub_id, rel) + if dep_key not in existing_dep_keys: + report.add( + severity="WARN", + check_id="C-20", + message=( + f"Missing DB dependency edge: task '{t_id}' " + f"{'starts after task' if is_task_target else 'depends on workplan'} {target}" + ), + file_path=f"{fname}#{t_id}", + db_id=ws_id, + file_value=target, + fixable=True, + _fix_context={ + "from_workstream_id": ws_id, + "from_task_id": t_sh_id, + "to_task_id" if is_task_target else "to_workstream_id": target_hub_id, + "relationship_type": rel, + "description": f"{t_id} depends_on {target}", + }, + ) + + if t_sh_id: + file_task_sh_ids.add(t_sh_id) + db_task = db_tasks_by_id.get(t_sh_id) + if db_task is None: + if t_id and t_sh_id == _derived_work_record_uuid(t_id): + report.add( + severity="WARN", + check_id="C-11", + message=f"Derived task '{t_id}' is absent from this projection", + file_path=f"{fname}#{t_id}", + db_id=t_sh_id, + fixable=True, + _fix_context={ + "ws_id": ws_id, + "ws_status": ws.get("status", ""), + "task": task, + "wp_file": str(wp_file), + "meta": meta, + "body": body, + "desired_task_id": t_sh_id, + }, + ) + else: + report.add( + severity="FAIL", check_id="C-03", + message=f"state_hub_task_id {t_sh_id[:8]}… not found in DB", + file_path=f"{fname}#{t_id}", + db_id=t_sh_id, + fixable=False, + ) + continue + # C-10 / C-15: task status drift. ADR-001: the file originates + # work. Same-rank wait/progress is not "DB ahead" — writing + # wait over progress/todo is the CFED/RMGR-WP-0004-T02 bug. + # C-15 remains a report-only review signal when the DB is + # terminal and the file is not. + db_t_status = normalise_task_status(db_task.get("status", "todo")) + if t_status and db_t_status and t_status != db_t_status: + db_rank = STATUS_ORDER.get(db_t_status, 0) + file_rank = STATUS_ORDER.get(t_status, 0) + db_terminal = db_t_status in {"done", "cancel"} + file_terminal = t_status in {"done", "cancel"} + if db_terminal and not file_terminal: + report.add( + severity="WARN", check_id="C-15", + message=( + f"DB task '{t_id}' is terminal " + f"(db={db_t_status!r}, file={t_status!r}) " + f"— report only; file wins (ADR-001 / RMGR-WP-0005-T06)" + ), + file_path=f"{fname}#{t_id}", + db_id=t_sh_id, + file_value=t_status, + db_value=db_t_status, + fixable=False, + ) + # File always wins, including same-rank wait ↔ progress. + report.add( + severity="WARN", check_id="C-10", + message=( + f"Task status drift '{t_id}': " + f"file={t_status!r} db={db_t_status!r} (file wins)" + ), + file_path=f"{fname}#{t_id}", + db_id=t_sh_id, + file_value=t_status, + db_value=db_t_status, + fixable=True, + _fix_context={ + "task_id": t_sh_id, + "status": t_status, + # A wait needs its qualifiers in the same PATCH. + "wait_fields": wait_fields if t_status == "wait" else {}, + }, + ) + file_description = task.get("description") + if isinstance(file_description, str): + file_description = file_description.strip() or None + else: + file_description = None + db_description = db_task.get("description") + if isinstance(db_description, str): + db_description = db_description.strip() or None + else: + db_description = None + if file_description and file_description != db_description: + report.add( + severity="WARN", check_id="C-22", + message=f"Task description drift '{t_id}': file content differs from DB (file wins)", + file_path=f"{fname}#{t_id}", + db_id=t_sh_id, + file_value=file_description[:120], + db_value=(db_description or "")[:120], + fixable=True, + _fix_context={"task_id": t_sh_id, "description": file_description}, + ) + file_task_flavor = normalize_flavor(task.get("flavor")) + if file_task_flavor is not None and file_task_flavor not in WORK_RECORD_FLAVORS: + report.add( + severity="WARN", + check_id="C-36", + message=( + f"Unknown work-record flavor {file_task_flavor!r} on task '{t_id}'" + ), + file_path=f"{fname}#{t_id}", + db_id=t_sh_id, + file_value=file_task_flavor, + fixable=False, + ) + file_task_flavor = None + db_task_flavor = normalize_flavor(db_task.get("flavor")) + if file_task_flavor != db_task_flavor: + report.add( + severity="WARN", + check_id="C-38", + message=( + f"Task flavor drift '{t_id}': " + f"file={file_task_flavor!r} db={db_task_flavor!r} (file wins)" + ), + file_path=f"{fname}#{t_id}", + db_id=t_sh_id, + file_value=file_task_flavor, + db_value=db_task_flavor, + fixable=True, + _fix_context={"task_id": t_sh_id, "flavor": file_task_flavor}, + ) + # C-41: wait qualifiers — only where the file speaks, so hub-set + # flags on blocks that never carried the keys are left alone. + if any(key in task for key in _TASK_WAIT_FIELD_KEYS): + db_wait_fields = { + "needs_human": bool(db_task.get("needs_human")), + "blocking_reason": str(db_task.get("blocking_reason") or "").strip() or None, + "decision_id": str(db_task.get("decision_id") or "").strip() or None, + "blocked_on": str(db_task.get("blocked_on") or "").strip() or None, + "wait_until": str(db_task.get("wait_until") or "").strip() or None, + } + file_wait_fields = {key: wait_fields[key] for key in _TASK_WAIT_FIELD_KEYS} + if file_wait_fields != db_wait_fields: + report.add( + severity="WARN", check_id="C-41", + message=( + f"Task wait-qualifier drift '{t_id}': " + f"file={file_wait_fields!r} db={db_wait_fields!r} (file wins)" + ), + file_path=f"{fname}#{t_id}", + db_id=t_sh_id, + file_value=json.dumps(file_wait_fields, sort_keys=True), + db_value=json.dumps(db_wait_fields, sort_keys=True), + fixable=True, + _fix_context={"task_id": t_sh_id, "wait_fields": wait_fields}, + ) + elif t_id: + # C-11: task exists in file but not linked to DB + ws_status = ws.get("status", "") + report.add( + severity="WARN", check_id="C-11", + message=f"Task '{t_id}' has no state_hub_task_id", + file_path=f"{fname}#{t_id}", + fixable=True, + _fix_context={ + "ws_id": ws_id, + "ws_status": ws_status, + "task": task, + "wp_file": str(wp_file), + "meta": meta, + "body": body, + }, + ) + + # C-12: DB tasks with no file backing + if isinstance(db_tasks, list): + ws_status = ws.get("status", "") + ws_finished = normalise_workstream_status(ws_status) in CLOSED_WORKSTREAM_STATUSES + for db_t in db_tasks: + if db_t["id"] not in file_task_sh_ids: + db_t_status = normalise_task_status(db_t.get("status", "todo")) + open_task = db_t_status not in TERMINAL_TASK_STATUSES + # Terminal orphans (cancel/done) are historical duplicates after + # file linkage or explicit retirement. The public task DELETE route + # is a cancel operation, so these are not further actionable. + if not open_task: + continue + # Auto-cancel fixable when workstream is finished and task is open. + fixable = ws_finished and open_task + report.add( + severity="WARN", check_id="C-12", + message=( + f"DB task '{db_t.get('title', '')}' " + f"(id={db_t['id'][:8]}…, status={db_t_status}) " + f"in workstream '{ws.get('slug')}' has no file backing" + ), + db_id=db_t["id"], + fixable=fixable, + _fix_context={ + "task_id": db_t["id"], + "ws_finished": ws_finished, + }, + ) + + # C-13: all DB tasks done but workstream still active — worker forgot to close + db_status = ws.get("status", "") + if normalise_workstream_status(db_status) == "active" and isinstance(db_tasks, list) and db_tasks: + non_terminal = [ + t for t in db_tasks + if normalise_task_status(t.get("status", "todo")) not in TERMINAL_TASK_STATUSES + ] + if not non_terminal: + report.add( + severity="WARN", check_id="C-13", + message=( + f"All {len(db_tasks)} DB tasks for '{ws.get('slug')}' are " + f"done/cancel but workstream status is still 'active' — " + f"worker likely forgot update_workstream_status()" + ), + file_path=fname, + db_id=ws_id, + db_value="active", + fixable=True, + _fix_context={ + "ws_id": ws_id, + "field": "status", + "value": "finished", + }, + ) + + # C-07 / C-08: orphan DB workstreams (have repo_id=this_repo but no backing file) + _check_orphan_db(api_base, repo_id, set(file_ws_ids.keys()), report, active_file_ws_ids) + + # C-14: ghost duplicate — active workstream on same topic with no repo_id whose + # title matches a file-backed workstream. Root cause: create_workstream() called + # before the workplan file was written; fix-consistency then created a second + # workstream from the file, leaving the first as an invisible orphan. + _check_ghost_duplicates(api_base, workplan_infos, file_ws_ids, report) + + _check_blocked_unblock_sweep(api_base, repo_slug, repo_dir, workplan_infos, report) + if report.source_revision is not None: + # The tree must not have moved while the files were read and C-25 ran. + if scan_source_revision(repo_dir) != (report.source_revision, None): + report.source_revision, report.source_revision_reason = None, "changed_during_scan" + _check_inbox_hygiene(api_base, repo_slug, report) + _check_unlinked_supersession(api_base, set(file_ws_ids), report) + _check_workplan_prefixes(repo_dir, repo_slug, workplan_infos, report) + _check_workplan_id_collisions(api_base, repo_slug, repo_dir, workplan_infos, report) + _check_scope_freshness(repo_dir, workplan_infos, report) + + if _refuse_frozen_fix_batch(report, repo_slug): + return report + _sync_workplan_bindings(api_base, repo_slug, workplan_infos, repo_dir, report) + + return report + + +def _sync_workplan_bindings( + api_base: str, + repo_slug: str, + workplan_infos: list[tuple[Path, dict, str]], + repo_dir: Path, + report: ConsistencyReport, +) -> None: + if repo_slug == "railiance-cluster": + scanned_paths = {workplan_display_path(repo_dir, path) for path, _, _ in workplan_infos} + missing = [entry["path"] for entry in FROZEN_CLUSTER_BINDINGS + if entry["path"] not in scanned_paths] + if missing: + report.add(severity="FAIL", check_id="C-44", + message="Frozen binding batch refused: bound source missing from scan", + fixable=False) + return + bindings: list[dict[str, Any]] = [] + for wp_file, meta, _ in workplan_infos: + ws_id = str(meta.get("state_hub_workstream_id", "")).strip().strip('"') + archived = wp_file.parent.name == "archived" + file_status = normalise_workstream_status(str(meta.get("status", "")).strip()) + binding = { + "workplan_id": ws_id, "filename": wp_file.name, + "relative_path": workplan_display_path(repo_dir, wp_file), + "repo_slug": repo_slug, "archived": archived, + "status": file_status or None, + } + try: + frozen = frozen_cluster_binding(binding) + if frozen is not None: + # Verify exact source before omitting the immutable binding. + # This is safe even against a receiver predating the API guard. + from hashlib import sha256 + if (wp_file.is_symlink() or not wp_file.is_file() + or sha256(wp_file.read_bytes()).hexdigest() != frozen["sha256"]): + raise ValueError("frozen historical source missing or changed") + continue + except ValueError as exc: + report.add(severity="FAIL", check_id="C-44", + message=f"Frozen binding batch refused: {exc}", + file_path=str(wp_file), fixable=False) + return # Never send a partial batch after a frozen-source refusal. + if not ws_id: + continue + bindings.append(binding) + if not bindings: + return + result = _api_put(api_base, "/workplans/index/bindings", {"bindings": bindings}) + if isinstance(result, dict) and "_error" in result: + report.fixes_applied.append(f"bindings WARN: {result['_error']}") + elif isinstance(result, dict): + report.fixes_applied.append( + f"bindings: synced {result.get('updated', 0)}/{result.get('received', len(bindings))}" + ) + + +RETIRED_SLUG_MARK = "@retired-" # mirrors api.services.forge_projection + + +def _is_projection_retired(ws: dict) -> bool: + """True for a row forge projection retired (field, or the slug stamp on older APIs).""" + return bool(ws.get("projection_retired_at")) or RETIRED_SLUG_MARK in (ws.get("slug") or "") + + +def _check_orphan_db( + api_base: str, + repo_id: str, + file_ws_ids: set[str], + report: ConsistencyReport, + active_file_ws_ids: set[str] | None = None, +) -> None: + """Flag DB workstreams with repo_id=this_repo that have no backing workplan file. + + A row retired by forge projection is a tombstone: retirement stamps the + slug with ``@retired-YYYYMMDD`` and sets ``projection_retired_at`` but + deliberately leaves status alone, so a row retired while blocked stays + blocked. Its canonical successor is file-backed under a new id. Such rows + are reported as C-08 INFO, never C-07 FAIL — the repo has nothing to fix, + and closing them by hand would rewrite history the retirement preserved. + """ + active_file_ws_ids = active_file_ws_ids or file_ws_ids + all_ws = _api_get(api_base, "/workplans", {"repo_id": repo_id}) + if not isinstance(all_ws, list): + return + for ws in all_ws: + ws_id = ws["id"] + ws_status = ws.get("status", "") + normalised_status = normalise_workstream_status(ws_status) + if normalised_status not in CLOSED_WORKSTREAM_STATUSES and ws_id in active_file_ws_ids: + continue + if normalised_status in CLOSED_WORKSTREAM_STATUSES and ws_id in file_ws_ids: + continue + ws_slug = ws.get("slug", "") + if _is_projection_retired(ws) and ws_id not in file_ws_ids: + report.add( + severity="INFO", check_id="C-08", + message=( + f"Retired DB workstream '{ws_slug}' " + f"(id={ws_id[:8]}…, status={ws_status}) has no backing workplan file " + f"— projection tombstone, not live work" + ), + db_id=ws_id, + fixable=False, + ) + continue + if normalised_status not in CLOSED_WORKSTREAM_STATUSES: + report.add( + severity="FAIL", check_id="C-07", + message=( + f"Non-closed DB workstream '{ws_slug}' (id={ws_id[:8]}…) " + f"has no backing workplan file — ADR-001 violation" + ), + db_id=ws_id, + fixable=False, + ) + elif normalised_status in CLOSED_WORKSTREAM_STATUSES: + report.add( + severity="INFO", check_id="C-08", + message=( + f"Closed DB workstream '{ws_slug}' " + f"(id={ws_id[:8]}…, status={ws_status}) has no backing workplan file" + ), + db_id=ws_id, + fixable=False, + ) + + +def _check_ghost_duplicates( + api_base: str, + workplan_infos: list[tuple], + file_ws_ids: dict[str, tuple], + report: ConsistencyReport, +) -> None: + """C-14: detect active workstreams with repo_id=null whose title matches a + file-backed workstream — these are ghosts created by premature create_workstream() + calls before the workplan file existed. + """ + # Build lookup: normalised title → file-backed workstream id + file_titles: dict[str, str] = {} + for _, meta, _ in workplan_infos: + ws_id = str(meta.get("state_hub_workstream_id", "")).strip().strip('"') + title = str(meta.get("title", "")).strip().lower() + if title and ws_id: + file_titles[title] = ws_id + + if not file_titles: + return + + # Gather topic_ids from all file-backed workstreams so we can query by topic + topic_ids: set[str] = set() + for ws_id in file_ws_ids: + ws = _api_get(api_base, f"/workplans/{ws_id}") + if ws and ws.get("topic_id"): + topic_ids.add(ws["topic_id"]) + + for topic_id in topic_ids: + topic_ws: list[dict] = [] + for status in OPEN_WORKSTREAM_STATUSES: + status_rows = _api_get(api_base, "/workplans", {"topic_id": topic_id, "status": status}) + if isinstance(status_rows, list): + topic_ws.extend(status_rows) + for ws in topic_ws: + ws_id = ws["id"] + if ws_id in file_ws_ids: + continue # legitimately linked + if ws.get("repo_id"): + continue # C-07 covers repo-scoped orphans + ws_title = ws.get("title", "").strip().lower() + if ws_title in file_titles: + file_backed_id = file_titles[ws_title] + report.add( + severity="WARN", + check_id="C-14", + message=( + f"Ghost duplicate: active workstream '{ws.get('slug')}' " + f"(id={ws_id[:8]}…, repo_id=null) has same title as " + f"file-backed workstream {file_backed_id[:8]}… — " + f"likely created via create_workstream() before workplan file existed; " + f"archive it" + ), + db_id=ws_id, + fixable=False, + ) + + +# --------------------------------------------------------------------------- +# Git sync (T02–T04: pull gate, writeback, push seal) — see repo_sync.py +# --------------------------------------------------------------------------- +# repo_sync.py owns the push-seal invariant and all git lifecycle primitives. +# The aliases below keep internal call sites and existing tests unchanged. + +from repo_sync import ( # noqa: E402 (import after top-level imports intentional) + count_local_ahead as _detect_ahead_of_remote, + count_remote_ahead as _count_remote_ahead, + pull_ff as _git_pull, + push_ff as _git_push, +) + + +def _detect_behind_remote(repo_path: str) -> bool: + """True if remote has commits the local repo lacks (C-16 predicate). + + Delegates to repo_sync.count_remote_ahead, which fetches before counting. + Returns False on any error so C-16 is never spuriously triggered. + """ + return _count_remote_ahead(repo_path) > 0 + + +def _patch_task_status_in_file( + file_path: Path, task_block_id: str, new_status: str +) -> bool: + """Update the ``status:`` field inside a task block identified by its id. + + Only modifies lines inside a ```task … ``` fenced block whose ``id:`` + matches *task_block_id*. Returns True if the file was changed. + """ + text = file_path.read_text(encoding="utf-8") + + def _replace(m: re.Match) -> str: + # m.group(0) is the full ```task...``` block including fences. + # m.group(1) is the inner YAML content (no fences). + full_block = m.group(0) + inner = m.group(1).strip() + task_meta = _parse_yaml_block(inner) + if str(task_meta.get("id", "")).strip() != task_block_id: + return full_block + # Replace the status line only, leave everything else untouched. + return re.sub( + r"^(status:\s*)\S+", + rf"\g<1>{new_status}", + full_block, + flags=re.MULTILINE, + ) + + new_text = _TASK_BLOCK_RE.sub(_replace, text) + if new_text != text: + file_path.write_text(new_text, encoding="utf-8") + return True + return False + + +def _git_identity_config() -> list[str]: + import os as _os + + name = _os.environ.get("GIT_SYNC_USER_NAME", "custodian-sync") + email = _os.environ.get("GIT_SYNC_USER_EMAIL", "custodian-sync@railiance.local") + return ["-c", f"user.name={name}", "-c", f"user.email={email}"] + + +def _git_commit_writeback( + repo_path: str, + file_path: Path, + changes: list[str], + *, + subject: str = "chore(consistency): sync task status from DB [auto]", +) -> bool: + """Stage *file_path* and commit with a standard writeback message. + + Returns True on success, False on any error (errors are logged to stderr + but do not abort the consistency run). + """ + from datetime import date as _date + summary = "\n".join(f" - {c}" for c in changes) + msg = ( + f"{subject}\n\n" + f"Updated by fix-consistency on {_date.today().isoformat()}:\n" + f"{summary}" + ) + import os as _os + # Pass GIT_CUSTODIAN_SYNC=1 so the post-commit hook can detect it is + # running from within a sync pass and exit early, preventing re-entrancy. + sync_env = {**_os.environ, "GIT_CUSTODIAN_SYNC": "1"} + try: + subprocess.run( + ["git", "-C", repo_path, "add", "--", str(file_path)], + check=True, capture_output=True, env=sync_env, + ) + subprocess.run( + ["git", "-C", repo_path, *_git_identity_config(), + "commit", "--only", "-m", msg, "--", str(file_path)], + check=True, capture_output=True, env=sync_env, + ) + return True + except subprocess.CalledProcessError as e: + print( + f"WARN: git commit failed for writeback: {e.stderr.decode().strip()}", + file=sys.stderr, + ) + return False + + +# --------------------------------------------------------------------------- +# Worker orientation brief (.custodian-brief.md) +# --------------------------------------------------------------------------- + +def default_wp_prefix(repo_slug: str) -> str: + first = repo_slug.split("-", 1)[0].upper() + return f"{first}-WP" + + +def infer_wp_prefix(repo_path: Path, repo_slug: str) -> str: + """Prefer established on-disk workplan prefixes over first-token derivation.""" + counts: Counter[str] = Counter() + workplans_dir = repo_path / "workplans" + if workplans_dir.is_dir(): + for workplan in iter_workplan_files(workplans_dir): + if workplan.name.startswith("ADHOC"): + continue + try: + text = workplan.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + id_match = _WP_ID_PREFIX_RE.search(text) or _WP_ID_BARE_RE.search(text) + if id_match: + counts[id_match.group(1).upper()] += 1 + continue + file_match = _WP_FILE_PREFIX_RE.match(workplan.name) or _WP_FILE_BARE_RE.match(workplan.name) + if file_match: + counts[file_match.group(1).upper()] += 1 + if not counts: + return default_wp_prefix(repo_slug) + return counts.most_common(1)[0][0] + + +def parse_blocked_on(value: str) -> str | None: + if not value: + return None + match = _BLOCKED_ON_RE.match(value.strip()) + return match.group("agent").strip() if match else None + + +def _parse_message_timestamp(value: Any) -> datetime | None: + if not value: + return None + if isinstance(value, datetime): + return value if value.tzinfo else value.replace(tzinfo=timezone.utc) + try: + parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except ValueError: + return None + return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) + + +def _summarise_inbox_message(msg: dict[str, Any], *, now: datetime) -> dict[str, Any]: + created = _parse_message_timestamp(msg.get("created_at")) + age_days = (now - created).days if created else None + return { + "id": str(msg.get("id", ""))[:8], + "from_agent": msg.get("from_agent", ""), + "subject": msg.get("subject", ""), + "age_days": age_days, + } + + +def collect_inbox_hygiene(api_base: str, repo_slug: str) -> dict[str, Any]: + messages = _api_get( + api_base, + "/messages", + {"to_agent": repo_slug, "unread_only": True, "limit": 100}, + ) or [] + if not isinstance(messages, list): + return { + "stale_unread": [], + "missing_thread": [], + "work_requests_unpromoted": [], + "stale_unread_count": 0, + } + + now = datetime.now(timezone.utc) + stale_cutoff = now - timedelta(days=STALE_UNREAD_DAYS) + thread_cutoff = now - timedelta(days=1) + + stale_unread: list[dict[str, Any]] = [] + missing_thread: list[dict[str, Any]] = [] + work_requests_unpromoted: list[dict[str, Any]] = [] + + for msg in messages: + created = _parse_message_timestamp(msg.get("created_at")) + if created is None: + continue + summary = _summarise_inbox_message(msg, now=now) + if created < stale_cutoff: + stale_unread.append(summary) + if not msg.get("thread_id") and created < thread_cutoff: + missing_thread.append(summary) + body = f"{msg.get('subject', '')} {msg.get('body', '')}" + if _WORK_REQUEST_RE.search(body) and created < stale_cutoff: + work_requests_unpromoted.append(summary) + + return { + "stale_unread": stale_unread, + "missing_thread": missing_thread, + "work_requests_unpromoted": work_requests_unpromoted, + "stale_unread_count": len(stale_unread), + } + + +_FULL_SHA = re.compile(r"^[0-9a-f]{40}$") + + +def scan_source_revision(repo_dir: Path) -> tuple[str | None, str | None]: + """Return ``(sha, None)`` for a clean ``workplans/`` tree, else ``(None, reason)``. + + ``sha`` is the full 40-hex HEAD commit. Any change under ``workplans/`` + (modified, staged, untracked or ignored) means the scanned files are not + that commit, so no revision is claimed. Reasons: ``not_a_git_tree``, + ``no_head_commit``, ``unsupported_object_format``, ``dirty_workplans``, + ``git_status_failed``. + """ + def git(*args: str) -> str | None: + try: + return subprocess.run( + ["git", *args], cwd=repo_dir, capture_output=True, text=True, + timeout=30, check=True, + ).stdout + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError): + return None + + if not Path(repo_dir).is_dir() or git("rev-parse", "--is-inside-work-tree") is None: + return None, "not_a_git_tree" + head = git("rev-parse", "--verify", "--quiet", "HEAD^{commit}") + if head is None or not head.strip(): + return None, "no_head_commit" + sha = head.strip() + if not _FULL_SHA.fullmatch(sha): + return None, "unsupported_object_format" + status = git("status", "--porcelain", "--untracked-files=all", "--ignored", + "--", "workplans") + if status is None: + return None, "git_status_failed" + if status.strip(): + return None, "dirty_workplans" + return sha, None + + +C25_PAGE_SIZE = 200 +C25_MAX_PAGES = 50 + + +def _collect_counterpart_messages( + api_base: str, repo_slug: str, counterpart: str +) -> tuple[list[dict], bool]: + """Page through every reply from ``counterpart`` (STATE-WP-0094-T03). + + Returns ``(messages, complete)``. Messages are deduplicated by id, so a + reply shifting between pages while new mail arrives is counted once and the + finding's message ids stay stable. ``complete`` is False when the hub + ignored ``offset`` (a page repeated only known ids) or the page bound was + hit; the caller reports that instead of silently capping. + """ + seen: dict[str, dict] = {} + anonymous: list[dict] = [] + for page in range(C25_MAX_PAGES): + rows = _api_get(api_base, "/messages", { + "to_agent": repo_slug, "from_agent": counterpart, + "unread_only": False, "limit": C25_PAGE_SIZE, "offset": page * C25_PAGE_SIZE, + }) or [] + if not isinstance(rows, list): + rows = [] + new = 0 + for row in rows: + if not isinstance(row, dict): + continue + message_id = str(row.get("id") or "") + if not message_id: + anonymous.append(row) + new += 1 + elif message_id not in seen: + seen[message_id] = row + new += 1 + if len(rows) < C25_PAGE_SIZE: + return list(seen.values()) + anonymous, True + if new == 0: + return list(seen.values()) + anonymous, False + return list(seen.values()) + anonymous, False + + +def _check_blocked_unblock_sweep( + api_base: str, + repo_slug: str, + repo_dir: Path, + workplan_infos: list[tuple[Path, dict, str]], + report: ConsistencyReport, +) -> None: + for wp_file, meta, _ in workplan_infos: + file_status = normalise_workstream_status(str(meta.get("status", "")).strip()) + if file_status != "blocked": + continue + blocked_on = str(meta.get("blocked_on", "")).strip() + counterpart = parse_blocked_on(blocked_on) + if not counterpart: + continue + messages = _api_get( + api_base, + "/messages", + { + "to_agent": repo_slug, + "from_agent": counterpart, + "unread_only": True, + "limit": 20, + }, + ) or [] + if not isinstance(messages, list) or not messages: + continue + fname = workplan_display_path(repo_dir, wp_file) + wp_id = str(meta.get("id", wp_file.stem)).strip() + report.add( + severity="WARN", + check_id="C-25", + message=( + f"Blocked workplan '{wp_id}' waiting on {blocked_on!r} has " + f"{len(messages)} unread message(s) from {counterpart} — " + "blocker may have cleared" + ), + file_path=fname, + file_value=blocked_on, + fixable=False, + ) + + + # Task-level returns can clear a wait even after somebody read the reply. + # Preserve the legacy workplan check above; task findings carry stable IDs + # for an owner runner to deduplicate without changing task status here. + messages_by_agent: dict[str, list[dict]] = {} + for wp_file, meta, body in workplan_infos: + if ( + normalise_workstream_status(str(meta.get("status", ""))) in CLOSED_WORKSTREAM_STATUSES + or is_residual_flavor(meta.get("flavor")) + ): + continue + for task in get_tasks_from_workplan(meta, body): + if normalise_task_status(task.get("status", "todo")) != "wait": + continue + counterpart = parse_blocked_on(str(task.get("blocked_on") or "")) + if not counterpart: + continue + if counterpart not in messages_by_agent: + rows, complete = _collect_counterpart_messages(api_base, repo_slug, counterpart) + messages_by_agent[counterpart] = rows + if not complete: + report.add( + severity="WARN", check_id="C-25", + message=(f"Owner-return reply collection from {counterpart} is incomplete " + f"({len(rows)} message(s) read; hub ignored paging or the " + f"{C25_MAX_PAGES}-page bound was reached) — returns may be missing"), + file_path=workplan_display_path(repo_dir, wp_file), + file_value=f"message-from:{counterpart}", + fixable=False, + ) + # Dated notes are the available acknowledgement watermark. Date-only + # notes cover the whole UTC day, avoiding same-day re-notification. + note_dates = [] + for value in re.findall(r"(?m)^\s*(?:[-*]\s+)?(?:\*\*)?(\d{4}-\d{2}-\d{2})\b", str(task.get("description") or "")): + parsed = _parse_message_timestamp(value) + if parsed: + note_dates.append(parsed + timedelta(days=1)) + explicit_updated = _parse_message_timestamp(task.get("updated")) + cutoff = max(note_dates + ([explicit_updated] if explicit_updated else []), default=None) + hits = [] + for message in messages_by_agent[counterpart]: + if message.get("from_agent") != counterpart or message.get("archived_at"): + continue + created = _parse_message_timestamp(message.get("created_at")) + if created and (created >= cutoff if cutoff else not message.get("read_at")): + hits.append(message) + if not hits: + continue + hits.sort(key=lambda message: str(message.get("created_at", ""))) + task_id = str(task.get("id", "")).strip() + message_ids = [str(message["id"]) for message in hits if message.get("id")] + report.add( + severity="WARN", check_id="C-25", + message=(f"Owner-return task '{task_id}' waiting on {counterpart} has " + f"{len(hits)} reply/replies after its last dated note " + f"(or unread when undated) — owner action needed: {', '.join(message_ids)}"), + file_path=f"{workplan_display_path(repo_dir, wp_file)}#{task_id}", + file_value=str(task.get("blocked_on")), + fixable=False, + _fix_context={"task_record_id": task_id, "message_ids": message_ids}, + ) + + +_SUPERSEDES_RE = re.compile( + r"\bsupersedes\s+(?:decision\s+)?([0-9a-f]{8}(?:-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})?)\b", + re.IGNORECASE, +) + + +def find_unlinked_supersessions( + decisions: list[dict], workplan_ids: set[str] +) -> list[tuple[dict, dict]]: + """(citing, cited) pairs where a resolved decision of these workplans says + "supersedes " but the cited decision is not superseded_by it. "Superseded + by" is the other direction and is not matched; ambiguous prefixes are skipped.""" + pairs: list[tuple[dict, dict]] = [] + for citing in decisions: + if str(citing.get("status") or "") != "resolved": + continue + if str(citing.get("workplan_id") or "") not in workplan_ids: + continue + text = " ".join(str(citing.get(k) or "") for k in ("title", "rationale", "description")) + for ref in dict.fromkeys(m.lower() for m in _SUPERSEDES_RE.findall(text)): + matches = [d for d in decisions if str(d.get("id") or "").lower().startswith(ref)] + if len(matches) != 1 or matches[0] is citing: + continue + cited = matches[0] + if ( + str(cited.get("status") or "") != "superseded" + or str(cited.get("superseded_by") or "") != str(citing["id"]) + ): + pairs.append((citing, cited)) + return pairs + + +def _check_unlinked_supersession( + api_base: str, workplan_ids: set[str], report: ConsistencyReport +) -> None: + workplan_ids = {str(w) for w in workplan_ids if w} + if not workplan_ids: + return + decisions = _api_get(api_base, "/decisions/") + if not isinstance(decisions, list): + return + for citing, cited in find_unlinked_supersessions(decisions, workplan_ids): + report.add( + severity="WARN", + check_id="C-43", + message=( + f"Decision {citing['id']} supersedes {cited['id']}, but the cited " + f"decision is status={cited.get('status')!r} " + f"superseded_by={cited.get('superseded_by')!r}; the decider should " + f"set status=superseded and superseded_by" + ), + db_id=str(cited["id"]), + fixable=False, + ) + + +def _check_inbox_hygiene(api_base: str, repo_slug: str, report: ConsistencyReport) -> None: + hygiene = collect_inbox_hygiene(api_base, repo_slug) + if hygiene["stale_unread_count"]: + preview = ", ".join( + f"{m['from_agent']}:{m['id']}" for m in hygiene["stale_unread"][:5] + ) + extra = "" if hygiene["stale_unread_count"] <= 5 else ", ..." + report.add( + severity="WARN", + check_id="C-28", + message=( + f"{hygiene['stale_unread_count']} unread inbox message(s) older than " + f"{STALE_UNREAD_DAYS} day(s): {preview}{extra}" + ), + fixable=False, + ) + if hygiene["missing_thread"]: + preview = ", ".join( + f"{m['from_agent']}:{m['id']}" for m in hygiene["missing_thread"][:5] + ) + report.add( + severity="WARN", + check_id="C-28", + message=( + f"{len(hygiene['missing_thread'])} unread message(s) lack thread_id " + f"for supersession tracking: {preview}" + ), + fixable=False, + ) + for msg in hygiene["work_requests_unpromoted"]: + report.add( + severity="WARN", + check_id="C-29", + message=( + f"Unread work request from {msg['from_agent']} ({msg['id']}) may need " + f"a workplan file: {msg['subject'][:120]}" + ), + fixable=False, + ) + + +def _check_workplan_prefixes( + repo_dir: Path, + repo_slug: str, + workplan_infos: list[tuple[Path, dict, str]], + report: ConsistencyReport, +) -> None: + canonical = infer_wp_prefix(repo_dir, repo_slug) + for wp_file, meta, _ in workplan_infos: + if wp_file.name.startswith("ADHOC"): + continue + wp_id = str(meta.get("id", "")).strip() + if not wp_id: + continue + match = _WP_ID_PREFIX_RE.match(wp_id) or _WP_ID_BARE_RE.match(wp_id) + if not match: + continue + prefix = match.group(1).upper() + if prefix == canonical.upper(): + continue + report.add( + severity="WARN", + check_id="C-26", + message=( + f"Workplan id '{wp_id}' uses prefix '{prefix}' but repo canonical " + f"prefix is '{canonical}' — new plans must conform" + ), + file_path=workplan_display_path(repo_dir, wp_file), + file_value=wp_id, + db_value=canonical, + fixable=False, + ) + + +def _scan_repo_workplan_ids(repos: list[dict[str, Any]]) -> dict[str, list[tuple[str, str]]]: + id_map: dict[str, list[tuple[str, str]]] = {} + for repo in repos: + slug = repo["slug"] + path = resolve_repo_path(repo) + if not path or not Path(path).is_dir(): + continue + workplans_dir = Path(path) / "workplans" + if not workplans_dir.is_dir(): + continue + for wp_file in iter_workplan_files(workplans_dir): + if wp_file.name.startswith("ADHOC"): + continue + try: + text = wp_file.read_text(encoding="utf-8") + except OSError: + continue + if not text.startswith("---"): + continue + meta, _ = parse_frontmatter(text) + if not meta or meta.get("_parse_error"): + continue + wp_id = str(meta.get("id", "")).strip() + if wp_id: + id_map.setdefault(wp_id, []).append((slug, wp_file.name)) + return id_map + + +def _check_workplan_id_collisions( + api_base: str, + repo_slug: str, + repo_dir: Path, + workplan_infos: list[tuple[Path, dict, str]], + report: ConsistencyReport, +) -> None: + repos = _api_get(api_base, "/repos") or [] + if not isinstance(repos, list): + return + id_map = _scan_repo_workplan_ids(repos) + for wp_file, meta, _ in workplan_infos: + wp_id = str(meta.get("id", "")).strip() + if not wp_id: + continue + locations = id_map.get(wp_id, []) + if len(locations) <= 1: + continue + report.add( + severity="WARN", + check_id="C-27", + message=( + f"Workplan id '{wp_id}' collides across repos: " + + ", ".join(f"{slug}/{fname}" for slug, fname in locations) + ), + file_path=workplan_display_path(repo_dir, wp_file), + file_value=wp_id, + fixable=False, + ) + + +def _scope_current_state_lines(repo_dir: Path) -> dict[str, str]: + scope_path = repo_dir / "SCOPE.md" + if not scope_path.exists(): + return {} + text = scope_path.read_text(encoding="utf-8", errors="replace") + match = re.search(r"## Current State\s*\n(.*?)(?:\n## |\Z)", text, re.DOTALL) + if not match: + return {} + result: dict[str, str] = {} + for line in match.group(1).splitlines(): + stripped = line.strip() + if not stripped.startswith("-"): + continue + parts = stripped[1:].split(":", 1) + if len(parts) != 2: + continue + result[parts[0].strip().lower()] = parts[1].strip() + return result + + +def _repo_has_open_workplans(workplan_infos: list[tuple[Path, dict, str]]) -> bool: + for wp_file, meta, _ in workplan_infos: + if wp_file.parent.name == "archived": + continue + status = normalise_workstream_status(str(meta.get("status", "")).strip()) + if status in _OPEN_WORKPLAN_STATUSES: + return True + return False + + +def _check_scope_freshness( + repo_dir: Path, + workplan_infos: list[tuple[Path, dict, str]], + report: ConsistencyReport, +) -> None: + scope = _scope_current_state_lines(repo_dir) + if not scope: + return + has_open_workplans = _repo_has_open_workplans(workplan_infos) + scope_status = scope.get("status", "").lower() + warnings: list[str] = [] + if "active" in scope_status and not has_open_workplans: + warnings.append( + "SCOPE.md Current State says active but no open workplans remain — may be stale" + ) + if has_open_workplans and any( + token in scope_status for token in ("finished", "archived", "idle", "dormant") + ): + warnings.append( + "SCOPE.md Current State does not reflect active workplans — may be stale" + ) + implementation = scope.get("implementation", "").lower() + if has_open_workplans and implementation and "not yet started" in implementation: + warnings.append( + "SCOPE.md mentions work not yet started but active workplans exist — may be stale" + ) + for warning in warnings: + report.add( + severity="WARN", + check_id="C-30", + message=warning, + file_path="SCOPE.md", + fixable=False, + ) + + +_BRIEF_HEADER = "" +_TASK_STATUS_ICON = {"done": "✓", "cancel": "✗", "progress": "►", "wait": "!", "todo": "·"} +_OPEN_STATUSES = set(OPEN_TASK_STATUSES) + + +def _commit_working_memory_notes(repo_slug: str, repo_path: str) -> bool: + """Stage and commit activity-core daily-triage working-memory notes. + + Only the-custodian carries the fleet working-memory sink. Notes are + repo-relative under memory/working/ and are written by activity-core on + Railiance via hostPath into the clone checkout. + """ + if repo_slug != "the-custodian": + return False + + wm_dir = Path(repo_path) / "memory" / "working" + if not wm_dir.is_dir(): + return False + + notes = sorted(wm_dir.glob("daily-triage-*.md")) + if not notes: + return False + + import os as _os + + sync_env = {**_os.environ, "GIT_CUSTODIAN_SYNC": "1"} + rel_paths = [str(note.relative_to(repo_path)) for note in notes] + try: + subprocess.run( + ["git", "-C", repo_path, "add", "--", *rel_paths], + check=True, + capture_output=True, + env=sync_env, + ) + staged = subprocess.run( + ["git", "-C", repo_path, "diff", "--cached", "--quiet"], + capture_output=True, + env=sync_env, + ) + if staged.returncode == 0: + return False + from datetime import date as _date + + msg = ( + "chore(working-memory): sync daily-triage notes from activity-core " + f"[auto {_date.today().isoformat()}]" + ) + subprocess.run( + ["git", "-C", repo_path, *_git_identity_config(), "commit", "-m", msg], + check=True, + capture_output=True, + env=sync_env, + ) + return True + except subprocess.CalledProcessError as e: + print( + f"WARN: working-memory commit failed: {e.stderr.decode().strip()}", + file=sys.stderr, + ) + return False + + +def _write_custodian_brief(api_base: str, repo_slug: str, repo_path: str) -> bool: + """Generate .custodian-brief.md at the repo root and git-commit if changed. + + The brief gives any agent — including subagents without MCP access and + workers on remote machines — instant orientation without a live hub + connection. Returns True if the file was written (content changed). + """ + import datetime as _dt + from datetime import timezone as _tz + + repo = _api_get(api_base, f"/repos/{repo_slug}") + if not repo: + return False + + repo_id: str = repo.get("id", "") + domain_slug = str(repo.get("domain_slug") or "") + + # The repository projection owns domain identity. Retain the workplan/topic + # lookup only for compatibility with older API responses that omitted it. + workstreams: list[dict] = [] + for status in OPEN_WORKSTREAM_STATUSES: + rows = _api_get(api_base, "/workplans", {"repo_id": repo_id, "status": status}) or [] + if isinstance(rows, list): + workstreams.extend(rows) + _ws_for_domain = workstreams if workstreams else [] + if not _ws_for_domain: + all_ws = _api_get(api_base, "/workplans", {"repo_id": repo_id}) or [] + _ws_for_domain = all_ws if isinstance(all_ws, list) else [] + if not domain_slug and _ws_for_domain: + topic = _api_get(api_base, f"/topics/{_ws_for_domain[0].get('topic_id', '')}") + if topic: + domain_slug = topic.get("domain_slug", "") + + # Active repo goal (first active one if multiple) + goal_text = "" + goals = _api_get(api_base, "/repo-goals", {"repo_slug": repo_slug}) or [] + if isinstance(goals, list): + active_goals = [g for g in goals if g.get("status") == "active"] + if active_goals: + g = active_goals[0] + goal_text = g.get("title", "") or g.get("description", "") + + now_utc = _dt.datetime.now(_tz.utc) + ts = now_utc.strftime("%Y-%m-%d %H:%M UTC") + + lines = [ + _BRIEF_HEADER, + f"# Custodian Brief — {repo_slug}", + "", + f"**Domain:** {domain_slug or '(unknown)'} ", + f"**Last synced:** {ts} ", + "**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*", + "", + ] + + if goal_text: + lines += ["## Current Goal", "", goal_text, ""] + + if isinstance(workstreams, list) and workstreams: + lines.append("## Active Workstreams") + for ws in workstreams: + ws_title = ws.get("title", ws.get("slug", "?")) + ws_id = ws["id"] + tasks = _api_get(api_base, "/tasks", {"workplan_id": ws_id}) or [] + if not isinstance(tasks, list): + tasks = [] + + done = sum( + 1 for t in tasks + if normalise_task_status(t.get("status", "todo")) in TERMINAL_TASK_STATUSES + ) + total = len(tasks) + pct = f"{done}/{total}" if total else "no tasks" + + open_tasks = [ + t for t in tasks + if normalise_task_status(t.get("status", "todo")) in _OPEN_STATUSES + ] + # Show wait first, then progress, then todo (cap at 5). + priority_order = {"wait": 0, "progress": 1, "todo": 2} + open_tasks.sort( + key=lambda t: priority_order.get( + normalise_task_status(t.get("status", "todo")), 9 + ) + ) + + lines += [ + "", + f"### {ws_title}", + f"Progress: {pct} done | workplan_id: `{ws_id}`", + ] + + if open_tasks: + lines.append("") + lines.append("**Open tasks:**") + for t in open_tasks[:7]: + status = normalise_task_status(t.get("status", "todo")) + icon = _TASK_STATUS_ICON.get(status, "·") + title = t.get("title", t["id"]) + tid = t["id"] + blocker = t.get("blocking_reason", "") + task_line = f"- {icon} {title} `{tid[:8]}`" + if status == "wait" and blocker: + task_line += f"\n *(wait: {blocker})*" + lines.append(task_line) + if len(open_tasks) > 7: + lines.append(f"- … and {len(open_tasks) - 7} more open tasks") + else: + lines += ["## Active Workstreams", "", "*(none — repo may need first-session setup)*"] + + hygiene = collect_inbox_hygiene(api_base, repo_slug) + if hygiene["stale_unread_count"] or hygiene["missing_thread"] or hygiene["work_requests_unpromoted"]: + lines += ["", "## Inbox Hygiene", ""] + if hygiene["stale_unread_count"]: + lines.append( + f"**Stale unread:** {hygiene['stale_unread_count']} message(s) older than " + f"{STALE_UNREAD_DAYS} day(s) — triage at session start." + ) + if hygiene["missing_thread"]: + lines.append( + f"**Missing thread_id:** {len(hygiene['missing_thread'])} unread message(s) " + "lack supersession chains." + ) + for msg in hygiene["work_requests_unpromoted"][:3]: + lines.append( + f"- ! {msg['from_agent']}: {msg['subject'][:100]} `{msg['id']}`" + ) + + blocked_warnings: list[str] = [] + workplans_dir = Path(repo_path) / "workplans" + if workplans_dir.is_dir(): + for wp_file in iter_workplan_files(workplans_dir): + try: + text = wp_file.read_text(encoding="utf-8") + except OSError: + continue + if not text.startswith("---"): + continue + meta, _ = parse_frontmatter(text) + if not meta or meta.get("_parse_error"): + continue + if normalise_workstream_status(str(meta.get("status", "")).strip()) != "blocked": + continue + blocked_on = str(meta.get("blocked_on", "")).strip() + counterpart = parse_blocked_on(blocked_on) + if not counterpart: + continue + messages = _api_get( + api_base, + "/messages", + { + "to_agent": repo_slug, + "from_agent": counterpart, + "unread_only": True, + "limit": 5, + }, + ) or [] + if isinstance(messages, list) and messages: + wp_id = str(meta.get("id", wp_file.stem)).strip() + blocked_warnings.append( + f"- ! **{wp_id}** — blocker may have cleared: " + f"{len(messages)} unread message(s) from `{counterpart}`" + ) + if blocked_warnings: + lines += ["", "## Blocked Workplans", ""] + lines.extend(blocked_warnings) + + scope_warnings: list[str] = [] + scope = _scope_current_state_lines(Path(repo_path)) + has_open_workplans = False + if workplans_dir.is_dir(): + for wp_file in iter_workplan_files(workplans_dir): + try: + text = wp_file.read_text(encoding="utf-8") + except OSError: + continue + if not text.startswith("---"): + continue + meta, _ = parse_frontmatter(text) + if not meta or meta.get("_parse_error"): + continue + if wp_file.parent.name == "archived": + continue + status = normalise_workstream_status(str(meta.get("status", "")).strip()) + if status in _OPEN_WORKPLAN_STATUSES: + has_open_workplans = True + break + if scope: + scope_status = scope.get("status", "").lower() + if "active" in scope_status and not has_open_workplans: + scope_warnings.append( + "SCOPE.md says active but no open workplans remain — refresh Current State." + ) + if has_open_workplans and any( + token in scope_status for token in ("finished", "archived", "idle", "dormant") + ): + scope_warnings.append( + "SCOPE.md Current State does not reflect active workplans — may be stale." + ) + if scope_warnings: + lines += ["", "## SCOPE Freshness", ""] + lines.extend(f"- {warning}" for warning in scope_warnings) + + lines += [ + "", + "---", + "## MCP Orientation (when available)", + "", + "If the state-hub MCP server is reachable, call:", + f"`get_domain_summary(\"{domain_slug}\")`", + "This provides richer cross-domain context.", + "If the MCP call fails, use this file as your orientation source.", + ] + + content = "\n".join(lines) + "\n" + + brief_path = Path(repo_path) / ".custodian-brief.md" + existing = brief_path.read_text(encoding="utf-8") if brief_path.exists() else "" + + # Strip the timestamp line before comparing to avoid spurious writes + def _strip_ts(text: str) -> str: + return "\n".join( + ln for ln in text.splitlines() + if not ln.startswith("**Last synced:**") + ) + + if _strip_ts(content) == _strip_ts(existing): + return False # no meaningful change + + brief_path.write_text(content, encoding="utf-8") + + # Commit the brief so remote workers can pull it + _git_commit_writeback( + repo_path, + brief_path, + [f"update .custodian-brief.md for {repo_slug}"], + ) + return True + + +# --------------------------------------------------------------------------- +# Fix engine +# --------------------------------------------------------------------------- + +def _refuse_frozen_fix_batch(report: ConsistencyReport, repo_slug: str) -> bool: + """Refuse a complete repair batch touching the exact bound histories.""" + if any(issue.check_id == "C-44" and issue.severity == "FAIL" for issue in report.issues): + return True + protected_ids = {entry["uuid"] for entry in FROZEN_CLUSTER_BINDINGS} + protected_ids.update(task["uuid"] for entry in FROZEN_CLUSTER_BINDINGS + for task in entry["tasks"]) + paths = {entry["path"] for entry in FROZEN_CLUSTER_BINDINGS} + root = Path(report.repo_path).absolute() if report.repo_path else None + + def protected(value, key=""): + if isinstance(value, dict): + return any(protected(item, name) for name, item in value.items()) + if isinstance(value, (list, tuple)): + return any(protected(item, key) for item in value) + text = str(value) + if key in {"db_id", "ws_id", "task_id", "from_workstream_id", + "from_workplan_id", "from_task_id", "desired_ws_id", + "desired_task_id", "desired_record_id", "state_hub_workstream_id", "state_hub_task_id"}: + if text in protected_ids: + return True + if key in {"file_path", "wp_file", "md_path", "path"} and text: + candidate = Path(text) + if candidate.is_absolute(): + return root is not None and any(candidate.absolute() == root / path for path in paths) + return repo_slug == "railiance-cluster" and candidate.as_posix() in paths + return False + + for issue in report.issues: + if issue.fixable and (protected(issue.db_id, "db_id") + or protected(issue.file_path, "file_path") + or protected(issue._fix_context)): + report.add(severity="FAIL", check_id="C-44", + message="Frozen historical repair batch refused; owner review required", + fixable=False) + return True + return False + + +def fix_repo( + api_base: str, + repo_slug: str, + repo_path_override: str | None = None, + no_writeback: bool = False, + bootstrap_empty_projection: bool = False, +) -> ConsistencyReport: + """Run checks then apply all auto-fixable issues. Returns updated report.""" + report = check_repo( + api_base, + repo_slug, + repo_path_override, + bootstrap_empty_projection=bootstrap_empty_projection, + ) + if _refuse_frozen_fix_batch(report, repo_slug): + return report + if any(i.check_id in {"C-00", "C-36", "C-44"} for i in report.failures): + return report + + # RMGR-WP-0002: optional repo-manager reconcile proxy (index rebuild) for pilots + try: + from api.services.repo_manager_dual_run import reconcile_for_repo, rm_reconcile + + if reconcile_for_repo(repo_slug) and report.repo_path: + rm_rec = rm_reconcile(repo_path=report.repo_path, repo_slug=repo_slug) + if rm_rec.get("ok") or rm_rec.get("exit_code") == 0: + report.fixes_applied.append( + f"RM dual-run reconcile ok: index={rm_rec.get('index_path') or 'written'}" + ) + else: + report.fixes_applied.append( + f"RM dual-run reconcile note: {rm_rec.get('error') or rm_rec}" + ) + except Exception as rec_exc: # noqa: BLE001 + report.fixes_applied.append(f"RM dual-run reconcile skipped: {rec_exc}") + + # Auto-register this machine's path in host_paths so future runs work + # without --repo-path. Idempotent: skipped when already correct. + repo_path = report.repo_path + if repo_path: + repo_record = _api_get(api_base, f"/repos/{repo_slug}") + if repo_record: + hostname = _sweep_hostname() + if (repo_record.get("host_paths") or {}).get(hostname) != repo_path: + result = _api_post( + api_base, f"/repos/{repo_slug}/paths", + {"host": hostname, "path": repo_path}, + ) + if result and "_error" not in result: + report.fixes_applied.append( + f"host_paths[{hostname}] → {repo_path}" + ) + + # T02 — pull gate: warn and skip all write operations when local repo is + # behind its remote tracking branch. + if repo_path and _detect_behind_remote(repo_path): + report.add( + severity="WARN", check_id="C-16", + message=( + f"Repo '{repo_slug}' is behind its remote tracking branch — " + f"pull before fixing to avoid clobbering remote progress. " + f"Run: git -C {repo_path} pull --ff-only" + ), + fixable=False, + ) + report.fixes_applied.append( + "C-16: all write operations skipped — local repo is behind remote" + ) + return report + + # C-17: backlog guard — if local has unpushed commits from a prior failed push, + # try to push them before making more. Skipping writes prevents runaway divergence. + if repo_path: + ahead = _detect_ahead_of_remote(repo_path) + if ahead > 0: + push_ok, push_msg = _git_push(repo_path) + if not push_ok: + report.add( + severity="WARN", check_id="C-17", + message=( + f"Repo '{repo_slug}' has {ahead} unpushed commit(s) and push " + f"failed ({push_msg}) — skipping writes to prevent runaway divergence" + ), + fixable=False, + ) + report.fixes_applied.append( + "C-17: all write operations skipped — unpushed commits, push failed" + ) + return report + # Push succeeded — local is now in sync; proceed normally + report.fixes_applied.append(f"C-17 cleared: pushed {ahead} backlogged commit(s)") + + fixable = [i for i in report.issues if i.fixable] + + for issue in fixable: + ctx = issue._fix_context + try: + if issue.check_id in ("C-04", "C-05", "C-13", "C-19"): + ws_id = ctx["ws_id"] + result = _api_patch(api_base, f"/workplans/{ws_id}", + {ctx["field"]: ctx["value"]}) + if result is not None and "_error" not in result: + report.fixes_applied.append( + f"{issue.check_id} fixed: workstream {ws_id[:8]}… " + f"{ctx['field']} → {ctx['value']!r}" + ) + elif result is not None: + report.fixes_applied.append( + f"{issue.check_id} FAILED: workstream {ws_id[:8]}… " + f"{ctx['field']} → {ctx['value']!r}: {result['_error']}" + ) + + elif issue.check_id == "C-23": + ws_id = ctx["ws_id"] + target_status = ctx["target_status"] + result = _api_patch(api_base, f"/workplans/{ws_id}", {"status": target_status}) + if result is not None and "_error" not in result: + report.fixes_applied.append( + f"C-23 fixed: workstream {ws_id[:8]}… status → {target_status!r}" + ) + elif result is not None: + report.fixes_applied.append( + f"C-23 FAILED: workstream {ws_id[:8]}… " + f"status → {target_status!r}: {result['_error']}" + ) + + if no_writeback: + report.fixes_applied.append( + f"C-23 skipped file repair (--no-writeback): {Path(ctx['wp_file']).name}" + ) + else: + wp_file = Path(ctx["wp_file"]) + old_status = ctx["file_status"] + if _patch_frontmatter_field(wp_file, "status", target_status): + committed = _git_commit_writeback( + repo_path, + wp_file, + [f"workplan status: {old_status} → {target_status}"], + subject="chore(consistency): renormalize lifecycle state [auto]", + ) + suffix = " (committed)" if committed else " (file patched, commit failed)" + report.fixes_applied.append( + f"C-23 fixed: {wp_file.name} status " + f"{old_status} → {target_status}{suffix}" + ) + else: + report.fixes_applied.append( + f"C-23 SKIP: {wp_file.name} already has status {target_status!r}" + ) + + elif issue.check_id == "C-06": + wp_file = Path(ctx["wp_file"]) + meta = ctx["meta"] + domain = ctx["domain"] + repo_market_domain = str(ctx.get("repo_market_domain") or "").strip() + repo_id_val = ctx["repo_id"] + body = ctx.get("body", "") + wp_id = str(meta.get("id", "")).strip() + desired_ws_id = str( + ctx.get("desired_ws_id") or _derived_work_record_uuid(wp_id) + ) + title = str(meta.get("title", "")).strip() + status = str(meta.get("status", "active")).strip() + status = normalise_workstream_status(status) + if status not in VALID_WP_STATUSES: + status = "active" + + # Find topic_id — workplan frontmatter may still use legacy + # coordination slugs (e.g. custodian); map to market domain first. + topic_domain = resolve_topic_domain_slug( + domain, + repo_market_domain=repo_market_domain or None, + ) + topics = _api_get(api_base, "/topics") + topic_id = None + if isinstance(topics, list): + for t in topics: + if t.get("domain_slug") == topic_domain: + topic_id = t["id"] + break + if topic_id is None: + report.fixes_applied.append( + f"C-06 SKIP {wp_id}: no topic found for domain " + f"'{topic_domain}' (workplan domain={domain!r})" + ) + continue + + base_slug = re.sub(r"[^a-z0-9-]", "-", wp_id.lower()).strip("-") or "workplan" + repo_slug_part = re.sub( + r"[^a-z0-9-]", "-", str(ctx.get("repo_slug") or "").lower() + ).strip("-") + slug_candidates = [base_slug] + repo_qualified_slug = base_slug + if repo_slug_part and not base_slug.startswith(f"{repo_slug_part}-"): + repo_qualified_slug = f"{repo_slug_part}-{base_slug}" + slug_candidates.append(repo_qualified_slug) + for suffix in range(2, 21): + slug_candidates.append(f"{repo_qualified_slug}-{suffix}") + + ws_data = None + last_error = None + for slug in slug_candidates: + existing = _api_get(api_base, "/workplans", {"slug": slug}, return_error=True) + if isinstance(existing, dict) and "_error" in existing: + last_error = existing["_error"] + continue + if isinstance(existing, list) and existing: + existing_same_repo = next( + (w for w in existing if w.get("repo_id") == repo_id_val), + None, + ) + if ( + existing_same_repo + and existing_same_repo.get("title") == (title or wp_id) + and existing_same_repo.get("id") == desired_ws_id + ): + ws_data = existing_same_repo + report.fixes_applied.append( + f"C-06 reusing existing workstream {ws_data['id'][:8]}... for {wp_id}" + ) + break + last_error = ( + f"slug {slug!r} already belongs to a different workplan identity" + ) + continue + + create_flavor = normalize_flavor(meta.get("flavor")) + if create_flavor is not None and create_flavor not in WORK_RECORD_FLAVORS: + create_flavor = None + create_reason = normalize_promotion_reason(meta.get("flavor_promotion_reason")) + if create_reason is not None and create_reason not in FLAVOR_PROMOTION_REASONS: + create_reason = None + create_from = normalize_flavor(meta.get("flavor_promoted_from")) + if create_from is not None and create_from not in WORK_RECORD_FLAVORS: + create_from = None + ws_data = _api_post(api_base, "/workplans", { + "id": desired_ws_id, + "topic_id": topic_id, + "repo_id": repo_id_val, + "slug": slug, + "title": title or wp_id, + "status": status, + "owner": str(meta.get("owner", "")).strip() or None, + "planning_priority": str(meta.get("planning_priority", "")).strip() or None, + "planning_order": _as_int_or_none(meta.get("planning_order")), + "flavor": create_flavor, + "flavor_promotion_reason": create_reason, + "flavor_promoted_from": create_from, + }) + if ws_data is None or (isinstance(ws_data, dict) and "_error" in ws_data): + last_error = ws_data.get("_error") if isinstance(ws_data, dict) else "no response" + ws_data = None + continue + break + + if ws_data is None: + report.fixes_applied.append( + f"C-06 FAIL {wp_id}: could not create workstream in DB: {last_error or 'no usable slug'}" + ) + continue + + new_ws_id = ws_data["id"] + if new_ws_id != desired_ws_id: + report.fixes_applied.append( + f"C-06 FAIL {wp_id}: projection returned unexpected UUID {new_ws_id}" + ) + continue + if not meta.get("state_hub_workstream_id"): + _add_frontmatter_field(wp_file, "state_hub_workstream_id", new_ws_id) + report.fixes_applied.append( + f"C-06 fixed: created workstream {new_ws_id[:8]}… " + f"for {wp_id}, wrote ID to {wp_file.name}" + ) + + # Create tasks and inject IDs + tasks = get_tasks_from_workplan(meta, body) + for task in tasks: + if task.get("_parse_error"): + continue + t_id = str(task.get("id", "")).strip() + if not t_id: + continue + t_status = normalise_task_status(task.get("status", "todo")) + t_priority = str(task.get("priority", "medium")).strip() + if t_priority not in VALID_TASK_PRIORITIES: + t_priority = "medium" + raw_task_id = task.get("state_hub_task_id") + desired_task_id = ( + str(raw_task_id).strip().strip('"') + if raw_task_id not in (None, "", "~", "null", "None", "none") + else _derived_work_record_uuid(t_id) + ) + task_flavor = normalize_flavor(task.get("flavor")) + if task_flavor is not None and task_flavor not in WORK_RECORD_FLAVORS: + task_flavor = None + t_data = _api_post(api_base, "/tasks", { + "id": desired_task_id, + "workplan_id": new_ws_id, + "title": str(task.get("title", t_id)).strip() or t_id, + "description": task.get("description") or None, + "status": t_status, + "priority": t_priority, + "assignee": task.get("assignee") or None, + "flavor": task_flavor, + **_task_wait_fields(task), + }) + if t_data and "_error" not in t_data: + t_db_id = t_data["id"] + if t_db_id != desired_task_id: + report.fixes_applied.append( + f" ! task {t_id} returned unexpected UUID {t_db_id}" + ) + continue + if not raw_task_id: + injected = _inject_task_id_into_block( + wp_file, "state_hub_task_id", t_db_id, t_id + ) + if not injected: + _inject_task_id_frontmatter_list(wp_file, t_db_id, t_id) + report.fixes_applied.append(f" + task {t_id} → {t_db_id[:8]}…") + elif t_data: + report.fixes_applied.append( + f" ! task {t_id} not created: {t_data.get('_error', t_data)}" + ) + + elif issue.check_id == "C-32": + md_path = ctx["md_path"] + kind = ctx["kind"] + rid = ctx["rid"] + meta = ctx["meta"] + id_field = _WORK_RECORD_ID_FIELD_BY_KIND[kind] + title = str(meta.get("title") or rid) + desired_record_id = ctx.get("desired_record_id") + + repo_record = _api_get(api_base, f"/repos/{repo_slug}") + if not repo_record: + report.fixes_applied.append( + f"C-32 SKIP {rid}: could not look up repo '{repo_slug}'" + ) + continue + created = None + if kind == "intake": + payload = { + "id": desired_record_id, + "title": title, + "repo_id": repo_record.get("id"), + "description": meta.get("description"), + "lane": meta.get("lane", "green"), + "origin": meta.get("origin"), + "origin_ref": meta.get("origin_ref"), + "source_repo_path": str(md_path.relative_to(Path(report.repo_path))), + } + created = _api_post(api_base, "/intakes", payload) + elif kind == "decision": + repo_market_domain = str(repo_record.get("domain_slug") or "").strip() + topic_domain = resolve_topic_domain_slug( + repo_market_domain, repo_market_domain=repo_market_domain or None + ) + topics = _api_get(api_base, "/topics") + topic_id = None + if isinstance(topics, list): + for t in topics: + if t.get("domain_slug") == topic_domain: + topic_id = t["id"] + break + if topic_id is None: + report.fixes_applied.append( + f"C-32 SKIP {rid}: no topic found for domain '{topic_domain}'" + ) + continue + payload = { + "id": desired_record_id, + "title": title, + "topic_id": topic_id, + "decision_type": normalise_decision_type(meta), + "description": meta.get("description"), + "rationale": meta.get("agent_recommendation") or meta.get("rationale"), + } + created = _api_post(api_base, "/decisions", payload) + + if not created or "_error" in created: + report.fixes_applied.append( + f"C-32 FAIL {rid}: could not create {kind} in DB: " + f"{(created or {}).get('_error', 'no response')}" + ) + continue + + new_id = created["id"] + if desired_record_id and new_id != desired_record_id: + report.fixes_applied.append( + f"C-32 FAIL {rid}: projection returned unexpected UUID {new_id}" + ) + continue + if desired_record_id: + report.fixes_applied.append( + f"C-32 fixed: created {kind} {new_id[:8]}… for {rid} " + "with the authoritative ID" + ) + continue + if _inject_yaml_block_field(md_path, id_field, new_id, rid): + report.fixes_applied.append( + f"C-32 fixed: created {kind} {new_id[:8]}… for {rid}, " + f"wrote {id_field} to {md_path.name}" + ) + else: + report.fixes_applied.append( + f"C-32 WARN {rid}: created {kind} {new_id[:8]}… but " + f"failed to write {id_field} back to {md_path.name}" + ) + + elif issue.check_id == "C-33": + ctx["index_file"].write_text(ctx["content"], encoding="utf-8") + report.fixes_applied.append( + f"C-33 fixed: regenerated {ctx['index_file'].name}" + ) + + elif issue.check_id == "C-09": + ws_id = ctx["ws_id"] + correct_repo_id = ctx["correct_repo_id"] + result = _api_patch(api_base, f"/workplans/{ws_id}", + {"repo_id": correct_repo_id}) + if result is not None: + report.fixes_applied.append( + f"C-09 fixed: workstream {ws_id[:8]}… " + f"repo_id → {correct_repo_id[:8]}…" + ) + + elif issue.check_id == "C-20": + from_workstream_id = ctx["from_workstream_id"] + body = { + "to_workstream_id": ctx.get("to_workstream_id"), + "to_task_id": ctx.get("to_task_id"), + "relationship_type": ctx["relationship_type"], + } + if ctx.get("from_task_id"): + # A hub without migration e8f9a0b1c2d3 ignores the field + # and would store a workplan-level edge that later + # duplicates the task-scoped one. Skip until deployed. + if not _hub_accepts_from_task_id(api_base): + report.fixes_applied.append( + f"C-20 SKIP: hub at {api_base} does not accept " + f"from_task_id yet (task '{ctx.get('description', '')}')" + ) + continue + body["from_task_id"] = ctx["from_task_id"] + body["description"] = ctx.get("description") + result = _api_post(api_base, f"/workplans/{from_workstream_id}/dependencies", body) + if result is not None and "_error" not in result: + target = ctx.get("to_workstream_id") or ctx.get("to_task_id") + report.fixes_applied.append( + f"C-20 fixed: dependency {from_workstream_id[:8]}… " + f"{ctx['relationship_type']} → {target[:8]}…" + ) + elif result is not None: + report.fixes_applied.append( + f"C-20 FAILED: {result['_error']}" + ) + + elif issue.check_id == "C-10": + task_id = ctx["task_id"] + status = ctx["status"] + result = _api_patch(api_base, f"/tasks/{task_id}", + {"status": status, "suppress_token_event": True, + **ctx.get("wait_fields", {})}) + if result is not None and "_error" not in result: + report.fixes_applied.append( + f"C-10 fixed: task {task_id[:8]}… status → {status!r}" + ) + elif result is not None: + report.fixes_applied.append( + f"C-10 FAILED: task {task_id[:8]}… status → {status!r}: {result['_error']}" + ) + + elif issue.check_id == "C-11": + ws_id = ctx["ws_id"] + ws_status = ctx.get("ws_status", "") + task = ctx["task"] + wp_file = Path(ctx["wp_file"]) + meta = ctx["meta"] + body = ctx.get("body", "") + t_id = str(task.get("id", "")).strip() + # Skip creating tasks for finished workstreams — the workstream is + # done/archived so unlinked tasks are stale file artefacts, not gaps. + if normalise_workstream_status(ws_status) in CLOSED_WORKSTREAM_STATUSES: + report.fixes_applied.append( + f"C-11 skipped: task '{t_id}' in {ws_status} workstream — not created" + ) + else: + t_status = normalise_task_status(task.get("status", "todo")) + t_priority = str(task.get("priority", "medium")).strip() + if t_priority not in VALID_TASK_PRIORITIES: + t_priority = "medium" + desired_task_id = str( + ctx.get("desired_task_id") or _derived_work_record_uuid(t_id) + ) + t_data = _api_post(api_base, "/tasks", { + "id": desired_task_id, + "workplan_id": ws_id, + "title": str(task.get("title", t_id)).strip() or t_id, + "description": task.get("description") or None, + "status": t_status, + "priority": t_priority, + "assignee": task.get("assignee") or None, + **_task_wait_fields(task), + }) + if t_data: + t_db_id = t_data["id"] + if t_db_id != desired_task_id: + report.fixes_applied.append( + f"C-11 FAIL: task '{t_id}' returned unexpected UUID {t_db_id}" + ) + continue + if not task.get("state_hub_task_id"): + injected = _inject_task_id_into_block( + wp_file, "state_hub_task_id", t_db_id, t_id + ) + if not injected: + _inject_task_id_frontmatter_list(wp_file, t_db_id, t_id) + report.fixes_applied.append( + f"C-11 fixed: task '{t_id}' → {t_db_id[:8]}…" + ) + + elif issue.check_id == "C-12": + task_id = ctx["task_id"] + if ctx.get("ws_finished"): + result = _api_patch(api_base, f"/tasks/{task_id}", {"status": "cancel"}) + if result is not None: + report.fixes_applied.append( + f"C-12 fixed: orphan task {task_id[:8]}… canceled (workstream finished)" + ) + + elif issue.check_id == "C-22": + task_id = ctx["task_id"] + description = ctx["description"] + result = _api_patch(api_base, f"/tasks/{task_id}", {"description": description}) + if result is not None and "_error" not in result: + report.fixes_applied.append( + f"C-22 fixed: task {task_id[:8]}… description updated" + ) + elif result is not None: + report.fixes_applied.append( + f"C-22 FAILED: task {task_id[:8]}… description update: {result['_error']}" + ) + + elif issue.check_id == "C-38": + task_id = ctx["task_id"] + flavor = ctx.get("flavor") + result = _api_patch(api_base, f"/tasks/{task_id}", {"flavor": flavor}) + if result is not None and "_error" not in result: + report.fixes_applied.append( + f"C-38 fixed: task {task_id[:8]}… flavor → {flavor!r}" + ) + elif result is not None: + report.fixes_applied.append( + f"C-38 FAILED: task {task_id[:8]}… flavor → {flavor!r}: {result['_error']}" + ) + + elif issue.check_id == "C-41": + task_id = ctx["task_id"] + wait_fields = ctx["wait_fields"] + result = _api_patch(api_base, f"/tasks/{task_id}", wait_fields) + if result is not None and "_error" not in result: + report.fixes_applied.append( + f"C-41 fixed: task {task_id[:8]}… wait qualifiers synced" + ) + elif result is not None: + report.fixes_applied.append( + f"C-41 FAILED: task {task_id[:8]}… wait qualifiers: {result['_error']}" + ) + + elif issue.check_id == "C-15": + # T03 — writeback: DB is ahead of file — patch file to match DB. + if no_writeback: + report.fixes_applied.append( + f"C-15 skipped (--no-writeback): task '{ctx['task_block_id']}' " + f"db={ctx['db_status']!r}" + ) + else: + wp_file = Path(ctx["wp_file"]) + task_block_id = ctx["task_block_id"] + db_status = ctx["db_status"] + old_status = issue.file_value + # RMGR-WP-0002 dual-run: prefer repo-manager writeback when flagged + rm_done = False + try: + from api.services.repo_manager_dual_run import ( + try_writeback_for_task, + writeback_for_repo, + ) + + if writeback_for_repo(repo_slug) and repo_path: + # Prefer hub UUID when available for robust matching + task_ref = ctx.get("task_id") or task_block_id + rm_result = try_writeback_for_task( + repo_path=repo_path, + repo_slug=repo_slug, + task_id=str(task_ref), + status=db_status, + reason="state-hub C-15 dual-run writeback", + ) + if rm_result and rm_result.get("status") == "applied": + git_sha = (rm_result.get("evidence") or {}).get("git_sha") + suffix = f" (repo-manager git_sha={git_sha})" if git_sha else " (repo-manager)" + report.fixes_applied.append( + f"C-15 fixed: task '{task_block_id}' " + f"{old_status} → {db_status}{suffix}" + ) + rm_done = True + except Exception as rm_exc: # noqa: BLE001 + report.fixes_applied.append( + f"C-15 dual-run fallback to native: {rm_exc}" + ) + if not rm_done: + if _patch_task_status_in_file(wp_file, task_block_id, db_status): + committed = _git_commit_writeback( + repo_path, + wp_file, + [f"{task_block_id}: {old_status} → {db_status}"], + ) + try: + from api.services.repo_manager_dual_run import record_mutation + + record_mutation( + source="state-hub", + kind="task_status_writeback_c15", + repo_slug=repo_slug, + detail={ + "task_block_id": task_block_id, + "status": db_status, + "committed": committed, + }, + ) + except Exception: + pass + suffix = " (committed)" if committed else " (file patched, commit failed)" + report.fixes_applied.append( + f"C-15 fixed: task '{task_block_id}' " + f"{old_status} → {db_status}{suffix}" + ) + else: + report.fixes_applied.append( + f"C-15 SKIP: could not locate task block '{task_block_id}' " + f"in {wp_file.name}" + ) + + except Exception as e: + report.fixes_applied.append(f"{issue.check_id} ERROR: {e}") + + # Record that a sync run happened for this repo + from datetime import timezone as _tz + import datetime as _dt + now_iso = _dt.datetime.now(_tz.utc).isoformat() + _api_patch(api_base, f"/repos/{repo_slug}", {"last_state_synced_at": now_iso}) + + # Write the worker orientation brief (.custodian-brief.md) + if repo_path: + brief_written = _write_custodian_brief(api_base, repo_slug, repo_path) + if brief_written: + report.fixes_applied.append("brief: .custodian-brief.md updated") + + if repo_path: + wm_committed = _commit_working_memory_notes(repo_slug, repo_path) + if wm_committed: + report.fixes_applied.append("working-memory: daily-triage notes committed") + + # Push all commits made this run (writebacks + brief) to close the loop. + # This ensures the next run starts with local == remote, making the + # timer idempotent and preventing commit pile-up. + if repo_path: + push_ok, push_msg = _git_push(repo_path) + if push_ok: + report.fixes_applied.append(f"push: {push_msg}") + else: + report.fixes_applied.append(f"push WARN: {push_msg}") + + return report + + +# Check IDs that are known-background noise in multi-machine setups: +# C-08 = finished/archived DB workstream with no file (pre-ADR-001 legacy) +# These alone do not warrant a pull+fix cycle. +_BACKGROUND_CHECKS: frozenset[str] = frozenset({"C-08"}) + + +def _report_needs_action( + report: ConsistencyReport, behind_remote: bool, ahead_of_remote: int = 0 +) -> bool: + """Return True if the repo warrants a pull+fix cycle. + + A repo is considered clean (no action needed) when: + - It is not behind its remote tracking branch, AND + - It has no unpushed local commits (from a prior failed push), AND + - It has no FAIL issues, AND + - Every WARN/INFO issue is in the background-noise set (C-08). + """ + if behind_remote or ahead_of_remote > 0: + return True + if report.assessment_failures or report.automation_errors: + return True + actionable_warns = [ + i for i in report.warnings + report.infos + if i.check_id not in _BACKGROUND_CHECKS + ] + return bool(actionable_warns) + + +def fix_all_remote( + api_base: str, + no_writeback: bool = False, + max_seconds: int = DEFAULT_REMOTE_ALL_MAX_SECONDS, +) -> list[ConsistencyReport]: + """Pull-then-fix all registered repos that need attention. + + For each repo with a resolvable local path on this machine: + 1. Skip if the path does not exist (repo lives on another machine). + 2. Run check_repo() and detect_behind_remote() — read-only. + 3. If the repo is clean (no actionable issues, not behind remote): skip. + 4. Otherwise: git pull --ff-only, then fix_repo(). + + Returns one ConsistencyReport per repo that was *not* skipped as clean. + Repos skipped as clean are printed to stdout but not included in the return value. + """ + repos = _api_get(api_base, "/repos") + if not isinstance(repos, list): + print("ERROR: Could not fetch repos from state-hub API", file=sys.stderr) + return None + + started = time.monotonic() + reports: list[ConsistencyReport] = [] + skipped_clean: list[str] = [] + skipped_missing: list[str] = [] + skipped_budget: list[str] = [] + + for repo in repos: + slug = repo["slug"] + if max_seconds > 0 and time.monotonic() - started > max_seconds: + skipped_budget.append(slug) + skipped_budget.extend(r.get("slug", "?") for r in repos[repos.index(repo) + 1:]) + break + + # Resolve path using the same priority as check_repo + path = resolve_repo_path(repo) + if not path or not Path(path).is_dir(): + skipped_missing.append(slug) + continue + + # Read-only pass: detect issues and remote staleness. + # _detect_behind_remote does a fetch, so _detect_ahead_of_remote + # after it sees an up-to-date tracking branch. + pre_report = check_repo(api_base, slug) + behind = _detect_behind_remote(path) + ahead = _detect_ahead_of_remote(path) + + if not _report_needs_action(pre_report, behind, ahead): + skipped_clean.append(slug) + continue + + # Pull before fixing + pull_ok, pull_msg = _git_pull(path) + pull_tag = f"pull: {pull_msg}" + if not pull_ok: + pull_tag = f"pull WARN: {pull_msg} — proceeding anyway" + + report = fix_repo(api_base, slug, no_writeback=no_writeback) + report.fixes_applied.insert(0, pull_tag) + reports.append(report) + + # Print clean/missing summary lines before the detailed reports + if skipped_clean: + print(f" CLEAN (skipped): {', '.join(skipped_clean)}") + if skipped_missing: + print(f" NOT ON THIS HOST (skipped): {', '.join(skipped_missing)}") + if skipped_budget: + print( + f" BUDGET EXHAUSTED after {max_seconds}s (skipped): " + f"{', '.join(skipped_budget)}" + ) + if skipped_clean or skipped_missing or skipped_budget: + print() + + return reports + + +def archive_closed_workplans( + repo_path: str, + completion_date: str | None = None, + workplan: str | None = None, +) -> list[str]: + """Move closed root workplans into workplans/archived/ with YYMMDD prefix. + + Only root-level files whose frontmatter status normalises to finished or + archived are moved. Files with any open task blocks are left in place. + """ + repo_dir = Path(repo_path) + workplans_dir = repo_dir / "workplans" + archived_dir = workplans_dir / "archived" + if not workplans_dir.is_dir(): + return [] + + date_prefix = completion_date or datetime.now().strftime("%y%m%d") + archived_dir.mkdir(exist_ok=True) + moved: list[str] = [] + + for wp_file in sorted(workplans_dir.glob("*.md")): + text = wp_file.read_text(encoding="utf-8") + if not text.startswith("---"): + continue + meta, body = parse_frontmatter(text) + if not meta or meta.get("_parse_error"): + continue + if workplan: + wanted = workplan.removesuffix(".md") + if wanted not in {str(meta.get("id", "")), wp_file.stem, wp_file.name}: + continue + status = normalise_workstream_status(str(meta.get("status", "")).strip()) + if status not in CLOSED_WORKSTREAM_STATUSES: + continue + tasks = get_tasks_from_workplan(meta, body) + open_tasks = [ + t for t in tasks + if normalise_task_status(t.get("status", "todo")) not in TERMINAL_TASK_STATUSES + ] + if open_tasks: + continue + + target = archived_dir / f"{date_prefix}-{canonical_workplan_filename(wp_file)}" + if target.exists(): + raise FileExistsError(f"Archived workplan already exists: {target}") + wp_file.rename(target) + moved.append(f"{wp_file.relative_to(repo_dir)} -> {target.relative_to(repo_dir)}") + + return moved + + +# --------------------------------------------------------------------------- +# Output / rendering +# --------------------------------------------------------------------------- + +def render_renormalization_guide() -> str: + lines = ["Lifecycle Renormalization Rules", "=" * 33] + for rule in RENORMALIZATION_RULES: + lines.extend( + [ + "", + f"{rule.check_id}: {rule.invariant}", + f" Detect: {rule.detection}", + f" Repair: {rule.repair}", + f" Tests: {rule.test_anchor}", + ] + ) + lines.extend(["", "Add The Next Guard"]) + for idx, item in enumerate(RENORMALIZATION_NEXT_GUARD_CHECKLIST, 1): + lines.append(f" {idx}. {item}") + return "\n".join(lines) + + +def render_text(report: ConsistencyReport, show_info: bool = True) -> str: + SEP = "=" * 66 + lines = [ + f"ADR-001 Consistency Report", + f"Repo: {report.repo_slug}", + f"Path: {report.repo_path}", + SEP, + ] + + error_section = report.automation_errors + fail_section = report.assessment_failures + if error_section: + lines.append(f"\n AUTOMATION ERRORS ({len(error_section)}):") + for i in error_section: + loc = f" [{i.file_path}]" if i.file_path else "" + lines.append(f" {i.check_id}{loc}") + lines.append(f" {i.message}") + + if fail_section: + lines.append(f"\n ASSESSMENT FAILURES ({len(fail_section)}):") + for i in fail_section: + loc = f" [{i.file_path}]" if i.file_path else "" + fix_tag = " [fixable]" if i.fixable else "" + lines.append(f" {i.check_id}{loc}{fix_tag}") + lines.append(f" {i.message}") + if i.file_value or i.db_value: + lines.append(f" file={i.file_value!r} db={i.db_value!r}") + + for sev in ("WARN", "INFO"): + section = [i for i in report.issues if i.severity == sev] + if not section or (sev == "INFO" and not show_info): + continue + lines.append(f"\n {sev}S ({len(section)}):") + for i in section: + loc = f" [{i.file_path}]" if i.file_path else "" + fix_tag = " [fixable]" if i.fixable else "" + lines.append(f" {i.check_id}{loc}{fix_tag}") + lines.append(f" {i.message}") + if i.file_value or i.db_value: + lines.append(f" file={i.file_value!r} db={i.db_value!r}") + + if report.fixes_applied: + lines.append(f"\n FIXES APPLIED ({len(report.fixes_applied)}):") + for f in report.fixes_applied: + lines.append(f" {f}") + + lines.append(f"\n{SEP}") + n_err = len(report.automation_errors) + n_fail = len(report.assessment_failures) + n_warn = len(report.warnings) + n_info = len(report.infos) + lines.append( + f" {n_err} automation-error | {n_fail} assessment-fail | " + f"{n_warn} warn | {n_info} info" + ) + if n_err: + lines.append(" RESULT: ✗ AUTOMATION ERROR") + elif n_fail: + lines.append(" RESULT: ✗ ASSESSMENT FAIL (follow-up needed)") + elif n_warn: + lines.append(" RESULT: ✓ PASS (with warnings)") + else: + lines.append(" RESULT: ✓ PASS") + lines.append(SEP) + return "\n".join(lines) + + +def report_to_dict(report: ConsistencyReport) -> dict: + revision = { + "source_revision": report.source_revision, + "source_revision_reason": report.source_revision_reason, + } + return { + "repo_slug": report.repo_slug, + "repo_path": report.repo_path, + **revision, + "issues": [ + { + "severity": i.severity, + "check_id": i.check_id, + "message": i.message, + "file_path": i.file_path, + "db_id": i.db_id, + "file_value": i.file_value, + "db_value": i.db_value, + "fixable": i.fixable, + **({"repo_slug": report.repo_slug, **revision} if i.check_id == "C-25" else {}), + **({"owner_return": i._fix_context} if i.check_id == "C-25" and i._fix_context.get("task_record_id") else {}), + } + for i in report.issues + ], + "fixes_applied": report.fixes_applied, + "summary": { + "fail": len(report.assessment_failures), + "automation_error": len(report.automation_errors), + "warn": len(report.warnings), + "info": len(report.infos), + }, + "result": ( + "error" if report.automation_errors else + "fail" if report.assessment_failures else + "warn" if report.warnings else + "pass" + ), + } + + +def consistency_exit_code(reports: list[ConsistencyReport], *, remote_all: bool = False) -> int: + """Return the strict CLI exit code for consistency reports.""" + any_automation_error = any(r.automation_errors for r in reports) + any_assessment_fail = any(r.assessment_failures for r in reports) + any_warn = any(r.warnings for r in reports) + if remote_all: + return 1 if any_automation_error else 0 + if any_automation_error or any_assessment_fail: + return 1 + return 2 if any_warn else 0 + + +# --------------------------------------------------------------------------- +# CLI entry point +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="ADR-001 consistency checker — bidirectional file↔DB validation", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--repo", metavar="SLUG", + help="Registered repo slug (e.g. the-custodian)") + group.add_argument("--all", action="store_true", + help="Run checks against all repos with a resolvable path on this host") + group.add_argument("--here", metavar="PATH", nargs="?", const="", + help="Infer repo slug from git remote URL at PATH (default: CWD)") + group.add_argument("--renormalization-guide", action="store_true", + help="Print lifecycle renormalization rules and the add-next-guard checklist") + parser.add_argument("--fix", action="store_true", + help="Apply auto-fixable issues (status drift, repo mismatch, etc.)") + parser.add_argument("--remote", action="store_true", + help="Pull each repo before fixing; when used with --all, skips repos " + "that are already clean (no actionable issues, not behind remote). " + "Implies --fix.") + parser.add_argument("--max-seconds", type=int, default=DEFAULT_REMOTE_ALL_MAX_SECONDS, + help="Wall-clock budget for --remote --all before remaining repos are skipped " + f"(default: {DEFAULT_REMOTE_ALL_MAX_SECONDS}; 0 disables)") + parser.add_argument("--no-writeback", action="store_true", dest="no_writeback", + help="Disable DB→file status writeback (C-15) while keeping other fixes") + parser.add_argument( + "--bootstrap-empty-projection", + action="store_true", + help=( + "Rebuild authoritative legacy UUIDs only after proving the target " + "repository has zero projected workplans" + ), + ) + parser.add_argument("--archive-closed", action="store_true", + help="Move closed root workplans to workplans/archived/YYMMDD-*.md") + parser.add_argument("--archive-workplan", metavar="ID_OR_FILE", default=None, + help="When archiving, only move the matching workplan id or filename") + parser.add_argument("--archive-date", metavar="YYMMDD", default=None, + help="Completion date prefix for --archive-closed (default: today)") + parser.add_argument("--repo-path", metavar="PATH", default=None, + help="Override the local repo path (useful when the DB has a different " + "machine's path). Takes priority over host_paths and local_path.") + parser.add_argument("--api-base", default="http://127.0.0.1:8000", + help="State Hub API base URL") + parser.add_argument("--json", action="store_true", dest="as_json", + help="Output JSON instead of human-readable text") + args = parser.parse_args() + + if args.renormalization_guide: + if args.as_json: + print(json.dumps({ + "rules": [ + { + "check_id": rule.check_id, + "invariant": rule.invariant, + "detection": rule.detection, + "repair": rule.repair, + "test_anchor": rule.test_anchor, + } + for rule in RENORMALIZATION_RULES + ], + "add_next_guard": list(RENORMALIZATION_NEXT_GUARD_CHECKLIST), + }, indent=2)) + else: + print(render_renormalization_guide()) + sys.exit(0) + + import os as _os + no_wb = getattr(args, "no_writeback", False) + do_fix = args.fix or args.remote + if args.bootstrap_empty_projection and (not do_fix or args.all or args.remote): + parser.error( + "--bootstrap-empty-projection requires --fix for one local repository " + "and cannot be combined with --all or --remote" + ) + + # --here: infer slug from git remote URL, then run as single-repo check/fix + if args.here is not None: + search_path = args.here or _os.getcwd() + inferred = _infer_slug_from_path(args.api_base, search_path) + if inferred is None: + print( + f"ERROR: No registered repo with a matching remote_url found at '{search_path}'.", + file=sys.stderr, + ) + print( + " Register the repo first: POST /repos/ with remote_url set.", + file=sys.stderr, + ) + sys.exit(1) + inferred_slug, git_root = inferred + print(f" Detected: {inferred_slug} ({git_root})") + if do_fix: + reports = [ + fix_repo( + args.api_base, + inferred_slug, + git_root, + no_writeback=no_wb, + bootstrap_empty_projection=args.bootstrap_empty_projection, + ) + ] + else: + reports = [check_repo(args.api_base, inferred_slug, git_root)] + if args.archive_closed: + moved = archive_closed_workplans(git_root, args.archive_date, args.archive_workplan) + reports[0].fixes_applied.extend(f"archive: {m}" for m in moved) + # --remote --all: smart pull+fix across all repos + elif args.remote and args.all: + with run_lock("consistency-remote-all") as acquired: + if not acquired: + print( + "SKIP: another fix-consistency-remote --all run is already active", + file=sys.stderr, + ) + sys.exit(0) + reports = fix_all_remote( + args.api_base, + no_writeback=no_wb, + max_seconds=args.max_seconds, + ) + if reports is None: + sys.exit(1) + if not reports: + sys.exit(0) + else: + # Resolve repo list + hostname = _sweep_hostname() + repo_slugs: list[str] = [] + if args.all: + repos = _api_get(args.api_base, "/repos") + if not isinstance(repos, list): + print("ERROR: Could not fetch repos from state-hub API", file=sys.stderr) + sys.exit(1) + repo_slugs = [ + r["slug"] for r in repos + if r.get("local_path") or (r.get("host_paths") or {}).get(hostname) + ] + if not repo_slugs: + print( + f"No repos with a path registered for host '{hostname}'.", + file=sys.stderr, + ) + sys.exit(0) + else: + repo_slugs = [args.repo] + + # --repo-path only applies to single-repo runs; silently ignored with --all + path_override = args.repo_path if not args.all else None + + if args.remote: + # Single-repo remote: pull first, then fix + slug = repo_slugs[0] + repo = _api_get(args.api_base, f"/repos/{slug}") + path = resolve_repo_path(repo or {}, path_override) if repo else (path_override or "") + if path: + pull_ok, pull_msg = _git_pull(path) + prefix = "pull" if pull_ok else "pull WARN" + print(f" {prefix}: {pull_msg}") + report = fix_repo(args.api_base, slug, path_override, no_writeback=no_wb) + reports = [report] + elif do_fix: + reports = [ + fix_repo( + args.api_base, + slug, + path_override, + no_writeback=no_wb, + bootstrap_empty_projection=args.bootstrap_empty_projection, + ) + for slug in repo_slugs + ] + else: + reports = [check_repo(args.api_base, slug, path_override) for slug in repo_slugs] + + if args.archive_closed: + for report in reports: + moved = archive_closed_workplans(report.repo_path, args.archive_date, args.archive_workplan) + report.fixes_applied.extend(f"archive: {m}" for m in moved) + + if args.as_json: + output = ( + report_to_dict(reports[0]) + if len(reports) == 1 + else [report_to_dict(r) for r in reports] + ) + print(json.dumps(output, indent=2)) + else: + for report in reports: + print(render_text(report)) + print() + + sys.exit(consistency_exit_code(reports, remote_all=args.remote and args.all)) + + +if __name__ == "__main__": + main() diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r6/state-hub/tests/test_writeback_git_isolated.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r6/state-hub/tests/test_writeback_git_isolated.py new file mode 100644 index 00000000..63e20a09 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r6/state-hub/tests/test_writeback_git_isolated.py @@ -0,0 +1,60 @@ +"""Real disposable Git proof; does not import owner app or test conftest.""" +import ast +from pathlib import Path +import subprocess +import sys +import pytest + +SOURCE=Path(__file__).resolve().parents[1]/'scripts/consistency_check.py' +def actual_writeback(): + tree=ast.parse(SOURCE.read_text());nodes=[] + for node in tree.body: + if isinstance(node,ast.FunctionDef) and node.name in {'_git_identity_config','_git_commit_writeback'}: + node.returns=None + for arg in node.args.args+node.args.kwonlyargs:arg.annotation=None + nodes.append(node) + env=dict(subprocess=subprocess,sys=sys,Path=Path) + exec(compile(ast.Module(body=nodes,type_ignores=[]),str(SOURCE),'exec'),env) + return env['_git_commit_writeback'] +def git(root,*args):return subprocess.check_output(['git','-C',str(root),*args]).decode().strip() +@pytest.fixture +def repo(tmp_path): + root=tmp_path/'owner';root.mkdir();git(root,'init','-b','main');git(root,'config','user.name','Synthetic Test');git(root,'config','user.email','synthetic@example.invalid');git(root,'config','core.hooksPath','.git/hooks') + for name in ['writeback.md','.custodian-brief.md','evidence.json']:(root/name).write_text('original\n') + git(root,'add','.');git(root,'commit','-m','baseline') + (root/'.custodian-brief.md').write_text('staged unrelated\n');git(root,'add','.custodian-brief.md');(root/'.custodian-brief.md').write_text('working unrelated\n') + (root/'evidence.json').write_text('dirty evidence\n') + return root + +def test_writeback_commits_only_target_preserves_unrelated_stage_and_worktree(repo): + before_index=git(repo,'ls-files','--stage','.custodian-brief.md','evidence.json');before_head=git(repo,'rev-parse','HEAD') + target=repo/'writeback.md';target.write_text('new status\n') + assert actual_writeback()(str(repo),target,['synthetic status']) + assert git(repo,'rev-parse','HEAD')!=before_head + assert git(repo,'diff-tree','--no-commit-id','--name-only','-r','HEAD')=='writeback.md' + assert git(repo,'show','HEAD:.custodian-brief.md')=='original' + assert git(repo,'show',':.custodian-brief.md')=='staged unrelated' + assert git(repo,'ls-files','--stage','.custodian-brief.md','evidence.json')==before_index + assert (repo/'.custodian-brief.md').read_text()=='working unrelated\n' + assert (repo/'evidence.json').read_text()=='dirty evidence\n' + +def test_noop_never_commits_unrelated_staged_change(repo): + head=git(repo,'rev-parse','HEAD');index=git(repo,'ls-files','--stage') + assert not actual_writeback()(str(repo),repo/'writeback.md',['no change']) + assert git(repo,'rev-parse','HEAD')==head and git(repo,'ls-files','--stage')==index + +@pytest.mark.parametrize('name',['new writeback.md','-leading-dash.md']) +def test_new_target_and_argument_boundary(repo,name): + target=repo/name;target.write_text('new file\n') + assert actual_writeback()(str(repo),target,['new synthetic target']) + assert git(repo,'diff-tree','--no-commit-id','--name-only','-r','HEAD')==name + assert git(repo,'show',':.custodian-brief.md')=='staged unrelated' + +def test_normal_hook_refusal_preserves_unrelated_state(repo): + hook=repo/'.git/hooks/pre-commit';hook.write_text('#!/bin/sh\nexit 1\n');hook.chmod(0o755) + head=git(repo,'rev-parse','HEAD');index=git(repo,'ls-files','--stage','.custodian-brief.md','evidence.json') + target=repo/'writeback.md';target.write_text('new status\n') + assert not actual_writeback()(str(repo),target,['refused synthetic change']) + assert git(repo,'rev-parse','HEAD')==head + assert git(repo,'ls-files','--stage','.custodian-brief.md','evidence.json')==index + assert git(repo,'show',':.custodian-brief.md')=='staged unrelated' diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r6/state-hub/workplans/STATE-WP-0095-historical-source-receiving.md b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r6/state-hub/workplans/STATE-WP-0095-historical-source-receiving.md new file mode 100644 index 00000000..d9c7ec74 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r6/state-hub/workplans/STATE-WP-0095-historical-source-receiving.md @@ -0,0 +1,359 @@ +--- +id: STATE-WP-0095 +type: workplan +title: "Preserve exact closed historical source bindings in Forge receiving" +domain: infotech +repo: state-hub +status: active +owner: codex +created: "2026-10-04" +updated: "2026-10-17" +flavor: implementation +depends_on: [] +state_hub_workstream_id: "79579572-8120-5d3b-aca5-dd4aff60d058" +--- + +## Goal + +Normal Forge receiving must preserve the seven bound Activity legacy source +views and the Custodian retired ADHOC source without new identities, source +rewrites, task movement, deletion, relabeling or unretirement. This implements +receiving support before RMGR-WP-0019 registrar admission. CUST-WP-0077 owns +cross-owner coordination; deployment and replay remain explicit owner returns. + +## Verify source bindings and shared views + +```task +id: STATE-WP-0095-T01 +status: done +priority: high +state_hub_task_id: "6a5f87bb-e63d-5e20-b9fc-c1b5cd403ff7" +``` + +Reviewed seven exact Activity files: four existing workplans and 64 UUID-bound +closed tasks. The archived custodian-WP-0003 parent and three split_from views +share one existing parent UUID; every repeated task binding/content agrees. +Custodian ADHOC-2026-08-25-retired retains parent +1c53d3db-6633-5e2d-987f-10706d73be9e and task +d8af3e6b-26ae-580c-bbeb-caa8ad419bec. No identity migration is authorized. + +## Implement preservation receiving and global collision refusal + +```task +id: STATE-WP-0095-T02 +status: done +priority: high +state_hub_task_id: "ebb24194-e4cc-5130-aaa6-663f2ea2c822" +``` + +Prepared exact source digest manifest, existing-parent/task verification, +immutable receiving and UUID-based historical diff. Ordinary canonical sources +continue deriving their identities. Missing/changed/symlinked source, absent +bound rows or nonterminal hub histories refuse. Check task UUID holders globally, +including retired and acknowledged-stale parent workplans, before INSERT. + +Offline preview tests and snapshot proof are evidence of source behavior only; +finish after applying reviewed source and its focused tests to the owning repo. + +## Deliver receiving through normal deployment and verify owner return + +```task +id: STATE-WP-0095-T03 +status: done +priority: high +depends_on: [STATE-WP-0095-T02] +state_hub_task_id: "e8566341-cf6e-5b9c-ad80-8564d14cd52a" +decision_id: "36772026-f43d-4331-b466-5f4b1afe4eca" +``` + +No direct live code patch. This task is a deployment return, not policy authority +or permission to resolve identity decisions. Exact receiving fixture acceptance +and global collision rejection must hold in the delivered version. + +## Verify normal historical replay preserves existing identities + +```task +id: STATE-WP-0095-T04 +status: done +priority: high +depends_on: [STATE-WP-0095-T03, RMGR-WP-0019-T03] +state_hub_task_id: "3bbb311f-194e-5862-8aed-358a580f22b4" +``` + +Verify Activity four historical parent UUIDs/64 task UUIDs and Custodian retired +parent/task unchanged; no historical row creation, movement, deletion, +relabeling or unretirement. Unrelated stale projections remain separate decisions. + +2026-10-05: Source exact-set retirement support now exists. Repository projection +reconcile accepts `expected_retirement_uuids`; that set must equal the stale +projections, and any task or dependency edge on a stale row refuses the call. +Live apply still needs a reviewed rollout. On 2026-10-05 the founder accepted +review `review-1ece4f3e-d0e3-4fd6-abc8-272f1b1876f4`, which names only the three +empty projections and preserves parent `b4eb45a9-69e3-4ab0-b00c-67a53c3117c5`. +That acceptance is not a live retirement and does not resolve +`91b7c633-a66f-5d43-9f4b-6565aefb9356`. + +## Offline evidence, 2026-10-04 + +Prepared receiver suite: Forge projection regression plus historical preservation, +missing/changed source, terminality, immutable UUID diff, and global holder query. +Real-source offline snapshot proof preserves four Activity parents/64 tasks and +one Custodian parent/task without mutations; no API writes or deployment occurred. +Exact original bindings are maintained in api/services/historical_projection.py. + +2026-10-04 actual owning-source validation: 155 focused receiver/qualifier/planner +cases passed; the independent offline current-source/current-hub-snapshot proof +retained five existing parents and 65 task UUIDs, no mutations, clean historical +diff. T02 is source-complete; T03/T04 remain wait for reviewed deployment and +normal registrar/replay receiving, respectively. Evidence: docs/evidence/2026-10-04-historical-receiving-source.json. + + +## Verified receiving rollout and remaining replay gate, 2026-10-04 + +T03 is done on actual founder approval of proposal +STATE-WP-0095-T03-historical-receiving revision 1, SHA256 +`d6789359b3fe7988a6201fff6f367cb46948aa197e1cc0ca66c31f45e1ea9981`, +approval receipt reference `user_reply:call_o87uEfFs3AtXfxO5YqUnSpcg:0`. +The normal owner Helm path delivered `main-5dd3df9`, commit +`5dd3df9c69eb564af10ea5ea01e349786212501a`, revision 72; API and MCP are Ready, +primary/railiance01 health and schema a0b1c2d3e4f5 verified, running source +hashes match. Rollback remains revision 71; no receipt-table downgrade is needed. +Actual historical preservation and global task-holder refusal were verified in +the delivered version. Durable receipt: +[verified historical receiving rollout](../docs/evidence/2026-10-04-historical-receiving-rollout.json). + +Custodian immutable replay applied while preserving the retired historical +parent/task UUIDs. Activity immutable replay retained all four historical parent +and 64 task UUIDs and historical row contents, but refused transactionally on +three unrelated stale projections: custodian-wp-0003 (archived), +custodian-wp-0003a (done), custodian-wp-0003b (done). No retirement acknowledgement +was issued and that refusal is not represented as normal replay success. + +Registrar source `c41d36c2` has 38 actual passing tests and Repo Manager's own +normal sync succeeded; registrar admission does not authorize retiring Activity +projections. T04 therefore remains wait, retaining RMGR-WP-0019-T03 and awaiting +Activity Core's precise reviewed disposition of those three stale rows. No +rename, identity migration, new paid native trial or further live action is +included in this source status update. + +## Activity Core return, 2026-10-06 + +Activity Core returned the exact disposition (message +`cf2d5847-8b3f-4653-9d9f-d5fcfbe3f1e4`, ACTIVITY-WP-0045, activity-core commit +`941fac0`). `expected_retirement_uuids`: +`683724d9-4249-5e0e-88e0-7baff4330d58` (custodian-wp-0003), +`644017c7-c5f2-5a64-81c3-d8ee806f8251` (custodian-wp-0003a), +`a5806df9-5b9a-50da-81c1-7e606076bed9` (custodian-wp-0003b). Each has zero +tasks and zero dependency edges. None is preserved parent +`b4eb45a9-69e3-4ab0-b00c-67a53c3117c5`. Repo Manager independently verified the +set (message `916841f6`, repo-manager commit `498682c`). Nothing was retired. +T04 now waits on decision `91b7c633` and a reviewed live rollout. After that, +Repo Manager re-runs the Activity owner sync and closes RMGR-WP-0019-T03. + +## Retirement rollout and T04 verification, 2026-10-06 + +The founder resolved decision `91b7c633` (retire exactly the three projections) +and approved the rollout. The founder ran the normal Helm upgrade to +`main-968a811` (revision 74; rollback revision 73, `main-fad9b3f`). API and MCP +are Ready, health is ok on primary/railiance01, and schema `b3c4d5e6f7a8` is +unchanged; no migrations. The activity-core reconcile at commit +`67a92d2ff8e033baf2c36998d24187fe83edec84` with `expected_retirement_uuids` set +to the three UUIDs applied: 0 created, 3 retired (tombstoned with an +`@retired-20261006` slug; UUIDs and statuses kept), 0 refused, 0 tasks +cancelled. All 7 bound Activity source files still match their digests. The four +historical parents and 64 task UUIDs are present under their bound parents with +unchanged fields. The Custodian retired parent and task are unchanged. Activity +had 54 workplans and 288 tasks before and after. Evidence: +[activity stale retirement rollout](../docs/evidence/2026-10-06-activity-stale-retirement-rollout.json). +Repo Manager's re-run of the Activity owner sync (RMGR-WP-0019-T03) and +ACTIVITY-WP-0045-T02 remain with their owners. + + +## Cluster exact historical receiving successor, 2026-10-17 + +This scoped extension retains the completed Activity/Custodian outcomes above. +Three frozen cluster archives contain three existing parent UUIDs and 15 existing +closed task UUIDs. Extend the existing exact historical receiving path before +registrar admission; never derive replacement identities or modify those +sources. Cross-owner observation attaches to existing SHR-WP-0001, with +RMGR-WP-0019 as obligor/beneficiary and NK-WP-0049-T35 benefiting indirectly. +The local derived case is waiting; no live worker injection is authorized. + +## Prepare exact cluster source preservation and refusal cases + +```task +id: STATE-WP-0095-T05 +status: done +priority: high +state_hub_task_id: "339b9a37-181c-5599-9a89-0393e199bd4b" +``` + +Prepared the same full-file SHA256 and 15 task binding manifest as Repo Manager. +Candidate receiving tests pass 26: existing rows remain entirely unchanged; +missing/foreign/nonterminal rows and unknown, changed, missing or symlinked +source refuse. Read-only public metadata confirms all 18 existing rows have the +expected ownership and terminal status. These are source and metadata checks, +not delivered receiving or a committed native trial. + +## Deliver reviewed cluster receiving successor before registrar admission + +```task +id: STATE-WP-0095-T06 +status: done +priority: high +depends_on: [STATE-WP-0095-T05] +needs_human: false +intervention_note: "Exact revision 4 approved and verified; no pending rollout intervention." +state_hub_task_id: "6af973b4-1964-5d4d-a2f8-8dbd4c01fda4" +``` + +Completed 2026-10-17 on the founder's approval of exact revision 4 of stable +proposal STATE-WP-0095-T06-cluster-frozen-receiving-r1 (decision + e3798419-8aa9-5425-8c08-20df6bd82a19; approved digest + sha256:63a02e8296d126c0158b7eb2c4ce79c7eeb6f8d6f61298d473c1065c7557ba32). +The ordinary owner Helm path delivered revision 76, runtime source 51bc70d and +exact image manifest sha256:c802690ee1811b3d77df4077936f9cdced1385514e8673fcbc9232b84d645015. +API/MCP Ready and primary/railiance01 health report expected schema +c4d5e6f7a8b9; delivered historical module sha256:e3c4bf965764b32a6df5d61c71272ee22569df9a9ddae2a3fc3df97ec554e0b2 matches. +All three parent and 15 task full DB row hashes/columns/UUIDs remained unchanged. +Five actual native probes passed: preserve, actual foreign-parent holder move, +nonterminal task, nonterminal parent and missing task. Every probe rolled back; +a fresh transaction confirmed complete row restoration. The foreign-holder +case precisely verified historical binding refusal, not the separate global +UUID-holder creation branch. No committed probe writes were made. + +Evidence: [verified receiving rollout](../docs/evidence/2026-10-17-cluster-historical-receiving-rollout.json). +This actual receiving return satisfies the prerequisite of RMGR-WP-0019-T05. +Normal cluster publication and its immutable-source/row return remain T07; +source readiness and this deployment do not count that later acceptance twice. +The additive inbox migration is retained; Helm rollback does not downgrade +schema or stop an active migration Job. Original failures and exact-UID recovery +limits remain in the referenced approved packet. No TLS recovery/native private +admission or new autonomy/cost grant is inferred. + +## Verify ordinary cluster receiving and immutable historical rows + +```task +id: STATE-WP-0095-T07 +status: wait +priority: high +depends_on: [STATE-WP-0095-T06, RMGR-WP-0019-T06] +state_hub_task_id: "cfd1ea99-e75b-542e-a434-acefb62f3c7d" +``` + +Capture normal cluster owner publication/receiving receipts and compare entire +existing row snapshots and exact source bytes. No creation, movement, relabel, +unretirement or retirement is authorized for these histories. A rollback first +fences cluster sync and restores registrar refusal before removing receiving +support; it never rewrites archived IDs or UUIDs. This work does not establish +original TLS recovery or owner-native admission. + +## Fix policy-compatible Secret-template server dry-run preparation + +```task +id: STATE-WP-0095-T08 +status: done +priority: high +state_hub_task_id: "2dd9b04f-2250-5a8a-b5bc-c8a009872858" +``` + +Prepared the narrow Makefile successor after the existing readiness command +refused its first Secret-template phase. Ordinary client-side apply adds the +last-applied annotation, correctly denied by the existing Platform +reject-secret-last-applied policy. Only the two Secret-template server +dry-run requests now use server-side apply with dry-run; no force-conflicts, +Secret value change, policy relaxation or actual Secret write is introduced. +Command-fixture tests verify both requests and preserve other dry-run phases. +Evidence and original refusal remain in the Custodian cluster historical +publication packet, revisions/r2 and r3-readiness. This is completed source +preparation; T06 separately records the verified actual rollout. The runtime image +continues to be main-51bc70d; Makefile/workplan changes do not create a new +application image or native acceptance. + + +T08 source repair refinement, 2026-10-17: the required normal Make render +correctly refused a pre-existing generated chart classification copy at v1.0 +after authoritative canon reached v1.1. Regenerated only through the existing +scripts/sync_classification_allowed.py, preserving canon and strict check. +The exact source diff is version 1.0→1.1 plus publication/history/participation. +Normal Make rendering and the focused classification check passed in an +isolated owner-layout copy. The original refusal is retained in the Custodian +owner-followthrough-r1 validation receipt. This is source repair only: no live +ConfigMap apply, new runtime image, policy relaxation or T07 completion. + + +T07 actual ordinary-publication return, 2026-10-17: cluster owner deep consistency +and normal fast projection completed, exact archived source bytes and all 15 +historical task full-row hashes remained unchanged. The later full-row check +FAILED for exactly the three parent rows. The earlier approved T06 rollout had +preserved all 18 rows at its verification stage; that success is not the later +whole-pipeline acceptance. The original snapshots retain hashes only, so exact +changed columns and original field preimages are not established. No guessed +DB repair or claim that all 18 original row hashes are restored is authorized. +T07 and RMGR-WP-0019-T06 remain waiting pending T09/T10 and a reviewed return. + +## Guard immutable historical index bindings in CLI and receiving API + +```task +id: STATE-WP-0095-T09 +status: done +priority: high +depends_on: [STATE-WP-0095-T06] +state_hub_task_id: "8be8f13b-6352-5295-9755-de6fbfb13593" +``` + +Prepare and publish the narrowly scoped ordinary-binding repair for the three +existing task-bound Cluster registry entries. The CLI verifies exact frozen +source metadata/bytes and omits those bindings, including on old receivers. +The API independently validates the complete batch before any mutation, checks +actual repo ownership and the existing terminal parent/task closure, and +preserves valid frozen rows without field or timestamp assignments. Invalid +metadata, missing/foreign/nonterminal rows refuse the batch. Unknown ordinary +bindings retain existing behavior; historical IDs, UUIDs and source bytes stay +unchanged. Isolated regression tests and normal owner rendering are required. +Reviewed source preparation and canonical regression proof completed on +2026-10-17: 102 isolated tests pass and normal Make render/classification +check passes (eight objects, three unchanged delivered runtime images). +This completion record accompanies the same source publication/projection; +source preparation does not establish delivered runtime or T07 acceptance. +Central check/fix engines also refuse the complete repair batch if any fixable +context touches an exact registered parent/task UUID or frozen source path. +This runs before index bindings and before reconcile/push/individual fixes; +ordinary earlier fixes cannot partially execute before a later frozen refusal. + +## Deliver and verify the ordinary-binding guard successor + +```task +id: STATE-WP-0095-T10 +status: wait +priority: high +depends_on: [STATE-WP-0095-T09] +needs_human: true +intervention_note: "New API runtime requires its own exact image, reviewed preflight and scoped rollout approval; prior revision-4 approval does not cover it." +state_hub_task_id: "88585c69-d9b9-5e1f-a256-6416ff71e824" +``` + +After source publication, resolve normal CI status and immutable image identity, +verify delivered guard source closure, prepare exact chart/reuse-values dry-run, +full closure/headroom/schema baseline and conditional recovery. Retain additive +migration and active-Job cancellation limitations; no Helm schema downgrade or +bounded-outage claim. Obtain scoped approval before live rollout. Then verify +new full-row baseline preservation and actual rollback-only refusal probes, +and only then return to T07 ordinary-pipeline acceptance. This prevents future +binding mutation; it does not reconstruct or restore original parent hashes. +No production changes, new autonomy or credential authority are granted here. + + +T09 source repair refinement, 2026-10-17: the attempted current-client regression +actually loaded an older packaged consistency checker from the owner virtualenv, +not the reviewed canonical source. That trial again failed parent-row hash +preservation; it is retained separately and does not count as R5 client proof. +Normal C-23 also legitimately normalized WP18 ready to active and created a +source commit, but the generic writeback commit included an unrelated staged +brief. A narrow successor scopes git add and git commit --only to the actual +writeback file, preserving unrelated index/worktree state. Real disposable Git +regression tests cover pre-existing staged changes, hooks, no-op and path names. +Verify the actually loaded CLI checker hash after ordinary owner package refresh +before any retry; capture legitimate source HEAD transitions rather than assume +a static revision. T07 and T10 remain wait. No original parent preimage recovery, +new runtime delivery or broader authority follows from this source repair. diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r7/state-hub/workplans/STATE-WP-0095-historical-source-receiving.md b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r7/state-hub/workplans/STATE-WP-0095-historical-source-receiving.md new file mode 100644 index 00000000..93f44cf8 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r7/state-hub/workplans/STATE-WP-0095-historical-source-receiving.md @@ -0,0 +1,383 @@ +--- +id: STATE-WP-0095 +type: workplan +title: "Preserve exact closed historical source bindings in Forge receiving" +domain: infotech +repo: state-hub +status: active +owner: codex +created: "2026-10-04" +updated: "2026-10-17" +flavor: implementation +depends_on: [] +state_hub_workstream_id: "79579572-8120-5d3b-aca5-dd4aff60d058" +--- + +## Goal + +Normal Forge receiving must preserve the seven bound Activity legacy source +views and the Custodian retired ADHOC source without new identities, source +rewrites, task movement, deletion, relabeling or unretirement. This implements +receiving support before RMGR-WP-0019 registrar admission. CUST-WP-0077 owns +cross-owner coordination; deployment and replay remain explicit owner returns. + +## Verify source bindings and shared views + +```task +id: STATE-WP-0095-T01 +status: done +priority: high +state_hub_task_id: "6a5f87bb-e63d-5e20-b9fc-c1b5cd403ff7" +``` + +Reviewed seven exact Activity files: four existing workplans and 64 UUID-bound +closed tasks. The archived custodian-WP-0003 parent and three split_from views +share one existing parent UUID; every repeated task binding/content agrees. +Custodian ADHOC-2026-08-25-retired retains parent +1c53d3db-6633-5e2d-987f-10706d73be9e and task +d8af3e6b-26ae-580c-bbeb-caa8ad419bec. No identity migration is authorized. + +## Implement preservation receiving and global collision refusal + +```task +id: STATE-WP-0095-T02 +status: done +priority: high +state_hub_task_id: "ebb24194-e4cc-5130-aaa6-663f2ea2c822" +``` + +Prepared exact source digest manifest, existing-parent/task verification, +immutable receiving and UUID-based historical diff. Ordinary canonical sources +continue deriving their identities. Missing/changed/symlinked source, absent +bound rows or nonterminal hub histories refuse. Check task UUID holders globally, +including retired and acknowledged-stale parent workplans, before INSERT. + +Offline preview tests and snapshot proof are evidence of source behavior only; +finish after applying reviewed source and its focused tests to the owning repo. + +## Deliver receiving through normal deployment and verify owner return + +```task +id: STATE-WP-0095-T03 +status: done +priority: high +depends_on: [STATE-WP-0095-T02] +state_hub_task_id: "e8566341-cf6e-5b9c-ad80-8564d14cd52a" +decision_id: "36772026-f43d-4331-b466-5f4b1afe4eca" +``` + +No direct live code patch. This task is a deployment return, not policy authority +or permission to resolve identity decisions. Exact receiving fixture acceptance +and global collision rejection must hold in the delivered version. + +## Verify normal historical replay preserves existing identities + +```task +id: STATE-WP-0095-T04 +status: done +priority: high +depends_on: [STATE-WP-0095-T03, RMGR-WP-0019-T03] +state_hub_task_id: "3bbb311f-194e-5862-8aed-358a580f22b4" +``` + +Verify Activity four historical parent UUIDs/64 task UUIDs and Custodian retired +parent/task unchanged; no historical row creation, movement, deletion, +relabeling or unretirement. Unrelated stale projections remain separate decisions. + +2026-10-05: Source exact-set retirement support now exists. Repository projection +reconcile accepts `expected_retirement_uuids`; that set must equal the stale +projections, and any task or dependency edge on a stale row refuses the call. +Live apply still needs a reviewed rollout. On 2026-10-05 the founder accepted +review `review-1ece4f3e-d0e3-4fd6-abc8-272f1b1876f4`, which names only the three +empty projections and preserves parent `b4eb45a9-69e3-4ab0-b00c-67a53c3117c5`. +That acceptance is not a live retirement and does not resolve +`91b7c633-a66f-5d43-9f4b-6565aefb9356`. + +## Offline evidence, 2026-10-04 + +Prepared receiver suite: Forge projection regression plus historical preservation, +missing/changed source, terminality, immutable UUID diff, and global holder query. +Real-source offline snapshot proof preserves four Activity parents/64 tasks and +one Custodian parent/task without mutations; no API writes or deployment occurred. +Exact original bindings are maintained in api/services/historical_projection.py. + +2026-10-04 actual owning-source validation: 155 focused receiver/qualifier/planner +cases passed; the independent offline current-source/current-hub-snapshot proof +retained five existing parents and 65 task UUIDs, no mutations, clean historical +diff. T02 is source-complete; T03/T04 remain wait for reviewed deployment and +normal registrar/replay receiving, respectively. Evidence: docs/evidence/2026-10-04-historical-receiving-source.json. + + +## Verified receiving rollout and remaining replay gate, 2026-10-04 + +T03 is done on actual founder approval of proposal +STATE-WP-0095-T03-historical-receiving revision 1, SHA256 +`d6789359b3fe7988a6201fff6f367cb46948aa197e1cc0ca66c31f45e1ea9981`, +approval receipt reference `user_reply:call_o87uEfFs3AtXfxO5YqUnSpcg:0`. +The normal owner Helm path delivered `main-5dd3df9`, commit +`5dd3df9c69eb564af10ea5ea01e349786212501a`, revision 72; API and MCP are Ready, +primary/railiance01 health and schema a0b1c2d3e4f5 verified, running source +hashes match. Rollback remains revision 71; no receipt-table downgrade is needed. +Actual historical preservation and global task-holder refusal were verified in +the delivered version. Durable receipt: +[verified historical receiving rollout](../docs/evidence/2026-10-04-historical-receiving-rollout.json). + +Custodian immutable replay applied while preserving the retired historical +parent/task UUIDs. Activity immutable replay retained all four historical parent +and 64 task UUIDs and historical row contents, but refused transactionally on +three unrelated stale projections: custodian-wp-0003 (archived), +custodian-wp-0003a (done), custodian-wp-0003b (done). No retirement acknowledgement +was issued and that refusal is not represented as normal replay success. + +Registrar source `c41d36c2` has 38 actual passing tests and Repo Manager's own +normal sync succeeded; registrar admission does not authorize retiring Activity +projections. T04 therefore remains wait, retaining RMGR-WP-0019-T03 and awaiting +Activity Core's precise reviewed disposition of those three stale rows. No +rename, identity migration, new paid native trial or further live action is +included in this source status update. + +## Activity Core return, 2026-10-06 + +Activity Core returned the exact disposition (message +`cf2d5847-8b3f-4653-9d9f-d5fcfbe3f1e4`, ACTIVITY-WP-0045, activity-core commit +`941fac0`). `expected_retirement_uuids`: +`683724d9-4249-5e0e-88e0-7baff4330d58` (custodian-wp-0003), +`644017c7-c5f2-5a64-81c3-d8ee806f8251` (custodian-wp-0003a), +`a5806df9-5b9a-50da-81c1-7e606076bed9` (custodian-wp-0003b). Each has zero +tasks and zero dependency edges. None is preserved parent +`b4eb45a9-69e3-4ab0-b00c-67a53c3117c5`. Repo Manager independently verified the +set (message `916841f6`, repo-manager commit `498682c`). Nothing was retired. +T04 now waits on decision `91b7c633` and a reviewed live rollout. After that, +Repo Manager re-runs the Activity owner sync and closes RMGR-WP-0019-T03. + +## Retirement rollout and T04 verification, 2026-10-06 + +The founder resolved decision `91b7c633` (retire exactly the three projections) +and approved the rollout. The founder ran the normal Helm upgrade to +`main-968a811` (revision 74; rollback revision 73, `main-fad9b3f`). API and MCP +are Ready, health is ok on primary/railiance01, and schema `b3c4d5e6f7a8` is +unchanged; no migrations. The activity-core reconcile at commit +`67a92d2ff8e033baf2c36998d24187fe83edec84` with `expected_retirement_uuids` set +to the three UUIDs applied: 0 created, 3 retired (tombstoned with an +`@retired-20261006` slug; UUIDs and statuses kept), 0 refused, 0 tasks +cancelled. All 7 bound Activity source files still match their digests. The four +historical parents and 64 task UUIDs are present under their bound parents with +unchanged fields. The Custodian retired parent and task are unchanged. Activity +had 54 workplans and 288 tasks before and after. Evidence: +[activity stale retirement rollout](../docs/evidence/2026-10-06-activity-stale-retirement-rollout.json). +Repo Manager's re-run of the Activity owner sync (RMGR-WP-0019-T03) and +ACTIVITY-WP-0045-T02 remain with their owners. + + +## Cluster exact historical receiving successor, 2026-10-17 + +This scoped extension retains the completed Activity/Custodian outcomes above. +Three frozen cluster archives contain three existing parent UUIDs and 15 existing +closed task UUIDs. Extend the existing exact historical receiving path before +registrar admission; never derive replacement identities or modify those +sources. Cross-owner observation attaches to existing SHR-WP-0001, with +RMGR-WP-0019 as obligor/beneficiary and NK-WP-0049-T35 benefiting indirectly. +The local derived case is waiting; no live worker injection is authorized. + +## Prepare exact cluster source preservation and refusal cases + +```task +id: STATE-WP-0095-T05 +status: done +priority: high +state_hub_task_id: "339b9a37-181c-5599-9a89-0393e199bd4b" +``` + +Prepared the same full-file SHA256 and 15 task binding manifest as Repo Manager. +Candidate receiving tests pass 26: existing rows remain entirely unchanged; +missing/foreign/nonterminal rows and unknown, changed, missing or symlinked +source refuse. Read-only public metadata confirms all 18 existing rows have the +expected ownership and terminal status. These are source and metadata checks, +not delivered receiving or a committed native trial. + +## Deliver reviewed cluster receiving successor before registrar admission + +```task +id: STATE-WP-0095-T06 +status: done +priority: high +depends_on: [STATE-WP-0095-T05] +needs_human: false +intervention_note: "Exact revision 4 approved and verified; no pending rollout intervention." +state_hub_task_id: "6af973b4-1964-5d4d-a2f8-8dbd4c01fda4" +``` + +Completed 2026-10-17 on the founder's approval of exact revision 4 of stable +proposal STATE-WP-0095-T06-cluster-frozen-receiving-r1 (decision + e3798419-8aa9-5425-8c08-20df6bd82a19; approved digest + sha256:63a02e8296d126c0158b7eb2c4ce79c7eeb6f8d6f61298d473c1065c7557ba32). +The ordinary owner Helm path delivered revision 76, runtime source 51bc70d and +exact image manifest sha256:c802690ee1811b3d77df4077936f9cdced1385514e8673fcbc9232b84d645015. +API/MCP Ready and primary/railiance01 health report expected schema +c4d5e6f7a8b9; delivered historical module sha256:e3c4bf965764b32a6df5d61c71272ee22569df9a9ddae2a3fc3df97ec554e0b2 matches. +All three parent and 15 task full DB row hashes/columns/UUIDs remained unchanged. +Five actual native probes passed: preserve, actual foreign-parent holder move, +nonterminal task, nonterminal parent and missing task. Every probe rolled back; +a fresh transaction confirmed complete row restoration. The foreign-holder +case precisely verified historical binding refusal, not the separate global +UUID-holder creation branch. No committed probe writes were made. + +Evidence: [verified receiving rollout](../docs/evidence/2026-10-17-cluster-historical-receiving-rollout.json). +This actual receiving return satisfies the prerequisite of RMGR-WP-0019-T05. +Normal cluster publication and its immutable-source/row return remain T07; +source readiness and this deployment do not count that later acceptance twice. +The additive inbox migration is retained; Helm rollback does not downgrade +schema or stop an active migration Job. Original failures and exact-UID recovery +limits remain in the referenced approved packet. No TLS recovery/native private +admission or new autonomy/cost grant is inferred. + +## Verify ordinary cluster receiving and immutable historical rows + +```task +id: STATE-WP-0095-T07 +status: wait +priority: high +depends_on: [STATE-WP-0095-T06, RMGR-WP-0019-T06, STATE-WP-0095-T10] +state_hub_task_id: "cfd1ea99-e75b-542e-a434-acefb62f3c7d" +``` + +Capture normal cluster owner publication/receiving receipts and compare entire +existing row snapshots and exact source bytes. No creation, movement, relabel, +unretirement or retirement is authorized for these histories. A rollback first +fences cluster sync and restores registrar refusal before removing receiving +support; it never rewrites archived IDs or UUIDs. This work does not establish +original TLS recovery or owner-native admission. + +## Fix policy-compatible Secret-template server dry-run preparation + +```task +id: STATE-WP-0095-T08 +status: done +priority: high +state_hub_task_id: "2dd9b04f-2250-5a8a-b5bc-c8a009872858" +``` + +Prepared the narrow Makefile successor after the existing readiness command +refused its first Secret-template phase. Ordinary client-side apply adds the +last-applied annotation, correctly denied by the existing Platform +reject-secret-last-applied policy. Only the two Secret-template server +dry-run requests now use server-side apply with dry-run; no force-conflicts, +Secret value change, policy relaxation or actual Secret write is introduced. +Command-fixture tests verify both requests and preserve other dry-run phases. +Evidence and original refusal remain in the Custodian cluster historical +publication packet, revisions/r2 and r3-readiness. This is completed source +preparation; T06 separately records the verified actual rollout. The runtime image +continues to be main-51bc70d; Makefile/workplan changes do not create a new +application image or native acceptance. + + +T08 source repair refinement, 2026-10-17: the required normal Make render +correctly refused a pre-existing generated chart classification copy at v1.0 +after authoritative canon reached v1.1. Regenerated only through the existing +scripts/sync_classification_allowed.py, preserving canon and strict check. +The exact source diff is version 1.0→1.1 plus publication/history/participation. +Normal Make rendering and the focused classification check passed in an +isolated owner-layout copy. The original refusal is retained in the Custodian +owner-followthrough-r1 validation receipt. This is source repair only: no live +ConfigMap apply, new runtime image, policy relaxation or T07 completion. + + +T07 actual ordinary-publication return, 2026-10-17: cluster owner deep consistency +and normal fast projection completed, exact archived source bytes and all 15 +historical task full-row hashes remained unchanged. The later full-row check +FAILED for exactly the three parent rows. The earlier approved T06 rollout had +preserved all 18 rows at its verification stage; that success is not the later +whole-pipeline acceptance. The original snapshots retain hashes only, so exact +changed columns and original field preimages are not established. No guessed +DB repair or claim that all 18 original row hashes are restored is authorized. +T07 and RMGR-WP-0019-T06 remain waiting pending T09/T10 and a reviewed return. + +## Guard immutable historical index bindings in CLI and receiving API + +```task +id: STATE-WP-0095-T09 +status: done +priority: high +depends_on: [STATE-WP-0095-T06] +state_hub_task_id: "8be8f13b-6352-5295-9755-de6fbfb13593" +``` + +Prepare and publish the narrowly scoped ordinary-binding repair for the three +existing task-bound Cluster registry entries. The CLI verifies exact frozen +source metadata/bytes and omits those bindings, including on old receivers. +The API independently validates the complete batch before any mutation, checks +actual repo ownership and the existing terminal parent/task closure, and +preserves valid frozen rows without field or timestamp assignments. Invalid +metadata, missing/foreign/nonterminal rows refuse the batch. Unknown ordinary +bindings retain existing behavior; historical IDs, UUIDs and source bytes stay +unchanged. Isolated regression tests and normal owner rendering are required. +Reviewed source preparation and canonical regression proof completed on +2026-10-17: 102 isolated tests pass and normal Make render/classification +check passes (eight objects, three unchanged delivered runtime images). +This completion record accompanies the same source publication/projection; +source preparation does not establish delivered runtime or T07 acceptance. +Central check/fix engines also refuse the complete repair batch if any fixable +context touches an exact registered parent/task UUID or frozen source path. +This runs before index bindings and before reconcile/push/individual fixes; +ordinary earlier fixes cannot partially execute before a later frozen refusal. + +## Deliver and verify the ordinary-binding guard successor + +```task +id: STATE-WP-0095-T10 +status: wait +priority: high +depends_on: [STATE-WP-0095-T09] +needs_human: true +intervention_note: "New API runtime requires its own exact image, reviewed preflight and scoped rollout approval; prior revision-4 approval does not cover it." +state_hub_task_id: "88585c69-d9b9-5e1f-a256-6416ff71e824" +``` + +After source publication, resolve normal CI status and immutable image identity, +verify delivered guard source closure, prepare exact chart/reuse-values dry-run, +full closure/headroom/schema baseline and conditional recovery. Retain additive +migration and active-Job cancellation limitations; no Helm schema downgrade or +bounded-outage claim. Obtain scoped approval before live rollout. Then verify +new full-row baseline preservation and actual rollback-only refusal probes, +and only then return to T07 ordinary-pipeline acceptance. This prevents future +binding mutation; it does not reconstruct or restore original parent hashes. +No production changes, new autonomy or credential authority are granted here. + + +T09 source repair refinement, 2026-10-17: the attempted current-client regression +actually loaded an older packaged consistency checker from the owner virtualenv, +not the reviewed canonical source. That trial again failed parent-row hash +preservation; it is retained separately and does not count as R5 client proof. +Normal C-23 also legitimately normalized WP18 ready to active and created a +source commit, but the generic writeback commit included an unrelated staged +brief. A narrow successor scopes git add and git commit --only to the actual +writeback file, preserving unrelated index/worktree state. Real disposable Git +regression tests cover pre-existing staged changes, hooks, no-op and path names. +Verify the actually loaded CLI checker hash after ordinary owner package refresh +before any retry; capture legitimate source HEAD transitions rather than assume +a static revision. T07 and T10 remain wait. No original parent preimage recovery, +new runtime delivery or broader authority follows from this source repair. + + +T07 current-baseline client return, 2026-10-17: after the owner refreshed the +installed package and verified the actually loaded checker matched the reviewed +R6 source, ordinary Cluster deep consistency and fast projection passed. +Bindings synced 20/20, omitting the three frozen histories; all three parent +and 15 task CURRENT full-row hashes remained unchanged across +before-verified-client.json / after-verified-client.json. Unrelated staged brief +state was preserved. This proves the corrected installed client stage against +its fresh current baseline; earlier parent-hash failures remain retained +historical exceptions, not restored preimages or backfilled successes. + +T10 remains wait/needs_human for exact proposal +STATE-WP-0095-T10-frozen-bindings-guard-r1 revision 1, metadata +sha256:81d365926fc585a186955d8c98ee83501fd3cec12135b2256d71b46a75097932, +pending decision df703266-6719-4c04-ae1b-ba9afd3bca1c. The inspected candidate +image is main-c103cbf@sha256:4cc4ce17bbcdb1b66132d07c5b7b741f46df83124ef1c8403ffabdafba6993d1, +from runtime source c103cbf3c8bed5dba87a03e320595ddded9301a3. Image-only +server dry-run passed eight identities with only three image-field changes. +The exact proposal and recovery limits are retained in the Custodian dated +2026-10-17 guard-successor-review-r1 packet. No new API runtime is approved or +delivered by this source-only note; its later workplan commit does not change +the image's source pin. T09 stays done, T07/T10 stay wait pending reviewed API +delivery and explicit successor/current-baseline acceptance disposition. diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r8/close-readback.json b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r8/close-readback.json new file mode 100644 index 00000000..17039163 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r8/close-readback.json @@ -0,0 +1,79 @@ +{ + "schema": "state95.proposal-revision-close.v1", + "status": "applied", + "source_revision": "4c03c9d2c6a47f5c7247473f74b2e2f188b787bd", + "remote_revision": "4c03c9d2c6a47f5c7247473f74b2e2f188b787bd", + "instance_role": "primary", + "instance_label": "railiance01", + "expected_commit": "4c03c9d2c6a47f5c7247473f74b2e2f188b787bd", + "derived_commit": "4c03c9d2c6a47f5c7247473f74b2e2f188b787bd", + "tasks": [ + { + "uuid": "6a5f87bb-e63d-5e20-b9fc-c1b5cd403ff7", + "record_id": "STATE-WP-0095-T01", + "status": "done", + "needs_human": false + }, + { + "uuid": "ebb24194-e4cc-5130-aaa6-663f2ea2c822", + "record_id": "STATE-WP-0095-T02", + "status": "done", + "needs_human": false + }, + { + "uuid": "e8566341-cf6e-5b9c-ad80-8564d14cd52a", + "record_id": "STATE-WP-0095-T03", + "status": "done", + "needs_human": false + }, + { + "uuid": "3bbb311f-194e-5862-8aed-358a580f22b4", + "record_id": "STATE-WP-0095-T04", + "status": "done", + "needs_human": false + }, + { + "uuid": "339b9a37-181c-5599-9a89-0393e199bd4b", + "record_id": "STATE-WP-0095-T05", + "status": "done", + "needs_human": false + }, + { + "uuid": "6af973b4-1964-5d4d-a2f8-8dbd4c01fda4", + "record_id": "STATE-WP-0095-T06", + "status": "done", + "needs_human": false + }, + { + "uuid": "cfd1ea99-e75b-542e-a434-acefb62f3c7d", + "record_id": "STATE-WP-0095-T07", + "status": "wait", + "needs_human": false + }, + { + "uuid": "2dd9b04f-2250-5a8a-b5bc-c8a009872858", + "record_id": "STATE-WP-0095-T08", + "status": "done", + "needs_human": false + }, + { + "uuid": "8be8f13b-6352-5295-9755-de6fbfb13593", + "record_id": "STATE-WP-0095-T09", + "status": "done", + "needs_human": false + }, + { + "uuid": "88585c69-d9b9-5e1f-a256-6416ff71e824", + "record_id": "STATE-WP-0095-T10", + "status": "wait", + "needs_human": true + } + ], + "whole_unrelated_index_dirty_preserved": true, + "proposal_revision": 2, + "proposal_status": "pending_unapproved_unexecuted", + "original_raw_receipt": "docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r8/source-commit-result.json", + "original_raw_receipt_sha256": "sha256:3291234c856b2dfd4a84f054475d0372bc7cbf7bdee37b6c08a6d327f46ab322", + "runtime_changed": false, + "new_UUIDs": false +} diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r8/normal-consistency-result.json b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r8/normal-consistency-result.json new file mode 100644 index 00000000..d0687da5 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r8/normal-consistency-result.json @@ -0,0 +1,187 @@ +{ + "repo_slug": "state-hub", + "repo_path": "/home/worsch/state-hub", + "source_revision": "4c03c9d2c6a47f5c7247473f74b2e2f188b787bd", + "source_revision_reason": null, + "issues": [ + { + "severity": "WARN", + "check_id": "C-35", + "message": "Repo Manager warning: workplans/: repository uses more than one workplan prefix: ['CUST-WP', 'STATE-WP']", + "file_path": "workplans/", + "db_id": "", + "file_value": "", + "db_value": "", + "fixable": false + }, + { + "severity": "WARN", + "check_id": "C-33", + "message": "WORK-RECORDS.md is missing or stale \u2014 regenerate via fix-consistency (CUST-WP-0061-T04 generated work-record index)", + "file_path": "WORK-RECORDS.md", + "db_id": "", + "file_value": "", + "db_value": "", + "fixable": true + }, + { + "severity": "WARN", + "check_id": "C-20", + "message": "Workplan dependency target 'ACTIVITY-WP-0026' is not linked to State Hub", + "file_path": "workplans/STATE-WP-0078-ops-run-read-projection.md", + "db_id": "", + "file_value": "ACTIVITY-WP-0026", + "db_value": "", + "fixable": false + }, + { + "severity": "WARN", + "check_id": "C-39", + "message": "Wait task 'STATE-WP-0094-T03' in workplan 'STATE-WP-0094' has neither depends_on, needs_human: true, blocked_on: message-from:\u2026 nor wait_until \u2014 unqualified wait", + "file_path": "workplans/STATE-WP-0094-routed-gates-and-owner-returns.md#STATE-WP-0094-T03", + "db_id": "", + "file_value": "wait", + "db_value": "", + "fixable": false + }, + { + "severity": "WARN", + "check_id": "C-39", + "message": "Human wait task 'STATE-WP-0095-T10' in workplan 'STATE-WP-0095' has needs_human: true without decision_id \u2014 unrouted human gate", + "file_path": "workplans/STATE-WP-0095-historical-source-receiving.md#STATE-WP-0095-T10", + "db_id": "", + "file_value": "needs_human: true", + "db_value": "", + "fixable": false + }, + { + "severity": "WARN", + "check_id": "C-40", + "message": "blocker satisfied, task still wait: 'STATE-WP-0095-T10' in workplan 'STATE-WP-0095' \u2014 every depends_on target is terminal (STATE-WP-0095-T09)", + "file_path": "workplans/STATE-WP-0095-historical-source-receiving.md#STATE-WP-0095-T10", + "db_id": "", + "file_value": "STATE-WP-0095-T09", + "db_value": "", + "fixable": false + }, + { + "severity": "INFO", + "check_id": "C-08", + "message": "Retired DB workstream 'adhoc-2026-07-01@retired-20260827' (id=1cdb288a\u2026, status=finished) has no backing workplan file \u2014 projection tombstone, not live work", + "file_path": "", + "db_id": "1cdb288a-9bcb-4e53-8c4f-cb43e3abe9c5", + "file_value": "", + "db_value": "", + "fixable": false + }, + { + "severity": "INFO", + "check_id": "C-08", + "message": "Retired DB workstream 'adhoc-2026-06-04@retired-20260827' (id=2a8f3aff\u2026, status=finished) has no backing workplan file \u2014 projection tombstone, not live work", + "file_path": "", + "db_id": "2a8f3aff-8f5d-4e42-b33f-225d60e0b30b", + "file_value": "", + "db_value": "", + "fixable": false + }, + { + "severity": "WARN", + "check_id": "C-25", + "message": "Owner-return task 'STATE-WP-0094-T05' waiting on railiance-platform has 1 reply/replies after its last dated note (or unread when undated) \u2014 owner action needed: 639c6969-42ce-4080-b2f8-3eb3bb18c970", + "file_path": "workplans/STATE-WP-0094-routed-gates-and-owner-returns.md#STATE-WP-0094-T05", + "db_id": "", + "file_value": "message-from:railiance-platform", + "db_value": "", + "fixable": false, + "repo_slug": "state-hub", + "source_revision": "4c03c9d2c6a47f5c7247473f74b2e2f188b787bd", + "source_revision_reason": null, + "owner_return": { + "task_record_id": "STATE-WP-0094-T05", + "message_ids": [ + "639c6969-42ce-4080-b2f8-3eb3bb18c970" + ] + } + }, + { + "severity": "WARN", + "check_id": "C-28", + "message": "2 unread inbox message(s) older than 3 day(s): the-custodian:23172cb2, the-custodian:51e9eace", + "file_path": "", + "db_id": "", + "file_value": "", + "db_value": "", + "fixable": false + }, + { + "severity": "WARN", + "check_id": "C-28", + "message": "2 unread message(s) lack thread_id for supersession tracking: the-custodian:23172cb2, the-custodian:51e9eace", + "file_path": "", + "db_id": "", + "file_value": "", + "db_value": "", + "fixable": false + }, + { + "severity": "WARN", + "check_id": "C-26", + "message": "Workplan id 'CUST-WP-0003' uses prefix 'CUST-WP' but repo canonical prefix is 'STATE-WP' \u2014 new plans must conform", + "file_path": "workplans/CUST-WP-0003-whi-kpi-card.md", + "db_id": "", + "file_value": "CUST-WP-0003", + "db_value": "STATE-WP", + "fixable": false + }, + { + "severity": "WARN", + "check_id": "C-26", + "message": "Workplan id 'CUST-WP-0012' uses prefix 'CUST-WP' but repo canonical prefix is 'STATE-WP' \u2014 new plans must conform", + "file_path": "workplans/CUST-WP-0012-multi-user-onboarding.md", + "db_id": "", + "file_value": "CUST-WP-0012", + "db_value": "STATE-WP", + "fixable": false + }, + { + "severity": "WARN", + "check_id": "C-26", + "message": "Workplan id 'CUST-WP-0038' uses prefix 'CUST-WP' but repo canonical prefix is 'STATE-WP' \u2014 new plans must conform", + "file_path": "workplans/CUST-WP-0038-state-hub-threephoenix-ha.md", + "db_id": "", + "file_value": "CUST-WP-0038", + "db_value": "STATE-WP", + "fixable": false + }, + { + "severity": "WARN", + "check_id": "C-26", + "message": "Workplan id 'CUST-WP-0042' uses prefix 'CUST-WP' but repo canonical prefix is 'STATE-WP' \u2014 new plans must conform", + "file_path": "workplans/CUST-WP-0042-workplan-state-model-cleanup.md", + "db_id": "", + "file_value": "CUST-WP-0042", + "db_value": "STATE-WP", + "fixable": false + }, + { + "severity": "WARN", + "check_id": "C-26", + "message": "Workplan id 'CUST-WP-0011' uses prefix 'CUST-WP' but repo canonical prefix is 'STATE-WP' \u2014 new plans must conform", + "file_path": "workplans/archived/260706-CUST-WP-0011-state-hub-threephoenix-migration.md", + "db_id": "", + "file_value": "CUST-WP-0011", + "db_value": "STATE-WP", + "fixable": false + } + ], + "fixes_applied": [ + "bindings: synced 70/70" + ], + "summary": { + "fail": 0, + "automation_error": 0, + "warn": 14, + "info": 2 + }, + "result": "warn" +} diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r8/state-hub/workplans/STATE-WP-0095-historical-source-receiving.md b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r8/state-hub/workplans/STATE-WP-0095-historical-source-receiving.md new file mode 100644 index 00000000..29f999aa --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r8/state-hub/workplans/STATE-WP-0095-historical-source-receiving.md @@ -0,0 +1,394 @@ +--- +id: STATE-WP-0095 +type: workplan +title: "Preserve exact closed historical source bindings in Forge receiving" +domain: infotech +repo: state-hub +status: active +owner: codex +created: "2026-10-04" +updated: "2026-10-17" +flavor: implementation +depends_on: [] +state_hub_workstream_id: "79579572-8120-5d3b-aca5-dd4aff60d058" +--- + +## Goal + +Normal Forge receiving must preserve the seven bound Activity legacy source +views and the Custodian retired ADHOC source without new identities, source +rewrites, task movement, deletion, relabeling or unretirement. This implements +receiving support before RMGR-WP-0019 registrar admission. CUST-WP-0077 owns +cross-owner coordination; deployment and replay remain explicit owner returns. + +## Verify source bindings and shared views + +```task +id: STATE-WP-0095-T01 +status: done +priority: high +state_hub_task_id: "6a5f87bb-e63d-5e20-b9fc-c1b5cd403ff7" +``` + +Reviewed seven exact Activity files: four existing workplans and 64 UUID-bound +closed tasks. The archived custodian-WP-0003 parent and three split_from views +share one existing parent UUID; every repeated task binding/content agrees. +Custodian ADHOC-2026-08-25-retired retains parent +1c53d3db-6633-5e2d-987f-10706d73be9e and task +d8af3e6b-26ae-580c-bbeb-caa8ad419bec. No identity migration is authorized. + +## Implement preservation receiving and global collision refusal + +```task +id: STATE-WP-0095-T02 +status: done +priority: high +state_hub_task_id: "ebb24194-e4cc-5130-aaa6-663f2ea2c822" +``` + +Prepared exact source digest manifest, existing-parent/task verification, +immutable receiving and UUID-based historical diff. Ordinary canonical sources +continue deriving their identities. Missing/changed/symlinked source, absent +bound rows or nonterminal hub histories refuse. Check task UUID holders globally, +including retired and acknowledged-stale parent workplans, before INSERT. + +Offline preview tests and snapshot proof are evidence of source behavior only; +finish after applying reviewed source and its focused tests to the owning repo. + +## Deliver receiving through normal deployment and verify owner return + +```task +id: STATE-WP-0095-T03 +status: done +priority: high +depends_on: [STATE-WP-0095-T02] +state_hub_task_id: "e8566341-cf6e-5b9c-ad80-8564d14cd52a" +decision_id: "36772026-f43d-4331-b466-5f4b1afe4eca" +``` + +No direct live code patch. This task is a deployment return, not policy authority +or permission to resolve identity decisions. Exact receiving fixture acceptance +and global collision rejection must hold in the delivered version. + +## Verify normal historical replay preserves existing identities + +```task +id: STATE-WP-0095-T04 +status: done +priority: high +depends_on: [STATE-WP-0095-T03, RMGR-WP-0019-T03] +state_hub_task_id: "3bbb311f-194e-5862-8aed-358a580f22b4" +``` + +Verify Activity four historical parent UUIDs/64 task UUIDs and Custodian retired +parent/task unchanged; no historical row creation, movement, deletion, +relabeling or unretirement. Unrelated stale projections remain separate decisions. + +2026-10-05: Source exact-set retirement support now exists. Repository projection +reconcile accepts `expected_retirement_uuids`; that set must equal the stale +projections, and any task or dependency edge on a stale row refuses the call. +Live apply still needs a reviewed rollout. On 2026-10-05 the founder accepted +review `review-1ece4f3e-d0e3-4fd6-abc8-272f1b1876f4`, which names only the three +empty projections and preserves parent `b4eb45a9-69e3-4ab0-b00c-67a53c3117c5`. +That acceptance is not a live retirement and does not resolve +`91b7c633-a66f-5d43-9f4b-6565aefb9356`. + +## Offline evidence, 2026-10-04 + +Prepared receiver suite: Forge projection regression plus historical preservation, +missing/changed source, terminality, immutable UUID diff, and global holder query. +Real-source offline snapshot proof preserves four Activity parents/64 tasks and +one Custodian parent/task without mutations; no API writes or deployment occurred. +Exact original bindings are maintained in api/services/historical_projection.py. + +2026-10-04 actual owning-source validation: 155 focused receiver/qualifier/planner +cases passed; the independent offline current-source/current-hub-snapshot proof +retained five existing parents and 65 task UUIDs, no mutations, clean historical +diff. T02 is source-complete; T03/T04 remain wait for reviewed deployment and +normal registrar/replay receiving, respectively. Evidence: docs/evidence/2026-10-04-historical-receiving-source.json. + + +## Verified receiving rollout and remaining replay gate, 2026-10-04 + +T03 is done on actual founder approval of proposal +STATE-WP-0095-T03-historical-receiving revision 1, SHA256 +`d6789359b3fe7988a6201fff6f367cb46948aa197e1cc0ca66c31f45e1ea9981`, +approval receipt reference `user_reply:call_o87uEfFs3AtXfxO5YqUnSpcg:0`. +The normal owner Helm path delivered `main-5dd3df9`, commit +`5dd3df9c69eb564af10ea5ea01e349786212501a`, revision 72; API and MCP are Ready, +primary/railiance01 health and schema a0b1c2d3e4f5 verified, running source +hashes match. Rollback remains revision 71; no receipt-table downgrade is needed. +Actual historical preservation and global task-holder refusal were verified in +the delivered version. Durable receipt: +[verified historical receiving rollout](../docs/evidence/2026-10-04-historical-receiving-rollout.json). + +Custodian immutable replay applied while preserving the retired historical +parent/task UUIDs. Activity immutable replay retained all four historical parent +and 64 task UUIDs and historical row contents, but refused transactionally on +three unrelated stale projections: custodian-wp-0003 (archived), +custodian-wp-0003a (done), custodian-wp-0003b (done). No retirement acknowledgement +was issued and that refusal is not represented as normal replay success. + +Registrar source `c41d36c2` has 38 actual passing tests and Repo Manager's own +normal sync succeeded; registrar admission does not authorize retiring Activity +projections. T04 therefore remains wait, retaining RMGR-WP-0019-T03 and awaiting +Activity Core's precise reviewed disposition of those three stale rows. No +rename, identity migration, new paid native trial or further live action is +included in this source status update. + +## Activity Core return, 2026-10-06 + +Activity Core returned the exact disposition (message +`cf2d5847-8b3f-4653-9d9f-d5fcfbe3f1e4`, ACTIVITY-WP-0045, activity-core commit +`941fac0`). `expected_retirement_uuids`: +`683724d9-4249-5e0e-88e0-7baff4330d58` (custodian-wp-0003), +`644017c7-c5f2-5a64-81c3-d8ee806f8251` (custodian-wp-0003a), +`a5806df9-5b9a-50da-81c1-7e606076bed9` (custodian-wp-0003b). Each has zero +tasks and zero dependency edges. None is preserved parent +`b4eb45a9-69e3-4ab0-b00c-67a53c3117c5`. Repo Manager independently verified the +set (message `916841f6`, repo-manager commit `498682c`). Nothing was retired. +T04 now waits on decision `91b7c633` and a reviewed live rollout. After that, +Repo Manager re-runs the Activity owner sync and closes RMGR-WP-0019-T03. + +## Retirement rollout and T04 verification, 2026-10-06 + +The founder resolved decision `91b7c633` (retire exactly the three projections) +and approved the rollout. The founder ran the normal Helm upgrade to +`main-968a811` (revision 74; rollback revision 73, `main-fad9b3f`). API and MCP +are Ready, health is ok on primary/railiance01, and schema `b3c4d5e6f7a8` is +unchanged; no migrations. The activity-core reconcile at commit +`67a92d2ff8e033baf2c36998d24187fe83edec84` with `expected_retirement_uuids` set +to the three UUIDs applied: 0 created, 3 retired (tombstoned with an +`@retired-20261006` slug; UUIDs and statuses kept), 0 refused, 0 tasks +cancelled. All 7 bound Activity source files still match their digests. The four +historical parents and 64 task UUIDs are present under their bound parents with +unchanged fields. The Custodian retired parent and task are unchanged. Activity +had 54 workplans and 288 tasks before and after. Evidence: +[activity stale retirement rollout](../docs/evidence/2026-10-06-activity-stale-retirement-rollout.json). +Repo Manager's re-run of the Activity owner sync (RMGR-WP-0019-T03) and +ACTIVITY-WP-0045-T02 remain with their owners. + + +## Cluster exact historical receiving successor, 2026-10-17 + +This scoped extension retains the completed Activity/Custodian outcomes above. +Three frozen cluster archives contain three existing parent UUIDs and 15 existing +closed task UUIDs. Extend the existing exact historical receiving path before +registrar admission; never derive replacement identities or modify those +sources. Cross-owner observation attaches to existing SHR-WP-0001, with +RMGR-WP-0019 as obligor/beneficiary and NK-WP-0049-T35 benefiting indirectly. +The local derived case is waiting; no live worker injection is authorized. + +## Prepare exact cluster source preservation and refusal cases + +```task +id: STATE-WP-0095-T05 +status: done +priority: high +state_hub_task_id: "339b9a37-181c-5599-9a89-0393e199bd4b" +``` + +Prepared the same full-file SHA256 and 15 task binding manifest as Repo Manager. +Candidate receiving tests pass 26: existing rows remain entirely unchanged; +missing/foreign/nonterminal rows and unknown, changed, missing or symlinked +source refuse. Read-only public metadata confirms all 18 existing rows have the +expected ownership and terminal status. These are source and metadata checks, +not delivered receiving or a committed native trial. + +## Deliver reviewed cluster receiving successor before registrar admission + +```task +id: STATE-WP-0095-T06 +status: done +priority: high +depends_on: [STATE-WP-0095-T05] +needs_human: false +intervention_note: "Exact revision 4 approved and verified; no pending rollout intervention." +state_hub_task_id: "6af973b4-1964-5d4d-a2f8-8dbd4c01fda4" +``` + +Completed 2026-10-17 on the founder's approval of exact revision 4 of stable +proposal STATE-WP-0095-T06-cluster-frozen-receiving-r1 (decision + e3798419-8aa9-5425-8c08-20df6bd82a19; approved digest + sha256:63a02e8296d126c0158b7eb2c4ce79c7eeb6f8d6f61298d473c1065c7557ba32). +The ordinary owner Helm path delivered revision 76, runtime source 51bc70d and +exact image manifest sha256:c802690ee1811b3d77df4077936f9cdced1385514e8673fcbc9232b84d645015. +API/MCP Ready and primary/railiance01 health report expected schema +c4d5e6f7a8b9; delivered historical module sha256:e3c4bf965764b32a6df5d61c71272ee22569df9a9ddae2a3fc3df97ec554e0b2 matches. +All three parent and 15 task full DB row hashes/columns/UUIDs remained unchanged. +Five actual native probes passed: preserve, actual foreign-parent holder move, +nonterminal task, nonterminal parent and missing task. Every probe rolled back; +a fresh transaction confirmed complete row restoration. The foreign-holder +case precisely verified historical binding refusal, not the separate global +UUID-holder creation branch. No committed probe writes were made. + +Evidence: [verified receiving rollout](../docs/evidence/2026-10-17-cluster-historical-receiving-rollout.json). +This actual receiving return satisfies the prerequisite of RMGR-WP-0019-T05. +Normal cluster publication and its immutable-source/row return remain T07; +source readiness and this deployment do not count that later acceptance twice. +The additive inbox migration is retained; Helm rollback does not downgrade +schema or stop an active migration Job. Original failures and exact-UID recovery +limits remain in the referenced approved packet. No TLS recovery/native private +admission or new autonomy/cost grant is inferred. + +## Verify ordinary cluster receiving and immutable historical rows + +```task +id: STATE-WP-0095-T07 +status: wait +priority: high +depends_on: [STATE-WP-0095-T06, RMGR-WP-0019-T06, STATE-WP-0095-T10] +state_hub_task_id: "cfd1ea99-e75b-542e-a434-acefb62f3c7d" +``` + +Capture normal cluster owner publication/receiving receipts and compare entire +existing row snapshots and exact source bytes. No creation, movement, relabel, +unretirement or retirement is authorized for these histories. A rollback first +fences cluster sync and restores registrar refusal before removing receiving +support; it never rewrites archived IDs or UUIDs. This work does not establish +original TLS recovery or owner-native admission. + +## Fix policy-compatible Secret-template server dry-run preparation + +```task +id: STATE-WP-0095-T08 +status: done +priority: high +state_hub_task_id: "2dd9b04f-2250-5a8a-b5bc-c8a009872858" +``` + +Prepared the narrow Makefile successor after the existing readiness command +refused its first Secret-template phase. Ordinary client-side apply adds the +last-applied annotation, correctly denied by the existing Platform +reject-secret-last-applied policy. Only the two Secret-template server +dry-run requests now use server-side apply with dry-run; no force-conflicts, +Secret value change, policy relaxation or actual Secret write is introduced. +Command-fixture tests verify both requests and preserve other dry-run phases. +Evidence and original refusal remain in the Custodian cluster historical +publication packet, revisions/r2 and r3-readiness. This is completed source +preparation; T06 separately records the verified actual rollout. The runtime image +continues to be main-51bc70d; Makefile/workplan changes do not create a new +application image or native acceptance. + + +T08 source repair refinement, 2026-10-17: the required normal Make render +correctly refused a pre-existing generated chart classification copy at v1.0 +after authoritative canon reached v1.1. Regenerated only through the existing +scripts/sync_classification_allowed.py, preserving canon and strict check. +The exact source diff is version 1.0→1.1 plus publication/history/participation. +Normal Make rendering and the focused classification check passed in an +isolated owner-layout copy. The original refusal is retained in the Custodian +owner-followthrough-r1 validation receipt. This is source repair only: no live +ConfigMap apply, new runtime image, policy relaxation or T07 completion. + + +T07 actual ordinary-publication return, 2026-10-17: cluster owner deep consistency +and normal fast projection completed, exact archived source bytes and all 15 +historical task full-row hashes remained unchanged. The later full-row check +FAILED for exactly the three parent rows. The earlier approved T06 rollout had +preserved all 18 rows at its verification stage; that success is not the later +whole-pipeline acceptance. The original snapshots retain hashes only, so exact +changed columns and original field preimages are not established. No guessed +DB repair or claim that all 18 original row hashes are restored is authorized. +T07 and RMGR-WP-0019-T06 remain waiting pending T09/T10 and a reviewed return. + +## Guard immutable historical index bindings in CLI and receiving API + +```task +id: STATE-WP-0095-T09 +status: done +priority: high +depends_on: [STATE-WP-0095-T06] +state_hub_task_id: "8be8f13b-6352-5295-9755-de6fbfb13593" +``` + +Prepare and publish the narrowly scoped ordinary-binding repair for the three +existing task-bound Cluster registry entries. The CLI verifies exact frozen +source metadata/bytes and omits those bindings, including on old receivers. +The API independently validates the complete batch before any mutation, checks +actual repo ownership and the existing terminal parent/task closure, and +preserves valid frozen rows without field or timestamp assignments. Invalid +metadata, missing/foreign/nonterminal rows refuse the batch. Unknown ordinary +bindings retain existing behavior; historical IDs, UUIDs and source bytes stay +unchanged. Isolated regression tests and normal owner rendering are required. +Reviewed source preparation and canonical regression proof completed on +2026-10-17: 102 isolated tests pass and normal Make render/classification +check passes (eight objects, three unchanged delivered runtime images). +This completion record accompanies the same source publication/projection; +source preparation does not establish delivered runtime or T07 acceptance. +Central check/fix engines also refuse the complete repair batch if any fixable +context touches an exact registered parent/task UUID or frozen source path. +This runs before index bindings and before reconcile/push/individual fixes; +ordinary earlier fixes cannot partially execute before a later frozen refusal. + +## Deliver and verify the ordinary-binding guard successor + +```task +id: STATE-WP-0095-T10 +status: wait +priority: high +depends_on: [STATE-WP-0095-T09] +needs_human: true +intervention_note: "New API runtime requires its own exact image, reviewed preflight and scoped rollout approval; prior revision-4 approval does not cover it." +state_hub_task_id: "88585c69-d9b9-5e1f-a256-6416ff71e824" +``` + +After source publication, resolve normal CI status and immutable image identity, +verify delivered guard source closure, prepare exact chart/reuse-values dry-run, +full closure/headroom/schema baseline and conditional recovery. Retain additive +migration and active-Job cancellation limitations; no Helm schema downgrade or +bounded-outage claim. Obtain scoped approval before live rollout. Then verify +new full-row baseline preservation and actual rollback-only refusal probes, +and only then return to T07 ordinary-pipeline acceptance. This prevents future +binding mutation; it does not reconstruct or restore original parent hashes. +No production changes, new autonomy or credential authority are granted here. + + +T09 source repair refinement, 2026-10-17: the attempted current-client regression +actually loaded an older packaged consistency checker from the owner virtualenv, +not the reviewed canonical source. That trial again failed parent-row hash +preservation; it is retained separately and does not count as R5 client proof. +Normal C-23 also legitimately normalized WP18 ready to active and created a +source commit, but the generic writeback commit included an unrelated staged +brief. A narrow successor scopes git add and git commit --only to the actual +writeback file, preserving unrelated index/worktree state. Real disposable Git +regression tests cover pre-existing staged changes, hooks, no-op and path names. +Verify the actually loaded CLI checker hash after ordinary owner package refresh +before any retry; capture legitimate source HEAD transitions rather than assume +a static revision. T07 and T10 remain wait. No original parent preimage recovery, +new runtime delivery or broader authority follows from this source repair. + + +T07 current-baseline client return, 2026-10-17: after the owner refreshed the +installed package and verified the actually loaded checker matched the reviewed +R6 source, ordinary Cluster deep consistency and fast projection passed. +Bindings synced 20/20, omitting the three frozen histories; all three parent +and 15 task CURRENT full-row hashes remained unchanged across +before-verified-client.json / after-verified-client.json. Unrelated staged brief +state was preserved. This proves the corrected installed client stage against +its fresh current baseline; earlier parent-hash failures remain retained +historical exceptions, not restored preimages or backfilled successes. + +T10 remains wait/needs_human for exact proposal +STATE-WP-0095-T10-frozen-bindings-guard-r1, now revision 2, metadata +sha256:5d0ddebc21bcb035809cc8b57f4c2e1ba8ed09204be5b965460e178a90913f1e, +pending decision df703266-6719-4c04-ae1b-ba9afd3bca1c. The inspected candidate +image is main-c103cbf@sha256:4cc4ce17bbcdb1b66132d07c5b7b741f46df83124ef1c8403ffabdafba6993d1, +from runtime source c103cbf3c8bed5dba87a03e320595ddded9301a3. Image-only +server dry-run passed eight identities with only three image-field changes. +The exact proposal and recovery limits are retained in the Custodian dated +2026-10-17 guard-successor-review-r2 packet. No new API runtime is approved or +delivered by this source-only note; its later workplan commit does not change +the image's source pin. T09 stays done, T07/T10 stay wait pending reviewed API +delivery and explicit successor/current-baseline acceptance disposition. + + +T10 proposal revision history: revision 1 metadata +sha256:81d365926fc585a186955d8c98ee83501fd3cec12135b2256d71b46a75097932 +and its original helper remain immutable local evidence, unapproved and +unexecuted. Revision 2 of the same stable proposal changes only the native +probe's EXPECTED hash metadata into module/sha256 records for normal publication +scanning; probe behavior, exact image, recovery and acceptance scope remain +unchanged. The same decision df703266-6719-4c04-ae1b-ba9afd3bca1c remains open +and pending. This source-only amendment grants no runtime authority or new +image/code change; T07/T10 remain wait and T09 remains done. diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/closure-conditions.json b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/closure-conditions.json new file mode 100644 index 00000000..993d9dc8 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/closure-conditions.json @@ -0,0 +1,34 @@ +{ + "schema": "state95.conditional-verified-closure.v1", + "status": "preparation_only_not_reviewed_for_publication", + "base_revision": "4c03c9d2c6a47f5c7247473f74b2e2f188b787bd", + "approval": { + "proposal_id": "STATE-WP-0095-T10-frozen-bindings-guard-r1", + "revision": 2, + "sha256": "5d0ddebc21bcb035809cc8b57f4c2e1ba8ed09204be5b965460e178a90913f1e", + "disposition": "founder_approved_reported_by_root_execution_owned_by_root" + }, + "T10_done_requires": [ + "Exact approved new image/delivered guard hashes and expected schema/primary/API+MCP Ready", + "Eight exact native binding probe results with enforced rollback and fresh transaction restoration; no committed synthetic effects", + "Exact owned trial cleanup/quiescence or explicit verified no recovery needed; no undisclosed recovery condition" + ], + "T07_done_requires": [ + "Post-delivery actually loaded reviewed checker metadata verified before normal Cluster consistency/fast projection", + "Fresh CURRENT full3parent15task row hashes before/after ordinary pipeline equal", + "All15 original task identities/row hashes and all3 archive byte digests unchanged; no new/moved/renamed historical records", + "Actual receiving/Cluster owner return for RMGR19T06 verified, not source-only status assumption", + "Retained original three-parent hash mismatches accepted only as approved historical exception, never restored/backfilled" + ], + "owner_source_close": [ + "All10 tasks completed only after above evidence", + "Review own remaining scope; real residual work requires live record before finished; no invented acceptance", + "After root reviewed final source patch, commit only approved paths with normal hooks preserving original staged brief/dirty WORK/weekly evidence", + "Required normal owner rmgr sync --push applied primary/railiance01 exact HEAD=derived, remote and10UUID/status/human/edges readback" + ], + "runtime_source_pin": "c103cbf3c8bed5dba87a03e320595ddded9301a3", + "original_frozen_chart_unchanged": true, + "candidate_live_action": false, + "final_evidence_prose": "pending_root_exact_delivery_receipt_no_placeholder_may_be_published", + "source_pin_overlay": "separate reviewable proposal because Make/values still select previous image; not applied" +} diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/conditional-chart-metadata.patch b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/conditional-chart-metadata.patch new file mode 100644 index 00000000..2d413a2b --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/conditional-chart-metadata.patch @@ -0,0 +1,8 @@ +--- a/deploy/railiance/apps/charts/state-hub/Chart.yaml ++++ b/deploy/railiance/apps/charts/state-hub/Chart.yaml +@@ -3,4 +3,4 @@ + description: State Hub API service for private Railiance operation + type: application + version: 0.1.0 +-appVersion: "main-51bc70d" ++appVersion: "main-c103cbf" diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/conditional-declared-image-pins.patch b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/conditional-declared-image-pins.patch new file mode 100644 index 00000000..26decf47 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/conditional-declared-image-pins.patch @@ -0,0 +1,22 @@ +--- a/Makefile ++++ b/Makefile +@@ -9,7 +9,7 @@ + RAILIANCE_STATE_HUB_NAMESPACE ?= state-hub + RAILIANCE_STATE_HUB_CHART ?= deploy/railiance/apps/charts/state-hub + RAILIANCE_STATE_HUB_VALUES ?= deploy/railiance/apps/helm/state-hub-values.yaml +-RAILIANCE_STATE_HUB_IMAGE_TAG ?= main-51bc70d@sha256:c802690ee1811b3d77df4077936f9cdced1385514e8673fcbc9232b84d645015 ++RAILIANCE_STATE_HUB_IMAGE_TAG ?= main-c103cbf@sha256:4cc4ce17bbcdb1b66132d07c5b7b741f46df83124ef1c8403ffabdafba6993d1 + RAILIANCE_STATE_HUB_PLATFORM_DIR ?= deploy/railiance/platform + RAILIANCE_STATE_HUB_APP_MANIFESTS ?= deploy/railiance/apps/manifests + # Codex/WSL non-login shells may not source ~/.profile; keep uv discoverable. +--- a/deploy/railiance/apps/helm/state-hub-values.yaml ++++ b/deploy/railiance/apps/helm/state-hub-values.yaml +@@ -6,7 +6,7 @@ + + image: + repository: forgejo.coulomb.social/coulomb/state-hub +- tag: "main-51bc70d@sha256:c802690ee1811b3d77df4077936f9cdced1385514e8673fcbc9232b84d645015" ++ tag: "main-c103cbf@sha256:4cc4ce17bbcdb1b66132d07c5b7b741f46df83124ef1c8403ffabdafba6993d1" + + ingress: + enabled: false diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/conditional-state-hub/workplans/STATE-WP-0095-historical-source-receiving.md b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/conditional-state-hub/workplans/STATE-WP-0095-historical-source-receiving.md new file mode 100644 index 00000000..3972de61 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/conditional-state-hub/workplans/STATE-WP-0095-historical-source-receiving.md @@ -0,0 +1,394 @@ +--- +id: STATE-WP-0095 +type: workplan +title: "Preserve exact closed historical source bindings in Forge receiving" +domain: infotech +repo: state-hub +status: active +owner: codex +created: "2026-10-04" +updated: "2026-10-17" +flavor: implementation +depends_on: [] +state_hub_workstream_id: "79579572-8120-5d3b-aca5-dd4aff60d058" +--- + +## Goal + +Normal Forge receiving must preserve the seven bound Activity legacy source +views and the Custodian retired ADHOC source without new identities, source +rewrites, task movement, deletion, relabeling or unretirement. This implements +receiving support before RMGR-WP-0019 registrar admission. CUST-WP-0077 owns +cross-owner coordination; deployment and replay remain explicit owner returns. + +## Verify source bindings and shared views + +```task +id: STATE-WP-0095-T01 +status: done +priority: high +state_hub_task_id: "6a5f87bb-e63d-5e20-b9fc-c1b5cd403ff7" +``` + +Reviewed seven exact Activity files: four existing workplans and 64 UUID-bound +closed tasks. The archived custodian-WP-0003 parent and three split_from views +share one existing parent UUID; every repeated task binding/content agrees. +Custodian ADHOC-2026-08-25-retired retains parent +1c53d3db-6633-5e2d-987f-10706d73be9e and task +d8af3e6b-26ae-580c-bbeb-caa8ad419bec. No identity migration is authorized. + +## Implement preservation receiving and global collision refusal + +```task +id: STATE-WP-0095-T02 +status: done +priority: high +state_hub_task_id: "ebb24194-e4cc-5130-aaa6-663f2ea2c822" +``` + +Prepared exact source digest manifest, existing-parent/task verification, +immutable receiving and UUID-based historical diff. Ordinary canonical sources +continue deriving their identities. Missing/changed/symlinked source, absent +bound rows or nonterminal hub histories refuse. Check task UUID holders globally, +including retired and acknowledged-stale parent workplans, before INSERT. + +Offline preview tests and snapshot proof are evidence of source behavior only; +finish after applying reviewed source and its focused tests to the owning repo. + +## Deliver receiving through normal deployment and verify owner return + +```task +id: STATE-WP-0095-T03 +status: done +priority: high +depends_on: [STATE-WP-0095-T02] +state_hub_task_id: "e8566341-cf6e-5b9c-ad80-8564d14cd52a" +decision_id: "36772026-f43d-4331-b466-5f4b1afe4eca" +``` + +No direct live code patch. This task is a deployment return, not policy authority +or permission to resolve identity decisions. Exact receiving fixture acceptance +and global collision rejection must hold in the delivered version. + +## Verify normal historical replay preserves existing identities + +```task +id: STATE-WP-0095-T04 +status: done +priority: high +depends_on: [STATE-WP-0095-T03, RMGR-WP-0019-T03] +state_hub_task_id: "3bbb311f-194e-5862-8aed-358a580f22b4" +``` + +Verify Activity four historical parent UUIDs/64 task UUIDs and Custodian retired +parent/task unchanged; no historical row creation, movement, deletion, +relabeling or unretirement. Unrelated stale projections remain separate decisions. + +2026-10-05: Source exact-set retirement support now exists. Repository projection +reconcile accepts `expected_retirement_uuids`; that set must equal the stale +projections, and any task or dependency edge on a stale row refuses the call. +Live apply still needs a reviewed rollout. On 2026-10-05 the founder accepted +review `review-1ece4f3e-d0e3-4fd6-abc8-272f1b1876f4`, which names only the three +empty projections and preserves parent `b4eb45a9-69e3-4ab0-b00c-67a53c3117c5`. +That acceptance is not a live retirement and does not resolve +`91b7c633-a66f-5d43-9f4b-6565aefb9356`. + +## Offline evidence, 2026-10-04 + +Prepared receiver suite: Forge projection regression plus historical preservation, +missing/changed source, terminality, immutable UUID diff, and global holder query. +Real-source offline snapshot proof preserves four Activity parents/64 tasks and +one Custodian parent/task without mutations; no API writes or deployment occurred. +Exact original bindings are maintained in api/services/historical_projection.py. + +2026-10-04 actual owning-source validation: 155 focused receiver/qualifier/planner +cases passed; the independent offline current-source/current-hub-snapshot proof +retained five existing parents and 65 task UUIDs, no mutations, clean historical +diff. T02 is source-complete; T03/T04 remain wait for reviewed deployment and +normal registrar/replay receiving, respectively. Evidence: docs/evidence/2026-10-04-historical-receiving-source.json. + + +## Verified receiving rollout and remaining replay gate, 2026-10-04 + +T03 is done on actual founder approval of proposal +STATE-WP-0095-T03-historical-receiving revision 1, SHA256 +`d6789359b3fe7988a6201fff6f367cb46948aa197e1cc0ca66c31f45e1ea9981`, +approval receipt reference `user_reply:call_o87uEfFs3AtXfxO5YqUnSpcg:0`. +The normal owner Helm path delivered `main-5dd3df9`, commit +`5dd3df9c69eb564af10ea5ea01e349786212501a`, revision 72; API and MCP are Ready, +primary/railiance01 health and schema a0b1c2d3e4f5 verified, running source +hashes match. Rollback remains revision 71; no receipt-table downgrade is needed. +Actual historical preservation and global task-holder refusal were verified in +the delivered version. Durable receipt: +[verified historical receiving rollout](../docs/evidence/2026-10-04-historical-receiving-rollout.json). + +Custodian immutable replay applied while preserving the retired historical +parent/task UUIDs. Activity immutable replay retained all four historical parent +and 64 task UUIDs and historical row contents, but refused transactionally on +three unrelated stale projections: custodian-wp-0003 (archived), +custodian-wp-0003a (done), custodian-wp-0003b (done). No retirement acknowledgement +was issued and that refusal is not represented as normal replay success. + +Registrar source `c41d36c2` has 38 actual passing tests and Repo Manager's own +normal sync succeeded; registrar admission does not authorize retiring Activity +projections. T04 therefore remains wait, retaining RMGR-WP-0019-T03 and awaiting +Activity Core's precise reviewed disposition of those three stale rows. No +rename, identity migration, new paid native trial or further live action is +included in this source status update. + +## Activity Core return, 2026-10-06 + +Activity Core returned the exact disposition (message +`cf2d5847-8b3f-4653-9d9f-d5fcfbe3f1e4`, ACTIVITY-WP-0045, activity-core commit +`941fac0`). `expected_retirement_uuids`: +`683724d9-4249-5e0e-88e0-7baff4330d58` (custodian-wp-0003), +`644017c7-c5f2-5a64-81c3-d8ee806f8251` (custodian-wp-0003a), +`a5806df9-5b9a-50da-81c1-7e606076bed9` (custodian-wp-0003b). Each has zero +tasks and zero dependency edges. None is preserved parent +`b4eb45a9-69e3-4ab0-b00c-67a53c3117c5`. Repo Manager independently verified the +set (message `916841f6`, repo-manager commit `498682c`). Nothing was retired. +T04 now waits on decision `91b7c633` and a reviewed live rollout. After that, +Repo Manager re-runs the Activity owner sync and closes RMGR-WP-0019-T03. + +## Retirement rollout and T04 verification, 2026-10-06 + +The founder resolved decision `91b7c633` (retire exactly the three projections) +and approved the rollout. The founder ran the normal Helm upgrade to +`main-968a811` (revision 74; rollback revision 73, `main-fad9b3f`). API and MCP +are Ready, health is ok on primary/railiance01, and schema `b3c4d5e6f7a8` is +unchanged; no migrations. The activity-core reconcile at commit +`67a92d2ff8e033baf2c36998d24187fe83edec84` with `expected_retirement_uuids` set +to the three UUIDs applied: 0 created, 3 retired (tombstoned with an +`@retired-20261006` slug; UUIDs and statuses kept), 0 refused, 0 tasks +cancelled. All 7 bound Activity source files still match their digests. The four +historical parents and 64 task UUIDs are present under their bound parents with +unchanged fields. The Custodian retired parent and task are unchanged. Activity +had 54 workplans and 288 tasks before and after. Evidence: +[activity stale retirement rollout](../docs/evidence/2026-10-06-activity-stale-retirement-rollout.json). +Repo Manager's re-run of the Activity owner sync (RMGR-WP-0019-T03) and +ACTIVITY-WP-0045-T02 remain with their owners. + + +## Cluster exact historical receiving successor, 2026-10-17 + +This scoped extension retains the completed Activity/Custodian outcomes above. +Three frozen cluster archives contain three existing parent UUIDs and 15 existing +closed task UUIDs. Extend the existing exact historical receiving path before +registrar admission; never derive replacement identities or modify those +sources. Cross-owner observation attaches to existing SHR-WP-0001, with +RMGR-WP-0019 as obligor/beneficiary and NK-WP-0049-T35 benefiting indirectly. +The local derived case is waiting; no live worker injection is authorized. + +## Prepare exact cluster source preservation and refusal cases + +```task +id: STATE-WP-0095-T05 +status: done +priority: high +state_hub_task_id: "339b9a37-181c-5599-9a89-0393e199bd4b" +``` + +Prepared the same full-file SHA256 and 15 task binding manifest as Repo Manager. +Candidate receiving tests pass 26: existing rows remain entirely unchanged; +missing/foreign/nonterminal rows and unknown, changed, missing or symlinked +source refuse. Read-only public metadata confirms all 18 existing rows have the +expected ownership and terminal status. These are source and metadata checks, +not delivered receiving or a committed native trial. + +## Deliver reviewed cluster receiving successor before registrar admission + +```task +id: STATE-WP-0095-T06 +status: done +priority: high +depends_on: [STATE-WP-0095-T05] +needs_human: false +intervention_note: "Exact revision 4 approved and verified; no pending rollout intervention." +state_hub_task_id: "6af973b4-1964-5d4d-a2f8-8dbd4c01fda4" +``` + +Completed 2026-10-17 on the founder's approval of exact revision 4 of stable +proposal STATE-WP-0095-T06-cluster-frozen-receiving-r1 (decision + e3798419-8aa9-5425-8c08-20df6bd82a19; approved digest + sha256:63a02e8296d126c0158b7eb2c4ce79c7eeb6f8d6f61298d473c1065c7557ba32). +The ordinary owner Helm path delivered revision 76, runtime source 51bc70d and +exact image manifest sha256:c802690ee1811b3d77df4077936f9cdced1385514e8673fcbc9232b84d645015. +API/MCP Ready and primary/railiance01 health report expected schema +c4d5e6f7a8b9; delivered historical module sha256:e3c4bf965764b32a6df5d61c71272ee22569df9a9ddae2a3fc3df97ec554e0b2 matches. +All three parent and 15 task full DB row hashes/columns/UUIDs remained unchanged. +Five actual native probes passed: preserve, actual foreign-parent holder move, +nonterminal task, nonterminal parent and missing task. Every probe rolled back; +a fresh transaction confirmed complete row restoration. The foreign-holder +case precisely verified historical binding refusal, not the separate global +UUID-holder creation branch. No committed probe writes were made. + +Evidence: [verified receiving rollout](../docs/evidence/2026-10-17-cluster-historical-receiving-rollout.json). +This actual receiving return satisfies the prerequisite of RMGR-WP-0019-T05. +Normal cluster publication and its immutable-source/row return remain T07; +source readiness and this deployment do not count that later acceptance twice. +The additive inbox migration is retained; Helm rollback does not downgrade +schema or stop an active migration Job. Original failures and exact-UID recovery +limits remain in the referenced approved packet. No TLS recovery/native private +admission or new autonomy/cost grant is inferred. + +## Verify ordinary cluster receiving and immutable historical rows + +```task +id: STATE-WP-0095-T07 +status: done +priority: high +depends_on: [STATE-WP-0095-T06, RMGR-WP-0019-T06, STATE-WP-0095-T10] +state_hub_task_id: "cfd1ea99-e75b-542e-a434-acefb62f3c7d" +``` + +Capture normal cluster owner publication/receiving receipts and compare entire +existing row snapshots and exact source bytes. No creation, movement, relabel, +unretirement or retirement is authorized for these histories. A rollback first +fences cluster sync and restores registrar refusal before removing receiving +support; it never rewrites archived IDs or UUIDs. This work does not establish +original TLS recovery or owner-native admission. + +## Fix policy-compatible Secret-template server dry-run preparation + +```task +id: STATE-WP-0095-T08 +status: done +priority: high +state_hub_task_id: "2dd9b04f-2250-5a8a-b5bc-c8a009872858" +``` + +Prepared the narrow Makefile successor after the existing readiness command +refused its first Secret-template phase. Ordinary client-side apply adds the +last-applied annotation, correctly denied by the existing Platform +reject-secret-last-applied policy. Only the two Secret-template server +dry-run requests now use server-side apply with dry-run; no force-conflicts, +Secret value change, policy relaxation or actual Secret write is introduced. +Command-fixture tests verify both requests and preserve other dry-run phases. +Evidence and original refusal remain in the Custodian cluster historical +publication packet, revisions/r2 and r3-readiness. This is completed source +preparation; T06 separately records the verified actual rollout. The runtime image +continues to be main-51bc70d; Makefile/workplan changes do not create a new +application image or native acceptance. + + +T08 source repair refinement, 2026-10-17: the required normal Make render +correctly refused a pre-existing generated chart classification copy at v1.0 +after authoritative canon reached v1.1. Regenerated only through the existing +scripts/sync_classification_allowed.py, preserving canon and strict check. +The exact source diff is version 1.0→1.1 plus publication/history/participation. +Normal Make rendering and the focused classification check passed in an +isolated owner-layout copy. The original refusal is retained in the Custodian +owner-followthrough-r1 validation receipt. This is source repair only: no live +ConfigMap apply, new runtime image, policy relaxation or T07 completion. + + +T07 actual ordinary-publication return, 2026-10-17: cluster owner deep consistency +and normal fast projection completed, exact archived source bytes and all 15 +historical task full-row hashes remained unchanged. The later full-row check +FAILED for exactly the three parent rows. The earlier approved T06 rollout had +preserved all 18 rows at its verification stage; that success is not the later +whole-pipeline acceptance. The original snapshots retain hashes only, so exact +changed columns and original field preimages are not established. No guessed +DB repair or claim that all 18 original row hashes are restored is authorized. +T07 and RMGR-WP-0019-T06 remain waiting pending T09/T10 and a reviewed return. + +## Guard immutable historical index bindings in CLI and receiving API + +```task +id: STATE-WP-0095-T09 +status: done +priority: high +depends_on: [STATE-WP-0095-T06] +state_hub_task_id: "8be8f13b-6352-5295-9755-de6fbfb13593" +``` + +Prepare and publish the narrowly scoped ordinary-binding repair for the three +existing task-bound Cluster registry entries. The CLI verifies exact frozen +source metadata/bytes and omits those bindings, including on old receivers. +The API independently validates the complete batch before any mutation, checks +actual repo ownership and the existing terminal parent/task closure, and +preserves valid frozen rows without field or timestamp assignments. Invalid +metadata, missing/foreign/nonterminal rows refuse the batch. Unknown ordinary +bindings retain existing behavior; historical IDs, UUIDs and source bytes stay +unchanged. Isolated regression tests and normal owner rendering are required. +Reviewed source preparation and canonical regression proof completed on +2026-10-17: 102 isolated tests pass and normal Make render/classification +check passes (eight objects, three unchanged delivered runtime images). +This completion record accompanies the same source publication/projection; +source preparation does not establish delivered runtime or T07 acceptance. +Central check/fix engines also refuse the complete repair batch if any fixable +context touches an exact registered parent/task UUID or frozen source path. +This runs before index bindings and before reconcile/push/individual fixes; +ordinary earlier fixes cannot partially execute before a later frozen refusal. + +## Deliver and verify the ordinary-binding guard successor + +```task +id: STATE-WP-0095-T10 +status: done +priority: high +depends_on: [STATE-WP-0095-T09] +needs_human: false +intervention_note: "Exact revision 2 approved and verified; no pending rollout intervention." +state_hub_task_id: "88585c69-d9b9-5e1f-a256-6416ff71e824" +``` + +After source publication, resolve normal CI status and immutable image identity, +verify delivered guard source closure, prepare exact chart/reuse-values dry-run, +full closure/headroom/schema baseline and conditional recovery. Retain additive +migration and active-Job cancellation limitations; no Helm schema downgrade or +bounded-outage claim. Obtain scoped approval before live rollout. Then verify +new full-row baseline preservation and actual rollback-only refusal probes, +and only then return to T07 ordinary-pipeline acceptance. This prevents future +binding mutation; it does not reconstruct or restore original parent hashes. +No production changes, new autonomy or credential authority are granted here. + + +T09 source repair refinement, 2026-10-17: the attempted current-client regression +actually loaded an older packaged consistency checker from the owner virtualenv, +not the reviewed canonical source. That trial again failed parent-row hash +preservation; it is retained separately and does not count as R5 client proof. +Normal C-23 also legitimately normalized WP18 ready to active and created a +source commit, but the generic writeback commit included an unrelated staged +brief. A narrow successor scopes git add and git commit --only to the actual +writeback file, preserving unrelated index/worktree state. Real disposable Git +regression tests cover pre-existing staged changes, hooks, no-op and path names. +Verify the actually loaded CLI checker hash after ordinary owner package refresh +before any retry; capture legitimate source HEAD transitions rather than assume +a static revision. T07 and T10 remain wait. No original parent preimage recovery, +new runtime delivery or broader authority follows from this source repair. + + +T07 current-baseline client return, 2026-10-17: after the owner refreshed the +installed package and verified the actually loaded checker matched the reviewed +R6 source, ordinary Cluster deep consistency and fast projection passed. +Bindings synced 20/20, omitting the three frozen histories; all three parent +and 15 task CURRENT full-row hashes remained unchanged across +before-verified-client.json / after-verified-client.json. Unrelated staged brief +state was preserved. This proves the corrected installed client stage against +its fresh current baseline; earlier parent-hash failures remain retained +historical exceptions, not restored preimages or backfilled successes. + +T10 remains wait/needs_human for exact proposal +STATE-WP-0095-T10-frozen-bindings-guard-r1, now revision 2, metadata +sha256:5d0ddebc21bcb035809cc8b57f4c2e1ba8ed09204be5b965460e178a90913f1e, +pending decision df703266-6719-4c04-ae1b-ba9afd3bca1c. The inspected candidate +image is main-c103cbf@sha256:4cc4ce17bbcdb1b66132d07c5b7b741f46df83124ef1c8403ffabdafba6993d1, +from runtime source c103cbf3c8bed5dba87a03e320595ddded9301a3. Image-only +server dry-run passed eight identities with only three image-field changes. +The exact proposal and recovery limits are retained in the Custodian dated +2026-10-17 guard-successor-review-r2 packet. No new API runtime is approved or +delivered by this source-only note; its later workplan commit does not change +the image's source pin. T09 stays done, T07/T10 stay wait pending reviewed API +delivery and explicit successor/current-baseline acceptance disposition. + + +T10 proposal revision history: revision 1 metadata +sha256:81d365926fc585a186955d8c98ee83501fd3cec12135b2256d71b46a75097932 +and its original helper remain immutable local evidence, unapproved and +unexecuted. Revision 2 of the same stable proposal changes only the native +probe's EXPECTED hash metadata into module/sha256 records for normal publication +scanning; probe behavior, exact image, recovery and acceptance scope remain +unchanged. The same decision df703266-6719-4c04-ae1b-ba9afd3bca1c remains open +and pending. This source-only amendment grants no runtime authority or new +image/code change; T07/T10 remain wait and T09 remains done. diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/conditional-task-status.patch b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/conditional-task-status.patch new file mode 100644 index 00000000..a1b9670f --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/conditional-task-status.patch @@ -0,0 +1,26 @@ +--- a/workplans/STATE-WP-0095-historical-source-receiving.md ++++ b/workplans/STATE-WP-0095-historical-source-receiving.md +@@ -235,7 +235,7 @@ + + ```task + id: STATE-WP-0095-T07 +-status: wait ++status: done + priority: high + depends_on: [STATE-WP-0095-T06, RMGR-WP-0019-T06, STATE-WP-0095-T10] + state_hub_task_id: "cfd1ea99-e75b-542e-a434-acefb62f3c7d" +@@ -325,11 +325,11 @@ + + ```task + id: STATE-WP-0095-T10 +-status: wait ++status: done + priority: high + depends_on: [STATE-WP-0095-T09] +-needs_human: true +-intervention_note: "New API runtime requires its own exact image, reviewed preflight and scoped rollout approval; prior revision-4 approval does not cover it." ++needs_human: false ++intervention_note: "Exact revision 2 approved and verified; no pending rollout intervention." + state_hub_task_id: "88585c69-d9b9-5e1f-a256-6416ff71e824" + ``` + diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage1/canonical-validation.json b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage1/canonical-validation.json new file mode 100644 index 00000000..3503c24b --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage1/canonical-validation.json @@ -0,0 +1,9 @@ +{ + "normal_make_render_exit": 0, + "objects": 8, + "verified_actual_image_fields": 3, + "T10": "done", + "T07": "wait", + "parent": "active", + "runtime_mutation": false +} diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage1/manifest.json b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage1/manifest.json new file mode 100644 index 00000000..5413deed --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage1/manifest.json @@ -0,0 +1,32 @@ +{ + "schema": "state95.verified-guard-source-followthrough.v1", + "base_revision": "4c03c9d2c6a47f5c7247473f74b2e2f188b787bd", + "files": [ + { + "path": "workplans/STATE-WP-0095-historical-source-receiving.md", + "base_sha256": "163acbdf8252f37e309f4cb0b51d48706c6bd43d439fdbf7802b953b78ae35ac", + "candidate_sha256": "bcde1a6502dfad71586dc38b2f681723dcb310c22f66f052b305ded89bf9b00d" + }, + { + "path": "deploy/railiance/apps/charts/state-hub/Chart.yaml", + "base_sha256": "61a989fcf277d05ffbececb7332ee3ca1d8f369c72f960ab1bc4dbc6815536b8", + "candidate_sha256": "7d6d71dfe6dc85005f067d65b76d5f70215b96c6aa1c82b218ded1152c40fb43" + }, + { + "path": "Makefile", + "base_sha256": "c5b70e8d4b8ae70380e0ade1c35f9e7fe80038de10080a4723b21ef07a98001e", + "candidate_sha256": "3bc22d33cc055d0b7cb0a3b4ee6df406aa7b32d5916cb456264cbb348a3cf764" + }, + { + "path": "deploy/railiance/apps/helm/state-hub-values.yaml", + "base_sha256": "b51296fc937450f4ecf0ae43243262a631b551389c0d09c8bfe58fa10e2f3ee9", + "candidate_sha256": "0e05127167fce1508321866792ad58796eaa4dedfb2bcd05e2e3ff39db804c5b" + } + ], + "patch_sha256": "sha256:d5b3a123ed0ffaee4ff811b0864b9f1d35b39b8e793fd382c5f4a02f916b942b", + "T10": "done_verified", + "T07": "wait_for_RMGR19T06_actual_closure", + "parent": "active", + "new_UUIDs": false, + "runtime_changed": false +} diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage1/normal-sync-result.json b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage1/normal-sync-result.json new file mode 100644 index 00000000..b07725a5 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage1/normal-sync-result.json @@ -0,0 +1,207 @@ +{ + "ok": true, + "status": "applied", + "repo_slug": "state-hub", + "commit": "bb525bc304cbd79b8a8a1540aa28bf073a4e2c32", + "requests": 2, + "elapsed_ms": 19035.3, + "git": { + "upstream": "origin/main", + "behind": 0, + "ahead": 0 + }, + "identifiers": { + "schema": "repo-manager.identifier-assignment.v1", + "ok": true, + "executed": true, + "repo": "state-hub", + "namespace": "helixforge", + "assignments": [], + "historical_invalid_identifiers": [], + "historical_identity_collisions": [], + "files_changed": [] + }, + "receipt": { + "schema": "state-hub.repository-projection-reconcile.v1", + "instance_role": "primary", + "instance_label": "railiance01", + "expected_commit": "bb525bc304cbd79b8a8a1540aa28bf073a4e2c32", + "derived_commit": "bb525bc304cbd79b8a8a1540aa28bf073a4e2c32", + "outcome": { + "schema": "state-hub.projection-reset.v1", + "repo_slug": "state-hub", + "commit": "bb525bc304cbd79b8a8a1540aa28bf073a4e2c32", + "status": "applied", + "counts": { + "created": 0, + "updated": 70, + "retired": 0, + "refused": 0, + "released": 0, + "created_tasks": 0, + "updated_tasks": 1, + "cancelled_tasks": 0, + "created_edges": 0, + "removed_edges": 1, + "unresolved_edges": 0, + "external_edges": 6 + }, + "created": [], + "updated": [ + "CUST-WP-0003", + "CUST-WP-0011", + "CUST-WP-0012", + "CUST-WP-0038", + "CUST-WP-0042", + "STATE-WP-0043", + "STATE-WP-0044", + "STATE-WP-0045", + "STATE-WP-0046", + "STATE-WP-0047", + "STATE-WP-0048", + "STATE-WP-0049", + "STATE-WP-0050", + "STATE-WP-0051", + "STATE-WP-0052", + "STATE-WP-0053", + "STATE-WP-0054", + "STATE-WP-0055", + "STATE-WP-0056", + "STATE-WP-0057", + "STATE-WP-0058", + "STATE-WP-0059", + "STATE-WP-0060", + "STATE-WP-0061", + "STATE-WP-0062", + "STATE-WP-0063", + "STATE-WP-0064", + "STATE-WP-0065", + "STATE-WP-0066", + "STATE-WP-0067", + "STATE-WP-0068", + "STATE-WP-0069", + "STATE-WP-0070", + "STATE-WP-0071", + "STATE-WP-0072", + "STATE-WP-0073", + "STATE-WP-0074", + "STATE-WP-0075", + "STATE-WP-0076", + "STATE-WP-0077", + "STATE-WP-0078", + "STATE-WP-0079", + "STATE-WP-0080", + "STATE-WP-0081", + "STATE-WP-0082", + "STATE-WP-0083", + "STATE-WP-0084", + "STATE-WP-0085", + "STATE-WP-0086", + "STATE-WP-0087", + "STATE-WP-0088", + "STATE-WP-0089", + "STATE-WP-0090", + "STATE-WP-0091", + "STATE-WP-0092", + "STATE-WP-0093", + "STATE-WP-0094", + "STATE-WP-0095", + "STATE-WP-0096", + "STATE-WP-0097", + "STATE-WP-0098", + "STATE-WP-ADHOC-2026-06-04", + "STATE-WP-ADHOC-2026-07-01", + "STATE-WP-ADHOC-2026-08-08", + "STATE-WP-ADHOC-2026-08-23", + "STATE-WP-ADHOC-2026-08-30", + "STATE-WP-ADHOC-2026-09-04", + "STATE-WP-ADHOC-2026-09-22", + "STATE-WP-ADHOC-2026-10-06", + "STATE-WP-ADHOC-2026-10-13" + ], + "retired": [], + "released": [], + "created_tasks": [], + "updated_tasks": [ + "STATE-WP-0095-T10" + ], + "cancelled_tasks": [], + "created_edges": [], + "removed_edges": [ + { + "id": "2dbd5851-295e-4260-ba2f-2357324e5c8c", + "from_workplan_id": "79579572-8120-5d3b-aca5-dd4aff60d058", + "from_task_id": "88585c69-d9b9-5e1f-a256-6416ff71e824", + "to_workplan_id": null, + "to_task_id": "8be8f13b-6352-5295-9755-de6fbfb13593", + "relationship_type": "starts_after" + } + ], + "unresolved_edges": [], + "external_edges": [ + { + "workplan": "STATE-WP-0098", + "from": "STATE-WP-0098-T04", + "target": "HUB-WP-0011-T01", + "relationship_type": "starts_after", + "from_workplan_id": "0c68970e-295b-57fc-ac70-e472ee3a7bc4", + "from_task_id": "077797c8-289b-55ec-9597-28f223891b02", + "to_workplan_id": null, + "to_task_id": "588e4b58-4694-513c-8e7a-1fab38fb6ce3" + }, + { + "workplan": "CUST-WP-0038", + "from": "CUST-WP-0038", + "target": "CUST-WP-0011", + "relationship_type": "blocks", + "from_workplan_id": "ccb87fa4-6381-5f22-b097-6e026f56a9c1", + "from_task_id": null, + "to_workplan_id": "967baafb-d92d-405a-ba0b-0d00d37c4940", + "to_task_id": null + }, + { + "workplan": "STATE-WP-0079", + "from": "STATE-WP-0079-T04", + "target": "HUB-WP-0011-T03", + "relationship_type": "starts_after", + "from_workplan_id": "ed077b62-7048-5752-bf65-f90471f45854", + "from_task_id": "3a454438-1955-547f-a491-8eab7cd25710", + "to_workplan_id": null, + "to_task_id": "61c727a5-f4b1-54c0-b634-7d71a5416496" + }, + { + "workplan": "STATE-WP-0079", + "from": "STATE-WP-0079-T04", + "target": "HUB-WP-0006-T06", + "relationship_type": "starts_after", + "from_workplan_id": "ed077b62-7048-5752-bf65-f90471f45854", + "from_task_id": "3a454438-1955-547f-a491-8eab7cd25710", + "to_workplan_id": null, + "to_task_id": "7c8225f7-03dc-57b0-a668-c3d781bd828a" + }, + { + "workplan": "STATE-WP-0079", + "from": "STATE-WP-0079-T06", + "target": "STATE-WP-0079-T04", + "relationship_type": "starts_after", + "from_workplan_id": "ed077b62-7048-5752-bf65-f90471f45854", + "from_task_id": "9230c99b-af58-5794-9291-452e619a30fa", + "to_workplan_id": null, + "to_task_id": "3a454438-1955-547f-a491-8eab7cd25710" + }, + { + "workplan": "STATE-WP-0079", + "from": "STATE-WP-0079-T06", + "target": "STATE-WP-0079-T05", + "relationship_type": "starts_after", + "from_workplan_id": "ed077b62-7048-5752-bf65-f90471f45854", + "from_task_id": "9230c99b-af58-5794-9291-452e619a30fa", + "to_workplan_id": null, + "to_task_id": "f6cf8c01-eb59-5ca8-a541-23928d4a000c" + } + ], + "refused": [], + "notes": [] + } + } +} diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage1/publish_followthrough.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage1/publish_followthrough.py new file mode 100644 index 00000000..efe84a8a --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage1/publish_followthrough.py @@ -0,0 +1,31 @@ +from pathlib import Path +import hashlib,json,importlib.util,subprocess,yaml,re +p=Path(__file__).resolve().parent;owner=Path('/home/worsch/state-hub') +spec=importlib.util.spec_from_file_location('pub',p.parents[1]/'owner-followthrough-r5/publish_followthrough_source.py');pub=importlib.util.module_from_spec(spec);spec.loader.exec_module(pub) +d=json.loads((p/'manifest.json').read_text());sha=lambda b:hashlib.sha256(b).hexdigest();names={e['path'] for e in d['files']};assert len(names)==4 +assert pub.git(owner,'rev-parse','HEAD').stdout.decode().strip()==d['base_revision'] +assert sha((p/'state-hub-verified-followthrough.patch').read_bytes())=='d5b3a123ed0ffaee4ff811b0864b9f1d35b39b8e793fd382c5f4a02f916b942b' +before=pub.preserved_state(owner,names) +for e in d['files']: + f=owner/e['path'];s=p/'state-hub'/e['path'];assert not f.is_symlink() and not s.is_symlink() + assert sha(f.read_bytes())==e['base_sha256'] and sha(s.read_bytes())==e['candidate_sha256'] + assert not pub.git(owner,'diff','--cached','--',e['path']).stdout +for e in d['files']:(owner/e['path']).write_bytes((p/'state-hub'/e['path']).read_bytes()) +assert pub.preserved_state(owner,names)==before +render=subprocess.run(['make','railiance-state-hub-render'],cwd=owner,capture_output=True,text=True,timeout=45);assert render.returncode==0 +objects=[o for o in yaml.safe_load_all(render.stdout[render.stdout.index('---'):]) if o];images=[] +def walk(v): + if isinstance(v,dict): + for k,x in v.items(): + if k=='image':images.append(x) + walk(x) + elif isinstance(v,list): + for x in v:walk(x) +for obj in objects:walk(obj) +assert len(objects)==8 and len(images)==3 and set(images)=={'forgejo.coulomb.social/coulomb/state-hub:main-c103cbf@sha256:4cc4ce17bbcdb1b66132d07c5b7b741f46df83124ef1c8403ffabdafba6993d1'} +text=(owner/'workplans/STATE-WP-0095-historical-source-receiving.md').read_text();tasks={b['id']:b for b in (yaml.safe_load(x) for x in re.findall(r'```task\n(.*?)```',text,re.S))};assert len(tasks)==10 +assert tasks['STATE-WP-0095-T10']['status']=='done' and tasks['STATE-WP-0095-T10']['needs_human'] is False +assert tasks['STATE-WP-0095-T07']['status']=='wait' +(p/'canonical-validation.json').write_text(json.dumps({'normal_make_render_exit':0,'objects':8,'verified_actual_image_fields':3,'T10':'done','T07':'wait','parent':'active','runtime_mutation':False},indent=2)+'\n') +r=pub.commit_source(owner,d,pub.SCANNER,execute=True,push=False);assert pub.preserved_state(owner,names)==before +(p/'source-commit-result.json').write_text(json.dumps(r,indent=2)+'\n');print(json.dumps({k:v for k,v in r.items() if k not in {'preserved_before','preserved_after'}})) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage1/run_normal_sync.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage1/run_normal_sync.py new file mode 100644 index 00000000..3790505b --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage1/run_normal_sync.py @@ -0,0 +1,10 @@ +from pathlib import Path +import subprocess,json +p=Path(__file__).resolve().parent +r=subprocess.run(['make','sync','SYNC_PATH=.','SYNC_PUSH=1','STATE_HUB_API_BASE=http://127.0.0.1:8000'],cwd='/home/worsch/state-hub',capture_output=True,text=True,timeout=180) +try:d=json.loads(r.stdout[r.stdout.index('{'):]) +except Exception:d={'status':'unverified','reason':'normal_sync_receipt_unparseable','exit':r.returncode} +(p/'normal-sync-result.json').write_text(json.dumps(d,indent=2)+'\n') +receipt=d.get('receipt',{}) +print(json.dumps({'exit':r.returncode,'status':d.get('status'),'commit':d.get('commit'),'instance_role':receipt.get('instance_role'),'instance_label':receipt.get('instance_label'),'expected_commit':receipt.get('expected_commit'),'derived_commit':receipt.get('derived_commit'),'identifier_assignments':d.get('identifiers',{}).get('assignments')})) +raise SystemExit(r.returncode) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage1/source-publication-readback.json b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage1/source-publication-readback.json new file mode 100644 index 00000000..074fe678 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage1/source-publication-readback.json @@ -0,0 +1,80 @@ +{ + "schema": "state95.verified-guard-source-readback.v1", + "status": "applied", + "source_revision": "bb525bc304cbd79b8a8a1540aa28bf073a4e2c32", + "remote_revision": "bb525bc304cbd79b8a8a1540aa28bf073a4e2c32", + "instance_role": "primary", + "instance_label": "railiance01", + "expected_commit": "bb525bc304cbd79b8a8a1540aa28bf073a4e2c32", + "derived_commit": "bb525bc304cbd79b8a8a1540aa28bf073a4e2c32", + "tasks": [ + { + "uuid": "6a5f87bb-e63d-5e20-b9fc-c1b5cd403ff7", + "record_id": "STATE-WP-0095-T01", + "status": "done", + "needs_human": false + }, + { + "uuid": "ebb24194-e4cc-5130-aaa6-663f2ea2c822", + "record_id": "STATE-WP-0095-T02", + "status": "done", + "needs_human": false + }, + { + "uuid": "e8566341-cf6e-5b9c-ad80-8564d14cd52a", + "record_id": "STATE-WP-0095-T03", + "status": "done", + "needs_human": false + }, + { + "uuid": "3bbb311f-194e-5862-8aed-358a580f22b4", + "record_id": "STATE-WP-0095-T04", + "status": "done", + "needs_human": false + }, + { + "uuid": "339b9a37-181c-5599-9a89-0393e199bd4b", + "record_id": "STATE-WP-0095-T05", + "status": "done", + "needs_human": false + }, + { + "uuid": "6af973b4-1964-5d4d-a2f8-8dbd4c01fda4", + "record_id": "STATE-WP-0095-T06", + "status": "done", + "needs_human": false + }, + { + "uuid": "cfd1ea99-e75b-542e-a434-acefb62f3c7d", + "record_id": "STATE-WP-0095-T07", + "status": "wait", + "needs_human": false + }, + { + "uuid": "2dd9b04f-2250-5a8a-b5bc-c8a009872858", + "record_id": "STATE-WP-0095-T08", + "status": "done", + "needs_human": false + }, + { + "uuid": "8be8f13b-6352-5295-9755-de6fbfb13593", + "record_id": "STATE-WP-0095-T09", + "status": "done", + "needs_human": false + }, + { + "uuid": "88585c69-d9b9-5e1f-a256-6416ff71e824", + "record_id": "STATE-WP-0095-T10", + "status": "done", + "needs_human": false + } + ], + "parent_status": "active", + "whole_unrelated_index_dirty_preserved": true, + "new_UUIDs": false, + "runtime_mutated_by_source_followthrough": false, + "declared_runtime_source": "c103cbf3c8bed5dba87a03e320595ddded9301a3", + "declared_image": "main-c103cbf@sha256:4cc4ce17bbcdb1b66132d07c5b7b741f46df83124ef1c8403ffabdafba6993d1", + "original_frozen_archive_unchanged": true, + "T07": "wait_for_RMGR19T06_published_closure" +} diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage1/state-hub-verified-followthrough.patch b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage1/state-hub-verified-followthrough.patch new file mode 100644 index 00000000..3aabe954 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage1/state-hub-verified-followthrough.patch @@ -0,0 +1,79 @@ +--- a/workplans/STATE-WP-0095-historical-source-receiving.md ++++ b/workplans/STATE-WP-0095-historical-source-receiving.md +@@ -325,11 +325,11 @@ + + ```task + id: STATE-WP-0095-T10 +-status: wait ++status: done + priority: high + depends_on: [STATE-WP-0095-T09] +-needs_human: true +-intervention_note: "New API runtime requires its own exact image, reviewed preflight and scoped rollout approval; prior revision-4 approval does not cover it." ++needs_human: false ++intervention_note: "Exact revision 2 approved and verified; no pending rollout intervention." + state_hub_task_id: "88585c69-d9b9-5e1f-a256-6416ff71e824" + ``` + +@@ -392,3 +392,31 @@ + unchanged. The same decision df703266-6719-4c04-ae1b-ba9afd3bca1c remains open + and pending. This source-only amendment grants no runtime authority or new + image/code change; T07/T10 remain wait and T09 remains done. ++ ++ ++T10 verified receiving return, 2026-10-17: the founder approved and the owner ++executed exact revision 2 of STATE-WP-0095-T10-frozen-bindings-guard-r1, ++sha256:5d0ddebc21bcb035809cc8b57f4c2e1ba8ed09204be5b965460e178a90913f1e, ++under decision df703266-6719-4c04-ae1b-ba9afd3bca1c. Helm revision 77 delivered ++runtime source c103cbf3c8bed5dba87a03e320595ddded9301a3 and image ++main-c103cbf@sha256:4cc4ce17bbcdb1b66132d07c5b7b741f46df83124ef1c8403ffabdafba6993d1. ++API/MCP are Ready on expected schema c4d5e6f7a8b9. Eight actual native cases ++passed with zero probe commits. All three parent and 15 task CURRENT full-row ++hashes remained unchanged after delivery/probes and the actual normal Cluster ++pipeline (20 ordinary bindings, three frozen bindings omitted by the verified ++loaded checker). All three archived sources remained byte-identical. Exact ++owned Job/pod success was observed, then their absence and removal of owned ++chart staging were verified; no recovery was needed. ++ ++The verified return is the Custodian dated 2026-10-17 ++ guard-successor-execution-r2/verified-receiving-return.json, ++sha256:09958bd69367cc6903e2a21a56eb81b6a8a46c2a1907afc93f787cd63732e92a. ++The original three-parent hash failures remain the founder-accepted historical ++exception: unavailable original preimages were not guessed/restored, and failed ++outcomes were not backfilled. The 15 original task rows and archived bytes stay ++preserved. Earlier pending review notes are retained as proposal history; T10 ++is now done and needs_human false. T07 and this parent remain open until the ++actual RMGR-WP-0019-T06 owner closure is published and returned. No native ++security-target gate or broader autonomy is established by this infrastructure ++return. Source appVersion and both maintained image pins now declare this same ++verified image; the approved frozen rollout archive remains unchanged. +--- a/deploy/railiance/apps/charts/state-hub/Chart.yaml ++++ b/deploy/railiance/apps/charts/state-hub/Chart.yaml +@@ -3,4 +3,4 @@ + description: State Hub API service for private Railiance operation + type: application + version: 0.1.0 +-appVersion: "main-51bc70d" ++appVersion: "main-c103cbf" +--- a/Makefile ++++ b/Makefile +@@ -9,7 +9,7 @@ + RAILIANCE_STATE_HUB_NAMESPACE ?= state-hub + RAILIANCE_STATE_HUB_CHART ?= deploy/railiance/apps/charts/state-hub + RAILIANCE_STATE_HUB_VALUES ?= deploy/railiance/apps/helm/state-hub-values.yaml +-RAILIANCE_STATE_HUB_IMAGE_TAG ?= main-51bc70d@sha256:c802690ee1811b3d77df4077936f9cdced1385514e8673fcbc9232b84d645015 ++RAILIANCE_STATE_HUB_IMAGE_TAG ?= main-c103cbf@sha256:4cc4ce17bbcdb1b66132d07c5b7b741f46df83124ef1c8403ffabdafba6993d1 + RAILIANCE_STATE_HUB_PLATFORM_DIR ?= deploy/railiance/platform + RAILIANCE_STATE_HUB_APP_MANIFESTS ?= deploy/railiance/apps/manifests + # Codex/WSL non-login shells may not source ~/.profile; keep uv discoverable. +--- a/deploy/railiance/apps/helm/state-hub-values.yaml ++++ b/deploy/railiance/apps/helm/state-hub-values.yaml +@@ -6,7 +6,7 @@ + + image: + repository: forgejo.coulomb.social/coulomb/state-hub +- tag: "main-51bc70d@sha256:c802690ee1811b3d77df4077936f9cdced1385514e8673fcbc9232b84d645015" ++ tag: "main-c103cbf@sha256:4cc4ce17bbcdb1b66132d07c5b7b741f46df83124ef1c8403ffabdafba6993d1" + + ingress: + enabled: false