Compare commits

...
Sign in to create a new pull request.

323 commits

Author SHA1 Message Date
b434deafc7 Promote intakes with flavor and copy residual origin.
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 36s
CUST-WP-0072-T03: new workplans from intake promotion and register
bootstrap declare flavor and depends_on.

Assistant: grok
Assistant-Session: 01a09dc1-b21e-77e1-919e-fcad2f82b267
2026-09-14 15:51:06 +02:00
1a3375094f Classify open workplans with flavor (CUST-WP-0072).
Set flavor on open workplans from origin/prose/status. Copy existing
depends_on aliases only. Do not promote residuals.

Assistant: grok
Assistant-Session: 01a09dc1-b21e-77e1-919e-fcad2f82b267
2026-09-14 15:50:49 +02:00
a523f7ab76 Refresh WORK-RECORDS.md after STATE-WP-0092 finished.
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
C-04/C-10 synced the workplan and tasks on the hub.

Assistant: grok
Assistant-Session: 01a09dc1-b21e-77e1-919e-fcad2f82b267
2026-09-14 15:31:33 +02:00
ddc3338541 Index workplan flavor and omit residuals from default views.
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 32s
STATE-WP-0092: persist flavor from files, treat depends_on as the C-20
canonical edge, exclude flavor=residual from summary/next_steps/deps
unless include_residuals is set. Live primary still needs the alembic
revision applied.

Assistant: grok
Assistant-Session: 01a09dc1-b21e-77e1-919e-fcad2f82b267
2026-09-14 15:28:58 +02:00
ea11451e9c Index STATE-WP-0092 after fix-consistency writeback.
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
C-06 registered the workplan and tasks; WORK-RECORDS.md regenerated.

Assistant: grok
Assistant-Session: 01a09dc1-b21e-77e1-919e-fcad2f82b267
2026-09-14 13:50:48 +02:00
747c91df4c Add STATE-WP-0092 for workplan flavor, depends_on, and residual views.
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 4s
Schema and policy change so residuals are a flavor excluded from default
open-workplan views until demand or risk promotes them.

Assistant: grok
Assistant-Session: 01a09dc1-b21e-77e1-919e-fcad2f82b267
2026-09-14 13:48:17 +02:00
b0db0145be Refresh WORK-RECORDS.md after closed workplans.
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: grok
Assistant-Session: 01a09dc1-b21e-77e1-919e-fcad2f82b267
2026-09-14 11:40:31 +02:00
23f2038c5b Point STATE-WP-0091 residual at RAIL-BS-WP-0014.
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Assistant: grok
Assistant-Session: 01a09dc1-b21e-77e1-919e-fcad2f82b267
2026-09-14 10:17:40 +02:00
custodian-sync
8a9542ba9b chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Updated by fix-consistency on 2026-09-14:
  - update .custodian-brief.md for state-hub

Assistant: grok
Assistant-Session: 01a09dc1-b21e-77e1-919e-fcad2f82b267
2026-09-14 10:15:04 +02:00
261a48772c Add a read-only headroom preflight before State Hub promotion.
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 42s
STATE-WP-0091: refuse Helm when remaining CPU cannot cover the API
surge, migrate hook, or unrelated pending demand. 65m fails, 105m is
narrowly sufficient, not factory admission.

Assistant: grok
Assistant-Session: 01a09dc1-b21e-77e1-919e-fcad2f82b267
2026-09-14 10:13:48 +02:00
repo-manager
7a4f58b90c repo.work.assign_missing_identifiers
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
source: repo-manager
reason: deterministic projection registration

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
2026-09-10 16:33:17 +02:00
b0063abe18 docs: close live projection convergence and retain capacity residual
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
2026-09-10 16:32:58 +02:00
b5920d85e3 fix(deploy): size MCP reservation from observed CPU usage
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
2026-09-10 15:32:50 +02:00
281d24328c ops: pin tested projection convergence image
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
2026-09-10 15:20:24 +02:00
repo-manager
0747b4d95b repo.work.assign_missing_identifiers
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 35s
source: repo-manager
reason: deterministic projection registration

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
2026-09-10 15:14:41 +02:00
f767a1a245 fix: preserve source task sections and explicit empty notes
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
2026-09-10 15:11:03 +02:00
ccb285fc40 Record verified rename recovery and promoted image
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 3s
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a070b5-4994-7271-bd8b-7c3dbcedec4b
2026-09-05 20:20:22 +02:00
repo-manager
5944ea3c20 repo.work.assign_missing_identifiers
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 1s
source: repo-manager
reason: deterministic projection registration

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a070b5-4994-7271-bd8b-7c3dbcedec4b
2026-09-05 18:59:37 +02:00
fe6b8d96c2 Recover external repository renames through verified Forgejo redirects
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 24s
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a070b5-4994-7271-bd8b-7c3dbcedec4b
2026-09-05 18:57:15 +02:00
022cf4b727 fix: require signature invalidation in live rotation acceptance
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 31s
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a06ed7-828d-7ca0-a8d4-0c3e5a0c4102
2026-09-05 18:30:49 +02:00
29428bb304 Close live preflight signing and rotation acceptance
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a06ecb-456a-71c2-b41e-0755d336e883
2026-09-05 18:11:41 +02:00
2cae49fa58 test: add non-mutating live signing rotation acceptance probe
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 44s
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a06ed7-828d-7ca0-a8d4-0c3e5a0c4102
2026-09-05 17:26:55 +02:00
49e3182332 Enable verified preflight signing delivery for the primary API
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a06ecb-456a-71c2-b41e-0755d336e883
2026-09-05 16:48:42 +02:00
repo-manager
74c687c6bc repo.work.assign_missing_identifiers
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
source: repo-manager
reason: deterministic projection registration

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a06ed7-828d-7ca0-a8d4-0c3e5a0c4102
2026-09-05 16:39:04 +02:00
e96ef197cf feat: add API-only preflight signing delivery and rotation acceptance
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a06ed7-828d-7ca0-a8d4-0c3e5a0c4102
2026-09-05 16:39:02 +02:00
repo-manager
485275d868 repo.work.assign_missing_identifiers
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
source: repo-manager
reason: deterministic projection registration

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a06ed7-828d-7ca0-a8d4-0c3e5a0c4102
2026-09-05 11:27:23 +02:00
ccf691042d docs: complete retirement inbox parity gate and track reader cutover
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a06ed7-828d-7ca0-a8d4-0c3e5a0c4102
2026-09-05 11:27:21 +02:00
repo-manager
2c60e5bcf7 repo.work.assign_missing_identifiers
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
source: repo-manager
reason: deterministic projection registration

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a06ed7-828d-7ca0-a8d4-0c3e5a0c4102
2026-09-05 10:25:39 +02:00
f04de759a1 docs: advance retirement with SBOM receipts and caller migrations
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a06ed7-828d-7ca0-a8d4-0c3e5a0c4102
2026-09-05 10:24:54 +02:00
repo-manager
74a3b22c05 repo.work.assign_missing_identifiers
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 25s
source: repo-manager
reason: deterministic projection registration

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a06ed7-828d-7ca0-a8d4-0c3e5a0c4102
2026-09-05 09:40:58 +02:00
3578dc2f32 fix: bound repository collection identity projection
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a06ed7-828d-7ca0-a8d4-0c3e5a0c4102
2026-09-05 02:03:52 +02:00
4cef58cd98 docs: refresh work record index
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a06d83-1cbc-71f2-b0dc-e0f48cedae43
2026-09-04 22:30:58 +02:00
0b44cf12f5 feat: complete cluster self-sufficiency hardening
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 45s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a06d83-1cbc-71f2-b0dc-e0f48cedae43
2026-09-04 22:23:42 +02:00
87047faa8e docs: rebaseline cluster self-sufficiency workplan
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a06d83-1cbc-71f2-b0dc-e0f48cedae43
2026-09-04 21:40:41 +02:00
repo-manager
bec702d842 repo.work.assign_missing_identifiers
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
source: repo-manager
reason: deterministic projection registration

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a06d83-1cbc-71f2-b0dc-e0f48cedae43
2026-09-04 21:16:19 +02:00
e663f209f7 fix(register): require explicit topic when ambiguous
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a06d83-1cbc-71f2-b0dc-e0f48cedae43
2026-09-04 21:16:07 +02:00
803bb95e1d record D2 Fabric dual-read cutover evidence
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a053ff-1d6f-7fe2-ac1c-a6eb40a42a0c
2026-08-31 23:22:43 +02:00
cdff3b7e08 add Fabric read model rollback activation
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 27s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a053ff-1d6f-7fe2-ac1c-a6eb40a42a0c
2026-08-31 23:07:10 +02:00
cf40c7bb4e fix Fabric authority import compatibility
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 21s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a053ff-1d6f-7fe2-ac1c-a6eb40a42a0c
2026-08-31 23:00:39 +02:00
903a2fb090 record fabric retirement gate readiness
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a053ff-1d6f-7fe2-ac1c-a6eb40a42a0c
2026-08-31 22:26:51 +02:00
0e35f84ad5 record ops hub retirement gate completion
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a053ff-1d6f-7fe2-ac1c-a6eb40a42a0c
2026-08-31 21:36:46 +02:00
da30ce6723 docs: refresh architecture decision index
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a058f3-8ba0-7692-a042-9a870fc3d663
2026-08-31 21:17:38 +02:00
8f0667d8ff close rename plan and restate retirement gates
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a053ff-1d6f-7fe2-ac1c-a6eb40a42a0c
2026-08-31 20:07:51 +02:00
custodian-sync
bb319fea23 chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Updated by fix-consistency on 2026-08-31:
  - update .custodian-brief.md for state-hub

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a053ff-1d6f-7fe2-ac1c-a6eb40a42a0c
2026-08-31 20:06:31 +02:00
d8e1810359 pin registrar guard retirement deployment
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a053ff-1d6f-7fe2-ac1c-a6eb40a42a0c
2026-08-31 19:36:35 +02:00
5dd04dcb3d retire identifier registrar guard
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 26s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a053ff-1d6f-7fe2-ac1c-a6eb40a42a0c
2026-08-31 19:34:11 +02:00
667ed28037 pin duplicate convergence deployment
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a053ff-1d6f-7fe2-ac1c-a6eb40a42a0c
2026-08-31 16:48:45 +02:00
a7c91a6d65 coalesce safe identifier task duplicates
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 23s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a053ff-1d6f-7fe2-ac1c-a6eb40a42a0c
2026-08-31 16:26:10 +02:00
142c94287e docs: advance remaining state hub workplans
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a053ff-1d6f-7fe2-ac1c-a6eb40a42a0c
2026-08-31 12:14:33 +02:00
custodian-sync
306a6e0ce8 chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Updated by fix-consistency on 2026-08-31:
  - update .custodian-brief.md for state-hub

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a053ff-1d6f-7fe2-ac1c-a6eb40a42a0c
2026-08-31 11:58:30 +02:00
566d21f3bf deploy: close projection identity reset
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a053ff-1d6f-7fe2-ac1c-a6eb40a42a0c
2026-08-31 02:07:31 +02:00
custodian-sync
7df0dec715 chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Updated by fix-consistency on 2026-08-31:
  - update .custodian-brief.md for state-hub

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a053ff-1d6f-7fe2-ac1c-a6eb40a42a0c
2026-08-31 02:07:11 +02:00
a32112e5ab fix: preserve stale task identity history
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 26s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a053ff-1d6f-7fe2-ac1c-a6eb40a42a0c
2026-08-31 02:02:35 +02:00
ddce470944 fix: bound task identity ambiguity
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 26s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a053ff-1d6f-7fe2-ac1c-a6eb40a42a0c
2026-08-31 01:57:46 +02:00
14e865ab30 chore: converge state-hub live identifiers
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a053ff-1d6f-7fe2-ac1c-a6eb40a42a0c
2026-08-31 01:37:23 +02:00
f39612558d docs: capture legacy-meter weekly evidence
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a053ff-1d6f-7fe2-ac1c-a6eb40a42a0c
2026-08-31 01:29:48 +02:00
b9c87d1769 deploy: promote mixed identifier convergence
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a053ff-1d6f-7fe2-ac1c-a6eb40a42a0c
2026-08-31 01:29:08 +02:00
151fdcf397 feat: converge mixed identifier projections
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 24s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a053ff-1d6f-7fe2-ac1c-a6eb40a42a0c
2026-08-31 01:27:21 +02:00
custodian-sync
6f64f1aab8 chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Updated by fix-consistency on 2026-08-30:
  - update .custodian-brief.md for state-hub

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a053ff-1d6f-7fe2-ac1c-a6eb40a42a0c
2026-08-30 23:40:38 +02:00
fc8ec49d9e fix: package quality-debt CLI support
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 24s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a053ff-1d6f-7fe2-ac1c-a6eb40a42a0c
2026-08-30 23:36:23 +02:00
2343574a0c docs: capture legacy-meter weekly evidence
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a053ff-1d6f-7fe2-ac1c-a6eb40a42a0c
2026-08-30 23:27:47 +02:00
c5e15c7d7f docs: close fast projection rollout
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a053ff-1d6f-7fe2-ac1c-a6eb40a42a0c
2026-08-30 23:24:15 +02:00
b97cfbb2b1 deploy: promote fast work-record reconciliation
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a053ff-1d6f-7fe2-ac1c-a6eb40a42a0c
2026-08-30 23:21:30 +02:00
custodian-sync
e2fc488c40 chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 1s
Updated by fix-consistency on 2026-08-30:
  - update .custodian-brief.md for state-hub

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a053ff-1d6f-7fe2-ac1c-a6eb40a42a0c
2026-08-30 23:14:33 +02:00
8e36b22e26 chore: assign missing ad-hoc task identifiers
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a053ff-1d6f-7fe2-ac1c-a6eb40a42a0c
2026-08-30 22:51:34 +02:00
34f5cb3fc3 feat: add fast forge work-record reconciliation
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 24s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a053ff-1d6f-7fe2-ac1c-a6eb40a42a0c
2026-08-30 22:38:54 +02:00
a65cef02cf deploy: pin repository rename capable state-hub image
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a049a4-ee9f-78e1-9d66-2cb0f9bea3e3
2026-08-29 18:08:25 +02:00
41b0b24195 docs: register flex-auth rename adoption plan
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a049a4-ee9f-78e1-9d66-2cb0f9bea3e3
2026-08-29 18:06:54 +02:00
f7402bd711 docs: complete repository rename operator handoff
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a049a4-ee9f-78e1-9d66-2cb0f9bea3e3
2026-08-29 15:46:43 +02:00
custodian-sync
0ec5b9a98d chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Updated by fix-consistency on 2026-08-29:
  - update .custodian-brief.md for state-hub

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a049a4-ee9f-78e1-9d66-2cb0f9bea3e3
2026-08-29 15:45:52 +02:00
9f0a104b56 feat: prove repository rename continuity
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 24s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a049a4-ee9f-78e1-9d66-2cb0f9bea3e3
2026-08-29 15:05:09 +02:00
037c8360e2 Refresh WORK-RECORDS.md after every live record is archived.
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 35s
C-33 returned early when the generator produced no rows, so a leftover
index kept listing archived workplans as active. Rewrite that file to an
empty table instead of leaving the stale rows in place.

Assistant: grok
Assistant-Session: 01a04d7c-846d-77e3-af8d-020019e4eb61
2026-08-29 14:39:42 +02:00
ef0b6df3b8 feat: generate repository rename adoption plans
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 25s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a049a4-ee9f-78e1-9d66-2cb0f9bea3e3
2026-08-29 13:08:02 +02:00
custodian-sync
bdf3a1fdf8 chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 1s
Updated by fix-consistency on 2026-08-29:
  - update .custodian-brief.md for state-hub

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a049a4-ee9f-78e1-9d66-2cb0f9bea3e3
2026-08-29 13:07:19 +02:00
6b82215ae7 feat: add repository rename orchestration CLI
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 25s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a049a4-ee9f-78e1-9d66-2cb0f9bea3e3
2026-08-29 12:00:22 +02:00
custodian-sync
6312db8700 chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Updated by fix-consistency on 2026-08-29:
  - update .custodian-brief.md for state-hub

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a049a4-ee9f-78e1-9d66-2cb0f9bea3e3
2026-08-29 11:58:02 +02:00
2e2ae1e5d0 feat: make repository reads alias-aware
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 25s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a049a4-ee9f-78e1-9d66-2cb0f9bea3e3
2026-08-29 10:47:51 +02:00
custodian-sync
639b9aed08 chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Updated by fix-consistency on 2026-08-29:
  - update .custodian-brief.md for state-hub

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a049a4-ee9f-78e1-9d66-2cb0f9bea3e3
2026-08-29 10:47:26 +02:00
82ea38b180 feat: add repository rename lifecycle API
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 25s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a049a4-ee9f-78e1-9d66-2cb0f9bea3e3
2026-08-29 03:17:37 +02:00
custodian-sync
9e77a4a9a2 chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Updated by fix-consistency on 2026-08-29:
  - update .custodian-brief.md for state-hub

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a049a4-ee9f-78e1-9d66-2cb0f9bea3e3
2026-08-29 03:15:58 +02:00
8988a093f2 feat: persist repository rename identity
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 24s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a049a4-ee9f-78e1-9d66-2cb0f9bea3e3
2026-08-28 22:08:21 +02:00
custodian-sync
13f7a10018 chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Updated by fix-consistency on 2026-08-28:
  - update .custodian-brief.md for state-hub

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a049a4-ee9f-78e1-9d66-2cb0f9bea3e3
2026-08-28 22:06:39 +02:00
0020286bab docs(STATE-WP-0083-T04): record central fleet refuse-mode run
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
125 repos on main-54b09ee: 52 applied, 69 noop, 2 refused
(identifier collisions), 2 unreadable, 0 errors, 0 retirements.

Assistant: grok
Assistant-Session: 01a04996-76e8-7f53-b971-1885cfbed436
2026-08-28 21:42:05 +02:00
70dddf56f8 deploy: promote state-hub to main-54b09ee
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
UUID-match for existing tasks so the projection reset does not collide
on registrar-minted rows.

Assistant: grok
Assistant-Session: 01a04996-76e8-7f53-b971-1885cfbed436
2026-08-28 21:32:11 +02:00
54b09ee9e2 fix: do not recreate tasks that already have the derived UUID
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 25s
Registrar-minted tasks keep the derived UUID with record_id unset.
Inserting them again collides. Match by UUID, then write record_id.

Assistant: grok
Assistant-Session: 01a04996-76e8-7f53-b971-1885cfbed436
2026-08-28 21:31:13 +02:00
43e3edd727 deploy: promote state-hub image to main-3204baf
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 2s
Task-aware forge projection reset (CUST-WP-0068-T09). CI image.yaml #402
built this tag. Also drop the duplicated sweep.hostname key.

Assistant: grok
Assistant-Session: 01a04996-76e8-7f53-b971-1885cfbed436
2026-08-28 21:27:09 +02:00
8754983a0d docs: define repository rename identity contract
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a049a4-ee9f-78e1-9d66-2cb0f9bea3e3
2026-08-28 21:24:50 +02:00
custodian-sync
80daf659bb chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 1s
Updated by fix-consistency on 2026-08-28:
  - update .custodian-brief.md for state-hub

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a049a4-ee9f-78e1-9d66-2cb0f9bea3e3
2026-08-28 21:22:29 +02:00
custodian-sync
136256fa3d chore(consistency): renormalize lifecycle state [auto]
Updated by fix-consistency on 2026-08-28:
  - workplan status: ready → active

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a049a4-ee9f-78e1-9d66-2cb0f9bea3e3
2026-08-28 21:22:24 +02:00
3204baf8fd sync identified tasks of existing workplans on projection reset
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 25s
Hub tasks now carry record_id, so reset_repository_projection can create
missing derived tasks, update title/status, and cancel open tasks the
forge no longer derives. Rows with no record_id are left alone.

Assistant: grok
Assistant-Session: 01a04996-76e8-7f53-b971-1885cfbed436
2026-08-28 21:06:24 +02:00
repo-manager
bb1b8f4c95 chore(registrar): assign State Hub identifiers
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a049a4-ee9f-78e1-9d66-2cb0f9bea3e3
2026-08-28 21:04:05 +02:00
custodian-sync
47d6989bc5 chore(consistency): sync task status from DB [auto]
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Has been cancelled
Updated by fix-consistency on 2026-08-28:
  - update .custodian-brief.md for state-hub

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a049a4-ee9f-78e1-9d66-2cb0f9bea3e3
2026-08-28 21:04:02 +02:00
b08a5d43a0 docs(workplan): plan lineage-preserving repository renames
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a049a4-ee9f-78e1-9d66-2cb0f9bea3e3
2026-08-28 21:01:21 +02:00
custodian-sync
c835be5ea2 chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Updated by fix-consistency on 2026-08-28:
  - update .custodian-brief.md for state-hub

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a049a4-ee9f-78e1-9d66-2cb0f9bea3e3
2026-08-28 21:00:57 +02:00
custodian-sync
41d80429a7 chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Updated by fix-consistency on 2026-08-28:
  - update .custodian-brief.md for state-hub

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-28 17:54:20 +02:00
9c8b079a70 chore(deploy): pin state-hub main-500049f
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-28 15:33:34 +02:00
500049fc31 fix(projection): the title is derived too
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 24s
Retiring the displaced rows left `cust-wp-0010` and `kont-wp-0013` correctly
identified and correctly backed while describing a different workplan's work:
the update path syncs status, backing path and commit, but never title. I had
said it would repair the title; it did not.

Seven rows fleet-wide carry a title differing from their file, and three of them
are why a blanket sync would be wrong: activity-core's files parse to an empty
title, and blanking a real one is worse than leaving it stale. The sync applies
only when the derived title is non-empty, which makes it four real corrections.

The `_Row` fixture carried no `title` attribute at all, so no existing test
could have caught this. Added, with the new tests written against the fake
session rather than as assertions on source text.

755 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-28 15:32:29 +02:00
fadfddb6c6 chore(deploy): pin state-hub main-218f7fd
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-28 15:03:05 +02:00
218f7fd8b5 fix(projection): rank competing claims instead of overwriting
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 24s
Two rows can claim one record. `cust-wp-0010` claimed CUST-WP-0010 by its own
identifier while `workstream-lifecycle-documentation` claimed it by path, both
pointing at that workplan's file; `kont-wp-0013` and
`KONT-WP-0013-blob-storage-content-streaming` did the same. Assigning into
`matched` unconditionally let whichever row came last win and dropped the other
silently — never matched, so never stale, so never reported by any pass. It was
also order-dependent, so which row survived depended on row iteration order.

Claims are now ranked: derived UUID, then the record's own identifier, then
backing path, then prefix heuristic. The strongest wins and the loser is
displaced into `stale`, which makes it a retirement candidate rather than
invisible. Ranking is order-independent, verified both ways round.

Under ADR-007 the identifier is the identity, so the row naming the record wins
and its stale title and path are repaired from the file; the hub-first row that
never held the identifier retires with its history intact.

751 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-28 15:01:58 +02:00
1af9ec2f0c chore(deploy): pin state-hub main-5686b79
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-28 11:49:51 +02:00
5686b79d8c fix(projection): a resurrected row must enter matched to be seen
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 21s
The previous commit made a stamped slug count as retired, and skipped such rows
before the heuristics. That is right for a genuinely retired row and wrong for a
resurrected one: `stale` is computed from `matched`, so a row that never enters
`matched` is invisible — not retired, so not stale; path-matching its own file,
so never reported. All six kept returning `noop`.

A row whose slug carries the mark while its flag is clear now lands in `matched`
under its own key, which is what makes it stale and therefore retirable. A row
that is actually retired is still skipped outright: nothing to do, nothing to
re-decide.

This is the same mistake as the original path fallback, in the opposite
direction: there a row was matched when it should not have been, here it was not
matched when it had to be.

748 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-28 11:48:51 +02:00
95672e0c47 chore(deploy): pin state-hub main-702578c
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-28 11:31:30 +02:00
702578cca4 fix(projection): a stamped slug counts as retired even if the flag is clear
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 36s
Six rows were resurrected before the previous fix landed: tombstoned slug,
cleared retirement flag, sitting beside the correct record derived from the
same file. Nothing can see them. They are not retired, so they are not stale;
they path-match their own file, so every reset reports `noop` and they persist
indefinitely.

The stamp in the slug is itself evidence that a retirement happened, so a row
carrying the mark with a cleared flag is a contradiction to repair rather than
a live record. Such a row is now skipped by the heuristics like any retired
one, which makes it visible as a retirement candidate again.

747 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-28 11:30:25 +02:00
d1ace1295f chore(deploy): pin state-hub main-3c884dd
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-28 11:17:15 +02:00
3c884dd3f9 fix(projection): a retirement must survive the next pass
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 24s
Five rows came back tombstoned but not retired — a zombie beside the correct
record derived from the same file. Two of my own changes combined to produce it.

Releasing the identifier destroys the evidence that made the row a re-key: the
tombstoned slug is no longer a work-record identifier, and a legacy row is not
UUID-derived, so both guards fall through to path matching — which matches the
very file the row was retired for and resurrects it.

And the release loop ran before matching, so it also stamped rows that were
about to derive again, leaving them unable to match their own file and never
un-retired.

Two changes. A retired row is now matched by UUID or by its own identifier, and
never by the path heuristics; a record that genuinely returns still un-retires,
because its identifier is unchanged. And the release exempts any row the forge
still derives.

746 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-28 11:16:12 +02:00
240627937d chore(deploy): pin state-hub main-b43a1e5
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-28 10:39:18 +02:00
b43a1e5728 fix(projection): qualify bare task ids before deriving their identity
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 26s
Retiring llm-connect's five legacy rows failed on `duplicate key value violates
unique constraint "tasks_pkey"`, and the cause is that a bare `T01` is not an
identifier: it is unique within its workplan, not in the fleet. Unqualified,
uuid5("T01") is the same UUID for every workplan in the fleet that has one —
llm-connect's 91 task blocks derive 49 distinct UUIDs, so creating its
workplans inserts the same task primary key repeatedly in one flush.

Tasks are now qualified with their owning workplan before derivation, which is
the rule `task_record_id_backfill.qualify_task_id` already applies to stored
ids; the two must agree or the backfill and the projection disagree about what
a task is called. Already-qualified ids are untouched.

The create path's comment claimed it was "safe only because nothing exists to
mis-match against: this workplan is new to the hub". That was true of other
workplans and false within one: the collision was among the tasks it was
inserting itself.

The failed pass rolled back cleanly — llm-connect's five legacy rows are still
live and progress events are intact.

742 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-28 10:38:12 +02:00
034eda9287 chore(deploy): pin state-hub main-d4ca941
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 1s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-28 03:18:36 +02:00
d4ca94198e fix(projection): the derived UUID is identity, check it before the slug
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 26s
Two rows were being proposed for retirement that were correct all along.
`testdrive-jsui-publication` and `three-phoenix-ha-cluster` carry legacy title
slugs, but their UUIDs are exactly uuid5 of MARKITECT-WP-0002 and
RCLUSTER-WP-0007 — they are those records. `_identity_is_derived` short-circuits
on the UUID *version* before the slug rule ever runs, so both read as re-keys.
Acknowledging retirements for either repository would have retired a live
record on the strength of its slug.

The derived UUID is the strongest key available and was not used in matching at
all. It is now checked first: if a row's id equals uuid5 of a wanted
identifier, that row is that record, whatever its slug says. The slug and path
heuristics now only decide rows the UUID cannot identify.

This also corrects a claim I made from the retirement list alone: those files
derive normally. Nothing was wrong with them.

738 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-28 03:17:21 +02:00
b53daf648a chore(deploy): pin state-hub main-7a21fef
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-28 01:26:17 +02:00
7a21fef0e4 fix(projection): repair rows retired before retirement released identifiers
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 25s
The previous commit made retirement stamp the slug, but `stale` excludes rows
already retired — so nothing revisits them and the stamp never reaches the 32
rows retired earlier today: core-hub's two REPO-WP rows and the 30 ad-hoc
records. Each still holds an identifier no file derives, blocking whichever
repository legitimately owns it.

The reset now stamps any retired row whose identifier is still unreleased,
using that row's own retirement timestamp rather than today's, so the record
keeps saying when it was retired. Completing a decision already taken is not a
new retirement, so this runs without acknowledgement and is reported as
`released`, separately from `retired`.

Repairing it in the reset rather than by hand keeps the hub a read model and
makes the fix reproducible for any row that reaches this state again.

735 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-28 01:25:06 +02:00
95e30188e6 chore(deploy): pin state-hub main-70c585b
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-28 01:16:13 +02:00
70c585bb39 fix(projection): retirement must release the identifier it holds
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 0s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 24s
`slug` is unique across the whole table, so retiring a row by timestamp alone
leaves its identifier locked to a record nothing derives any more — and the
repository that legitimately owns it can never claim it. core-hub's inherited
REPO-WP-0001/0002 rows retired cleanly and repo-seed stayed refused with
"slug already belongs to another repository", pointing at two retired rows.

Retirement now stamps the slug `<slug>@retired-YYYYMMDD`. The row, its UUID and
its progress events are untouched, so history stays attached to the record it
happened under; only the human-facing identifier is freed. Re-retiring an
already-stamped row replaces the mark rather than stacking it, or the 100-char
column overflows after a few passes.

The UUIDs never collided here: core-hub's rows predate derived identity and
carry v4 UUIDs, while repo-seed derives v5 ones. The identifier was the only
thing in the way.

733 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-28 01:15:02 +02:00
custodian-sync
4b121f3796 chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Updated by fix-consistency on 2026-08-28:
  - update .custodian-brief.md for state-hub

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-28 01:05:30 +02:00
0ed4984706 chore(deploy): pin state-hub main-ccf4959
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-28 00:53:38 +02:00
ccf4959423 fix(projection): recognise a re-key by the slug, not the UUID version
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 24s
The previous commit tested UUID version as a proxy for "identity is derived".
It covers 19 of the 44 ad-hoc rows; the other 25 are legacy v4, so they kept
path matching and would have been updated in place — slug still
`adhoc-2026-07-02` while the file says `ADAPTIVE-WP-ADHOC-2026-07-02`. The
divergence would have persisted, and silently, for most of the records this
was meant to fix.

The distinction that actually matters is whether the row's slug is a
work-record identifier. A row whose slug is an identifier claims to be that
record, so a different derived identifier for the same file means two records.
A row whose slug is a title — `three-phoenix-ha-cluster` for RCLUSTER-WP-0007 —
never claimed one: those are hub-first rows from before ADR-001 where the
backing path is the only link there has ever been, and path matching is all
that holds them together.

730 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-28 00:52:26 +02:00
612476b536 chore(deploy): pin state-hub main-b2efadf
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-28 00:40:02 +02:00
b2efadf254 fix(projection): a re-keyed record is new, not a renamed file
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 27s
The ad-hoc requalification changed 30 identifiers while deliberately keeping
the filenames, per canon. The matcher's path fallback therefore matched every
old row to its new record and updated it in place — so the files said
CUST-WP-ADHOC-2026-08-25 while the hub still said adhoc-2026-08-25, with the
row's UUID still encoding the old identifier. Exactly the file/hub identity
divergence ADR-007 exists to prevent.

Path matching cannot distinguish a re-key from a rename when the path does not
change. Identity can: a derived row is UUIDv5 over the record id, so a changed
id is a different record — the old row retires and the new one is created.
Rows predating derived identity carry v4 UUIDs, where the identifier is a label
rather than the identity; those keep path matching, so rename detection still
works where it is the right answer.

The unmatched old row keeps its own slug as key, so it lands in `stale` and
becomes a retirement candidate rather than disappearing from the outcome.

720 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-28 00:38:50 +02:00
4f68c5609f fix(workplans): qualify ad-hoc identifiers with the repository prefix
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
`ADHOC-YYYY-MM-DD` is unique per date but not per repository, so any two repos
opening an ad-hoc on the same day collide. The 2026-08-26 fleet projection
reset refused 9 records for exactly this reason.

Canon (work-record-types_v0.1, CUST-WP-0066) settled the form as
`{PREFIX}-WP-ADHOC-YYYY-MM-DD`, filename unchanged, and grandfathered existing
ids on the condition they are never *silently* re-derived. This is the explicit
migration that clause allows for.

The hub id is derived from the record id, so a changed id is a different
record: stale state_hub_*_id fields are dropped and fix-consistency re-derives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-28 00:28:24 +02:00
5ae1f4ffc5 fix(deploy): pin main-470ece8, and check pins against real builds
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 27s
The upgrade failed as `pre-upgrade hooks failed: timed out waiting for the
condition`. The migrate hook was in ImagePullBackOff: tag main-11f689d does not
exist. `.forgejo/workflows/image.yaml` has a `paths:` filter that excludes
deploy/**, so the chart-only commit was green in CI and built nothing — the two
green runs on it were host-smoke and container-smoke, not build-and-push.

470ece8 carries all the code; every commit after it touches only the chart and
generated docs.

scripts/verify_image_pin.py refuses a pin whose commit has no successful
build-and-push run, and says so in those terms rather than as a timeout five
minutes later. Verified against both the bad pin and the good one; treats an
unreachable forge as inconclusive rather than as failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-28 00:08:31 +02:00
b140929dba chore(deploy): pin state-hub main-11f689d and enable the forge read lane
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-27 23:55:44 +02:00
11f689d86d feat(forge): point the credential lane at the built OpenBao objects
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
MASON-WP-0003-T02 delivered the token on 2026-08-27, and three of its details
differ from the chart's placeholders: KV v2 puts `data/` in the read path, the
field is `FORGE_READ_TOKEN` rather than `token`, and the address matches every
existing ClusterSecretStore (`http://openbao.openbao.svc:8200`, mount
`platform`, auth mount `kubernetes`).

The projected token's audience is now optional and defaults to empty. A token
carrying an audience the auth role does not bind is rejected at TokenReview, and
the role's audience binding is not readable without a privileged session — the
four existing external-secrets roles use the API server audience, so that is the
default that can be verified to work. Setting `openbao` here narrows the token
to OpenBao alone and is worth doing once the role binds it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-27 23:55:08 +02:00
custodian-sync
550a523435 chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Updated by fix-consistency on 2026-08-27:
  - update .custodian-brief.md for state-hub

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-27 23:14:32 +02:00
470ece82ed feat(forge): resolve an optional forge read credential (STATE-WP-0084-T02/T03)
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 27s
Nine repositories are invisible to derivation because central may not read
them. This adds the consuming half of the credential lane MASON-WP-0003 built.

The cluster has no agent injector and no secrets-store CSI driver, so the pod
authenticates to OpenBao with a projected ServiceAccount token (audience
`openbao`, not the API server) and reads the KV path itself. `forgeRead.*`
carries coordinates only; no credential is a chart value, an image layer, or a
Kubernetes Secret.

The credential reaches git through GIT_CONFIG_* setting http.extraHeader, not
through `-c` and not through userinfo in the clone URL — both of those put the
token in the process listing. It is redacted from ForgeDeriveError, which is
logged, stored in reset outcomes, and returned over the API.

Absent stays a supported state: with no credential, or with OpenBao
unreachable, resolution returns None and public derivation runs unchanged.
Raising would turn "nine repositories are unreadable" into "the pass failed",
which is what T01 exists to prevent.

Chart default is disabled. 717 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-27 23:12:57 +02:00
ab6438235e chore(work-records): regenerate after STATE-WP-0084-T01
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 3377672@bnt-lap001
Assistant-Session: 15463ccf-238f-4e13-b163-93aa25c6d166
2026-08-26 21:51:00 +02:00
custodian-sync
59099c4043 chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Updated by fix-consistency on 2026-08-26:
  - update .custodian-brief.md for state-hub

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 3377672@bnt-lap001
Assistant-Session: 15463ccf-238f-4e13-b163-93aa25c6d166
2026-08-26 21:49:26 +02:00
929bc29513 chore(workplan): STATE-WP-0084 active — T01 landed
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 3377672@bnt-lap001
Assistant-Session: 15463ccf-238f-4e13-b163-93aa25c6d166
2026-08-26 21:48:04 +02:00
85181cd3e4 feat(forge): report unreadable repositories as unreadable (STATE-WP-0084-T01)
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 25s
A private repository failed derivation the same way a broken one did, so
"cannot read" and "does not exist" were indistinguishable from outside.
They authorise opposite things: only the second can justify retiring a
record.

- ForgeUnreadableError (a ForgeDeriveError, so old callers still catch it)
  for permission-shaped clone failures, including Forgejo's 404 for an
  unauthenticated private repo — indistinguishable here, and the safe
  reading of an ambiguous answer cannot destroy a record.
- GIT_TERMINAL_PROMPT=0: an unattended pass must fail, not block on a
  username prompt. Failing is what makes the case observable.
- DerivedProjection.retirement_eligible separates "no records found" from
  "no records exist". A checkout with no workplans/ directory cannot
  evidence an absence — the empty-clone path that would have proposed
  every record in a repository for retirement.
- Retirement from an ineligible source is refused even when acknowledged.
- Fleet keeps unreadable out of the error bucket.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 3377672@bnt-lap001
Assistant-Session: 15463ccf-238f-4e13-b163-93aa25c6d166
2026-08-26 21:45:03 +02:00
repo-manager
8b38f815e5 chore(registrar): assign State Hub identifiers
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 3377672@bnt-lap001
Assistant-Session: 15463ccf-238f-4e13-b163-93aa25c6d166
2026-08-26 21:30:30 +02:00
custodian-sync
12be827331 chore(consistency): sync task status from DB [auto]
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Has been cancelled
Updated by fix-consistency on 2026-08-26:
  - update .custodian-brief.md for state-hub

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 3377672@bnt-lap001
Assistant-Session: 15463ccf-238f-4e13-b163-93aa25c6d166
2026-08-26 21:30:23 +02:00
78f4aaeb8e chore(work-records): regenerate for STATE-WP-0084
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 3377672@bnt-lap001
Assistant-Session: 15463ccf-238f-4e13-b163-93aa25c6d166
2026-08-26 21:28:35 +02:00
fc814bd094 feat(workplan): open STATE-WP-0084 for private-repository derivation
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Takes the three tasks split out of ops-mason/MASON-WP-0003: mount the
forge read credential, teach derive_from_forge() to use it, and re-run
the fleet reset over the nine repositories that failed in
STATE-WP-0083-T04.

T01 (report unreadable as unreadable, never as retirement work) is
promoted ahead of the credential work: it does not depend on it, it
closes the empty-clone path that nearly retired vergabe-teilnahme, and
MASON-WP-0003-T01's breadth recommendation is contingent on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 3377672@bnt-lap001
Assistant-Session: 15463ccf-238f-4e13-b163-93aa25c6d166
2026-08-26 21:25:55 +02:00
a6cab728cb chore(evidence): record legacy-meter weekly review for 2026-08-26
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-26 21:04:35 +02:00
52da6a17cb feat(workplan): close STATE-WP-0083-T05 — every hub record derives from a file
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
51 records retired across the fleet; 106 of 121 repositories now noop. Zero
repositories hold a record that no longer derives.

26 real workplans were rescued from wrongful retirement — more than half the
number retired. vergabe-teilnahme (17) predated the convention entirely; nine
further files across five repositories carried no type field, three of them
created by this session's own renames.

Also records the RPF-WP collision this session introduced by numbering from
0001 without checking whether the target prefix was in use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-26 21:04:16 +02:00
12d8a1d018 feat(workplan): close STATE-WP-0083-T04 — fleet reset is idempotent
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Second pass: 93 repositories noop with zero created, updated or retired,
demonstrating ADR-012 decision 7's requirement that reset twice produces the
same projection.

Slug refusal took errors from 12 to 9 — three repositories were erroring on slug
collisions, not just disaster-control. 60 records across 13 repositories no
longer derive from any file, which is the retirement work CUST-WP-0068-T09
waits on, now enumerated per repository.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-26 19:39:03 +02:00
4991ac79ed chore(deploy): bump to main-6c1262e for the slug collision refusal
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Refs STATE-WP-0083-T04

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-26 19:33:21 +02:00
6c1262ef6e fix(projection): refuse slug collisions, and record the first fleet pass
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 26s
The identifier refusal checked id only. slug carries its own unique constraint
across the whole table, so two repositories can derive different identifiers
whose slugs still collide — which left disaster-control raising IntegrityError.

First fleet-wide pass over 121 repositories: 91 applied (737 updated, 8
created), 16 refused covering 64 records, 12 errored. 745 workplans now carry
the commit they derived from, satisfying ADR-012 decision 2 for the first time.

64 is the measured size of the stale-row problem CUST-WP-0068-T09 has waited on.

Eleven of the twelve errors are private repositories the pod cannot clone
anonymously — a real limit on "the forge is the projection source", since their
absence currently looks like an error rather than a policy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-26 16:39:26 +02:00
5d09e8f5b0 chore(deploy): bump for the fleet reset form
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Refs STATE-WP-0083-T04

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-26 13:24:26 +02:00
532583ce17 feat(projection): add the fleet reset as a loop over the repository form
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 25s
ADR-012 decision 7 requires the fleet form to share the per-repository
implementation: the rarely-run wide operation must be the frequently-run narrow
one, or the wide one is trusted on the strength of never having been exercised.

Failure behaviour is the substance. A refusal does not stop the pass — aborting
on the first refusal means one unresolved repository blocks reconstruction
everywhere, which in practice means permanently. An error does not stop it
either. Each repository gets its own session so one failure cannot roll back
another's work, and only repositories that applied are committed.

Refs STATE-WP-0083-T04

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-26 13:23:08 +02:00
76a7c7ed24 feat(workplan): reset diff is clean — zero retirements across five repositories
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Eleven files declared types canon does not define while the hub held each as a
workplan. Eight had their type corrected; three lacking identifiers were given
one continuing their repository sequence, preserving hub identifiers.

The diff now proposes no retirements anywhere tested. Nothing in the reset's
logic changed: the retirements disappeared because the sources were repaired.
The reset had been reporting the truth about a fleet whose files could not be
read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-26 10:30:26 +02:00
7c45534115 feat(workplan): collision refusal verified live on net-kingdom
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Returns refused naming ADHOC-2026-08-23 and its holder, instead of
IntegrityError. Records that a first check against the cache returned refused
via the retirement path and was nearly reported as proof — the output looked
like success while testing nothing of the sort.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-26 10:21:43 +02:00
3f7307f665 chore(deploy): bump to main-5e4d0be for the collision refusal
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Refs STATE-WP-0083-T03

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-26 09:43:40 +02:00
5e4d0be31c fix(projection): refuse a colliding identifier instead of failing on a constraint
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 26s
The reset raised IntegrityError on net-kingdom: its ADHOC-2026-08-23 derives to
an identifier another repository already holds — the collision CUST-WP-0066
documents, where two repositories created the same daily identifier on the same
day. Derivation is deterministic, so the clash is real rather than incidental.

It now checks, before creating anything, whether a derived identifier belongs to
another repository, and refuses naming both the record and the holder. A refusal
is something the caller can rule on; a constraint violation is a stack trace.

Acknowledging retirements deliberately does not authorise a collision. Those are
different decisions — one says the work is gone, the other says take an
identifier another repository owns — and conflating them would let a routine
acknowledgement smuggle an identity change through.

Refs STATE-WP-0083-T03

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-26 09:42:14 +02:00
8404ab32f4 feat(workplan): record the post-repair reset diff and two findings
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
False retirements are gone: kontextual-engine 1 to 0, the-custodian 4 to 2.

The four records reported as hub-first with no backing file all had files, by
three different mechanisms — two with unterminated frontmatter, two declaring
type: feature. Each time 'the tool did not find it' was reported as 'it does not
exist'. Ten files in workplans/ declare a non-workplan type and need
disposition.

The reset crashes on net-kingdom with an IntegrityError where it should refuse:
ADHOC-2026-08-23 derives to an identifier another repository holds, which is the
collision CUST-WP-0066 documented. A refusal is actionable; a constraint
violation is a stack trace.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-26 08:35:59 +02:00
b47dd52c90 feat(workplan): close STATE-WP-0083-T08 — 18 files repaired, none unparseable
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Two shapes of one missing-newline defect: 16 with the closing delimiter fused
onto the last value, 2 with a value fused onto the following key. Only the
newline was inserted.

Forces a correction: state-hub-v0.1 and v0.2 were reported as hub-first records
with no backing file. They are CUST-WP-0000 and CUST-WP-0000b, which existed all
along and were unreadable — the claim came from a parser's silence rather than a
check against the directory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-26 08:29:47 +02:00
6606660632 feat(workplan): open STATE-WP-0083-T08 — 18 files have unterminated frontmatter
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Found by reviewing the reset diff before applying: it proposed retiring
kont-wp-0015, a live correctly-registered workplan. Cause is a closing --- fused
onto the last frontmatter value, so the frontmatter never terminates and the
whole body is swallowed. Any parser selecting on type: workplan sees nothing, so
the file is invisible rather than invalid.

18 files across citation-evidence, infospace-bench, kontextual-engine,
net-kingdom and others. Running the reset before repairing them would retire
live work: the derivation omits the workplan, so the reset reads its correct hub
record as no longer deriving.

Blocks the first production reset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-26 08:22:52 +02:00
fb681b58f8 feat(workplan): close STATE-WP-0083-T06 — tasks carry canonical identity
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Central: 5248 of 5974 tasks identified, 5213 distinct, 35 duplicates. The
pre-apply check was uniqueness rather than volume — all 136 pending updates were
verified to take identities held by nothing else, which is the lesson from the
first attempt where a clean dry run concealed 51 non-unique ids.

The 35 remaining duplicates predate this work: 18 from a workplan registered
twice, 12 from archived RAILIANCE-WP files left unrenamed by the active-only
migration decision, 4 from unqualified ad-hoc ids.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-26 07:49:27 +02:00
2c2d610b3b chore(deploy): bump to main-7f41d42 for qualified task identifiers
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Refs STATE-WP-0083-T06

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-26 02:19:20 +02:00
8386a0b81a feat(workplan): record the central backfill and the short-task-id defect
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
5248 rows identified but only 5100 identities distinct: bare T01 task ids stored
as canonical gave every workplan's first task one identity, 51 ids across 148
rows. The backfill's conflict check could not see it — it detects one UUID
claimed by two ids, and this was the inverse — so it surfaced only because the
two counts did not reconcile.

Central now holds 5112 of 5974 identified, 5077 distinct. The remaining 34
duplicates are unqualified ad-hoc ids reused across repositories, the problem
CUST-WP-0066 closed for new records only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-26 02:18:51 +02:00
7f41d42488 fix(backfill): qualify short task ids with their workplan
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 28s
A task id written as a bare "T01" is unique only inside its own workplan.
Stored as a canonical identifier it makes every workplan's first task share one
identity: 51 such ids were assigned to 148 rows on central before this was
caught, found because identified rows outnumbered distinct identities.

Short ids are now qualified as WORKPLAN-ID-T01. A short id in a file with no
workplan id in frontmatter is left unidentified — an identity that is not unique
is worse than none, which is the same rule the rest of this module already
follows.

The 136 affected rows on central have been cleared so the corrected backfill can
reassign them; the backfill never overwrites an existing identity, so they had to
be nulled rather than re-derived over.

Refs STATE-WP-0083-T06

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-26 02:17:46 +02:00
b1cf1a3d97 chore(deploy): bump to main-cdd5cef for the forge-sourced backfill
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Refs STATE-WP-0083-T06

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-26 02:12:49 +02:00
cdd5cef373 fix(backfill): source task identities from the forge, not a workstation
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 26s
The first implementation took local filesystem paths. Central has no workstation
checkout and must not depend on one: ADR-012 decision 1 makes the forge the
projection source, and a backfill reading someone's laptop would reintroduce the
exact coupling that ADR removes.

Surfaced concretely — central's postgres is not reachable from the workstation
(only the API tunnel, which is HTTP), so the local-path variant cannot reach the
database it needs to update, while the pod can clone the forge and already holds
the connection.

A repository that cannot be cloned contributes nothing rather than reducing what
the rest can identify.

Refs STATE-WP-0083-T06

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-26 02:11:23 +02:00
de853fc9e6 chore(deploy): bump to main-8b207a9 for task canonical identifiers
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Refs STATE-WP-0083-T06

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-26 02:08:58 +02:00
8b207a991a feat(tasks): give task rows a canonical record identifier
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 26s
Every work-record type carried a stable identifier except tasks, whose rows held
only id, workplan_id, title, status and priority — nothing connecting a row to
CUST-WP-0067-T01 in the file it came from. Matching was therefore by title, so a
renamed heading looked like one task vanishing and another appearing, and the
forge-derived reset had to refuse to touch tasks at all.

Adds tasks.record_id (nullable: no migration can invent an identity for an
existing row) and a backfill that reads the pairing from the repository files,
where a task declares both its canonical id and its projection UUID. 5516 pairs
across 121 repositories with zero conflicts; 4456 of 6073 cache task rows
identified.

Diff and reset now key on record_id where present, falling back to a
title-prefixed key so an unidentified row stays visibly unidentified.

Unknown stays unknown: a row the files do not claim keeps no identity and the
reset keeps refusing to act on it, and an existing identity is never
overwritten — a mismatch is recorded as a conflict rather than resolved.

Refs STATE-WP-0083-T06

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-26 02:05:51 +02:00
43ffe883c3 feat(projection): reconcile a repository's projection against the forge
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 26s
Implements ADR-012 decision 7 as amended (STATE-WP-0083-T03). Creates what the
forge has and the hub lacks, updates what differs, retires what no longer
derives. It never deletes: hub-native records reference workplans with ON DELETE
RESTRICT, and destroying a progress event to tidy a derived projection would
lose hub-native truth to fix a derived-state problem.

Retirement is refused by default. A record that stops deriving may mean a
deliberately deleted file or a caller pointed at the wrong branch; only the
caller can say which.

Verified against live data and rolled back: whitehat-security applied 5 updates
with no retirements; the-custodian refused, naming the four hub-first records
confirmed by hand to have no backing file.

Tasks of existing workplans are deliberately untouched — hub tasks carry no
canonical identifier, so matching is by title and a renamed heading would
destroy and recreate a record. Tasks are created only alongside a new workplan,
where nothing exists to mis-match. Tracked as T06.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-26 01:19:10 +02:00
6bb1fe823a feat(workplan): close STATE-WP-0083-T07 — central reports schema ok at head
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 1s
First execution failed in a way that proved the design: the pre-upgrade hook
applied both migrations and deleted itself on success, then the k3s tunnel
dropped before the API could roll. Schema was briefly ahead of code — the safe
direction, and why additive migrations were the right shape.

Records two gaps it exposed: a successful migration leaves no trace because of
the hook delete policy, and bridge status reported a dropped tunnel as
connected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-26 01:10:58 +02:00
e8445e99b6 chore(deploy): bump to main-97c8762 for the migration hook and schema reporting
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 2s
Refs STATE-WP-0083-T07

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-26 00:01:25 +02:00
97c8762a71 feat(deploy): run migrations as part of the release, and report schema state
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 27s
Central was serving two revisions behind the code it shipped: review_contracts
did not exist there although its migration was inside the running image. There
was no migration mechanism at all — bare uvicorn CMD, nothing chart-declared —
and nothing surfaced the mismatch. The API starts happily against a schema it
was not built for and only fails when a request touches a missing table.

Adds a chart-managed Helm pre-install/pre-upgrade hook running alembic upgrade
head, weighted to complete before the API rolls. A hook rather than an init
container: init containers run per pod, so more than one replica means
concurrent alembic upgrade with no locking. Failed jobs are deliberately
retained — a migration that fails and vanishes is how this drifted in the first
place.

/state/health now reports applied and expected revisions. "unknown" is
deliberately not "ok": an instance that cannot establish agreement must not
claim it, the same principle as instance_role defaulting to unknown.

Refs STATE-WP-0083-T07

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-26 00:00:09 +02:00
89ff2b2ea3 feat(projection): add workplan retirement columns, and record the migration gap
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 25s
Adds projection_retired_at, projection_retired_reason and derived_from_commit.
Retirement cannot be expressed by deletion — hub-native records reference
workplans with RESTRICT — nor by `status`, since an archived workplan was closed
by its owner while a retired one is simply no longer derived by the forge. Those
are different facts and must not share a field.

Discovered while applying this: central's schema is two revisions behind the
code it runs. review_contracts does not exist there although its migration ships
in the serving image, and there is no migration mechanism at all — bare uvicorn
CMD, no chart-declared job. Recorded as STATE-WP-0083-T07, which now blocks T03.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-25 23:49:13 +02:00
fd0d0d537b feat(projection): derive a repository's projection from the forge
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 23s
Implements ADR-012 decisions 1 and 2 (STATE-WP-0083 T01, T02 partial). Central
clones the default branch from Forgejo and derives its own projection: 69
workplans and 459 tasks from the-custodian at d5013ae, identical across runs,
with the commit recorded as provenance.

Identifiers are derived in the ADR-007 namespace and verified against live
records, so a forge-derived projection and a preliminary overlay agree on
identity without reconciliation.

The diff first matched hub records by UUID and was badly wrong: most hub records
carry pre-ADR-007 random identifiers, so nearly everything appeared
simultaneously missing and stale, and a reset built on it would have destroyed
and recreated the entire projection. It now matches canonical record id, falling
back to the backing file. whitehat-security — bootstrapped straight from files —
now reports clean, which is the control.

Task-level comparison is deliberately not trusted: hub tasks carry no canonical
record id, only a title, so matching is by title. Recorded as T06; T03 is
limited to workplans until it lands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-25 23:34:25 +02:00
6390b7bead feat(workplan): open STATE-WP-0083 for the forge-derived projection reset
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Implements ADR-012 decision 7. Placed in state-hub because central must do its
own reading: the pod can clone Forgejo anonymously, and driving this from Repo
Manager would push a workstation-computed projection into central, which
ADR-010 decision 5 forbids.

Also the only sanctioned removal path — the hub has no hard-delete for work
records by design.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-25 23:29:24 +02:00
custodian-sync
b02f3c22ad chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Updated by fix-consistency on 2026-08-25:
  - update .custodian-brief.md for state-hub

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-25 20:24:06 +02:00
3cb256ef5c fix(workplans): adopt ADR-007 derived identifiers for unregistered records
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
These workplans exist only in the retired local hub. Their random pre-ADR-007
identifiers are refused by C-06 as stale references, so they cannot be
registered. Deriving from the canonical record id takes no identity from
anything: central does not hold them and the old ids die with the cache.

Records central already holds were deliberately left untouched.

Refs CUST-WP-0068-T06

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-25 20:22:27 +02:00
2b2016f1df fix(workplan): point workplan at its authoritative projection id
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
The file carried an identifier minted by the retired local hub while central
held a different record for the same file — ADR-010's same-filename,
different-UUID duplicate-registration class.

Central is authoritative (ADR-010 decision 1) and decision 6 says to mitigate
by changing the repository file rather than editing central, so the file adopts
central's id. The cache-side id dies with the cache.

Refs CUST-WP-0068

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-25 17:37:52 +02:00
13ada219eb docs(workplan): record STATE-WP-0080's projection state after closing on central
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Central now reads finished, matching the file. Its task projection is still the
pre-retarget draft with four open tasks, because central never saw the
2026-08-16 retarget and none of this file's task identifiers exist there.

Left deliberately: re-registering would add eight records alongside eight stale
ones. Duplicate task identity resolution belongs to CUST-WP-0068.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-25 16:25:34 +02:00
custodian-sync
83fc8c43f5 chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 0s
Updated by fix-consistency on 2026-08-25:
  - update .custodian-brief.md for state-hub

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-25 16:24:43 +02:00
2e64fe3f71 chore(evidence): record legacy-meter weekly review for 2026-08-25
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 0s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-25 16:23:54 +02:00
c8b4a67bc3 chore(evidence): record legacy-meter weekly reviews for 2026-08-23 and 2026-08-24
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 0s
Routine generated artifacts, matching the 48 already tracked in docs/evidence/.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-25 16:23:37 +02:00
0839e308d1 fix(workplan): point STATE-WP-0080 at its authoritative projection id
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
The file carried 03f38314, minted by the retired local hub. Central holds a
different record for the same file (bbfce36a) — ADR-010's "same filename,
different UUID" duplicate-registration class.

Central is authoritative (ADR-010 decision 1), and decision 6 says to mitigate
by changing the repository file rather than editing central, so the file adopts
central's id. The cache-side id dies with the cache.

Refs CUST-WP-0068

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-25 16:23:12 +02:00
4253998cd2 chore(deploy): bump to main-5c73cd3 so the instance role actually binds
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 2s
Refs CUST-WP-0067-T03

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-25 12:56:26 +02:00
5c73cd32b3 fix(config): bind the instance-identity settings to the env vars the chart sets
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 24s
pydantic-settings derives the env var from the field name, so `instance_role`
bound INSTANCE_ROLE and silently ignored the chart's STATE_HUB_INSTANCE_ROLE.
The value reached the pod and was discarded: central reported "unknown" while
its ConfigMap said "primary".

That is the same failure this workplan closes — configuration declared but
never reaching what it configures — reintroduced while building the guard
against it. Rendering the key in `helm template` was mistaken for evidence
that it bound.

Renames to state_hub_instance_role / state_hub_instance_label, matching the
existing state_hub_report_dir precedent, so the env var the chart already sets
is the one that binds.

tests/test_instance_identity.py asserts the env var *name* binds, which is the
check that would have caught this before deploy, plus the unknown default and
rejection of invalid roles.

Refs CUST-WP-0067-T03

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-25 12:55:11 +02:00
56287708ca chore(deploy): bump to main-19fab26 so central declares its instance role
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 0s
Refs CUST-WP-0067-T03

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-25 10:37:37 +02:00
19fab26746 feat(state): let the hub declare what instance it is
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 24s
Answering on a port was the only evidence callers had that they had reached
the authoritative hub. A local cache and central both served port 8000,
separated only by IP family, and every default reached the cache for seven
weeks (ADR-010).

Adds instance_role and instance_label, surfaced on /state/health. The default
is "unknown" on purpose: an instance that has not declared itself is not the
primary. Production values declare primary/railiance01; the chart default
stays unknown because a chart can be installed anywhere.

statehub status now prints which instance answered.

Refs CUST-WP-0067-T03

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-25 10:36:37 +02:00
b28493a676 docs(agents): repoint remote State Hub URL to the in-cluster address
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
The remote row pointed at 127.0.0.1:18000, a reverse tunnel back to the
workstation. On railiance01 the State Hub runs in the cluster on that same
machine, so the request left the box and came back to reach a local service.

Refs CUST-WP-0067-T07

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-25 00:22:06 +02:00
4aeb7308e6 chore(deploy): bump to main-ac21acc for classification vocabulary
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Refs CUST-WP-0067-T09

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-24 23:48:07 +02:00
ac21accd7a feat(deploy): ship the canon classification vocabulary with the release
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 25s
The API validates repo classification against the-custodian canon allowed
values. A container has no such checkout, so every classification write failed
with a 500 and classification could only ever be written from a workstation.

Mounts the vocabulary as a ConfigMap and points
REPO_CLASSIFICATION_ALLOWED_PATH at it.

The copy is the risk, so it is owned rather than trusted:
scripts/sync_classification_allowed.py regenerates it from canon and --check
fails on drift. make check-classification-allowed and
make railiance-state-hub-render both refuse to proceed when the copy diverges,
so a release cannot silently validate against a stale vocabulary.

The container volumeMounts and env blocks are merged rather than appended —
a second pair would have produced duplicate YAML keys as soon as sweep was
re-enabled.

Refs CUST-WP-0067-T09

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-24 23:46:05 +02:00
05dda8d276 fix(classification): allow the allowed-values path to be configured
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 31s
_allowed_path() tried three developer workstation checkouts and nothing else,
so in a container none exist and every classification write fails with a 500.
That is why repo classification could only ever be written from a workstation.

Adds REPO_CLASSIFICATION_ALLOWED_PATH, checked first, and names it in the
error when no candidate is found.

Refs CUST-WP-0067-T04

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-24 23:34:34 +02:00
0fc955989a chore(deploy): enable central MCP on main-8221216
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
2026-08-24 23:17:08 +02:00
82212165f9 fix(ci): trigger image builds on every path the Dockerfile copies
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 25s
The build filtered on api/** while the Dockerfile COPYs nine more paths, so
changes to mcp_server/, flows/, policies/, prompts/, scripts/,
task_flow_engine/, templates/, alembic.ini and — most seriously —
migrations/ merged to main without ever producing a new image.

A schema migration would not have shipped.

Refs CUST-WP-0067-T08

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-24 23:00:57 +02:00
6d04544368 feat(mcp): deploy the MCP layer on central instead of tunnelling to a workstation
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
No MCP server ran on central, so remote agents reached dev-hub only through a
reverse tunnel back to the workstation — routing a request for a service on
their own machine out to another host and back.

Adds a gated mcp Deployment and ClusterIP Service running the same image with
`-m mcp_server.server`, API_BASE defaulted to the in-cluster API Service, and
tcpSocket probes. No Ingress: the MCP layer proxies an unauthenticated API and
must not be reachable from outside the cluster.

Two fixes were needed before the manifests could work:

- server.py hardcoded host="127.0.0.1". A Service routes to the pod IP, so a
  loopback bind is unreachable. Now MCP_HOST, still defaulting to loopback so
  local runs do not silently expose an unauthenticated proxy.
- The container runs `-m mcp_server.server`, not the file path, so /app lands
  on sys.path rather than /app/mcp_server.

mcp.enabled stays false in the deploy values: the running image predates
MCP_HOST, so enabling it before the tag is bumped would ship a pod that never
becomes reachable.

Refs CUST-WP-0067-T08

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-24 22:54:24 +02:00
4e68176492 fix(cli): correct task status keys in statehub status
cmd_status read tasks['in_progress'] and tasks['blocked']; the task vocabulary
is wait|todo|progress|done|cancel and the totals block never carried those two
keys, so the command always raised KeyError. Also accept either the workplans
or legacy workstreams totals key, and print the resolved API base so the
operator can see which hub instance answered.

Refs CUST-WP-0067-T01

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-24 22:10:15 +02:00
repo-manager
9bbe9e38ae chore(registrar): assign State Hub identifiers
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a02b22-9638-76d2-bbff-b7ea1770b118
2026-08-23 14:49:49 +02:00
a05a64bf0e fix(workplans): qualify State Hub daily identifier
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a02b22-9638-76d2-bbff-b7ea1770b118
2026-08-23 14:48:16 +02:00
284e28112d fix(project-rules): qualify ad-hoc record identifiers
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a02b22-9638-76d2-bbff-b7ea1770b118
2026-08-23 14:45:26 +02:00
fae2816199 fix(consistency): preserve repository domain in briefs
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a02b22-9638-76d2-bbff-b7ea1770b118
2026-08-23 13:57:22 +02:00
custodian-sync
b91b8705d1 chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Updated by fix-consistency on 2026-08-23:
  - update .custodian-brief.md for state-hub

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a02b22-9638-76d2-bbff-b7ea1770b118
2026-08-23 13:56:53 +02:00
57c3e08103 fix(classification): harden registration updates
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 26s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a02b22-9638-76d2-bbff-b7ea1770b118
2026-08-23 11:30:36 +02:00
custodian-sync
fb79d0d68d chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Updated by fix-consistency on 2026-08-23:
  - update .custodian-brief.md for state-hub

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a02b22-9638-76d2-bbff-b7ea1770b118
2026-08-23 11:29:52 +02:00
b0e1af24f9 fix(retirement): close projection and launch contract gaps
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 23s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a02b7c-1c49-76a0-955a-49e7b3ddfc0d
2026-08-23 00:52:18 +02:00
custodian-sync
c43266f626 chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Updated by fix-consistency on 2026-08-23:
  - update .custodian-brief.md for state-hub

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a02b7c-1c49-76a0-955a-49e7b3ddfc0d
2026-08-23 00:47:18 +02:00
2d114be84d docs(review): record final full-suite pass
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
2026-08-22 21:08:20 +02:00
c62e511de8 chore(index): refresh work record status
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
2026-08-22 20:59:51 +02:00
custodian-sync
0007ca232b chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Updated by fix-consistency on 2026-08-22:
  - update .custodian-brief.md for state-hub

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
2026-08-22 20:59:33 +02:00
598f6418e7 feat(review): add multi-owner contracts and receipts
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 25s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
2026-08-22 20:57:51 +02:00
52aefe39e4 ops: record promoted State Hub image
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a028f0-a42f-7582-89a8-ebaad7343834
2026-08-22 20:14:32 +02:00
76e6eda086 feat: project Nexus SBOM state into summaries
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 25s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a028f0-a42f-7582-89a8-ebaad7343834
2026-08-22 20:12:16 +02:00
e1e259cf87 ops: switch State Hub SBOM writes to Nexus
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a028f0-a42f-7582-89a8-ebaad7343834
2026-08-22 19:57:08 +02:00
b75234a533 feat: route SBOM writes to Nexus behind flag
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 24s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a028f0-a42f-7582-89a8-ebaad7343834
2026-08-22 19:55:08 +02:00
d01ae3971d ops: switch State Hub SBOM reads to Nexus
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a028f0-a42f-7582-89a8-ebaad7343834
2026-08-22 19:21:39 +02:00
98518091ae fix: preserve legacy SBOM source values
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 36s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a028f0-a42f-7582-89a8-ebaad7343834
2026-08-22 19:08:10 +02:00
1e5222d458 ops: preserve State Hub rolling headroom
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a028f0-a42f-7582-89a8-ebaad7343834
2026-08-22 19:04:38 +02:00
5fc4c56215 feat: add reversible SBOM Nexus read facade
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 25s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a028f0-a42f-7582-89a8-ebaad7343834
2026-08-22 18:39:21 +02:00
repo-manager
9e7e91300f chore(registrar): assign State Hub identifiers
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
2026-08-22 18:16:06 +02:00
custodian-sync
ccff2385b8 chore(consistency): sync task status from DB [auto]
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Has been cancelled
Updated by fix-consistency on 2026-08-22:
  - update .custodian-brief.md for state-hub

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
2026-08-22 18:16:05 +02:00
ceab9ce279 docs(review): add multi-owner contract workplan
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
2026-08-22 18:02:36 +02:00
70a708ff11 fix(consistency): preserve checker call compatibility
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
2026-08-22 12:41:46 +02:00
e2544b218d docs(retirement): record empty projection rebuild proof
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
2026-08-22 12:31:41 +02:00
059de9358e feat(consistency): rebuild authoritative intake IDs
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 23s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
2026-08-22 12:23:43 +02:00
03c7924b7f feat(consistency): bootstrap empty repo projections
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
2026-08-22 12:07:08 +02:00
52a7d6bb73 docs(pilot): record registrar projection cutover
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
2026-08-22 09:57:59 +02:00
custodian-sync
6b439ddd06 chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Updated by fix-consistency on 2026-08-22:
  - update .custodian-brief.md for state-hub

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
2026-08-22 09:44:54 +02:00
697dd49390 feat(consistency): rebuild deterministic projection IDs
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 26s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
2026-08-22 09:43:55 +02:00
cb1b028fd1 feat(identifiers): add reversible projection migration
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 24s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
2026-08-22 00:27:48 +02:00
ce52e9d1e2 docs(retirement): own identifier projection migration
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
2026-08-21 23:59:43 +02:00
custodian-sync
7e82dd4594 chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Updated by fix-consistency on 2026-08-21:
  - update .custodian-brief.md for state-hub

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
2026-08-21 23:59:29 +02:00
81861d816b feat(retirement): route SBOM scans through repo-manager
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 25s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
2026-08-21 23:15:34 +02:00
b9d9ffed5f feat(consistency): consume repo-manager conformance
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
2026-08-21 22:34:24 +02:00
repo-manager
ec780c05a2 chore(registrar): assign State Hub identifiers
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 1s
2026-08-21 21:53:39 +02:00
31fe49fa84 chore(consistency): refresh work record index
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 1s
2026-08-21 21:52:31 +02:00
custodian-sync
853a2259bb chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 2s
Updated by fix-consistency on 2026-08-21:
  - update .custodian-brief.md for state-hub
2026-08-21 21:52:09 +02:00
3f1f03076f chore(deps): refresh hub-core lock metadata
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 37s
2026-08-21 21:50:44 +02:00
a311ac3d06 docs(evidence): refresh weekly legacy meter review 2026-08-21 21:50:44 +02:00
433e104909 fix: route registrar skips through repo-manager
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
2026-08-21 21:41:15 +02:00
30b17adde1 docs: route registrar skips through repo-manager
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
2026-08-21 21:38:29 +02:00
de58a0cf90 feat(STATE-WP-0079): add repo-manager receiving adapters
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 23s
2026-08-21 17:15:28 +02:00
87f1296714 docs: STATE-WP-0081 cluster self-sufficiency and registrar fix
The registrar is broken by workstation coupling rather than beside it. Inside
the pod /home/tegwick mounts read-only despite the chart setting no readOnly
and securityContext being empty, so the sweep dies before it can mint. That
is why 12 sync requests queued and the newest custodian-sync commits are July.

Eight tasks: restore the write path, give the pod its own clone volume and
service identity, run non-root, correct 72 repo records still pointing at
/home/worsch with gitea remote_urls, serve the dashboard from the cluster,
write the boundary rule down, and close out the registrar.

End state: workstation coding agents push to forgejo, cluster infrastructure
reads from forgejo, neither reads the other's disk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 16:13:38 +02:00
2841bf371c ops: disable stale-lineage production sweep
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
2026-08-21 13:14:11 +02:00
9dc1affa21 docs(dashboard): use canonical work-record ids in the ralph-workplan example
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
The example taught 'id: WP-0001' / 'id: T-01'. The ralph-workplan spec
accepts that shape for standalone plans, but this doc ships in the State Hub
dashboard, where the hub-integrated PREFIX-WP-NNNN-TNN form applies -- the
spec's own state-hub example uses it.

Switching the example to EXAMPLE-WP-0001 / -T01 / -T02 teaches the right
shape for this audience and clears the two C-31 warnings, which fired
because the ids matched no kind in the canon work-record type registry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 08:26:36 +02:00
90824ca249 docs: scheduled legacy-meter captures for 2026-08-20/21
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Automated activity-core captures. Both run with days=7 windows, confirming
the capture-script fix landed in the scheduled path and not just locally.
Zero retirement candidates in each: the 15 evidenced interfaces are retired
and the 4 remaining are held by the volume-scaled quiet ladder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 08:15:51 +02:00
190bb41b89 docs(STATE-WP-0079): registrar bottleneck status check
The registrar is reachable and always was: state-hub-primary binds [::1]:8000
while the workstation API listens on 127.0.0.1:8000, so an IPv4 probe hits
the local hub and reads as a dead tunnel.

But the two databases have diverged by roughly a third -- 987 vs 649
workplans, 5710 vs 4037 tasks -- and the registrar runs pre-rename code. Nine
registrar sync requests from three agents have queued since 2026-08-20.

Re-checked the chain: RMGR-WP-0004-T08 is done, so RMGR-WP-0005-T03's stated
blocker has closed. Its substantive blocker has not -- ADR-007 Migration
option 2 permits historical identifier collisions, and UUIDv5 derivation from
a non-unique identifier manufactures collisions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 00:20:44 +02:00
7adfe1369f docs(STATE-WP-0079): A2a executed, dual-run live for repo-manager pilot
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
First live cutover slice. Installed the shipped pilot config (writeback and
reconcile on, writeback_push off, scoped to repo-manager), reloaded the API
via its own --reload watcher rather than restarting the operator's process,
and confirmed reconcile routed through rmgr with a new mutation meter entry
(8 workplans, 53 tasks, 61 records).

Divergence check clean: the one-workplan delta against the hub is
RMGR-WP-0008, unregistered because this instance is not the identifier
registrar, not a dual-run divergence.

Slice sits at dual, not owner -- the hub stays authoritative until a full
sweep cycle shows zero divergence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 09:10:55 +02:00
67c56e225c docs(STATE-WP-0079): record established dual-run pilot repo and procedure
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
The pilot repo is not an open question: RMGR-WP-0003 established repo-manager
and shipped config/dual-run.pilot.example.yaml. Records the exact enable and
rollback commands so A2a's off->dual step is a single operator action.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 09:02:57 +02:00
a824fd5dfe docs(STATE-WP-0079): A2 readiness detail and dual-run state
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 2s
Applied the A3 readiness check one level down. The State Hub adapter exposes
exactly three operations: rm_update_task_status, rm_reconcile, rm_scaffold.
No adapter for workplan CRUD, intake, decision, dependency or human-flag
records, and no repo-manager parser for them either.

A2 splits into A2a (task status + reconcile, ~6 items, receivable) and
A2b-A2e (~32 items, not receivable). A2a is the only slice in the plan
executable today -- roughly 6 of 425 inventory items.

Also records that dual-run is currently off entirely: no config file,
writeback and reconcile false, no pilot repos. The 23 metered mutations are
RMGR-WP-0002/0003 pilot residue. A2a's next step is operational, not code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 08:50:01 +02:00
5f747fbc1e docs(STATE-WP-0079): correct repo-manager owner readiness, withdraw A3 recommendation
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 2s
The T02 slice plan marked all 167 repo-manager items receivable because
RMGR-WP-0001/0002/0003 are finished. That reads readiness off workplan
status, which is too coarse.

Verified against repo-manager/src/repo_manager/: those workplans delivered
work-record dual-run, repository observation, and scaffolding. parse/ holds
only workplan.py, and the source tree contains no reference to sbom,
repo_goal, contribution, technical_debt, or extension_point.

Only A1 (partial) and A2 are receivable -- 86 items, not 165. A3, A4 and A5
have no owner-side surface. A3 was recommended twice as the low-blast-radius
first cut; it is in fact the least ready slice in Wave A. Recommendation
withdrawn.

Critical path moves to repo-manager: a workplan there must build the
register, UI, and topic surfaces. Building them here is inadmissible under
policies/retirement-freeze.md. First executable slice is A2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 08:46:04 +02:00
fb363b37d3 feat(STATE-WP-0079): retire the suggestion-backlog surface (slice E1)
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 37s
History is archived fleet-side, so the read surface has no remaining job.

- api/routers/suggestions.py: whole prefix 410s with a pointer to intake and
  to the archive; the router drops from 176 lines to a stub
- mcp_server: the 6 suggestion tools removed outright rather than stubbed --
  a retired tool that still appears in the tool list costs every agent
  session context on every call, which is the opposite of retiring it
- write_idempotency: 5 /suggestions rules dropped
- dashboard: suggestions.md deleted, nav entry removed, reference.md and
  wsjf-triage.md updated; docs/suggestions.md rewritten as archive pointer
- tests: two tests pinned the old read-live behaviour and now pin the
  retirement contract instead

Tables stay: they are retire/archive in SHR-INV-0001 and are captured by the
final dump at T06.

Untouched, and worth knowing during cutover: ui-feedback.md / todo.md
'suggestions' are Shift+click dashboard feedback backed by technical_debt, a
different entity that shares the word. E3 (dashboard-meta) is that page; its
owner is state-hub-until-cutover so it retires at the T06 window, not now.

Full suite 612 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 08:11:14 +02:00
98af485cd5 docs(STATE-WP-0079): archive suggestion-backlog history, record E1 status
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Slice E1 was already half-retired: mutations 410 since CUST-WP-0061-T06,
reads held open only to keep the history reachable. That history is now
archived fleet-side, so the reads can go.

Records a dependency the inventory's 17-item count misses: the suggestions
dashboard page is live and referenced from index.md and wsjf-triage.md, so
E1's removal must sequence with the UI slices A4/B5 rather than ahead of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 07:42:00 +02:00
ecc10b1f9f docs(STATE-WP-0079): execute slice E2, retire 15 legacy interfaces
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Retires the 15 workstream->workplan aliases that meet the retirement
standard: verified replacement, no manual hold, zero calls in a 7-day
window, and silence past the volume-scaled quiet threshold. Meter now reads
16 retired / 4 legacy with 0 outstanding candidates.

The 4 held are the three six-figure read paths and GET /tasks/?workstream_id
which still has traffic -- the set where a premature retirement would do the
most damage. They clear as their quiet counters run.

Journalled fleet-side in the-custodian/docs/retired-legacy-interfaces.md so
the record outlives this repo's archive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 07:28:54 +02:00
custodian-sync
133276daaf chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Updated by fix-consistency on 2026-08-20:
  - update .custodian-brief.md for state-hub
2026-08-20 07:21:48 +02:00
64b71320e7 docs(STATE-WP-0079): retirement freeze policy, close T01
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
States the four admissible reasons for a change to state-hub during
retirement -- preserve compatibility, enable extraction, reduce scope, fix
operational risk -- and the inadmissible case: establishing new permanent
ownership in the component being retired.

The operative test is 'where does this live after cutover?', not 'is this a
good change?' Most inadmissible changes are good changes; STATE-WP-0080's
retarget to RMGR-WP-0004 is the worked example.

Enforced by review rather than new automation: a freeze-checker built inside
State Hub would itself be new permanent automation here, so the policy would
violate itself. New tables/routes/MCP tools are the one hard stop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 07:20:56 +02:00
93e95f93e7 feat(legacy-meter): scale retirement quiet period to call volume
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 28s
A clean review window only proves an interface was silent for that window.
For a heavily used interface that is weak evidence -- an infrequent caller
can fall outside the window entirely, so the interface reads as retirable
while still having consumers.

_retirement_state now also requires silence since last_seen_at, scaled by
all-time volume (RETIREMENT_QUIET_LADDER): 7d under 100 calls, 30d under
10k, 60d above. Existing guards (retired / manual hold / replacement ref /
replacement verified / in-window traffic) still take precedence.

On the 2026-08-20 capture this makes 15 of 19 legacy interfaces retirable
and holds 4: the three six-figure /workstreams/ read paths, and
GET /tasks/?workstream_id which still has live traffic.

Unblocks the bulk of Wave E2 in the cutover slice plan. 7 new tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 07:13:44 +02:00
be7f2632c3 fix(legacy-meter): capture evidence over 7 days, not an 8-hour band
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
capture_legacy_meter_evidence.py fell back to hours=8 whenever --days was
omitted, and --hours itself defaulted to 8. Every unattended capture sampled
06:00Z-14:00Z while writing a file named weekly-review with cadence: weekly.
39 of 40 captures ran this way; only 2026-07-08 used a true 7-day window.

Calls outside the band were never sampled, so interfaces with live callers
reported as retirement candidates -- GET /tasks/?workstream_id was flagged on
2026-08-19 despite traffic on 2026-08-18.

Default the script to days=7; keep --hours for spot checks, documented as not
retirement evidence. Adds corrected capture for 2026-08-20 and records the
residual gap (candidate rule ignores last_seen_at) against STATE-WP-0079-T05.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 01:12:37 +02:00
custodian-sync
5fe38fa9d8 chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Updated by fix-consistency on 2026-08-19:
  - update .custodian-brief.md for state-hub
2026-08-19 23:45:28 +02:00
613477b4d2 docs(STATE-WP-0079): cutover slice plan, close T02
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Assign all 425 SHR-INV-0001 items to 19 slices across five waves plus Keep,
each with adapter flag, owner API, and rollback. Coverage computed against
the inventory rather than hand-counted.

Only Wave A (repo-manager, 165 items) is executable; B/D1/D2 are gated on
owner-side contracts still at proposed. Flags two findings for T04/T05: the
9 ops_dependency items cannot be strangled behind a flag, and the
legacy-meter weekly review window is 8h, not a week.

Also corrects the workplan status from proposed to active.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 23:44:27 +02:00
custodian-sync
7b2b9d03d9 chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Updated by fix-consistency on 2026-08-19:
  - update .custodian-brief.md for state-hub
2026-08-19 23:25:52 +02:00
1655b34fcd docs: close STATE-WP-0080, move templating removal to STATE-WP-0079-T04
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
T01-T03 are done and T05-T08 were already handed to RMGR-WP-0004. T04's
gates (RMGR-WP-0004-T06, STATE-WP-0079-T04) remain open, and the task
always specified that removal land inside the 0079 strangler rather than
as an isolated breaking change. Cancel it here with a pointer, record the
inherited residual in 0079-T04, and mark the workplan finished.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 23:24:07 +02:00
fb45fdae46 docs: refresh August 19 legacy-meter evidence
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
2026-08-19 19:13:25 +02:00
05dba43cae docs: audit STATE-WP-0080 removal readiness 2026-08-19 19:13:25 +02:00
custodian-sync
908deddc9b chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Updated by fix-consistency on 2026-08-19:
  - update .custodian-brief.md for state-hub
2026-08-19 14:18:39 +00:00
custodian-sync
69c54456f6 chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Updated by fix-consistency on 2026-08-19:
  - update .custodian-brief.md for state-hub
2026-08-19 16:16:15 +02:00
523751199a docs: record August legacy-meter reviews
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
2026-08-19 15:04:32 +02:00
custodian-sync
dac8ffe0af chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Updated by fix-consistency on 2026-08-18:
  - update .custodian-brief.md for state-hub
2026-08-18 23:18:35 +00:00
custodian-sync
3e9ee0d94b chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Updated by fix-consistency on 2026-08-19:
  - update .custodian-brief.md for state-hub
2026-08-19 01:13:03 +02:00
a301de0c02 Point State Hub arc42 chapter 9 at published CUST-ADR ids
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
2026-08-19 01:09:54 +02:00
custodian-sync
30ea4cf791 chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Updated by fix-consistency on 2026-08-18:
  - update .custodian-brief.md for state-hub
2026-08-18 20:48:29 +00:00
custodian-sync
b606d5d44c chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Updated by fix-consistency on 2026-08-18:
  - update .custodian-brief.md for state-hub
2026-08-18 22:42:19 +02:00
285dcc1c8f Add first-wave State Hub arc42 stub
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Records the read-model and retirement stance. Chapter 9 points at the
estate ADRs that still bind this service.
2026-08-18 22:40:29 +02:00
custodian-sync
8bb75b001b chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Updated by fix-consistency on 2026-08-18:
  - update .custodian-brief.md for state-hub
2026-08-18 20:03:58 +00:00
391647ca8b chore(consistency): refresh work-record index
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
STATE-WP-0080-T02 and T03 are done; regenerate the generated index.
2026-08-18 21:54:39 +02:00
custodian-sync
31525650e6 chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Updated by fix-consistency on 2026-08-18:
  - update .custodian-brief.md for state-hub
2026-08-18 21:53:10 +02:00
d8e0eddb22 feat: delegate project register; registrar-only ID minting
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 39s
STATE-WP-0080-T02: statehub register routes project-flavor scaffolding
through rmgr scaffold and keeps only repo + host-path registration.
T01 refuse remains when GOAL.md is missing and --wp-prefix is not set.

RMGR-WP-0005-T01: C-06/C-11/C-32 skip mint+writeback unless this
instance is the identifier registrar (STATEHUB_REGISTRAR or railiance
hostname).
2026-08-18 21:51:30 +02:00
custodian-sync
71ad6c5d17 chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Updated by fix-consistency on 2026-08-18:
  - update .custodian-brief.md for state-hub
2026-08-18 19:48:31 +00:00
custodian-sync
b320655640 chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Updated by fix-consistency on 2026-08-18:
  - update .custodian-brief.md for state-hub
2026-08-18 21:38:55 +02:00
f564e99a14 fix: refuse to scaffold prj- repos from statehub register
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
STATE-WP-0080-T01: detect project flavor from classification, GOAL.md,
or a prj- slug, then exit pointing at rmgr scaffold. Durable-repo
write path is unchanged. Rebind 0080 hub IDs to the live workstream
and open T02 now that RMGR-WP-0004-T03 has landed.
2026-08-18 21:37:09 +02:00
custodian-sync
131b824dd3 chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Updated by fix-consistency on 2026-08-18:
  - update .custodian-brief.md for state-hub
2026-08-18 16:18:28 +00:00
custodian-sync
0b7819ed0b chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Updated by fix-consistency on 2026-08-18:
  - update .custodian-brief.md for state-hub
2026-08-18 18:06:19 +02:00
custodian-sync
8e72f736bb chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Updated by fix-consistency on 2026-08-18:
  - update .custodian-brief.md for state-hub
2026-08-18 16:04:09 +00:00
custodian-sync
03af9085be chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Updated by fix-consistency on 2026-08-18:
  - update .custodian-brief.md for state-hub
2026-08-18 18:00:03 +02:00
6d038fd050 fix: stop C-23 promoting proposed and C-15 overwriting files
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
ADR-007 / RMGR-WP-0005-T06: proposed stays a human gate. Task status
follows the file (ADR-001); wait is not "ahead" of progress or todo.
2026-08-18 13:37:26 +02:00
custodian-sync
aa45c9b6b0 chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Updated by fix-consistency on 2026-08-16:
  - update .custodian-brief.md for state-hub
2026-08-16 16:33:37 +00:00
d327b2eaea Merge origin/main: reconcile STATE-WP-0080 hub IDs and retarget
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
2026-08-16 18:27:54 +02:00
c40eb08e8b Adopt railiance01 hub IDs in STATE-WP-0080
The workstation hub (127.0.0.1:8000) and the railiance01 primary each
registered this workplan under different UUIDs. Adopting the primary's IDs
so the shared file matches production.
2026-08-16 18:27:36 +02:00
30a4133809 docs(workplans): retarget STATE-WP-0080 to handoff after decision 747011c6
Repository standards conformance and governed scaffolding move to
repo-manager (RMGR-WP-0004). Hubs are registers; they do not scaffold or
rewrite repo files.

T01-T04 become guard, delegate, correct the inventory disposition, and
remove the templating path. T05-T08 are cancelled with pointers to their
RMGR-WP-0004 successors, retained so the handoff stays legible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 18:24:56 +02:00
custodian-sync
1f15ab8a90 chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Updated by fix-consistency on 2026-08-16:
  - update .custodian-brief.md for state-hub
2026-08-16 01:18:23 +00:00
custodian-sync
ff909e104e chore(consistency): renormalize lifecycle state [auto]
Updated by fix-consistency on 2026-08-16:
  - workplan status: proposed → active
2026-08-16 01:18:21 +00:00
custodian-sync
dddca9c6e2 chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Updated by fix-consistency on 2026-08-16:
  - update .custodian-brief.md for state-hub
2026-08-16 01:03:45 +00:00
custodian-sync
73072e5058 chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Updated by fix-consistency on 2026-08-16:
  - update .custodian-brief.md for state-hub
2026-08-16 02:54:22 +02:00
ddd6a66730 docs(workplans): STATE-WP-0080 register project-flavor awareness (proposed)
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 58s
Captures the SHR-WP-0001-T01 residual: statehub register scaffolds
INTENT.md and a flavor-derived workplan prefix for prj- repos, both of
which the project-repository-flavor standard forbids.

Root cause: statehub_register.py has no reference to
.repo-classification.yaml, category, or repo_flavor anywhere, so it cannot
distinguish a project repo from a durable product repo.

Hit twice now (prj-state-hub-retirement, prj-canon-federation). T08 carries
the fix forward to repo-manager so it is not stranded in a component
scheduled for retirement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:53:13 +02:00
71c94c940a feat: dual-run config file, bulk writeback, writeback_push
RMGR-WP-0003: load ~/.repo-manager/dual-run.yaml; bulk-status-sync uses
RM writeback; optional push after writeback when configured.
2026-08-16 02:53:13 +02:00
cb6eddf92b chore(consistency): refresh WORK-RECORDS after dual-run 2026-08-16 02:53:13 +02:00
custodian-sync
e5a083f444 chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 23s
Updated by fix-consistency on 2026-08-15:
  - update .custodian-brief.md for state-hub
2026-08-15 22:34:37 +00:00
bfaff50b4e feat: dual-run facade to repo-manager for task writeback
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Failing after 57s
RMGR-WP-0002 Stage B: when RM_WRITEBACK/RM_RECONCILE flags are set, PATCH
/tasks and C-15 writeback call rmgr; fix_repo runs rmgr reconcile for pilots.
2026-08-09 23:19:56 +02:00
462f4a47d2 docs(STATE-WP-0079): propose retirement strangler and disposition
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Parent prj-state-hub-retirement SHR-WP-0001 stream S4.
2026-08-09 21:22:51 +02:00
5927591be8 Prepare State Hub retirement baseline
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 1m0s
2026-08-09 16:19:53 +02:00
custodian-sync
2217bdd9f5 chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Updated by fix-consistency on 2026-08-08:
  - update .custodian-brief.md for state-hub
2026-08-08 23:51:33 +02:00
477ad73149 Align State Hub migration with ThreePhoenix
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
2026-08-08 22:44:10 +02:00
f5f2fe98da chore(consistency): sync workplan hub IDs [auto follow-up]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
2026-08-03 19:16:53 +02:00
custodian-sync
a8875868a1 chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Updated by fix-consistency on 2026-08-03:
  - update .custodian-brief.md for state-hub
2026-08-03 18:57:18 +02:00
custodian-sync
8e6bfc56a3 chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Updated by fix-consistency on 2026-08-03:
  - update .custodian-brief.md for state-hub
2026-08-03 18:56:22 +02:00
72c8f80b1f Add STATE-WP-0078 for ops_run read projection on the hub.
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
State Hub remains non-claim authority; surface open/failed ops runs for fleet
visibility after ACTIVITY-WP-0026.
2026-08-03 18:51:40 +02:00
c113605fc0 Adopt Target Revenue Source License V1C1 (org-wide preliminary rollout)
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Maintainer decision, 2026-07-29: adopts TRSL V1C1 as this repo's
preliminary governing license, per target-revenue's
workplans/TREV-WP-0008-governance-and-pilot-rollout.md T05. Full
specialist legal review is deferred until out of beta (target-revenue
SCOPE.md §1). No Phase is yet declared for this repo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 23:48:47 +02:00
custodian-sync
91d157821b chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Updated by fix-consistency on 2026-07-22:
  - update .custodian-brief.md for state-hub
2026-07-22 19:20:04 +00:00
fe4cfe22c9 feat: DoX assessment recording and soft visibility (STATE-WP-0077)
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 2s
Add quality_doc/dor/dod recording convention, quality-debt CLI, promote-intake
and C-34 soft warnings, agent protocol notes, and DoD policy badge language.
Mark STATE-WP-0077 finished.
2026-07-22 21:18:40 +02:00
custodian-sync
d356fde41c chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Updated by fix-consistency on 2026-07-22:
  - update .custodian-brief.md for state-hub
2026-07-22 21:18:12 +02:00
custodian-sync
42a2a702db chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 2s
Updated by fix-consistency on 2026-07-22:
  - update .custodian-brief.md for state-hub
2026-07-22 18:22:51 +00:00
f4ec935c42 plan: add STATE-WP-0077 DoX assessment recording and soft visibility
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Register follow-on to STATE-WP-0076: record DoX assessments, surface quality
debt, soft agent protocol, and light DoD badge wording. Hub status ready.
2026-07-22 20:10:24 +02:00
custodian-sync
e6ce49212a chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Updated by fix-consistency on 2026-07-22:
  - update .custodian-brief.md for state-hub
2026-07-22 20:10:04 +02:00
3008d9e2f0 feat: ship DoC and DoR quality policies (STATE-WP-0076)
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Add Intake Definition of Comprehension and work-item Definition of Ready as
hub policies with dashboard edit surfaces, document the Definition family and
DoX-Ok/Failed badge convention, and mark STATE-WP-0076 finished.
2026-07-22 19:59:37 +02:00
custodian-sync
5a392385bc chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Updated by fix-consistency on 2026-07-22:
  - update .custodian-brief.md for state-hub
2026-07-22 19:59:25 +02:00
d8a2bb5c6b docs: work-record consolidation references and STATE-WP-0076 ready
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Align dashboard reference pages with intake-as-discovery and suggestions as
legacy, frame work records as unit vs structure artefacts, and add the ready
STATE-WP-0076 plan for DoC/DoR quality policies. Include generated work-record
index and recent legacy-meter evidence captures.
2026-07-22 19:56:27 +02:00
custodian-sync
21d576c96f chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 3s
Updated by fix-consistency on 2026-07-22:
  - update .custodian-brief.md for state-hub
2026-07-22 19:50:24 +02:00
custodian-sync
96d036ce77 chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 2s
Updated by fix-consistency on 2026-07-22:
  - update .custodian-brief.md for state-hub
2026-07-22 16:34:44 +00:00
custodian-sync
b7e2dbb67d chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Updated by fix-consistency on 2026-07-22:
  - update .custodian-brief.md for state-hub
2026-07-22 18:17:28 +02:00
f8bd74e27e docs: residual handoff in hub docs, AGENTS, and templates
Some checks failed
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Has been cancelled
Align agent close/workplan finish with fleet residual convention
(live work records, origin residual + origin_ref). Reference pages for
work-records and intakes; session and workplan templates updated.
2026-07-22 18:15:45 +02:00
b5747d1104 feat(dashboard): text filter for Overview workplans chart
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Add a search field next to the Workplans by Repository mode selector so
operators can narrow large lists by title, repo, domain, filename, or owner.
2026-07-22 01:22:22 +02:00
c363549388 fix(dashboard): restore shift-click suggestion submit
Default domain was retired slug "custodian", so POST /technical-debt/
returned 422 and the modal failed after open. Use infotech, resolve
apiBase like config.js, and surface API error detail in the toast.
2026-07-22 01:22:22 +02:00
224584d379 Include templates/ in image for RecentlyOnScope digests
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 40s
Fixes missing domain-digest.md at /app/templates on the railiance01
edge-relay image (activity-core ACTIVITY-WP-0021-T04).
2026-07-21 04:21:55 +02:00
6c4fc64ef3 CUST-WP-0061-T06: retire suggestions to read-only legacy
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 19s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 1m4s
Founder-reviewed decision (WorkOrchestrationArchitectureDraft.md v0.2
section 8 item 6): the fresh intake work-record entity replaces
suggestions, not a rename-bridge. All 5 mutation endpoints (create, vet,
decline, promote, bump-relevance) now 410 with a pointer to POST
/intakes/ and the migration doc; GET/list stay live for the historical
record (10 rows migrated to file-backed intake records in the-custodian,
see that repo's intake-legacy-suggestions-migration.md and CUST-IN-0001
through CUST-IN-0010).

Removed dead code the retirement makes unreachable: Task/TaskPriority/
TaskStatus/normalize_task_status imports (only used by the deleted
promote body), the suggestion_relevance.bump_relevance import, and the
_ALLOWED_*_FROM stage-guard sets + _reject_stage helper (only used by
the deleted vet/decline/promote bodies). WSJF ranking (compute_wsjf,
cost_of_delay, suggestion_sort_key) stays -- still exercised by the
surviving GET /suggestions/?rank=wsjf read path.

MCP tool docstrings (create_suggestion, vet_suggestion,
decline_suggestion, promote_suggestion_to_task,
bump_suggestion_relevance) updated to point at the replacement
(create_intake/route_intake/close_intake) rather than silently 410ing
with no guidance.

tests/test_suggestions.py rewritten: verifies all 5 mutations 410,
GET/list still work for historical rows (seeded directly via the DB
session since creation is retired -- there's no other way to get
historical data into the table anymore), 404 still behaves normally on
unknown ids. Live-verified against the running dev API: POST 410s,
GET with include_terminal=true still returns all 10 migrated-and-declined
historical rows. No regressions: full repo suite green (563 tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 01:51:59 +02:00
b564ac7046 C-33: generated per-repo work-record index (CUST-WP-0061-T04)
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 4s
First cut of the transclusion-style index named in the stage-3 seed: a
WORK-RECORDS.md at repo root listing kind/id/status/lane/source for
every workplan, task, intake, decision, and engagement found in the
repo -- derived purely from files (no hub query), matching the ADR-001
rebuild principle. Full markitect transclusion rendering is a
follow-on, not this first cut.

- _generate_work_record_index(): reuses iter_workplan_files/
  parse_frontmatter/get_tasks_from_workplan for workplan+task rows,
  and the repo-wide yaml-block scan already proven by C-31/C-32 for
  intake/decision/engagement rows. Archived workplans excluded (index
  is for current orientation, not history); closed decisions/intakes/
  engagements stay listed. Returns None (no file written) for repos
  with zero work records, to avoid clutter.
- _check_work_record_index_freshness(): C-33, WARN+fixable when
  WORK-RECORDS.md is missing or its content differs from a fresh
  regeneration.
- fix_repo C-33 dispatch: overwrites the file with the regenerated
  content.

13 new tests (generation across all kinds, sort order, archived
exclusion, self-scan exclusion, freshness detection incl. idempotence).
No regressions: full repo suite green (561 tests).

Live-verified at two scales: binky-control (5 workplans, 31 tasks, 3
intake, 4 decisions, 5 engagements) and the-custodian (52 workplans,
332 tasks) -- both generated correctly on first run, confirmed
idempotent (identical second run produces no C-33 fix), and the larger
repo's scan added no material overhead to the existing ~1.5min
fix-consistency run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 01:25:26 +02:00
3dbbc753bc CUST-WP-0061-T03: the promotion transition (statehub promote-intake)
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 3s
The mechanism named in canon/standards/work-record-types_v0.1.md:
"Promotion is a first-class transition... manual transcription of an
intake item into other kinds is a process defect." This is what AWQ-010
needed and didn't have -- a human/agent had to notice, transcribe, and
re-register it by hand. One call now does what that manual pass did.

scripts/promote_intake.py: intake.routed -> workplan | task | decision |
engagement.
  - workplan: new ADR-001 file at workplans/{ID}-{slug}.md, registered
    against the hub (repo+topic resolution, POST /workplans, frontmatter
    id write-back)
  - task: appended as a ```task``` block to an existing --workplan-file,
    registered via POST /tasks, reuses the existing
    _inject_task_id_into_block writeback helper
  - decision: appended as a ```yaml``` block with a fresh
    {PREFIX}-DEC-{YYYY}-{NNN} id to --target-file, registered the same
    way C-32 registers decisions (reuses _inject_yaml_block_field)
  - engagement: appended as a ```yaml``` block with a fresh
    {PREFIX}-ENG-{YYYY}-{NNN} id -- file-only, no hub entity exists yet
    (same honest deferral as C-32), reported not silently skipped

In every case the intake is closed with outcome=promoted and
promoted_to=<new canonical id>; the new record carries an
origin: "intake:<id>" back-link.

Wired as `statehub promote-intake <intake-id> --to <kind> --repo-slug
<slug> --repo-path <path> --domain <domain> [--target-file ...]
[--workplan-file ...]`, matching the CLI shape named in the workplan text.

17 tests: pure helpers (_slugify, _next_number, _append_yaml_block,
frontmatter injection) offline; full promote_intake() flow with the hub
API mocked.

Live-verified against the real running API/DB and a real repo
(binky-control), not just mocks -- and the live proof caught a real bug:
the first workplan-promotion run silently produced a false success (the
intake was closed outcome=promoted, but /workplans/ actually 422'd on a
missing repo_id that the code never resolved, so no workstream was ever
created). Fixed to resolve repo_id via /repos/{slug} and to raise loudly
on registration failure instead of writing a half-registered file
silently; locked in as two regression tests. Re-verified clean:
workplan promotion (CLI direct + through `statehub promote-intake`
itself) and decision promotion both proven end-to-end against the live
hub, with all scratch artefacts (files + hub rows) cleaned up afterward.
No regressions: full state-hub suite (271 tests across
test_promote_intake, test_intake, test_work_record_registration,
test_work_record_check, test_routers_core, test_consistency_check,
test_consistency_sweep, test_mcp_smoke, test_mcp_write_tools) green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 01:06:01 +02:00
aade470f4a C-32: fix-consistency registration for intake/decision work records (CUST-WP-0061-T02)
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 2s
Extends fix-consistency to scan any file for kind: intake / kind: decision
YAML blocks (not just workplans/, per canon: any file is a potential
work-record source), create the corresponding hub entity when missing a
state_hub_intake_id / state_hub_decision_id, and write the id back into
the source block -- same write-back pattern as C-06 for workplans.
kind: engagement is reported INFO (deferred, not fixable): no hub entity
exists for it yet, a separate stage-3 follow-on.

- _load_work_record_kind_registry(): kind-aware registry loader, factored
  out so C-31's existing flat _load_work_record_patterns() builds on it
  without duplication (verified: C-31's 16 tests still pass unmodified)
- _check_work_record_registration(): detection, wired into check_repo
  right after C-31
- _inject_yaml_block_field(): write-back helper for  fenced
  blocks, mirroring _inject_task_id_into_block's pattern for
  blocks
- fix_repo C-32 dispatch: creates the intake (scoped to repo_id) or
  decision (scoped to resolved topic_id, reusing C-06's domain->topic
  resolution) via the REST API, then writes the id back
- tests/test_work_record_registration.py: 15 tests (classification,
  detection incl. engagement-deferred and workplan-kind-exclusion,
  injection incl. idempotence and non-interference with sibling blocks)

Live-verified end to end against a real registered repo (binky-control,
not just synthetic fixtures): a real fix-consistency run found and
registered 3 genuinely open, previously-unlinked intake items
(AWQ-002/003/006) sitting in AutopilotWorkQueue.md, and correctly
deferred 5 real OH- engagement items as INFO. No regressions: full
consistency_check + consistency_sweep suite (128 tests) and C-31's own
suite (16 tests) still green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 00:48:10 +02:00
88ba666c95 CUST-WP-0061-T01: intake work-record entity (stage 3)
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 1m23s
Fresh hub entity per the founder-reviewed decision (not a suggestions
rename-bridge): kind: intake per canon/standards/work-record-types_v0.1.md,
lifecycle open -> vetted -> routed -> closed(promoted|declined|absorbed).

- api/models/base.py::new_uuid7 -- dependency-free RFC 9562 UUIDv7
  generator (48-bit ms timestamp, version/variant bits, random remainder);
  existing tables keep new_uuid (UUIDv4) unchanged, this is opt-in for new
  work-record entities per the identity-layering canon
- api/models/intake.py: Intake + IntakeNote ORM models, mirroring
  Decision's shape (topic/workplan/repo scope, lane, status, outcome,
  promoted_to back-link); CHECK constraints enforce scope-required,
  closed-requires-outcome, promoted-requires-promoted_to at the DB level
- migrations/a7c3e9f1b4d2: intakes + intake_notes tables, 3 enum types
- api/routers/intake.py: list/create/get/patch + /route + /close + /notes
  actions, mirroring decisions.py's pattern (409 on invalid transitions,
  progress event on close)
- api/schemas/intake.py: Pydantic create/update/route/close/note schemas
- mcp_server/server.py: create_intake, list_intakes, route_intake,
  close_intake tool wrappers
- tests/test_intake.py: 12 tests against the real Postgres test DB
  (create/list/scope-validation, full lifecycle incl. 409s and the
  promoted-requires-promoted_to constraint, notes, UUIDv7 verification)

Verified live against the running dev API + DB (not just pytest): applied
the migration, restarted the MCP server, and ran a full create -> route ->
close cycle over the real REST endpoints. No regressions: full existing
suite (test_routers_core, test_suggestions, test_mcp_smoke,
test_mcp_write_tools, test_mcp_registration, test_consistency_check,
test_consistency_sweep) all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 00:27:45 +02:00
4541f1d6fc Test coverage for C-31 work-record sidetrack detector (CUST-WP-0060 review)
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
16 tests: registry loading (env override, missing registry, unparseable
registry -> None, no crash), detector behavior against a synthetic
registry fixture (independent of the-custodian's live state, so stable
across canon changes) -- rogue ids flagged, all registered kinds incl.
grandfathered legacy patterns (AWQ-/DEC-/OH-/single-digit-task) pass,
template placeholders and .git/history dirs skipped, malformed yaml
doesn't crash the scan, dedup of repeated ids within a repo, no-op when
no registry is reachable.

Full consistency_check + consistency_sweep suite still green (128 tests,
no regressions from the C-31 addition).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 23:09:40 +02:00
59770145fd C-31 work-record sidetrack detector (CUST-WP-0060-T05)
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Warn on YAML-block ids matching no kind in the canon work-record type
registry (~/the-custodian/canon/standards/work-record-types.yaml, override
via WORK_RECORD_REGISTRY). Detection only; registration of non-workplan
kinds is a later stage. Skips template placeholders and repos without the
registry available.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 02:07:17 +02:00
814595c317 evidence: legacy-meter weekly reviews 2026-07-16..18 (STATE-WP-0070 automation)
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 16:40:17 +02:00
451f02c1d5 feat(dashboard): expandable full text on decision cards
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Cards previously showed a hard 200-char snippet of description OR
rationale with no way to read the rest, and rationale was hidden
whenever a description existed. Long or dual-field cards now expand
in place (native <details>) to labeled full Description and Rationale
sections; short single-field cards render unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:26:12 +02:00
1906289997 docs(deploy): state-hub Forgejo image promote flow (CUST-WP-0059 T06)
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 2s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 14:48:50 +02:00
custodian-sync
4d2898e2e2 chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Updated by fix-consistency on 2026-07-13:
  - update .custodian-brief.md for state-hub
2026-07-13 23:52:49 +00:00
92b23f2534 chore: archive finished STATE-WP-0070 (workplan terminology phase-2 retirement)
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 3s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 00:27:57 +02:00
custodian-sync
739d76fb58 chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 56s
Updated by fix-consistency on 2026-07-14:
  - update .custodian-brief.md for state-hub
2026-07-14 00:27:13 +02:00
ac26bf2e66 STATE-WP-0070: closeout — T03/T04/T05 done, status finished
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 5s
Merge landed (df19c46), gate cleared, MCP restarted, suite green. Field-alias
+ flows/workstream.yaml retirement deferred to CUST-WP-0055 phase-3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 00:21:53 +02:00
df19c464ff Merge STATE-WP-0070: retire legacy workstream MCP aliases + open_workstreams
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 6m28s
Gate cleared: 10 consecutive 8h zero-usage meter windows (07-10 14:00 → 07-13 22:00).
Removes create/list/update_workstream(_status) MCP tools, state://workstreams
resource, and the open_workstreams summary mirror. workstream_id field alias and
flows/workstream.yaml remain (still have live callers).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 00:13:13 +02:00
custodian-sync
d15a9928b3 chore(consistency): sync task status from DB [auto]
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 3s
Updated by fix-consistency on 2026-07-13:
  - update .custodian-brief.md for state-hub
2026-07-13 13:22:05 +00:00
b59cff508a chore(wp-0070): daily legacy-meter evidence captures (07-12, 07-13)
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
8h zero-usage window snapshots for the STATE-WP-0070 retirement gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 10:53:31 +02:00
325 changed files with 111010 additions and 1394 deletions

View file

@ -42,6 +42,14 @@ ls workplans/
For each file with `status: ready`, `active`, or `blocked`, note pending
`wait`/`todo`/`progress` tasks.
Optional quality debt (STATE-WP-0077):
```bash
statehub quality-debt --repo-path .
```
Prefer **DoR-Ok** before heavy implementation on ready plans; **DoC-Ok** before
confident intake promote; record with `quality_dor` / `quality_doc` /
`quality_dod` fields — see `docs/work-record-quality-gates.md`.
**Step 4 — Present brief**
1. **Active workplans** for `infotech` — title, task counts, blocking decisions

View file

@ -2,25 +2,25 @@
# Custodian Brief — state-hub
**Domain:** infotech
**Last synced:** 2026-07-10 13:22 UTC
**Last synced:** 2026-09-14 08:15 UTC
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
## Active Workstreams
### Workplan terminology phase-2 legacy retirement
Progress: 2/5 done | workplan_id: `9aa92529-6ee8-4b3f-b573-78b0ef8d9788`
### State Hub retirement strangler and disposition execution
Progress: 4/9 done | workplan_id: `ed077b62-7048-5752-bf65-f90471f45854`
**Open tasks:**
- ► Task: MCP alias removal (Phase 2) `e2b9015a`
- ► Task: Internal dual-key and param alias cleanup `18af98bf`
- · Task: Closeout and fleet gate `dcc81665`
- ! Execute move/replace slices `3a454438`
- ! Retire legacy surfaces `f6cf8c01`
- ! Stabilization window and archive prep `9230c99b`
- ! Attribute and finish migrating remaining legacy readers `2ac16837`
- ! Gate the production inbox reader switch `da1d9a95`
## Inbox Hygiene
**Stale unread:** 4 message(s) older than 3 day(s) — triage at session start.
**Missing thread_id:** 4 unread message(s) lack supersession chains.
- ! activity-core: Optional pattern suggestion: standardized deployment evidence handoff `10d37752`
- ! inter-hub: Suggestion: document cross-repo coordination in State Hub suggestions handling `6e8510b1`
**Stale unread:** 2 message(s) older than 3 day(s) — triage at session start.
**Missing thread_id:** 1 unread message(s) lack supersession chains.
---
## MCP Orientation (when available)

View file

@ -12,6 +12,13 @@ PGADMIN_PASSWORD=admin
# API
API_BASE=http://127.0.0.1:8000
# Read-only activity-core ops_run projection (STATE-WP-0078).
ACTIVITY_CORE_URL=http://127.0.0.1:8010
# Inject from OpenBao/ESO in production; never commit the value.
ACTIVITY_CORE_WORKER_TOKEN=
OPS_RUN_PROJECTION_TTL_SECONDS=15
OPS_RUN_SLA_HOURS=1
# Gitea (for gitea_inventory.py)
GITEA_URL=http://92.205.130.254:32166
GITEA_TOKEN=

View file

@ -9,11 +9,24 @@ on:
push:
branches:
- main
# Must list every path the Dockerfile COPYs, or a change ships to git
# without ever reaching the image. This previously covered only api/**,
# so edits to mcp_server/ and — more seriously — migrations/ produced no
# rebuild (CUST-WP-0067-T08).
paths:
- ".forgejo/workflows/image.yaml"
- "Dockerfile"
- "alembic.ini"
- "api/**"
- "flows/**"
- "mcp_server/**"
- "migrations/**"
- "policies/**"
- "prompts/**"
- "pyproject.toml"
- "scripts/**"
- "task_flow_engine/**"
- "templates/**"
- "uv.lock"
workflow_dispatch:

1
.gitignore vendored
View file

@ -18,6 +18,7 @@ __pycache__/
.pytest_cache/
.mypy_cache/
.ruff_cache/
/docs/architecture/.ast_cache/
.coverage
.coverage.*
htmlcov/

View file

@ -13,13 +13,15 @@
## State Hub Integration
The Custodian State Hub tracks work across all domains. Interact via HTTP REST —
there is no MCP server for Codex agents.
The Custodian State Hub tracks work across all domains. Codex uses HTTP REST and
the `statehub` CLI by default. MCP is opt-in because the current Codex MCP bridge
adds severe call latency; the full administrative MCP surface remains available
to clients that need it.
| Context | URL |
|---------|-----|
| Local workstation | `http://127.0.0.1:8000` |
| Remote via tunnel | `http://127.0.0.1:18000` |
| Remote (railiance01, in-cluster) | `http://10.43.68.154:8000` |
| Optional local edge relay | http://127.0.0.1:18080 |
When an operator has enabled the edge relay, set API_BASE to the relay URL.
@ -27,21 +29,46 @@ Queueable writes return an explicit queued receipt if the central hub is
unreachable. Treat that as pending local evidence, then ask the operator to run
statehub outbox status/replay after connectivity returns.
Codex workspace-write sandboxes need network access enabled to reach the host's
loopback listener. Bootstrap this once with `make -C ~/state-hub configure-codex`
and restart Codex. The canonical REST health endpoint is `/state/health`, not
`/health`. If a sandboxed loopback probe fails, retry it with escalated execution
before declaring State Hub unavailable; a managed Codex permission profile may
still enforce isolated networking. Experimental MCP can be enabled explicitly
with `make -C ~/state-hub configure-codex WITH_MCP=1`.
### Orient at session start
```bash
# Offline brief — works without hub connection
cat .custodian-brief.md
# Active workplans for this domain
# Active workplans for this domain (default summary omits flavor: residual)
curl -s "http://127.0.0.1:8000/workplans/?topic_id=cee7bedf-2b48-46ef-8601-006474f2ad7a&status=active" \
| python3 -m json.tool
# Residuals are not implementation demand unless promoted:
# GET /state/summary (open_workplans omits flavor=residual)
# GET /workplans/?flavor=residual
# Check inbox
curl -s "http://127.0.0.1:8000/messages/?to_agent=state-hub&unread_only=true" \
| python3 -m json.tool
# Optional: DoX quality debt (ready without DoR-Ok, finished without DoD-Ok, …)
statehub quality-debt --repo-path .
```
### Definition quality (DoC / DoR / DoD)
Lifecycle `status` is independent of quality badges. Prefer:
- **DoC-Ok** on intakes before confident promote (`quality_doc` or note)
- **DoR-Ok** on workplans/tasks before heavy implementation (`quality_dor`)
- **DoD-Ok** when claiming quality-complete finish (`quality_dod`)
Recording form and examples: `docs/work-record-quality-gates.md`.
Policies: `policies/intake-doc.md`, `work-item-dor.md`, `workstream-dod.md`.
Mark a message read:
```bash
curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
@ -97,13 +124,19 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
**Close:**
1. Update workplan file task statuses to reflect progress
2. Log: `POST /progress/` with a summary of what changed
3. After workplan file changes, run:
2. If finishing a workplan: hand off **residuals** as live work records first
(intake with `origin: residual` + `origin_ref: <WP-id>`, or a next workplan /
decision / engagement). Do not park leftovers only in prose or `SCOPE.md`.
Canon: `the-custodian/canon/standards/work-record-types_v0.1.md` § Residuals.
3. Log: `POST /progress/` with a summary of what changed (name handoff ids)
4. After workplan file changes, run the fast authoritative projection path:
```bash
statehub fix-consistency
uv run --project ~/repo-manager rmgr sync --path . --push
```
Coding agents should run this directly; ask the operator only if the CLI or
State Hub API is unavailable. This syncs task status from files into the hub DB.
This assigns only missing deterministic identifiers, verifies the pushed
Forgejo commit, verifies `primary/railliance01`, and reconciles the repository
in one request. A queued result is pending evidence; retry after connectivity
returns. Run `statehub fix-consistency` separately when a deep audit is needed.
---
@ -176,9 +209,12 @@ read/cache/index layer that rebuilds from files.
the completion/archive date; the frontmatter `id` does not change.
**Ad Hoc Tasks:** small opportunistic fixes discovered during a session use
`workplans/ADHOC-YYYY-MM-DD.md` with task ids `ADHOC-YYYY-MM-DD-T01`, etc. Use
this only for low-risk work completed directly; create a normal workplan for
anything needing analysis, design, approval, dependencies, or multiple phases.
`workplans/ADHOC-YYYY-MM-DD.md`, workplan id
`STATE-WP-ADHOC-YYYY-MM-DD`, and task ids
`STATE-WP-ADHOC-YYYY-MM-DD-T01`, etc. Unqualified historic `ADHOC-*` ids are
grandfathered and must not be copied into new records. Use this only for
low-risk work completed directly; create a normal workplan for anything needing
analysis, design, approval, dependencies, or multiple phases.
**Frontmatter:**
@ -194,7 +230,7 @@ owner: codex
topic_slug: ...
created: "YYYY-MM-DD"
updated: "YYYY-MM-DD"
state_hub_workstream_id: "<uuid>" # written by fix-consistency — do not edit
state_hub_workstream_id: "<uuid>" # deterministic UUIDv5; managed by Repo Manager
---
```
@ -211,7 +247,7 @@ derived health labels, not frontmatter statuses.
id: STATE-WP-NNNN-T01
status: wait | todo | progress | done | cancel
priority: high | medium | low
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
state_hub_task_id: "<uuid>" # deterministic UUIDv5; managed by Repo Manager
` ` `
Task description text.
@ -219,7 +255,19 @@ Task description text.
Status progression: `todo``progress``done`; use `wait` for waiting/blocked work and `cancel` for stopped work.
**Residuals when finishing:** actionable leftovers become live work records
before `status: finished` — usually an intake (`origin: residual`,
`origin_ref: STATE-WP-NNNN`) or a spawned workplan. Residual is a *role*,
not a kind. Fleet list lives on State Hub, not in `SCOPE.md`.
To create a new workplan:
1. Write the file following the format above
2. Run `statehub fix-consistency` locally; ask the operator only if the CLI or
State Hub API is unavailable.
2. Run `uv run --project ~/repo-manager rmgr sync --path . --push`.
3. Use `statehub fix-consistency` only for a separate deep consistency audit.
## Repository / cluster boundary
Workstation agents commit and push to Forgejo. Cluster infrastructure reads a
verified Forge commit into ephemeral storage. Neither side reads or writes the
other side's checkout. `local_path` and `host_paths` are host observations, not
central storage configuration. See `docs/adr/ADR-002-forge-repository-boundary.md`.

View file

@ -39,6 +39,9 @@ COPY policies/ ./policies/
COPY prompts/ ./prompts/
COPY scripts/ ./scripts/
COPY task_flow_engine/ ./task_flow_engine/
# ACTIVITY-WP-0021 / ROS hourly: domain-digest.md must exist at
# templates/recently-on-scope/ for /recently-on-scope/hourly rendering.
COPY templates/ ./templates/
EXPOSE 8000

View file

@ -9,6 +9,26 @@ updated: "2026-05-17"
> This file explains why State Hub exists, where its authority begins and ends,
> and why it is now separate from the-custodian.
## Retirement status
State Hub is a legacy service in active retirement planning. It remains
operational during the migration, but it is not the target home for new
cross-domain capabilities.
The replacement program is coordinated in
`/home/worsch/prj-state-hub-retirement`, whose `GOAL.md` defines the migration
and retirement gates. Repository representation, file-backed record indexing,
consistency reconciliation, and governed repository changes are moving toward
`/home/worsch/repo-manager`. Cross-domain identity, communication, interaction,
state projections, telemetry, and domain-extension composition are moving
toward the consolidated `hub-core` framework.
Until the retirement gates are met, State Hub remains authoritative only where
the current contracts and repository-backed workflows require it. Changes made
during this period should preserve compatibility, enable extraction, reduce
scope, or address operational risk; they should not establish new permanent
architectural ownership here.
---
## Why it exists
@ -126,4 +146,3 @@ visible rather than fail opaquely when one component is unavailable.
**Narrow authority.** State Hub coordinates and exposes state; it does not make
irreversible human decisions or become the owner of every adjacent system.

161
LICENSE
View file

@ -1,16 +1,151 @@
MIT No Attribution
# Target Revenue Source License
Copyright <YEAR> <COPYRIGHT HOLDER>
**Version 1.0, Candidate 1 (V1C1)**
Permission is hereby granted, free of charge, to any person obtaining a copy of this
software and associated documentation files (the "Software"), to deal in the Software
without restriction, including without limitation the rights to use, copy, modify,
merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so.
---
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
> **PRELIMINARY CANDIDATE — SUBJECT TO CHANGE — NOT FINAL**
>
> This repository is governed by the Target Revenue Source License
> (TRSL), Version 1, Candidate 1. This is the framework's first working
> candidate, adopted as the org's preliminary operating license across
> all repos (maintainer decision, 2026-07-29) during the build/alpha
> stage — see `SCOPE.md` §1 and Appendix A of the canonical text (link
> below) for the alpha/beta risk-acceptance decision this reflects and
> what it does and does not mean. Full specialist legal review is
> explicitly deferred until the framework moves out of beta.
>
> **Canonical source, full candidate-status banner, and Appendix A
> (non-normative candidate notes tracking every open item):** the
> `coulomb` org's `target-revenue` repository,
> `specs/TargetRevenueSourceLicense-V1C1.md` — this file is the operative
> legal text (Preamble through Section 11) only; the canonical document
> is authoritative if this copy and that document ever diverge.
---
## Preamble
This Target Revenue Source License ("**License**") governs the Software identified in the applicable Phase Manifest. It implements the Target Revenue Framework: a defined development Phase accumulates Development Credit and Remission Credit against an immutable Initial Target until the Milestone Release automatically and irrevocably converts to a declared permissive Future License.
Commercial beneficiaries fund the creation and early availability of a software improvement; once the declared target is satisfied, the governed release becomes permissively open source.
## 1. Definitions
Capitalized terms used in this License have the meanings given below. Where a term is also defined in the Phase Manifest or Target Ledger for a specific Phase, the Phase Manifest and Target Ledger govern the *values* (amounts, dates, identifiers) and this License governs the *legal effect* of those values — the two must not be read as conflicting definitions of the same concept.
**"Commercial Entitlement"** means a right, purchased or otherwise granted under a Commercial Use Agreement, to make Commercial Use of the Software during a Phase.
**"Commercial Use"** means billing, invoicing, or otherwise charging any customer a fee, subscription, license fee, or other consideration for or in connection with use of the Software, at any time before the Conversion Event for the applicable Phase, regardless of whether the person or organization billed would otherwise qualify for Noncommercial Use. Commercial Use occurs by virtue of such billing alone, whether or not the resulting payment is registered with the Trust Service; in particular, billing a customer for pre-conversion use of the Software without recording the corresponding payment in the applicable Phase's Target Ledger is Commercial Use without a valid Commercial Entitlement — a violation of Section 3, addressed under Section 7 and, where applicable, the Enforcement Network described in the canonical repository's `specs/EnforcementNetworkConcept.md`.
**"Commercial Use Agreement"** means the separate agreement, referenced by the applicable Phase Manifest, under which a Commercial Entitlement is purchased or granted. This License does not itself set pricing, metering, or payment terms — those are governed by the Commercial Use Agreement.
**"Conversion Event"** means the moment the Outstanding Target for a Phase reaches zero, as computed from the Phase Manifest and Target Ledger per the Target Ledger Specification. The Conversion Event occurs automatically and is not conditioned on any declaration, attestation, or other act by the Licensor or any Trust Service.
**"Development Credit"** means the portion of a collected and settled payment explicitly allocated toward satisfying the Initial Target of a specific Phase, as recorded in that Phase's Target Ledger.
**"Future License"** means the permissive license identified in the applicable Phase Manifest, being either the MIT License or the Apache License, Version 2.0, which applies to the Milestone Release upon the Conversion Event.
**"Initial Target"** means the immutable monetary target declared for a Phase in its Phase Manifest.
**"Licensor"** means **Binky Hedgehog GmbH**, the party that publishes the Phase Manifest and holds the rights necessary to grant this License and the Future License for the Milestone Release.
**"Milestone Release"** means the precisely identified software release designated in the applicable Phase Manifest, identified by an immutable source revision, release artifact, or cryptographic digest.
**"Noncommercial Use"** means use of the Software for personal purposes, private study, hobby or amateur projects; use by any charitable organization, educational institution, public research organization, or government institution acting in a non-revenue-generating capacity; or other use of a materially similar character.
**"Outstanding Target"** means, at any time, `max(0, Initial Target cumulative Development Credit cumulative Remission Credit)` for a Phase, as computed from that Phase's Target Ledger.
**"Phase"** means a bounded development undertaking governed by one Initial Target, one Milestone Release, one degeneration policy, and one Future License declaration, as declared in a Phase Manifest.
**"Phase Manifest"** means the published, immutable declaration identifying a Phase, its Milestone Release, Initial Target, Future License, degeneration policy, and Target Ledger location, as specified in the Phase Manifest Specification.
**"Remission Credit"** means a transparent, non-revenue reduction of a Phase's Outstanding Target, generated under that Phase's published degeneration policy and recorded in the Target Ledger.
**"Settled Payment"** means a payment that has cleared through its payment processor and is no longer subject to reversal in the ordinary course (chargeback, dispute, or equivalent), as further specified by the applicable Commercial Use Agreement or monetization extension.
**"Software"** means the source code, object code, and associated documentation of the Milestone Release identified in the applicable Phase Manifest.
**"Target Ledger"** means the append-only record of Development Credit, Remission Credit, and correction entries for a Phase, as specified in the Target Ledger Specification.
**"You"** or **"Licensee"** means the individual or entity exercising rights under this License.
## 2. Grant of Rights for Noncommercial Use
Subject to the terms of this License, the Licensor grants You a worldwide, royalty-free, non-exclusive license, during the applicable Phase, to:
(a) use, reproduce, and study the Software for any Noncommercial Use;
(b) modify the Software and create derivative works of it for any Noncommercial Use; and
(c) redistribute the Software and Your modifications, in source or object form, for any Noncommercial Use, provided that You include this License, unmodified, with any such redistribution, and that You do not remove or alter any copyright, patent, trademark, or attribution notices contained in the Software.
This grant does not extend to Commercial Use. Commercial Use requires a Commercial Entitlement under Section 3.
## 3. Commercial Use
You may not make Commercial Use of the Software during the applicable Phase unless You hold a valid, current Commercial Entitlement under a Commercial Use Agreement with the Licensor covering the applicable Phase. A Commercial Entitlement granted under one Phase's Commercial Use Agreement does not extend to a later Phase's Milestone Release unless the Commercial Use Agreement expressly says so.
This Section 3 states the existence and boundary of the commercial-use restriction. It does not itself set pricing, invoicing, metering, audit rights, or payment terms — those are governed exclusively by the applicable Commercial Use Agreement.
## 4. Patent License
Subject to the terms of this License, each contributor to the Software grants You, during the applicable Phase and solely to the extent of rights granted under Sections 2 and 3, a perpetual (subject to the termination below), worldwide, non-exclusive, no-charge, royalty-free patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Software, limited to those patent claims licensable by that contributor that are necessarily infringed by their contribution(s) alone or by combination of their contribution(s) with the Software.
If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Software or a contribution incorporated within it constitutes direct or contributory patent infringement, then any patent licenses granted to You under this Section 4 for the Software shall terminate as of the date such litigation is filed.
## 5. Automatic Conversion to the Future License
**5.1 Automatic effect.** Upon the Conversion Event for a Phase, the rights and restrictions in Sections 3 (Commercial Use) of this License, as they apply to that Phase's Milestone Release, terminate automatically. In their place, the Milestone Release is licensed under the Future License identified in that Phase's Phase Manifest, effective as of the Conversion Event, without any further act, declaration, or attestation required by the Licensor, any Trust Service, or any other party.
**5.2 Irrevocability.** Once a valid Conversion Event has occurred for a Phase, no subsequent refund, chargeback, accounting correction, dispute, or termination of this License for an unrelated breach shall revoke, suspend, or otherwise impair the Future License grant for that Phase's Milestone Release. Any shortfall or dispute arising after a Conversion Event is a commercial or accounting matter between the relevant parties and does not reinstate a commercial-use restriction over already-converted Software.
**5.3 Prior freedom preserved.** A later Phase covering subsequent improvements to the Software does not restrict, withdraw, or otherwise affect the rights granted under the Future License for an earlier Phase's already-converted Milestone Release.
**5.4 Evidence, not cause.** A Trust Service may publish a Conversion Attestation documenting a Conversion Event. Such an attestation is evidence that the Conversion Event occurred; it is not a condition of, and its absence or delay does not postpone, the automatic effect described in Section 5.1. Any person may independently verify whether a Conversion Event has occurred directly from the Phase Manifest and Target Ledger.
## 6. Successive Phases
The Licensor may declare a new Phase covering subsequent improvements to the Software following a Milestone Release's Conversion Event. Each Phase is independently governed by its own Phase Manifest, Initial Target, degeneration policy, and Target Ledger. Nothing in a later Phase's Phase Manifest may be construed to reduce or withdraw rights already granted under Section 5 for an earlier Phase's Milestone Release.
## 7. Term and Termination
**7.1 Term.** This License applies to the Software for the duration of the applicable Phase, and, for the Milestone Release, indefinitely following that Phase's Conversion Event under the Future License.
**7.2 Termination for breach.** If You breach Section 3 (Commercial Use) or Section 2(c) (redistribution notice requirement), the Licensor may terminate this License as to You. Before such termination becomes effective, the Licensor shall provide You written notice of the breach; if You cure the breach within thirty (30) days of that notice, this License continues in effect. A second breach of the same provision within twelve (12) months may be terminated immediately without a further cure opportunity.
**7.3 Effect of termination.** Termination under this Section 7 affects only Your rights under Sections 2 and 3 for the Phase in which the breach occurred. It does not affect any rights already vested under Section 5 (Automatic Conversion) for a Milestone Release whose Conversion Event has already occurred, per Section 5.2.
**7.4 Public record of breach and resolution.** The Licensor shall cause the Trust Service to publish, as part of the public record for the affected Phase, notice of: (a) any breach notice issued under Section 7.2, stating the general nature of the breach and the date of notice; (b) whether the breach was cured within the applicable cure period, and the date of cure; and (c) any termination determination made under this Section 7, including its effective date and scope. This public record exists to give the ecosystem a transparent, verifiable conformity signal for the Phase, distinct from and in addition to the Development Credit and Remission Credit facts already published under Section 5.4 and the Target Ledger Specification.
A breach that You dispute, and that has not been finally determined, shall be recorded as **alleged**; it shall be recorded as **determined** only once the cure period has run without cure, or the dispute has been resolved against You under the applicable Commercial Use Agreement's dispute process, if any. The Trust Service shall update the record promptly upon resolution in either direction. Recording an alleged or determined breach under this Section 7.4 is a ministerial act of publishing the Licensor's determination (or a dispute process's outcome); it does not give the Trust Service discretionary authority to decide whether a breach occurred, consistent with Section 5.4's evidence-not-cause principle.
Whether, and under what conditions, the public record identifies a Commercial Entitlement holder by name is governed exclusively by the applicable Commercial Use Agreement, which the Licensor and that Commercial Entitlement holder negotiate and agree to directly. This License does not itself set a naming default. Where no Commercial Use Agreement addresses the question, or where the affected party has no Commercial Use Agreement at all (for example, a Section 2(c) breach by a Noncommercial Use licensee), the public record states the Phase and breach category only, without naming the party.
## 8. Disclaimer of Warranty
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. THE LICENSOR DOES NOT WARRANT THAT THE SOFTWARE WILL BE ERROR-FREE OR THAT ANY PHASE WILL REACH ITS CONVERSION EVENT.
## 9. Limitation of Liability
IN NO EVENT SHALL THE LICENSOR OR ANY CONTRIBUTOR BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE, EXCEPT TO THE EXTENT SUCH LIMITATION IS PROHIBITED BY APPLICABLE LAW.
## 10. Trademarks
This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary attribution.
## 11. General Provisions
**11.1 Governing law and venue.** Adopted for alpha/beta 2026-07-29: disputes arising under this License shall be resolved by binding arbitration, seated at a neutral, arbitration-mature venue (Singapore or London are the two candidates concretely supported by current research), rather than by litigation in a national court. The specific arbitral institution and substantive governing law remain a per-deployment blank pending final selection; they are not fixed by this candidate. See the canonical repository's `history/260729-TRSL-Jurisdiction-Synthesis.md` §2.
**11.2 Severability.** If any provision of this License is held unenforceable, the remaining provisions remain in full force, and the unenforceable provision shall be reformed to the minimum extent necessary to make it enforceable.
**11.3 No waiver.** Failure to enforce any provision of this License is not a waiver of future enforcement of that or any other provision.
**11.4 Entire agreement (as to licensing).** This License, together with the applicable Phase Manifest and, where applicable, the Commercial Use Agreement, constitutes the entire agreement between You and the Licensor regarding the Software's licensing terms. Operations, service, and consulting arrangements are governed by separate agreements, if any, and are not part of this License.
**11.5 Definitions control.** Marketing materials, documentation, or other non-normative communications about the Software must not describe pre-Conversion-Event Software as "Open Source," "free software," or "open core." Pre-conversion Noncommercial Use is **source-available**; pre-conversion Commercial Use requires a **Commercial Entitlement**; only post-conversion Software may be described as Open Source, under the Future License.
---
**No Phase is currently declared for this repository under this License.** Until a Phase Manifest is published and registered with the Trust Service for a Milestone Release in this repository, Sections 27 above have no operative subject matter here — this License establishes the governing framework in advance of that declaration, consistent with the org-wide rollout decision recorded in `target-revenue`'s `workplans/TREV-WP-0008-governance-and-pilot-rollout.md`.

113
Makefile
View file

@ -1,4 +1,4 @@
.PHONY: install install-cli dashboard-install dashboard-check db db-tools migrate seed api dashboard check test test-python clean register-project register-codex-project register-mcp 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
.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
@ -9,19 +9,28 @@ 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 ?= f2e042a
RAILIANCE_STATE_HUB_IMAGE_TAG ?= main-0747b4d
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 "# run in different terminals"
@echo "make db # docker compose up postgres"
@echo "make api # start backend api"
@echo "make mcp-http # start state-hub mcp service"
@echo "make dashboard # Observable dev server on :3000"
@echo "make bridges # Set up ssh bridges for cross machines access"
@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
@ -63,15 +72,80 @@ 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
dashboard:
## 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
check:
## 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
railiance-state-hub-render:
# 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) \
@ -126,7 +200,7 @@ benchmark-summary-cache:
## ops-bridge managed tunnels
## Requires ops-bridge: bridge is at /home/worsch/.local/bin/bridge
tunnels-up:
tunnels-up: primary-port-clear
bridge up
tunnels-status:
@ -139,7 +213,7 @@ tunnels-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:
bridges: primary-port-clear
@echo "==> Bringing up all tunnels..."
bridge up
@echo ""
@ -149,9 +223,10 @@ bridges:
@echo "==> Checking tunnel health..."
bridge check
## Start (or restart) the full backend — db + migrate + uvicorn.
## Stops uvicorn on :8000 if already running, then starts fresh.
## 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; \
@ -180,6 +255,14 @@ register-mcp:
$(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)

View file

@ -64,11 +64,15 @@ then run consistency sync.
All services bind to `127.0.0.1` only — nothing exposed to the network.
**Production:** the primary State Hub API runs on coulombcore-k3s. Workstation
port `8000` reaches it through the ops-bridge `state-hub-primary` tunnel. See
**Production:** the primary State Hub API runs in the railiance01-hosted k3s
cluster. Workstation port `8000` reaches it through the ops-bridge
`state-hub-primary` tunnel. See
[`docs/cluster-operating-model.md`](docs/cluster-operating-model.md) for access,
rollback, backups, and pragmatic limitations.
Repository rename operations use the phased, UUID-preserving workflow in
[`docs/repository-rename-operations.md`](docs/repository-rename-operations.md).
---
## Setup
@ -79,7 +83,11 @@ rollback, backups, and pragmatic limitations.
- Python 3.12+ with `uv` (`pip install uv`)
- Node.js 18+ (dashboard only)
### First-time
### First-time local development setup
The local database and API are retained for development and disaster fallback.
They are not the normal production access path and conflict with the production
tunnel on port `8000`.
```bash
cd /home/worsch/state-hub
@ -89,27 +97,51 @@ make install # uv sync
make db # docker compose up postgres
make migrate # alembic upgrade head
make seed # insert 6 canonical topics
make api # db + migrate + uvicorn :8000 (restarts if running)
make api # LOCAL fallback: db + migrate + uvicorn :8000
```
### Dashboard
For a dashboard against that deliberate local backend, use
`make dashboard-local`.
### Production dashboard (normal operation)
The dashboard remains a workstation process, but its API is the primary State
Hub on railiance01. Start the tunnel first, then start the dashboard:
```bash
make dashboard # installs dashboard deps if needed, then Observable dev server on :3000
make dashboard-check # installs deps if needed, then runs Observable build
make bridges # connect workstation :8000 to primary/railiance01
make dashboard # verify the primary identity, then serve http://127.0.0.1:3000
```
### Start Everything
`make dashboard` refuses to start if port `8000` is unreachable or identifies
itself as anything other than `primary/railiance01`. This prevents the empty
local fallback database from being mistaken for production.
To start all the infrastructure on separate consoles do:
The MCP adapter is optional and is not required by the dashboard:
```bash
make db # docker compose up postgres
make mcp-http # start state-hub mcp service
make dashboard # Observable dev server on :3000
make bridges # Set up ssh bridges for cross machines access
make mcp-http # optional local SSE adapter on :8001
```
### Fast work-record synchronization
Workplan files remain authoritative. Repo Manager assigns missing UUIDv5
identifiers locally, pushes the file commit, and asks the primary State Hub to
derive that exact commit from Forgejo in one transactional request:
```bash
make bridges
make sync
```
The sync refuses an uncommitted or behind branch and will not write to a local
empty database: the API must identify itself as `primary/railliance01`. If the
primary is unavailable, Repo Manager writes an explicit pending receipt under
the checkout's local `.git/repo-manager/` state; rerunning `make sync` safely
replays the current pushed state.
Use `statehub fix-consistency` only for the broader consistency/quality audit.
It is no longer the normal registration and task-update path.
### CLI
@ -127,17 +159,22 @@ custodian register-project # register cwd as a Custodian project
|--------|-------------|
| `make install` | `uv sync` — install Python deps + entry points |
| `make install-cli` | Symlink `custodian` to `~/.local/bin` |
| `make db` | Start postgres container |
| `make start` | Print the production UI and local fallback startup paths |
| `make bridges` | Connect managed tunnels, including workstation `:8000` to the railiance01 primary |
| `make db` | Start the local fallback/development Postgres container |
| `make db-tools` | Start postgres + pgadmin (http://127.0.0.1:5050) |
| `make migrate` | `alembic upgrade head` |
| `make seed` | Insert 6 canonical topics (legacy bootstrap) |
| `make register-from-classification REPO=slug` | Upsert repo from `.repo-classification.yaml` |
| `make register-from-classification-all` | Bulk reclassify all repos with classification files |
| `make api` | `db` + wait + `migrate` + `uvicorn` (restarts if running) |
| `make api` | Start the local fallback API; replaces the production tunnel on `:8000` |
| `make dashboard-install` | Install dashboard npm deps from `dashboard/package-lock.json` |
| `make dashboard-check` | Build the Observable dashboard as a smoke/regression check |
| `make dashboard` | Install deps if needed, then start Observable dev server (restarts if running) |
| `make check` | `curl /state/health` |
| `make dashboard` | Verify `primary/railiance01`, then start the Observable dashboard |
| `make dashboard-local` | Start the dashboard against an intentional local/fallback API |
| `make sync` | Push missing deterministic IDs and reconcile this repo from the exact Forgejo commit |
| `make check` | Require a healthy `primary/railiance01` on workstation `:8000` |
| `make check-local` | Print `/state/health` without enforcing production identity |
| `make test` | Python test suite plus `make dashboard-check` |
| `make register-project DOMAIN=x PROJECT_PATH=y` | Register a project |
| `make clean` | `docker compose down -v` (destroys DB volume) |
@ -219,7 +256,7 @@ use `/state/health`, not `/state/summary`.
|--------|-----------|
| `/topics` | CRUD (soft-delete: `archived`) |
| `/workplans` | Preferred CRUD surface for repo-backed workplans (soft-delete: `archived`) |
| `/workstreams` | Legacy compatibility CRUD surface; usage is recorded by legacy-meter |
| `/workstreams` | Retired REST routes (410); use `/workplans`. Rejected calls remain metered |
| `/tasks` | CRUD (soft-delete: `cancel`); `PATCH` updates status |
| `/decisions` | CRUD (soft-delete: `superseded`); auto-escalation |
| `/progress` | `GET` list + `POST` append — no DELETE |
@ -227,7 +264,8 @@ use `/state/health`, not `/state/summary`.
| `/state/summary` | Full snapshot |
| `/state/health` | DB connectivity check |
See `docs/workplan-terminology-transition.md` for the workstream-to-workplan
See `docs/work-record-flavor.md` for flavor buckets and residual
default-exclusion (STATE-WP-0092). See `docs/workplan-terminology-transition.md` for the workstream-to-workplan
compatibility policy and retirement criteria.
---
@ -285,7 +323,7 @@ Prints API health, totals, and any blocking decisions.
### What `register-project` does
1. Verifies the API is reachable (fails fast with `make api` hint)
1. Verifies the API is reachable
2. Looks up the topic ID for the domain via `/topics/?status=active`
3. Checks that `state-hub` is in `~/.claude.json`
4. Writes `$PROJECT_PATH/CLAUDE.md` from `scripts/project_claude_md.template`

View file

@ -2,9 +2,10 @@
## One-Liner
State Hub is the local-first coordination service for repo-anchored workplans,
State Hub is the transitional coordination service for repo-anchored workplans,
tasks, decisions, progress events, repo classification and metadata, MCP
tooling, and dashboard telemetry.
tooling, and dashboard telemetry. Its primary runtime is on railiance01; local
repository files remain authoritative for repository-owned work records.
## In Scope
@ -23,13 +24,16 @@ tooling, and dashboard telemetry.
| Repo | Role |
| --- | --- |
| `hub-core` | Shared library — State Hub mounts router factories and MCP from `hub_core` |
| `state-hub` | This repo — dev coordination host, workplan file authority |
| `core-hub` | Production framework — `/api/v2` at `hub.coulomb.social`; does not replace State Hub dev MCP |
| `hub-core` | Shared library and selected target runtime for capabilities retained after State Hub retirement |
| `state-hub` | This repo — transitional coordination runtime and read projection; repository files retain work-record authority |
| `core-hub` | Production `/api/v2` compatibility runtime on railiance01 during consolidation into `hub-core` |
Canon: `/home/worsch/the-custodian/docs/hub-ecosystem-architecture.md`
State Hub does **not** implement Inter-Hub `/api/v2` routes; those belong to core-hub.
New permanent cross-domain capability should land in its disposition owner, not
expand State Hub. The retirement program is governed by
`prj-state-hub-retirement`.
## Out Of Scope

365
WORK-RECORDS.md Normal file
View file

@ -0,0 +1,365 @@
# Work Records — state-hub
> 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 |
| --- | --- | --- | --- | --- |
| workplan | STATE-WP-ADHOC-2026-06-04 | finished | — | workplans/ADHOC-2026-06-04.md |
| workplan | STATE-WP-ADHOC-2026-07-01 | finished | — | workplans/ADHOC-2026-07-01.md |
| workplan | STATE-WP-ADHOC-2026-08-08 | finished | — | workplans/ADHOC-2026-08-08.md |
| workplan | STATE-WP-ADHOC-2026-08-23 | finished | — | workplans/ADHOC-2026-08-23.md |
| workplan | STATE-WP-ADHOC-2026-08-30 | finished | — | workplans/ADHOC-2026-08-30.md |
| workplan | STATE-WP-ADHOC-2026-09-04 | finished | — | workplans/ADHOC-2026-09-04.md |
| workplan | CUST-WP-0003 | finished | — | workplans/CUST-WP-0003-whi-kpi-card.md |
| workplan | CUST-WP-0012 | finished | — | workplans/CUST-WP-0012-multi-user-onboarding.md |
| workplan | CUST-WP-0038 | backlog | — | workplans/CUST-WP-0038-state-hub-threephoenix-ha.md |
| workplan | CUST-WP-0042 | finished | — | workplans/CUST-WP-0042-workplan-state-model-cleanup.md |
| workplan | STATE-WP-0043 | finished | — | workplans/STATE-WP-0043-dashboard-ui-experience.md |
| workplan | STATE-WP-0044 | finished | — | workplans/STATE-WP-0044-recently-on-scope-digest.md |
| workplan | STATE-WP-0045 | finished | — | workplans/STATE-WP-0045-token-measurement-accuracy.md |
| workplan | STATE-WP-0046 | finished | — | workplans/STATE-WP-0046-workplan-id-normalization.md |
| workplan | STATE-WP-0047 | finished | — | workplans/STATE-WP-0047-lifecycle-assertions-and-renormalization.md |
| workplan | STATE-WP-0048 | finished | — | workplans/STATE-WP-0048-ui-state-change-reconciliation.md |
| workplan | STATE-WP-0049 | finished | — | workplans/STATE-WP-0049-workplan-execution-queue.md |
| workplan | STATE-WP-0050 | finished | — | workplans/STATE-WP-0050-railiance-fabric-graph-read-model-ingest.md |
| workplan | STATE-WP-0051 | finished | — | workplans/STATE-WP-0051-financial-fabric-read-model-adaptation.md |
| workplan | STATE-WP-0052 | finished | — | workplans/STATE-WP-0052-task-state-canon-adaptation.md |
| workplan | STATE-WP-0053 | finished | — | workplans/STATE-WP-0053-wsjf-triage-review-page.md |
| workplan | STATE-WP-0054 | finished | — | workplans/STATE-WP-0054-workplan-terminology-transition-legacy-meter.md |
| workplan | STATE-WP-0055 | finished | — | workplans/STATE-WP-0055-statehub-register-cli.md |
| workplan | STATE-WP-0056 | finished | — | workplans/STATE-WP-0056-dashboard-loading-robustness.md |
| workplan | STATE-WP-0057 | finished | — | workplans/STATE-WP-0057-overview-workstream-stage-counts.md |
| workplan | STATE-WP-0058 | finished | — | workplans/STATE-WP-0058-agent-skill-hub-interaction.md |
| workplan | STATE-WP-0059 | finished | — | workplans/STATE-WP-0059-mcp-write-layer-reliability.md |
| workplan | STATE-WP-0060 | finished | — | workplans/STATE-WP-0060-fix-consistency-cross-flavor.md |
| workplan | STATE-WP-0062 | finished | — | workplans/STATE-WP-0062-service-catalog-two-dimensions.md |
| workplan | STATE-WP-0063 | finished | — | workplans/STATE-WP-0063-weekend-automation-repair.md |
| workplan | STATE-WP-0064 | finished | — | workplans/STATE-WP-0064-statehub-consistency-sync-railiance01.md |
| workplan | STATE-WP-0065 | finished | — | workplans/STATE-WP-0065-repo-anchored-classification-spine.md |
| workplan | STATE-WP-0066 | finished | — | workplans/STATE-WP-0066-state-summary-revision-cache.md |
| workplan | STATE-WP-0067 | finished | — | workplans/STATE-WP-0067-attached-repo-agent-normalization.md |
| workplan | STATE-WP-0068 | finished | — | workplans/STATE-WP-0068-offline-write-buffer-and-edge-relay.md |
| workplan | STATE-WP-0071 | finished | — | workplans/STATE-WP-0071-railiance01-workstream-caller-deploy.md |
| workplan | STATE-WP-0072 | finished | — | workplans/STATE-WP-0072-workstream-caller-remediation.md |
| workplan | STATE-WP-0073 | finished | — | workplans/STATE-WP-0073-legacy-meter-decline-and-remote-audit.md |
| workplan | STATE-WP-0074 | finished | — | workplans/STATE-WP-0074-hub-ecosystem-boundary-alignment.md |
| workplan | STATE-WP-0075 | finished | — | workplans/STATE-WP-0075-workstream-route-410-stranglers.md |
| workplan | STATE-WP-0076 | finished | — | workplans/STATE-WP-0076-definition-of-ready-and-comprehension.md |
| workplan | STATE-WP-0077 | finished | — | workplans/STATE-WP-0077-dox-assessment-recording-and-soft-visibility.md |
| workplan | STATE-WP-0078 | finished | — | workplans/STATE-WP-0078-ops-run-read-projection.md |
| workplan | STATE-WP-0079 | blocked | — | workplans/STATE-WP-0079-retirement-strangler.md |
| workplan | STATE-WP-0080 | finished | — | workplans/STATE-WP-0080-register-project-flavor-awareness.md |
| workplan | STATE-WP-0081 | finished | — | workplans/STATE-WP-0081-cluster-self-sufficiency-and-registrar.md |
| workplan | STATE-WP-0082 | finished | — | workplans/STATE-WP-0082-multi-owner-review-contracts-and-receipts.md |
| workplan | STATE-WP-0083 | finished | — | workplans/STATE-WP-0083-forge-derived-projection-reset.md |
| workplan | STATE-WP-0084 | finished | — | workplans/STATE-WP-0084-forge-read-for-private-repositories.md |
| workplan | STATE-WP-0085 | finished | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
| workplan | STATE-WP-0086 | finished | — | workplans/STATE-WP-0086-fast-forge-projection-reconcile.md |
| workplan | STATE-WP-0087 | finished | — | workplans/STATE-WP-0087-bounded-repository-collection-projection.md |
| workplan | STATE-WP-0088 | finished | — | workplans/STATE-WP-0088-preflight-signing-runtime-acceptance.md |
| workplan | STATE-WP-0089 | finished | — | workplans/STATE-WP-0089-rename-redirect-recovery.md |
| workplan | STATE-WP-0090 | finished | — | workplans/STATE-WP-0090-projection-task-field-convergence.md |
| workplan | STATE-WP-0091 | finished | — | workplans/STATE-WP-0091-release-headroom.md |
| workplan | STATE-WP-0092 | finished | — | workplans/STATE-WP-0092-workplan-flavor-and-depends-on-policy.md |
| task | STATE-WP-ADHOC-2026-06-04-T01 | done | — | workplans/ADHOC-2026-06-04.md |
| task | STATE-WP-ADHOC-2026-07-01-T01 | done | — | workplans/ADHOC-2026-07-01.md |
| task | STATE-WP-ADHOC-2026-07-01-T02 | done | — | workplans/ADHOC-2026-07-01.md |
| task | STATE-WP-ADHOC-2026-08-08-T01 | done | — | workplans/ADHOC-2026-08-08.md |
| task | STATE-WP-ADHOC-2026-08-08-T02 | done | — | workplans/ADHOC-2026-08-08.md |
| task | STATE-WP-ADHOC-2026-08-08-T03 | done | — | workplans/ADHOC-2026-08-08.md |
| task | STATE-WP-ADHOC-2026-08-08-T04 | done | — | workplans/ADHOC-2026-08-08.md |
| task | STATE-WP-ADHOC-2026-08-23-T01 | done | — | workplans/ADHOC-2026-08-23.md |
| task | STATE-WP-ADHOC-2026-08-30-T01 | done | — | workplans/ADHOC-2026-08-30.md |
| task | STATE-WP-ADHOC-2026-08-30-T02 | done | — | workplans/ADHOC-2026-08-30.md |
| task | STATE-WP-ADHOC-2026-09-04-T01 | done | — | workplans/ADHOC-2026-09-04.md |
| task | CUST-WP-0003-T01 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md |
| task | CUST-WP-0003-T02 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md |
| task | CUST-WP-0003-T03 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md |
| task | CUST-WP-0003-T04 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md |
| task | CUST-WP-0003-T05 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md |
| task | CUST-WP-0003-T06 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md |
| task | CUST-WP-0003-T07 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md |
| task | CUST-WP-0003-T08 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md |
| task | CUST-WP-0003-T09 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md |
| task | CUST-WP-0012-T01 | done | — | workplans/CUST-WP-0012-multi-user-onboarding.md |
| task | CUST-WP-0012-T02 | done | — | workplans/CUST-WP-0012-multi-user-onboarding.md |
| task | CUST-WP-0012-T03 | done | — | workplans/CUST-WP-0012-multi-user-onboarding.md |
| task | CUST-WP-0012-T04 | done | — | workplans/CUST-WP-0012-multi-user-onboarding.md |
| task | CUST-WP-0012-T05 | done | — | workplans/CUST-WP-0012-multi-user-onboarding.md |
| task | CUST-WP-0012-T06 | done | — | workplans/CUST-WP-0012-multi-user-onboarding.md |
| task | CUST-WP-0038-T01 | todo | — | workplans/CUST-WP-0038-state-hub-threephoenix-ha.md |
| task | CUST-WP-0038-T02 | todo | — | workplans/CUST-WP-0038-state-hub-threephoenix-ha.md |
| task | CUST-WP-0038-T03 | todo | — | workplans/CUST-WP-0038-state-hub-threephoenix-ha.md |
| task | CUST-WP-0038-T04 | todo | — | workplans/CUST-WP-0038-state-hub-threephoenix-ha.md |
| task | CUST-WP-0038-T05 | todo | — | workplans/CUST-WP-0038-state-hub-threephoenix-ha.md |
| task | CUST-WP-0038-T06 | todo | — | workplans/CUST-WP-0038-state-hub-threephoenix-ha.md |
| task | CUST-WP-0038-T07 | todo | — | workplans/CUST-WP-0038-state-hub-threephoenix-ha.md |
| task | CUST-WP-0038-T08 | todo | — | workplans/CUST-WP-0038-state-hub-threephoenix-ha.md |
| task | CUST-WP-0042-T01 | done | — | workplans/CUST-WP-0042-workplan-state-model-cleanup.md |
| task | CUST-WP-0042-T02 | done | — | workplans/CUST-WP-0042-workplan-state-model-cleanup.md |
| task | CUST-WP-0042-T03 | done | — | workplans/CUST-WP-0042-workplan-state-model-cleanup.md |
| task | CUST-WP-0042-T04 | done | — | workplans/CUST-WP-0042-workplan-state-model-cleanup.md |
| task | CUST-WP-0042-T05 | done | — | workplans/CUST-WP-0042-workplan-state-model-cleanup.md |
| task | CUST-WP-0042-T06 | done | — | workplans/CUST-WP-0042-workplan-state-model-cleanup.md |
| task | CUST-WP-0042-T07 | done | — | workplans/CUST-WP-0042-workplan-state-model-cleanup.md |
| task | CUST-WP-0042-T08 | done | — | workplans/CUST-WP-0042-workplan-state-model-cleanup.md |
| task | STATE-WP-0043-T01 | done | — | workplans/STATE-WP-0043-dashboard-ui-experience.md |
| task | STATE-WP-0043-T02 | done | — | workplans/STATE-WP-0043-dashboard-ui-experience.md |
| task | STATE-WP-0043-T03 | done | — | workplans/STATE-WP-0043-dashboard-ui-experience.md |
| task | STATE-WP-0043-T04 | done | — | workplans/STATE-WP-0043-dashboard-ui-experience.md |
| task | STATE-WP-0043-T05 | done | — | workplans/STATE-WP-0043-dashboard-ui-experience.md |
| task | STATE-WP-0043-T06 | done | — | workplans/STATE-WP-0043-dashboard-ui-experience.md |
| task | STATE-WP-0043-T07 | done | — | workplans/STATE-WP-0043-dashboard-ui-experience.md |
| task | STATE-WP-0043-T08 | done | — | workplans/STATE-WP-0043-dashboard-ui-experience.md |
| task | STATE-WP-0044-T01 | done | — | workplans/STATE-WP-0044-recently-on-scope-digest.md |
| task | STATE-WP-0044-T02 | done | — | workplans/STATE-WP-0044-recently-on-scope-digest.md |
| task | STATE-WP-0044-T03 | done | — | workplans/STATE-WP-0044-recently-on-scope-digest.md |
| task | STATE-WP-0044-T04 | done | — | workplans/STATE-WP-0044-recently-on-scope-digest.md |
| task | STATE-WP-0044-T05 | done | — | workplans/STATE-WP-0044-recently-on-scope-digest.md |
| task | STATE-WP-0044-T06 | done | — | workplans/STATE-WP-0044-recently-on-scope-digest.md |
| task | STATE-WP-0045-T01 | done | — | workplans/STATE-WP-0045-token-measurement-accuracy.md |
| task | STATE-WP-0045-T02 | done | — | workplans/STATE-WP-0045-token-measurement-accuracy.md |
| task | STATE-WP-0045-T03 | done | — | workplans/STATE-WP-0045-token-measurement-accuracy.md |
| task | STATE-WP-0045-T04 | done | — | workplans/STATE-WP-0045-token-measurement-accuracy.md |
| task | STATE-WP-0045-T05 | done | — | workplans/STATE-WP-0045-token-measurement-accuracy.md |
| task | STATE-WP-0045-T06 | done | — | workplans/STATE-WP-0045-token-measurement-accuracy.md |
| task | STATE-WP-0045-T07 | done | — | workplans/STATE-WP-0045-token-measurement-accuracy.md |
| task | STATE-WP-0045-T08 | done | — | workplans/STATE-WP-0045-token-measurement-accuracy.md |
| task | STATE-WP-0046-T01 | done | — | workplans/STATE-WP-0046-workplan-id-normalization.md |
| task | STATE-WP-0046-T02 | done | — | workplans/STATE-WP-0046-workplan-id-normalization.md |
| task | STATE-WP-0046-T03 | done | — | workplans/STATE-WP-0046-workplan-id-normalization.md |
| task | STATE-WP-0046-T04 | done | — | workplans/STATE-WP-0046-workplan-id-normalization.md |
| task | STATE-WP-0046-T05 | done | — | workplans/STATE-WP-0046-workplan-id-normalization.md |
| task | STATE-WP-0046-T06 | done | — | workplans/STATE-WP-0046-workplan-id-normalization.md |
| task | STATE-WP-0047-T01 | done | — | workplans/STATE-WP-0047-lifecycle-assertions-and-renormalization.md |
| task | STATE-WP-0047-T02 | done | — | workplans/STATE-WP-0047-lifecycle-assertions-and-renormalization.md |
| task | STATE-WP-0047-T03 | done | — | workplans/STATE-WP-0047-lifecycle-assertions-and-renormalization.md |
| task | STATE-WP-0047-T04 | done | — | workplans/STATE-WP-0047-lifecycle-assertions-and-renormalization.md |
| task | STATE-WP-0047-T05 | done | — | workplans/STATE-WP-0047-lifecycle-assertions-and-renormalization.md |
| task | STATE-WP-0047-T06 | done | — | workplans/STATE-WP-0047-lifecycle-assertions-and-renormalization.md |
| task | STATE-WP-0047-T07 | done | — | workplans/STATE-WP-0047-lifecycle-assertions-and-renormalization.md |
| task | STATE-WP-0048-T01 | done | — | workplans/STATE-WP-0048-ui-state-change-reconciliation.md |
| task | STATE-WP-0048-T02 | done | — | workplans/STATE-WP-0048-ui-state-change-reconciliation.md |
| task | STATE-WP-0048-T03 | done | — | workplans/STATE-WP-0048-ui-state-change-reconciliation.md |
| task | STATE-WP-0048-T04 | done | — | workplans/STATE-WP-0048-ui-state-change-reconciliation.md |
| task | STATE-WP-0048-T05 | done | — | workplans/STATE-WP-0048-ui-state-change-reconciliation.md |
| task | STATE-WP-0048-T06 | done | — | workplans/STATE-WP-0048-ui-state-change-reconciliation.md |
| task | STATE-WP-0048-T07 | done | — | workplans/STATE-WP-0048-ui-state-change-reconciliation.md |
| task | STATE-WP-0049-T01 | done | — | workplans/STATE-WP-0049-workplan-execution-queue.md |
| task | STATE-WP-0049-T02 | done | — | workplans/STATE-WP-0049-workplan-execution-queue.md |
| task | STATE-WP-0049-T03 | done | — | workplans/STATE-WP-0049-workplan-execution-queue.md |
| task | STATE-WP-0049-T04 | done | — | workplans/STATE-WP-0049-workplan-execution-queue.md |
| task | STATE-WP-0049-T05 | done | — | workplans/STATE-WP-0049-workplan-execution-queue.md |
| task | STATE-WP-0049-T06 | done | — | workplans/STATE-WP-0049-workplan-execution-queue.md |
| task | STATE-WP-0049-T07 | done | — | workplans/STATE-WP-0049-workplan-execution-queue.md |
| task | STATE-WP-0050-T01 | done | — | workplans/STATE-WP-0050-railiance-fabric-graph-read-model-ingest.md |
| task | STATE-WP-0050-T02 | done | — | workplans/STATE-WP-0050-railiance-fabric-graph-read-model-ingest.md |
| task | STATE-WP-0050-T03 | done | — | workplans/STATE-WP-0050-railiance-fabric-graph-read-model-ingest.md |
| task | STATE-WP-0050-T04 | done | — | workplans/STATE-WP-0050-railiance-fabric-graph-read-model-ingest.md |
| task | STATE-WP-0051-T01 | done | — | workplans/STATE-WP-0051-financial-fabric-read-model-adaptation.md |
| task | STATE-WP-0051-T02 | done | — | workplans/STATE-WP-0051-financial-fabric-read-model-adaptation.md |
| task | STATE-WP-0051-T03 | done | — | workplans/STATE-WP-0051-financial-fabric-read-model-adaptation.md |
| task | STATE-WP-0051-T04 | done | — | workplans/STATE-WP-0051-financial-fabric-read-model-adaptation.md |
| task | STATE-WP-0051-T05 | done | — | workplans/STATE-WP-0051-financial-fabric-read-model-adaptation.md |
| task | STATE-WP-0051-T06 | done | — | workplans/STATE-WP-0051-financial-fabric-read-model-adaptation.md |
| task | STATE-WP-0052-T01 | done | — | workplans/STATE-WP-0052-task-state-canon-adaptation.md |
| task | STATE-WP-0052-T02 | done | — | workplans/STATE-WP-0052-task-state-canon-adaptation.md |
| task | STATE-WP-0052-T03 | done | — | workplans/STATE-WP-0052-task-state-canon-adaptation.md |
| task | STATE-WP-0052-T04 | done | — | workplans/STATE-WP-0052-task-state-canon-adaptation.md |
| task | STATE-WP-0052-T05 | done | — | workplans/STATE-WP-0052-task-state-canon-adaptation.md |
| task | STATE-WP-0052-T06 | done | — | workplans/STATE-WP-0052-task-state-canon-adaptation.md |
| task | STATE-WP-0052-T07 | done | — | workplans/STATE-WP-0052-task-state-canon-adaptation.md |
| task | STATE-WP-0052-T08 | done | — | workplans/STATE-WP-0052-task-state-canon-adaptation.md |
| task | STATE-WP-0052-T09 | done | — | workplans/STATE-WP-0052-task-state-canon-adaptation.md |
| task | STATE-WP-0052-T10 | done | — | workplans/STATE-WP-0052-task-state-canon-adaptation.md |
| task | STATE-WP-0053-T01 | done | — | workplans/STATE-WP-0053-wsjf-triage-review-page.md |
| task | STATE-WP-0053-T02 | done | — | workplans/STATE-WP-0053-wsjf-triage-review-page.md |
| task | STATE-WP-0053-T03 | done | — | workplans/STATE-WP-0053-wsjf-triage-review-page.md |
| task | STATE-WP-0053-T04 | done | — | workplans/STATE-WP-0053-wsjf-triage-review-page.md |
| task | STATE-WP-0053-T05 | done | — | workplans/STATE-WP-0053-wsjf-triage-review-page.md |
| task | STATE-WP-0053-T06 | done | — | workplans/STATE-WP-0053-wsjf-triage-review-page.md |
| task | STATE-WP-0054-T01 | done | — | workplans/STATE-WP-0054-workplan-terminology-transition-legacy-meter.md |
| task | STATE-WP-0054-T02 | done | — | workplans/STATE-WP-0054-workplan-terminology-transition-legacy-meter.md |
| task | STATE-WP-0054-T03 | done | — | workplans/STATE-WP-0054-workplan-terminology-transition-legacy-meter.md |
| task | STATE-WP-0054-T04 | done | — | workplans/STATE-WP-0054-workplan-terminology-transition-legacy-meter.md |
| task | STATE-WP-0054-T05 | done | — | workplans/STATE-WP-0054-workplan-terminology-transition-legacy-meter.md |
| task | STATE-WP-0054-T06 | done | — | workplans/STATE-WP-0054-workplan-terminology-transition-legacy-meter.md |
| task | STATE-WP-0054-T07 | done | — | workplans/STATE-WP-0054-workplan-terminology-transition-legacy-meter.md |
| task | STATE-WP-0054-T08 | done | — | workplans/STATE-WP-0054-workplan-terminology-transition-legacy-meter.md |
| task | STATE-WP-0055-T01 | done | — | workplans/STATE-WP-0055-statehub-register-cli.md |
| task | STATE-WP-0056-T01 | done | — | workplans/STATE-WP-0056-dashboard-loading-robustness.md |
| task | STATE-WP-0056-T02 | done | — | workplans/STATE-WP-0056-dashboard-loading-robustness.md |
| task | STATE-WP-0056-T03 | done | — | workplans/STATE-WP-0056-dashboard-loading-robustness.md |
| task | STATE-WP-0056-T04 | done | — | workplans/STATE-WP-0056-dashboard-loading-robustness.md |
| task | STATE-WP-0056-T05 | done | — | workplans/STATE-WP-0056-dashboard-loading-robustness.md |
| task | STATE-WP-0056-T06 | done | — | workplans/STATE-WP-0056-dashboard-loading-robustness.md |
| task | STATE-WP-0056-T07 | done | — | workplans/STATE-WP-0056-dashboard-loading-robustness.md |
| task | STATE-WP-0056-T08 | done | — | workplans/STATE-WP-0056-dashboard-loading-robustness.md |
| task | STATE-WP-0057-T01 | done | — | workplans/STATE-WP-0057-overview-workstream-stage-counts.md |
| task | STATE-WP-0057-T02 | done | — | workplans/STATE-WP-0057-overview-workstream-stage-counts.md |
| task | STATE-WP-0057-T03 | done | — | workplans/STATE-WP-0057-overview-workstream-stage-counts.md |
| task | STATE-WP-0057-T04 | done | — | workplans/STATE-WP-0057-overview-workstream-stage-counts.md |
| task | STATE-WP-0057-T05 | done | — | workplans/STATE-WP-0057-overview-workstream-stage-counts.md |
| task | STATE-WP-0057-T06 | done | — | workplans/STATE-WP-0057-overview-workstream-stage-counts.md |
| task | STATE-WP-0058-T01 | done | — | workplans/STATE-WP-0058-agent-skill-hub-interaction.md |
| task | STATE-WP-0058-T02 | done | — | workplans/STATE-WP-0058-agent-skill-hub-interaction.md |
| task | STATE-WP-0058-T03 | done | — | workplans/STATE-WP-0058-agent-skill-hub-interaction.md |
| task | STATE-WP-0059-T01 | done | — | workplans/STATE-WP-0059-mcp-write-layer-reliability.md |
| task | STATE-WP-0059-T02 | done | — | workplans/STATE-WP-0059-mcp-write-layer-reliability.md |
| task | STATE-WP-0059-T03 | done | — | workplans/STATE-WP-0059-mcp-write-layer-reliability.md |
| task | STATE-WP-0060-T01 | done | — | workplans/STATE-WP-0060-fix-consistency-cross-flavor.md |
| task | STATE-WP-0060-T02 | done | — | workplans/STATE-WP-0060-fix-consistency-cross-flavor.md |
| task | STATE-WP-0060-T03 | done | — | workplans/STATE-WP-0060-fix-consistency-cross-flavor.md |
| task | STATE-WP-0062-T01 | done | — | workplans/STATE-WP-0062-service-catalog-two-dimensions.md |
| task | STATE-WP-0062-T02 | done | — | workplans/STATE-WP-0062-service-catalog-two-dimensions.md |
| task | STATE-WP-0062-T03 | done | — | workplans/STATE-WP-0062-service-catalog-two-dimensions.md |
| task | STATE-WP-0062-T04 | done | — | workplans/STATE-WP-0062-service-catalog-two-dimensions.md |
| task | STATE-WP-0062-T05 | done | — | workplans/STATE-WP-0062-service-catalog-two-dimensions.md |
| task | STATE-WP-0063-T01 | done | — | workplans/STATE-WP-0063-weekend-automation-repair.md |
| task | STATE-WP-0063-T02 | done | — | workplans/STATE-WP-0063-weekend-automation-repair.md |
| task | STATE-WP-0063-T03 | done | — | workplans/STATE-WP-0063-weekend-automation-repair.md |
| task | STATE-WP-0063-T04 | done | — | workplans/STATE-WP-0063-weekend-automation-repair.md |
| task | STATE-WP-0063-T05 | done | — | workplans/STATE-WP-0063-weekend-automation-repair.md |
| task | STATE-WP-0064-T01 | done | — | workplans/STATE-WP-0064-statehub-consistency-sync-railiance01.md |
| task | STATE-WP-0064-T02 | done | — | workplans/STATE-WP-0064-statehub-consistency-sync-railiance01.md |
| task | STATE-WP-0064-T03 | done | — | workplans/STATE-WP-0064-statehub-consistency-sync-railiance01.md |
| task | STATE-WP-0064-T04 | done | — | workplans/STATE-WP-0064-statehub-consistency-sync-railiance01.md |
| task | STATE-WP-0064-T05 | done | — | workplans/STATE-WP-0064-statehub-consistency-sync-railiance01.md |
| task | STATE-WP-0065-T01 | done | — | workplans/STATE-WP-0065-repo-anchored-classification-spine.md |
| task | STATE-WP-0065-T02 | done | — | workplans/STATE-WP-0065-repo-anchored-classification-spine.md |
| task | STATE-WP-0065-T03 | done | — | workplans/STATE-WP-0065-repo-anchored-classification-spine.md |
| task | STATE-WP-0065-T04 | done | — | workplans/STATE-WP-0065-repo-anchored-classification-spine.md |
| task | STATE-WP-0066-T01 | done | — | workplans/STATE-WP-0066-state-summary-revision-cache.md |
| task | STATE-WP-0066-T02 | done | — | workplans/STATE-WP-0066-state-summary-revision-cache.md |
| task | STATE-WP-0066-T03 | done | — | workplans/STATE-WP-0066-state-summary-revision-cache.md |
| task | STATE-WP-0066-T04 | done | — | workplans/STATE-WP-0066-state-summary-revision-cache.md |
| task | STATE-WP-0066-T05 | done | — | workplans/STATE-WP-0066-state-summary-revision-cache.md |
| task | STATE-WP-0066-T06 | done | — | workplans/STATE-WP-0066-state-summary-revision-cache.md |
| task | STATE-WP-0066-T07 | done | — | workplans/STATE-WP-0066-state-summary-revision-cache.md |
| task | STATE-WP-0066-T08 | done | — | workplans/STATE-WP-0066-state-summary-revision-cache.md |
| task | STATE-WP-0067-T01 | done | — | workplans/STATE-WP-0067-attached-repo-agent-normalization.md |
| task | STATE-WP-0067-T02 | done | — | workplans/STATE-WP-0067-attached-repo-agent-normalization.md |
| task | STATE-WP-0067-T03 | done | — | workplans/STATE-WP-0067-attached-repo-agent-normalization.md |
| task | STATE-WP-0067-T04 | done | — | workplans/STATE-WP-0067-attached-repo-agent-normalization.md |
| task | STATE-WP-0067-T05 | done | — | workplans/STATE-WP-0067-attached-repo-agent-normalization.md |
| task | STATE-WP-0067-T06 | done | — | workplans/STATE-WP-0067-attached-repo-agent-normalization.md |
| task | STATE-WP-0068-T01 | done | — | workplans/STATE-WP-0068-offline-write-buffer-and-edge-relay.md |
| task | STATE-WP-0068-T02 | done | — | workplans/STATE-WP-0068-offline-write-buffer-and-edge-relay.md |
| task | STATE-WP-0068-T03 | done | — | workplans/STATE-WP-0068-offline-write-buffer-and-edge-relay.md |
| task | STATE-WP-0068-T04 | done | — | workplans/STATE-WP-0068-offline-write-buffer-and-edge-relay.md |
| task | STATE-WP-0068-T05 | done | — | workplans/STATE-WP-0068-offline-write-buffer-and-edge-relay.md |
| task | STATE-WP-0068-T06 | done | — | workplans/STATE-WP-0068-offline-write-buffer-and-edge-relay.md |
| task | STATE-WP-0068-T07 | done | — | workplans/STATE-WP-0068-offline-write-buffer-and-edge-relay.md |
| task | STATE-WP-0068-T08 | done | — | workplans/STATE-WP-0068-offline-write-buffer-and-edge-relay.md |
| task | STATE-WP-0068-T09 | done | — | workplans/STATE-WP-0068-offline-write-buffer-and-edge-relay.md |
| task | STATE-WP-0071-T01 | done | — | workplans/STATE-WP-0071-railiance01-workstream-caller-deploy.md |
| task | STATE-WP-0071-T02 | done | — | workplans/STATE-WP-0071-railiance01-workstream-caller-deploy.md |
| task | STATE-WP-0071-T03 | done | — | workplans/STATE-WP-0071-railiance01-workstream-caller-deploy.md |
| task | STATE-WP-0071-T04 | done | — | workplans/STATE-WP-0071-railiance01-workstream-caller-deploy.md |
| task | STATE-WP-0071-T05 | done | — | workplans/STATE-WP-0071-railiance01-workstream-caller-deploy.md |
| task | STATE-WP-0071-T06 | done | — | workplans/STATE-WP-0071-railiance01-workstream-caller-deploy.md |
| task | STATE-WP-0072-T01 | done | — | workplans/STATE-WP-0072-workstream-caller-remediation.md |
| task | STATE-WP-0072-T02 | done | — | workplans/STATE-WP-0072-workstream-caller-remediation.md |
| task | STATE-WP-0072-T03 | done | — | workplans/STATE-WP-0072-workstream-caller-remediation.md |
| task | STATE-WP-0072-T04 | done | — | workplans/STATE-WP-0072-workstream-caller-remediation.md |
| task | STATE-WP-0073-T01 | done | — | workplans/STATE-WP-0073-legacy-meter-decline-and-remote-audit.md |
| task | STATE-WP-0073-T02 | done | — | workplans/STATE-WP-0073-legacy-meter-decline-and-remote-audit.md |
| task | STATE-WP-0073-T03 | done | — | workplans/STATE-WP-0073-legacy-meter-decline-and-remote-audit.md |
| task | STATE-WP-0073-T04 | done | — | workplans/STATE-WP-0073-legacy-meter-decline-and-remote-audit.md |
| task | STATE-WP-0073-T05 | cancel | — | workplans/STATE-WP-0073-legacy-meter-decline-and-remote-audit.md |
| task | STATE-WP-0074-T01 | done | — | workplans/STATE-WP-0074-hub-ecosystem-boundary-alignment.md |
| task | STATE-WP-0074-T02 | done | — | workplans/STATE-WP-0074-hub-ecosystem-boundary-alignment.md |
| task | STATE-WP-0074-T03 | done | — | workplans/STATE-WP-0074-hub-ecosystem-boundary-alignment.md |
| task | STATE-WP-0074-T04 | done | — | workplans/STATE-WP-0074-hub-ecosystem-boundary-alignment.md |
| task | STATE-WP-0074-T05 | done | — | workplans/STATE-WP-0074-hub-ecosystem-boundary-alignment.md |
| task | STATE-WP-0075-T01 | done | — | workplans/STATE-WP-0075-workstream-route-410-stranglers.md |
| task | STATE-WP-0075-T02 | done | — | workplans/STATE-WP-0075-workstream-route-410-stranglers.md |
| task | STATE-WP-0075-T03 | done | — | workplans/STATE-WP-0075-workstream-route-410-stranglers.md |
| task | STATE-WP-0075-T04 | done | — | workplans/STATE-WP-0075-workstream-route-410-stranglers.md |
| task | STATE-WP-0076-T01 | done | — | workplans/STATE-WP-0076-definition-of-ready-and-comprehension.md |
| task | STATE-WP-0076-T02 | done | — | workplans/STATE-WP-0076-definition-of-ready-and-comprehension.md |
| task | STATE-WP-0076-T03 | done | — | workplans/STATE-WP-0076-definition-of-ready-and-comprehension.md |
| task | STATE-WP-0076-T04 | done | — | workplans/STATE-WP-0076-definition-of-ready-and-comprehension.md |
| task | STATE-WP-0076-T05 | cancel | — | workplans/STATE-WP-0076-definition-of-ready-and-comprehension.md |
| task | STATE-WP-0076-T06 | cancel | — | workplans/STATE-WP-0076-definition-of-ready-and-comprehension.md |
| task | STATE-WP-0077-T01 | done | — | workplans/STATE-WP-0077-dox-assessment-recording-and-soft-visibility.md |
| task | STATE-WP-0077-T02 | done | — | workplans/STATE-WP-0077-dox-assessment-recording-and-soft-visibility.md |
| task | STATE-WP-0077-T03 | done | — | workplans/STATE-WP-0077-dox-assessment-recording-and-soft-visibility.md |
| task | STATE-WP-0077-T04 | done | — | workplans/STATE-WP-0077-dox-assessment-recording-and-soft-visibility.md |
| task | STATE-WP-0078-T01 | done | — | workplans/STATE-WP-0078-ops-run-read-projection.md |
| task | STATE-WP-0078-T02 | done | — | workplans/STATE-WP-0078-ops-run-read-projection.md |
| task | STATE-WP-0078-T03 | done | — | workplans/STATE-WP-0078-ops-run-read-projection.md |
| task | STATE-WP-0079-T01 | done | — | workplans/STATE-WP-0079-retirement-strangler.md |
| task | STATE-WP-0079-T02 | done | — | workplans/STATE-WP-0079-retirement-strangler.md |
| task | STATE-WP-0079-T03 | done | — | workplans/STATE-WP-0079-retirement-strangler.md |
| task | STATE-WP-0079-T04 | wait | — | workplans/STATE-WP-0079-retirement-strangler.md |
| task | STATE-WP-0079-T05 | wait | — | workplans/STATE-WP-0079-retirement-strangler.md |
| task | STATE-WP-0079-T06 | wait | — | workplans/STATE-WP-0079-retirement-strangler.md |
| task | STATE-WP-0079-T07 | wait | — | workplans/STATE-WP-0079-retirement-strangler.md |
| task | STATE-WP-0079-T08 | done | — | workplans/STATE-WP-0079-retirement-strangler.md |
| task | STATE-WP-0079-T09 | wait | — | workplans/STATE-WP-0079-retirement-strangler.md |
| task | STATE-WP-0080-T01 | done | — | workplans/STATE-WP-0080-register-project-flavor-awareness.md |
| task | STATE-WP-0080-T02 | done | — | workplans/STATE-WP-0080-register-project-flavor-awareness.md |
| task | STATE-WP-0080-T03 | done | — | workplans/STATE-WP-0080-register-project-flavor-awareness.md |
| task | STATE-WP-0080-T04 | cancel | — | workplans/STATE-WP-0080-register-project-flavor-awareness.md |
| task | STATE-WP-0080-T05 | cancel | — | workplans/STATE-WP-0080-register-project-flavor-awareness.md |
| task | STATE-WP-0080-T06 | cancel | — | workplans/STATE-WP-0080-register-project-flavor-awareness.md |
| task | STATE-WP-0080-T07 | cancel | — | workplans/STATE-WP-0080-register-project-flavor-awareness.md |
| task | STATE-WP-0080-T08 | cancel | — | workplans/STATE-WP-0080-register-project-flavor-awareness.md |
| task | STATE-WP-0081-T01 | cancel | — | workplans/STATE-WP-0081-cluster-self-sufficiency-and-registrar.md |
| task | STATE-WP-0081-T02 | cancel | — | workplans/STATE-WP-0081-cluster-self-sufficiency-and-registrar.md |
| task | STATE-WP-0081-T03 | done | — | workplans/STATE-WP-0081-cluster-self-sufficiency-and-registrar.md |
| task | STATE-WP-0081-T04 | done | — | workplans/STATE-WP-0081-cluster-self-sufficiency-and-registrar.md |
| task | STATE-WP-0081-T05 | done | — | workplans/STATE-WP-0081-cluster-self-sufficiency-and-registrar.md |
| task | STATE-WP-0081-T06 | done | — | workplans/STATE-WP-0081-cluster-self-sufficiency-and-registrar.md |
| task | STATE-WP-0081-T07 | done | — | workplans/STATE-WP-0081-cluster-self-sufficiency-and-registrar.md |
| task | STATE-WP-0081-T08 | done | — | workplans/STATE-WP-0081-cluster-self-sufficiency-and-registrar.md |
| task | STATE-WP-0082-T01 | done | — | workplans/STATE-WP-0082-multi-owner-review-contracts-and-receipts.md |
| task | STATE-WP-0082-T02 | done | — | workplans/STATE-WP-0082-multi-owner-review-contracts-and-receipts.md |
| task | STATE-WP-0082-T03 | done | — | workplans/STATE-WP-0082-multi-owner-review-contracts-and-receipts.md |
| task | STATE-WP-0082-T04 | done | — | workplans/STATE-WP-0082-multi-owner-review-contracts-and-receipts.md |
| task | STATE-WP-0082-T05 | done | — | workplans/STATE-WP-0082-multi-owner-review-contracts-and-receipts.md |
| task | STATE-WP-0082-T06 | done | — | workplans/STATE-WP-0082-multi-owner-review-contracts-and-receipts.md |
| task | STATE-WP-0083-T01 | done | — | workplans/STATE-WP-0083-forge-derived-projection-reset.md |
| task | STATE-WP-0083-T02 | done | — | workplans/STATE-WP-0083-forge-derived-projection-reset.md |
| task | STATE-WP-0083-T03 | done | — | workplans/STATE-WP-0083-forge-derived-projection-reset.md |
| task | STATE-WP-0083-T04 | done | — | workplans/STATE-WP-0083-forge-derived-projection-reset.md |
| task | STATE-WP-0083-T05 | done | — | workplans/STATE-WP-0083-forge-derived-projection-reset.md |
| task | STATE-WP-0083-T06 | done | — | workplans/STATE-WP-0083-forge-derived-projection-reset.md |
| task | STATE-WP-0083-T07 | done | — | workplans/STATE-WP-0083-forge-derived-projection-reset.md |
| task | STATE-WP-0083-T08 | done | — | workplans/STATE-WP-0083-forge-derived-projection-reset.md |
| task | STATE-WP-0084-T01 | done | — | workplans/STATE-WP-0084-forge-read-for-private-repositories.md |
| task | STATE-WP-0084-T02 | done | — | workplans/STATE-WP-0084-forge-read-for-private-repositories.md |
| task | STATE-WP-0084-T03 | done | — | workplans/STATE-WP-0084-forge-read-for-private-repositories.md |
| task | STATE-WP-0084-T04 | done | — | workplans/STATE-WP-0084-forge-read-for-private-repositories.md |
| task | STATE-WP-0085-T01 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
| task | STATE-WP-0085-T02 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
| task | STATE-WP-0085-T03 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
| task | STATE-WP-0085-T04 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
| task | STATE-WP-0085-T05 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
| task | STATE-WP-0085-T06 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
| task | STATE-WP-0085-T07 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
| task | STATE-WP-0085-T08 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
| task | STATE-WP-0085-T09 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
| task | STATE-WP-0086-T01 | done | — | workplans/STATE-WP-0086-fast-forge-projection-reconcile.md |
| task | STATE-WP-0086-T02 | done | — | workplans/STATE-WP-0086-fast-forge-projection-reconcile.md |
| task | STATE-WP-0086-T03 | done | — | workplans/STATE-WP-0086-fast-forge-projection-reconcile.md |
| task | STATE-WP-0087-T01 | done | — | workplans/STATE-WP-0087-bounded-repository-collection-projection.md |
| task | STATE-WP-0088-T01 | done | — | workplans/STATE-WP-0088-preflight-signing-runtime-acceptance.md |
| task | STATE-WP-0088-T02 | done | — | workplans/STATE-WP-0088-preflight-signing-runtime-acceptance.md |
| task | STATE-WP-0089-T01 | done | — | workplans/STATE-WP-0089-rename-redirect-recovery.md |
| task | STATE-WP-0089-T02 | done | — | workplans/STATE-WP-0089-rename-redirect-recovery.md |
| task | STATE-WP-0090-T01 | done | — | workplans/STATE-WP-0090-projection-task-field-convergence.md |
| task | STATE-WP-0090-T02 | done | — | workplans/STATE-WP-0090-projection-task-field-convergence.md |
| task | STATE-WP-0090-T03 | done | — | workplans/STATE-WP-0090-projection-task-field-convergence.md |
| task | STATE-WP-0091-T01 | done | — | workplans/STATE-WP-0091-release-headroom.md |
| task | STATE-WP-0091-T02 | done | — | workplans/STATE-WP-0091-release-headroom.md |
| task | STATE-WP-0092-T01 | done | — | workplans/STATE-WP-0092-workplan-flavor-and-depends-on-policy.md |
| task | STATE-WP-0092-T02 | done | — | workplans/STATE-WP-0092-workplan-flavor-and-depends-on-policy.md |
| task | STATE-WP-0092-T03 | done | — | workplans/STATE-WP-0092-workplan-flavor-and-depends-on-policy.md |
| task | STATE-WP-0092-T04 | done | — | workplans/STATE-WP-0092-workplan-flavor-and-depends-on-policy.md |
| task | STATE-WP-0092-T05 | done | — | workplans/STATE-WP-0092-workplan-flavor-and-depends-on-policy.md |

View file

@ -9,8 +9,16 @@ import re
from dataclasses import dataclass, field
from pathlib import Path
import os
import yaml
# Explicit override first — every other candidate below is a developer
# workstation path, so in a container none of them exist and classification
# validation fails outright. Set REPO_CLASSIFICATION_ALLOWED_PATH in any
# deployment that does not carry a the-custodian checkout (CUST-WP-0067-T04).
_ENV_ALLOWED = os.environ.get("REPO_CLASSIFICATION_ALLOWED_PATH")
# Workstation checkout, railiance01 fleet checkout, then state-hub sibling fallback.
_PRIMARY_ALLOWED = Path(
"/home/worsch/the-custodian/canon/standards/repo-classification.allowed.yaml"
@ -70,12 +78,21 @@ class ClassificationData:
def _allowed_path() -> Path:
if _ENV_ALLOWED:
candidate = Path(_ENV_ALLOWED)
if candidate.is_file():
return candidate
raise FileNotFoundError(
f"REPO_CLASSIFICATION_ALLOWED_PATH is set to {candidate}, which is not a file"
)
for candidate in (_PRIMARY_ALLOWED, _RAILIANCE_ALLOWED, _FALLBACK_ALLOWED):
if candidate.is_file():
return candidate
raise FileNotFoundError(
"repo-classification.allowed.yaml not found at "
f"{_PRIMARY_ALLOWED}, {_RAILIANCE_ALLOWED}, or {_FALLBACK_ALLOWED}"
f"{_PRIMARY_ALLOWED}, {_RAILIANCE_ALLOWED}, or {_FALLBACK_ALLOWED}. "
"Set REPO_CLASSIFICATION_ALLOWED_PATH when running without a "
"the-custodian checkout (for example in a container)."
)

View file

@ -1,3 +1,5 @@
from typing import Literal
from pydantic_settings import BaseSettings, SettingsConfigDict
@ -10,9 +12,36 @@ class Settings(BaseSettings):
database_url: str = "postgresql+asyncpg://custodian:changeme@127.0.0.1:5432/custodian"
api_base: str = "http://127.0.0.1:8000"
# What this instance claims to be. Deliberately defaults to "unknown":
# an instance that has not declared itself is not the primary, and callers
# that need the authoritative hub must be able to tell the difference.
# Answering on a port is not evidence of authority — that assumption cost
# seven weeks of onboarding (CUST-WP-0067-T03, ADR-010).
state_hub_instance_role: Literal["primary", "cache", "unknown"] = "unknown"
# Env vars are STATE_HUB_INSTANCE_ROLE / STATE_HUB_INSTANCE_LABEL — the
# field name *is* the binding, so renaming either breaks the deployment
# silently. Covered by tests/test_instance_identity.py.
# Free-form label to name *which* instance answered, e.g. "railiance01".
state_hub_instance_label: str | None = None
debug: bool = False
state_hub_report_dir: str = "reports/recently-on-scope"
state_hub_markitect_cli_path: str | None = None
activity_core_url: str | None = None
activity_core_worker_token: str | None = None
ops_run_projection_ttl_seconds: float = 15.0
ops_run_sla_hours: float = 1.0
sbom_nexus_url: str | None = None
sbom_nexus_read_mode: Literal["legacy", "nexus"] = "legacy"
sbom_nexus_write_mode: Literal["legacy", "nexus"] = "legacy"
sbom_nexus_timeout_seconds: float = 5.0
# Repository renames are central-only, compare-and-set operations. The
# secret signs short-lived, non-persistent preflight evidence; it must be
# supplied by the deployment (normally through OpenBao), never recorded in
# State Hub. With no secret the read-only report remains available but no
# mutation token can be issued.
repository_rename_preflight_secret: str | None = None
repository_rename_preflight_ttl_seconds: int = 900
repository_rename_forge_timeout_seconds: float = 10.0
settings = Settings()

View file

@ -12,7 +12,7 @@ from starlette.responses import Response as StarletteResponse
from api.database import engine
from api.events import shutdown_publisher
from api.services.write_idempotency import WriteIdempotencyMiddleware
from api.routers import decisions, extension_points, progress, state, suggestions, tasks, technical_debt, topics, workstreams, workstream_dependencies
from api.routers import decisions, extension_points, intake, ops_runs, progress, state, suggestions, tasks, technical_debt, topics, workstreams, workstream_dependencies
from api.routers import domains, repos, contributions, sbom, policy, domain_goals, repo_goals, messages, capability_requests, tpsc, services
from api.routers import token_events
from api.routers import interface_changes
@ -23,6 +23,10 @@ from api.routers import reconciliation
from api.routers import execution
from api.routers import fabric
from api.routers import legacy_meter
from api.routers import review_contracts
from api.routers import identifier_migrations
from api.routers import repository_renames
from api.routers import work_record_projection
class ETagMiddleware(BaseHTTPMiddleware):
@ -107,6 +111,9 @@ app.include_router(recently_on_scope.hourly_router)
app.include_router(recently_on_scope.router)
app.include_router(consistency_sweep.router)
app.include_router(repos.router)
app.include_router(repository_renames.router)
app.include_router(repository_renames.operation_router)
app.include_router(work_record_projection.router)
app.include_router(topics.router)
app.include_router(workstreams.router)
app.include_router(workstreams.workplan_router)
@ -114,6 +121,7 @@ app.include_router(workstream_dependencies.router)
app.include_router(workstream_dependencies.workplan_router)
app.include_router(tasks.router)
app.include_router(decisions.router)
app.include_router(intake.router)
app.include_router(extension_points.router)
app.include_router(technical_debt.router)
app.include_router(progress.router)
@ -133,7 +141,10 @@ app.include_router(reconciliation.router)
app.include_router(execution.router)
app.include_router(fabric.router)
app.include_router(legacy_meter.router)
app.include_router(review_contracts.router)
app.include_router(identifier_migrations.router)
app.include_router(state.router)
app.include_router(ops_runs.router)
app.include_router(policy.router)

View file

@ -10,6 +10,13 @@ from api.models.workstream import Workstream
from api.models.workstream_dependency import WorkstreamDependency
from api.models.task import Task, TaskStatus, TaskPriority
from api.models.decision import Decision, DecisionType, DecisionStatus
from api.models.intake import (
Intake,
IntakeNote,
IntakeLane,
IntakeStatus,
IntakeOutcome,
)
from api.models.progress_event import ProgressEvent
from api.models.extension_point import ExtensionPoint, EPStatus
from api.models.technical_debt import TechnicalDebt, TDStatus
@ -34,6 +41,13 @@ from api.models.workplan_launch_request import WorkplanLaunchRequest
from api.models.fabric_graph import FabricGraphImport, FabricGraphNode, FabricGraphEdge
from api.models.legacy_meter import LegacyInterface, LegacyInterfaceUsageBucket
from api.models.write_idempotency_key import WriteIdempotencyKey
from api.models.work_record_identifier_alias import WorkRecordIdentifierAlias
from api.models.review_contract import ReviewContract, ReviewReceipt
from api.models.repository_rename import (
RepositoryForgeIdentity,
RepositoryRenameOperation,
RepositorySlug,
)
from api.models.suggestion import (
Suggestion,
SuggestionNote,
@ -54,6 +68,7 @@ __all__ = [
"WorkstreamDependency",
"Task", "TaskStatus", "TaskPriority",
"Decision", "DecisionType", "DecisionStatus",
"Intake", "IntakeNote", "IntakeLane", "IntakeStatus", "IntakeOutcome",
"ProgressEvent",
"ExtensionPoint", "EPStatus",
"TechnicalDebt", "TDStatus",
@ -73,5 +88,8 @@ __all__ = [
"FabricGraphImport", "FabricGraphNode", "FabricGraphEdge",
"LegacyInterface", "LegacyInterfaceUsageBucket",
"WriteIdempotencyKey",
"WorkRecordIdentifierAlias",
"ReviewContract", "ReviewReceipt",
"RepositoryForgeIdentity", "RepositoryRenameOperation", "RepositorySlug",
"Suggestion", "SuggestionNote", "SuggestionRelevanceBump", "SuggestionStage",
]
]

View file

@ -1,3 +1,5 @@
import os
import time
import uuid
from datetime import datetime
@ -24,3 +26,28 @@ class TimestampMixin:
def new_uuid() -> uuid.UUID:
return uuid.uuid4()
def new_uuid7() -> uuid.UUID:
"""Generate a UUIDv7 (RFC 9562): 48-bit big-endian ms timestamp, version
and variant bits, remaining bits random. Time-sortable, so primary keys
generated with this helper order chronologically without a separate
created_at index lookup the identity layering canon
(work-record-types_v0.1.md) calls this out as the primary internal key
for new work-record entities.
Dependency-free (no uuid7 in stdlib before Python 3.14, no third-party
lib added for a ~15-line, non-cryptographic layout).
"""
unix_ts_ms = int(time.time() * 1000)
rand = int.from_bytes(os.urandom(10), "big")
rand_a = (rand >> 62) & 0x0FFF # top 12 bits of the 80 random bits
rand_b = rand & 0x3FFFFFFFFFFFFFFF # bottom 62 bits
value = (
(unix_ts_ms << 80)
| (0x7 << 76) # version 7
| (rand_a << 64)
| (0x2 << 62) # variant 10
| rand_b
)
return uuid.UUID(int=value)

View file

@ -33,7 +33,7 @@ class CapabilityRequest(Base, TimestampMixin):
)
requesting_workplan_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("workplans.id", ondelete="SET NULL"),
ForeignKey("workplans.id", ondelete="SET NULL", onupdate="CASCADE"),
nullable=True,
)
requesting_agent: Mapped[str] = mapped_column(String(100), nullable=False)
@ -47,7 +47,7 @@ class CapabilityRequest(Base, TimestampMixin):
)
fulfilling_workplan_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("workplans.id", ondelete="SET NULL"),
ForeignKey("workplans.id", ondelete="SET NULL", onupdate="CASCADE"),
nullable=True,
)
fulfilling_agent: Mapped[str | None] = mapped_column(String(100), nullable=True)
@ -55,7 +55,7 @@ class CapabilityRequest(Base, TimestampMixin):
# Links
blocking_task_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("tasks.id", ondelete="SET NULL"),
ForeignKey("tasks.id", ondelete="SET NULL", onupdate="CASCADE"),
nullable=True,
)
catalog_entry_id: Mapped[uuid.UUID | None] = mapped_column(

View file

@ -2,7 +2,7 @@ import enum
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String, Text
from sqlalchemy import DateTime, Enum, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
@ -48,7 +48,9 @@ class Contribution(Base, TimestampMixin):
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
)
related_workplan_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True
UUID(as_uuid=True),
ForeignKey("workplans.id", ondelete="SET NULL", onupdate="CASCADE"),
nullable=True,
)
repo_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True

View file

@ -37,7 +37,10 @@ class Decision(Base, TimestampMixin):
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=True, index=True
)
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="RESTRICT"), nullable=True, index=True
UUID(as_uuid=True),
ForeignKey("workplans.id", ondelete="RESTRICT", onupdate="CASCADE"),
nullable=True,
index=True,
)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)

View file

@ -45,7 +45,9 @@ class ExtensionPoint(Base, TimestampMixin):
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
)
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True
UUID(as_uuid=True),
ForeignKey("workplans.id", ondelete="SET NULL", onupdate="CASCADE"),
nullable=True,
)
domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821

View file

@ -23,7 +23,7 @@ class FabricGraphImport(Base, TimestampMixin):
)
source_repo_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
source_url: Mapped[str | None] = mapped_column(Text, nullable=True)
source_commit: Mapped[str | None] = mapped_column(String(80), nullable=True, index=True)
source_commit: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
source_path: Mapped[str | None] = mapped_column(Text, nullable=True)
api_version: Mapped[str | None] = mapped_column(String(100), nullable=True)
schema_version: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)

129
api/models/intake.py Normal file
View file

@ -0,0 +1,129 @@
import enum
import uuid
from datetime import datetime
from sqlalchemy import CheckConstraint, DateTime, Enum, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from api.models.base import Base, TimestampMixin, new_uuid7
class IntakeLane(str, enum.Enum):
green = "green"
blue = "blue"
yellow = "yellow"
orange = "orange"
red = "red"
class IntakeStatus(str, enum.Enum):
open = "open"
vetted = "vetted"
routed = "routed"
closed = "closed"
class IntakeOutcome(str, enum.Enum):
promoted = "promoted"
declined = "declined"
absorbed = "absorbed"
OPEN_INTAKE_STATUSES = (IntakeStatus.open, IntakeStatus.vetted, IntakeStatus.routed)
class Intake(Base, TimestampMixin):
"""A `kind: intake` work record — a spark: idea, finding, directive, or
request, per canon/standards/work-record-types_v0.1.md. Lifecycle:
open -> vetted -> routed -> closed(promoted|declined|absorbed).
Fresh entity per the founder-reviewed architecture draft (2026-07-20,
WorkOrchestrationArchitectureDraft.md §8 item 6): not a rename/reuse of
the legacy `suggestions` table.
"""
__tablename__ = "intakes"
__table_args__ = (
CheckConstraint(
"topic_id IS NOT NULL OR workplan_id IS NOT NULL OR repo_id IS NOT NULL",
name="ck_intakes_topic_or_workplan_or_repo",
),
CheckConstraint(
"(status != 'closed') OR (outcome IS NOT NULL)",
name="ck_intakes_closed_requires_outcome",
),
CheckConstraint(
"(outcome != 'promoted') OR (promoted_to IS NOT NULL)",
name="ck_intakes_promoted_requires_promoted_to",
),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid7
)
topic_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True, index=True
)
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("workplans.id", ondelete="SET NULL", onupdate="CASCADE"),
nullable=True,
index=True,
)
repo_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True, index=True
)
title: Mapped[str] = mapped_column(String(500), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
lane: Mapped[IntakeLane] = mapped_column(
Enum(IntakeLane, name="intakelane"), nullable=False, default=IntakeLane.green
)
status: Mapped[IntakeStatus] = mapped_column(
Enum(IntakeStatus, name="intakestatus"),
nullable=False,
default=IntakeStatus.open,
index=True,
)
outcome: Mapped[IntakeOutcome | None] = mapped_column(
Enum(IntakeOutcome, name="intakeoutcome"), nullable=True
)
origin: Mapped[str | None] = mapped_column(String(200), nullable=True)
origin_ref: Mapped[str | None] = mapped_column(String(200), nullable=True, index=True)
promoted_to: Mapped[str | None] = mapped_column(String(200), nullable=True)
source_repo_path: Mapped[str | None] = mapped_column(
Text, nullable=True, doc="Repo-relative path of the source file this record was authored in."
)
routed_note: Mapped[str | None] = mapped_column(Text, nullable=True)
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
topic: Mapped["Topic | None"] = relationship("Topic", lazy="selectin") # noqa: F821
workplan: Mapped["Workplan | None"] = relationship("Workplan", lazy="selectin") # noqa: F821
repo: Mapped["ManagedRepo | None"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821
notes: Mapped[list["IntakeNote"]] = relationship(
"IntakeNote",
back_populates="intake",
lazy="selectin",
order_by="IntakeNote.created_at",
cascade="all, delete-orphan",
)
class IntakeNote(Base):
__tablename__ = "intake_notes"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=new_uuid7)
intake_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("intakes.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
author: Mapped[str | None] = mapped_column(String(100), nullable=True)
content: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
intake: Mapped["Intake"] = relationship("Intake", back_populates="notes")

View file

@ -53,6 +53,21 @@ class ManagedRepo(Base, TimestampMixin):
"RepoGoal", back_populates="repo", lazy="selectin"
)
forge_identity: Mapped["RepositoryForgeIdentity | None"] = relationship( # noqa: F821
"RepositoryForgeIdentity",
back_populates="repo",
lazy="selectin",
uselist=False,
)
slug_records: Mapped[list["RepositorySlug"]] = relationship( # noqa: F821
"RepositorySlug", back_populates="repo", lazy="selectin"
)
rename_operations: Mapped[list["RepositoryRenameOperation"]] = relationship( # noqa: F821
"RepositoryRenameOperation", back_populates="repo", lazy="selectin"
)
@property
def domain_slug(self) -> str:
return self.domain.slug if self.domain is not None else ""

View file

@ -20,10 +20,16 @@ class ProgressEvent(Base):
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=True, index=True
)
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="RESTRICT"), nullable=True, index=True
UUID(as_uuid=True),
ForeignKey("workplans.id", ondelete="RESTRICT", onupdate="CASCADE"),
nullable=True,
index=True,
)
task_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="RESTRICT"), nullable=True, index=True
UUID(as_uuid=True),
ForeignKey("tasks.id", ondelete="RESTRICT", onupdate="CASCADE"),
nullable=True,
index=True,
)
decision_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("decisions.id", ondelete="RESTRICT"), nullable=True, index=True

View file

@ -0,0 +1,292 @@
import uuid
from datetime import datetime
from sqlalchemy import (
BigInteger,
Boolean,
CheckConstraint,
DateTime,
ForeignKey,
ForeignKeyConstraint,
Index,
String,
Text,
UniqueConstraint,
text,
)
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.models.base import Base, TimestampMixin, new_uuid
FORGE_IDENTITY_STATES = ("unverified", "verified")
REPOSITORY_SLUG_KINDS = ("canonical", "alias")
REPOSITORY_RENAME_PHASES = (
"draft",
"preflighted",
"forge-renamed",
"statehub-rebound",
"source-synced",
"consumers-verified",
"completed",
"rollback-preflight",
"rolled-back",
)
TERMINAL_REPOSITORY_RENAME_PHASES = ("completed", "rolled-back")
class RepositoryForgeIdentity(Base, TimestampMixin):
"""One explicitly verified (or explicitly unverified) Forge identity per repo."""
__tablename__ = "repository_forge_identities"
__table_args__ = (
UniqueConstraint("repo_id", name="uq_repository_forge_identities_repo_id"),
UniqueConstraint(
"id",
"repo_id",
"verification_state",
name="uq_repository_forge_identity_verified_ref",
),
UniqueConstraint(
"provider",
"forge_instance",
"forge_owner",
"forge_repository_id",
name="uq_repository_forge_identity_tuple",
),
CheckConstraint(
"verification_state IN ('unverified', 'verified')",
name="ck_repository_forge_identity_state",
),
CheckConstraint(
"forge_repository_id IS NULL OR forge_repository_id > 0",
name="ck_repository_forge_id_positive",
),
CheckConstraint(
"verification_state != 'verified' OR "
"(provider IS NOT NULL AND forge_instance IS NOT NULL "
"AND forge_owner IS NOT NULL AND forge_repository_id IS NOT NULL "
"AND verified_at IS NOT NULL AND verified_by IS NOT NULL)",
name="ck_repository_forge_verified_complete",
),
CheckConstraint(
"verification_state = 'verified' OR "
"(verified_at IS NULL AND verified_by IS NULL)",
name="ck_repository_forge_unverified_has_no_attestation",
),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid
)
repo_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("managed_repos.id", ondelete="RESTRICT"),
nullable=False,
)
provider: Mapped[str | None] = mapped_column(String(40), nullable=True)
forge_instance: Mapped[str | None] = mapped_column(String(255), nullable=True)
forge_owner: Mapped[str | None] = mapped_column(String(255), nullable=True)
forge_repository_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
verification_state: Mapped[str] = mapped_column(
String(20), nullable=False, default="unverified", server_default="unverified"
)
verified_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
verified_by: Mapped[str | None] = mapped_column(String(160), nullable=True)
verification_evidence: Mapped[dict] = mapped_column(
JSONB, nullable=False, default=dict, server_default="{}"
)
repo: Mapped["ManagedRepo"] = relationship( # noqa: F821
"ManagedRepo", back_populates="forge_identity", lazy="selectin"
)
rename_operations: Mapped[list["RepositoryRenameOperation"]] = relationship(
"RepositoryRenameOperation",
back_populates="forge_identity",
lazy="selectin",
viewonly=True,
)
class RepositoryRenameOperation(Base, TimestampMixin):
"""Append-preserving journal for one phased canonical-coordinate change."""
__tablename__ = "repository_rename_operations"
__table_args__ = (
CheckConstraint(
"phase IN ('draft', 'preflighted', 'forge-renamed', "
"'statehub-rebound', 'source-synced', 'consumers-verified', "
"'completed', 'rollback-preflight', 'rolled-back')",
name="ck_repository_rename_phase",
),
CheckConstraint(
"old_slug != new_slug", name="ck_repository_rename_distinct_slugs"
),
CheckConstraint(
"expected_forge_repository_id > 0",
name="ck_repository_rename_forge_id_positive",
),
CheckConstraint(
"forge_identity_state = 'verified'",
name="ck_repository_rename_verified_identity",
),
CheckConstraint(
"phase = 'draft' OR preflighted_at IS NOT NULL",
name="ck_repository_rename_preflight_timestamp",
),
CheckConstraint(
"phase != 'completed' OR completed_at IS NOT NULL",
name="ck_repository_rename_completed_timestamp",
),
CheckConstraint(
"phase != 'rolled-back' OR rolled_back_at IS NOT NULL",
name="ck_repository_rename_rollback_timestamp",
),
Index(
"uq_repository_rename_active_repo",
"repo_id",
unique=True,
postgresql_where=text("phase NOT IN ('completed', 'rolled-back')"),
),
Index(
"uq_repository_rename_active_new_slug",
"new_slug",
unique=True,
postgresql_where=text("phase NOT IN ('completed', 'rolled-back')"),
),
Index(
"ix_repository_rename_phase", "phase"
),
ForeignKeyConstraint(
["forge_identity_id", "repo_id", "forge_identity_state"],
[
"repository_forge_identities.id",
"repository_forge_identities.repo_id",
"repository_forge_identities.verification_state",
],
name="fk_repository_rename_verified_identity",
ondelete="RESTRICT",
),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid
)
repo_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("managed_repos.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
forge_identity_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), nullable=False
)
forge_identity_state: Mapped[str] = mapped_column(
String(20), nullable=False, default="verified", server_default="verified"
)
expected_provider: Mapped[str] = mapped_column(String(40), nullable=False)
expected_forge_instance: Mapped[str] = mapped_column(String(255), nullable=False)
expected_forge_owner: Mapped[str] = mapped_column(String(255), nullable=False)
expected_forge_repository_id: Mapped[int] = mapped_column(
BigInteger, nullable=False
)
expected_source_commit: Mapped[str] = mapped_column(String(64), nullable=False)
expected_default_branch: Mapped[str] = mapped_column(String(255), nullable=False)
old_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
new_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
old_coordinates: Mapped[dict] = mapped_column(JSONB, nullable=False)
new_coordinates: Mapped[dict] = mapped_column(JSONB, nullable=False)
phase: Mapped[str] = mapped_column(
String(32), nullable=False, default="draft", server_default="draft"
)
actor: Mapped[str] = mapped_column(String(160), nullable=False)
phase_changed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
preflighted_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
preflight_expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
completed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
rolled_back_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
evidence: Mapped[dict] = mapped_column(
JSONB, nullable=False, default=dict, server_default="{}"
)
error_code: Mapped[str | None] = mapped_column(String(80), nullable=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
error_details: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
error_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
repo: Mapped["ManagedRepo"] = relationship( # noqa: F821
"ManagedRepo", back_populates="rename_operations", lazy="selectin"
)
forge_identity: Mapped[RepositoryForgeIdentity] = relationship(
"RepositoryForgeIdentity",
back_populates="rename_operations",
lazy="selectin",
viewonly=True,
)
slugs: Mapped[list["RepositorySlug"]] = relationship(
"RepositorySlug", back_populates="source_operation", lazy="selectin"
)
class RepositorySlug(Base, TimestampMixin):
"""Global uniqueness boundary for current and protected prior slugs."""
__tablename__ = "repository_slugs"
__table_args__ = (
UniqueConstraint("slug", name="uq_repository_slugs_slug"),
CheckConstraint(
"kind IN ('canonical', 'alias')", name="ck_repository_slug_kind"
),
CheckConstraint(
"kind != 'alias' OR protected",
name="ck_repository_slug_alias_protected",
),
Index(
"uq_repository_slugs_one_canonical",
"repo_id",
unique=True,
postgresql_where=text("kind = 'canonical'"),
),
Index("ix_repository_slugs_repo_kind", "repo_id", "kind"),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid
)
repo_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("managed_repos.id", ondelete="RESTRICT"),
nullable=False,
)
slug: Mapped[str] = mapped_column(String(100), nullable=False)
kind: Mapped[str] = mapped_column(String(16), nullable=False)
protected: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default="true"
)
source_operation_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("repository_rename_operations.id", ondelete="RESTRICT"),
nullable=True,
index=True,
)
repo: Mapped["ManagedRepo"] = relationship( # noqa: F821
"ManagedRepo", back_populates="slug_records", lazy="selectin"
)
source_operation: Mapped[RepositoryRenameOperation | None] = relationship(
"RepositoryRenameOperation", back_populates="slugs", lazy="selectin"
)

View file

@ -0,0 +1,128 @@
import uuid
from datetime import datetime
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Index,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.models.base import Base, TimestampMixin, new_uuid7
class ReviewContract(Base, TimestampMixin):
"""Replaceable projection of one authoritative review-contract revision."""
__tablename__ = "review_contracts"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid7
)
contract_key: Mapped[str] = mapped_column(String(160), nullable=False, index=True)
schema_version: Mapped[str] = mapped_column(String(40), nullable=False)
contract_digest: Mapped[str] = mapped_column(
String(64), nullable=False, unique=True
)
source_repo: Mapped[str] = mapped_column(String(100), nullable=False)
source_path: Mapped[str] = mapped_column(Text, nullable=False)
source_revision: Mapped[str] = mapped_column(String(64), nullable=False)
document: Mapped[dict] = mapped_column(JSONB, nullable=False)
active: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, index=True
)
required_for_decision: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
decision_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("decisions.id", ondelete="RESTRICT"),
nullable=True,
index=True,
)
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("workplans.id", ondelete="RESTRICT", onupdate="CASCADE"),
nullable=True,
index=True,
)
task_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("tasks.id", ondelete="RESTRICT", onupdate="CASCADE"),
nullable=True,
index=True,
)
projected_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
receipts: Mapped[list["ReviewReceipt"]] = relationship(
"ReviewReceipt", back_populates="contract", lazy="selectin"
)
__table_args__ = (
UniqueConstraint(
"contract_key",
"source_repo",
"source_path",
"source_revision",
name="uq_review_contract_source_revision",
),
Index("ix_review_contract_key_active", "contract_key", "active"),
)
class ReviewReceipt(Base, TimestampMixin):
"""Immutable evidence submitted against an exact contract digest."""
__tablename__ = "review_receipts"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid7
)
contract_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("review_contracts.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
owner_id: Mapped[str] = mapped_column(String(160), nullable=False, index=True)
actor: Mapped[str] = mapped_column(String(160), nullable=False)
disposition: Mapped[str] = mapped_column(String(32), nullable=False)
contract_digest: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
receipt_digest: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
artifact_hashes: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
assertion_ids: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
checks: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
source_repo: Mapped[str] = mapped_column(String(100), nullable=False)
source_path: Mapped[str] = mapped_column(Text, nullable=False)
source_revision: Mapped[str] = mapped_column(String(64), nullable=False)
submitted_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
document: Mapped[dict] = mapped_column(JSONB, nullable=False)
contract: Mapped[ReviewContract] = relationship(
"ReviewContract", back_populates="receipts"
)
__table_args__ = (
UniqueConstraint(
"source_repo",
"source_path",
"source_revision",
name="uq_review_receipt_source_revision",
),
Index(
"ix_review_receipt_contract_owner_time",
"contract_id",
"owner_id",
"submitted_at",
),
)

View file

@ -36,7 +36,9 @@ class Suggestion(Base, TimestampMixin):
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
)
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True
UUID(as_uuid=True),
ForeignKey("workplans.id", ondelete="SET NULL", onupdate="CASCADE"),
nullable=True,
)
title: Mapped[str] = mapped_column(String(500), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
@ -61,7 +63,9 @@ class Suggestion(Base, TimestampMixin):
Float, nullable=False, default=1.0, server_default="1"
)
promoted_task_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
UUID(as_uuid=True),
ForeignKey("tasks.id", ondelete="SET NULL", onupdate="CASCADE"),
nullable=True,
)
domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821
@ -117,4 +121,4 @@ class SuggestionRelevanceBump(Base):
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
)

View file

@ -31,8 +31,18 @@ class Task(Base, TimestampMixin):
UUID(as_uuid=True), primary_key=True, default=new_uuid
)
workplan_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="RESTRICT"), nullable=False, index=True
UUID(as_uuid=True),
ForeignKey("workplans.id", ondelete="RESTRICT", onupdate="CASCADE"),
nullable=False,
index=True,
)
# Canonical work-record identifier, e.g. "CUST-WP-0067-T01" (ADR-007).
# Nullable because rows created before STATE-WP-0083-T06 have none; only the
# repository files know the mapping. Without it a task can be matched only by
# title, so renaming a heading looks like one task vanishing and another
# appearing.
record_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
flavor: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[TaskStatus] = mapped_column(
@ -47,7 +57,9 @@ class Task(Base, TimestampMixin):
needs_human: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
intervention_note: Mapped[str | None] = mapped_column(Text, nullable=True)
parent_task_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
UUID(as_uuid=True),
ForeignKey("tasks.id", ondelete="SET NULL", onupdate="CASCADE"),
nullable=True,
)
workplan: Mapped["Workplan"] = relationship("Workplan", back_populates="tasks") # noqa: F821

View file

@ -77,7 +77,9 @@ class TechnicalDebt(Base, TimestampMixin):
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
)
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True
UUID(as_uuid=True),
ForeignKey("workplans.id", ondelete="SET NULL", onupdate="CASCADE"),
nullable=True,
)
domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821

View file

@ -1,9 +1,16 @@
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import DateTime, Float, ForeignKey, Integer, Text, UniqueConstraint, func
from sqlalchemy import (
DateTime,
Float,
ForeignKey,
Integer,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
@ -25,10 +32,16 @@ class TokenEvent(Base):
UUID(as_uuid=True), primary_key=True, default=new_uuid
)
task_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True, index=True
UUID(as_uuid=True),
ForeignKey("tasks.id", ondelete="SET NULL", onupdate="CASCADE"),
nullable=True,
index=True,
)
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True, index=True
UUID(as_uuid=True),
ForeignKey("workplans.id", ondelete="SET NULL", onupdate="CASCADE"),
nullable=True,
index=True,
)
repo_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True, index=True

View file

@ -0,0 +1,44 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import CheckConstraint, DateTime, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from api.models.base import Base, TimestampMixin, new_uuid
class WorkRecordIdentifierAlias(Base, TimestampMixin):
"""Durable provenance for a canonical work-record UUID replacement."""
__tablename__ = "work_record_identifier_aliases"
__table_args__ = (
CheckConstraint(
"record_kind IN ('workplan', 'task')",
name="ck_work_record_identifier_aliases_kind",
),
CheckConstraint(
"migration_status IN ('prepared', 'applied', 'reversed')",
name="ck_work_record_identifier_aliases_status",
),
UniqueConstraint("old_id", name="uq_work_record_identifier_aliases_old_id"),
UniqueConstraint("new_id", name="uq_work_record_identifier_aliases_new_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid
)
old_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
new_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
record_kind: Mapped[str] = mapped_column(String(20), nullable=False)
record_id: Mapped[str] = mapped_column(String(160), nullable=False, index=True)
repo_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
namespace: Mapped[str] = mapped_column(String(64), nullable=False)
plan_sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
migration_status: Mapped[str] = mapped_column(
String(20), nullable=False, default="prepared", server_default="prepared"
)
applied_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
reversed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)

View file

@ -39,6 +39,9 @@ class Workplan(Base, TimestampMixin):
queue_rank: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
execution_group: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
scheduled_for: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
flavor: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
flavor_promotion_reason: Mapped[str | None] = mapped_column(String(32), nullable=True)
flavor_promoted_from: Mapped[str | None] = mapped_column(String(32), nullable=True)
repo_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
@ -53,6 +56,16 @@ class Workplan(Base, TimestampMixin):
index=True,
)
backing_filename: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Projection provenance and retirement (ADR-012 decisions 2 and 7).
# A retired record is one the forge no longer derives. It is not deleted:
# hub-native records reference it with RESTRICT and must survive. It is not
# `archived` either — that says the owner closed the work, which is a
# different fact.
projection_retired_at: Mapped["datetime | None"] = mapped_column( # noqa: F821
DateTime(timezone=True), nullable=True, index=True
)
projection_retired_reason: Mapped[str | None] = mapped_column(String(255), nullable=True)
derived_from_commit: Mapped[str | None] = mapped_column(String(40), nullable=True)
backing_relative_path: Mapped[str | None] = mapped_column(Text, nullable=True)
backing_archived: Mapped[bool | None] = mapped_column(nullable=True)
backing_synced_at: Mapped[datetime | None] = mapped_column(

View file

@ -45,19 +45,19 @@ class WorkplanDependency(Base, TimestampMixin):
)
from_workplan_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("workplans.id", ondelete="CASCADE"),
ForeignKey("workplans.id", ondelete="CASCADE", onupdate="CASCADE"),
nullable=False,
index=True,
)
to_workplan_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("workplans.id", ondelete="CASCADE"),
ForeignKey("workplans.id", ondelete="CASCADE", onupdate="CASCADE"),
nullable=True,
index=True,
)
to_task_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("tasks.id", ondelete="CASCADE"),
ForeignKey("tasks.id", ondelete="CASCADE", onupdate="CASCADE"),
nullable=True,
index=True,
)
@ -72,4 +72,4 @@ class WorkplanDependency(Base, TimestampMixin):
to_workplan: Mapped["Workplan | None"] = relationship( # noqa: F821
"Workplan", foreign_keys=[to_workplan_id]
)
to_task: Mapped["Task | None"] = relationship("Task", foreign_keys=[to_task_id]) # noqa: F821
to_task: Mapped["Task | None"] = relationship("Task", foreign_keys=[to_task_id]) # noqa: F821

View file

@ -15,7 +15,7 @@ class WorkplanLaunchRequest(Base, TimestampMixin):
)
workplan_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("workplans.id", ondelete="CASCADE"),
ForeignKey("workplans.id", ondelete="CASCADE", onupdate="CASCADE"),
nullable=False,
index=True,
)

View file

@ -1,7 +1,7 @@
import re
import uuid
from datetime import datetime, timezone
from fastapi import HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@ -11,7 +11,6 @@ from api.models.agent_message import AgentMessage
from api.models.capability_catalog import CapabilityCatalog
from api.models.capability_request import CapabilityRequest
from api.models.domain import Domain
from api.models.managed_repo import ManagedRepo
from api.models.task import Task
from api.services.suggestion_relevance import bump_matching_for_capability_request
from api.schemas.capability_request import (
@ -22,12 +21,15 @@ from api.schemas.capability_request import (
CapabilityRequestRead,
CapabilityRequestReroute,
CapabilityRequestStatusPatch,
CatalogCreate,
CatalogPatch,
CatalogRead,
)
from hub_core.routers.capabilities import (
create_capability_catalog_router,
create_capability_request_read_router,
create_capability_request_write_router,
)
from api.services.repository_aliases import resolve_repository_slug
# ---------------------------------------------------------------------------
@ -394,12 +396,102 @@ def _check_transition(current: str, target: str) -> None:
)
router = create_capability_catalog_router(
get_session,
domain_model=Domain,
repo_model=ManagedRepo,
catalog_model=CapabilityCatalog,
router = APIRouter(tags=["capability-requests"])
async def _catalog_domain(slug: str, session: AsyncSession) -> Domain:
domain = (
await session.execute(select(Domain).where(Domain.slug == slug))
).scalar_one_or_none()
if domain is None:
raise HTTPException(status_code=404, detail=f"Domain '{slug}' not found")
return domain
@router.post(
"/capability-catalog/",
response_model=CatalogRead,
status_code=status.HTTP_201_CREATED,
)
async def create_catalog_entry(
body: CatalogCreate,
session: AsyncSession = Depends(get_session),
) -> CapabilityCatalog:
domain = await _catalog_domain(body.domain, session)
repo_id = None
if body.repo_slug:
repo_id = (await resolve_repository_slug(session, body.repo_slug)).repo.id
entry = CapabilityCatalog(
domain_id=domain.id,
repo_id=repo_id,
capability_type=body.capability_type,
title=body.title,
description=body.description,
keywords=body.keywords,
)
session.add(entry)
try:
await session.commit()
except Exception:
await session.rollback()
raise HTTPException(
status_code=409,
detail=(
f"Catalog entry '{body.title}' for type '{body.capability_type}' "
f"already exists in domain '{body.domain}'"
),
)
await session.refresh(entry)
return entry
@router.get("/capability-catalog/", response_model=list[CatalogRead])
async def list_catalog(
domain: str | None = Query(None),
capability_type: str | None = Query(None),
status_filter: str | None = Query(None, alias="status"),
repo_slug: str | None = Query(None),
session: AsyncSession = Depends(get_session),
) -> list[CapabilityCatalog]:
query = select(CapabilityCatalog).order_by(CapabilityCatalog.created_at.desc())
if domain:
query = query.where(
CapabilityCatalog.domain_id == (await _catalog_domain(domain, session)).id
)
if capability_type:
query = query.where(CapabilityCatalog.capability_type == capability_type)
if repo_slug:
query = query.where(
CapabilityCatalog.repo_id
== (await resolve_repository_slug(session, repo_slug)).repo.id
)
if status_filter and status_filter != "all":
query = query.where(CapabilityCatalog.status == status_filter)
elif not status_filter:
query = query.where(CapabilityCatalog.status == "active")
return list((await session.execute(query)).scalars().all())
@router.patch("/capability-catalog/{entry_id}", response_model=CatalogRead)
async def patch_catalog_entry(
entry_id: uuid.UUID,
body: CatalogPatch,
session: AsyncSession = Depends(get_session),
) -> CapabilityCatalog:
entry = await session.get(CapabilityCatalog, entry_id)
if entry is None:
raise HTTPException(status_code=404, detail=f"Catalog entry '{entry_id}' not found")
if body.repo_slug is not None:
entry.repo_id = (await resolve_repository_slug(session, body.repo_slug)).repo.id
for field in ("description", "keywords", "status"):
value = getattr(body, field)
if value is not None:
setattr(entry, field, value)
await session.commit()
await session.refresh(entry)
return entry
router.include_router(
create_capability_request_read_router(
get_session,
@ -432,4 +524,4 @@ router.include_router(
after_dispute=_notify_on_dispute,
after_reroute=_notify_on_reroute,
)
)
)

View file

@ -6,7 +6,6 @@ from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
logger = logging.getLogger(__name__)
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@ -14,9 +13,12 @@ from api.database import get_session
from api.events import EventEnvelope, publish_event
from api.models.decision import Decision, DecisionStatus, DecisionType
from api.models.progress_event import ProgressEvent
from api.models.review_contract import ReviewContract
from api.schemas.decision import DecisionCreate, DecisionRead, DecisionResolve, DecisionUpdate
from api.services.legacy_compat import meter_legacy_body_from_model, meter_legacy_query_param
from api.services.review_contracts import aggregate
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/decisions", tags=["decisions"])
_FINANCIAL_LEGAL_KEYWORDS = (
@ -154,6 +156,25 @@ async def resolve_decision_action(
if decision.status == DecisionStatus.resolved:
raise HTTPException(status_code=409, detail="Decision already resolved")
review_rows = await session.execute(
select(ReviewContract).where(
ReviewContract.decision_id == decision.id,
ReviewContract.active.is_(True),
ReviewContract.required_for_decision.is_(True),
)
)
for contract in review_rows.scalars():
review_state = await aggregate(session, contract)
if not review_state.satisfied:
raise HTTPException(
status_code=409,
detail={
"message": "required multi-owner review is not satisfied",
"contract_key": contract.contract_key,
"contract_digest": contract.contract_digest,
},
)
decision.status = DecisionStatus.resolved
decision.decision_type = DecisionType.made
decision.rationale = body.rationale

View file

@ -21,9 +21,9 @@ from api.services.execution_queue import (
ACTIVITY_CORE_RESPONSIBILITIES,
CONCURRENCY_MODES,
EXECUTION_STATES,
EXECUTION_REPLACEMENTS,
LAUNCH_MODES,
STATE_HUB_RESPONSIBILITIES,
execution_state_for_launch,
queue_sort_key,
workplan_blockers,
)
@ -43,6 +43,8 @@ async def execution_semantics() -> ExecutionSemantics:
concurrency_modes=CONCURRENCY_MODES,
state_hub_responsibility=STATE_HUB_RESPONSIBILITIES,
activity_core_responsibility=ACTIVITY_CORE_RESPONSIBILITIES,
launch_requests_accepted=False,
replacements=EXECUTION_REPLACEMENTS,
)
@ -161,37 +163,27 @@ async def workplan_stack(
@router.post(
"/launch-requests",
response_model=LaunchRequestRead,
status_code=status.HTTP_201_CREATED,
status_code=status.HTTP_410_GONE,
)
async def create_launch_request(
request: Request,
response: Response,
body: LaunchRequestCreate,
session: AsyncSession = Depends(get_session),
) -> WorkplanLaunchRequest:
ws = await session.get(Workplan, body.workplan_id)
if ws is None:
raise HTTPException(status_code=404, detail="Workplan not found")
launch_request = WorkplanLaunchRequest(
workplan_id=ws.id,
requested_by=body.requested_by,
requested_actor=body.requested_actor,
launch_mode=body.launch_mode,
concurrency_mode=body.concurrency_mode,
priority=body.priority or ws.planning_priority,
repo_id=body.repo_id or ws.repo_id,
branch_preference=body.branch_preference,
immediate_pickup=body.immediate_pickup,
notes=body.notes,
request_metadata=body.request_metadata,
) -> None:
del body # Request shape stays documented while the retired route returns 410.
await retire_legacy_route(
session=session,
request=request,
response=response,
interface_key="rest_api:POST /execution/launch-requests",
replacement_ref="repo file queue or activity-core ActivityDefinition + ops_run",
detail=(
"State Hub workplan launch requests are retired: no consumer picks up these rows. "
"Queue development work in the authoritative repository file; use an "
"ActivityDefinition and activity-core ops_run only for recurring or operational fires."
),
)
ws.launch_mode = body.launch_mode
ws.concurrency_mode = body.concurrency_mode
ws.execution_state = execution_state_for_launch(body.launch_mode, body.immediate_pickup)
session.add(launch_request)
await session.commit()
await session.refresh(launch_request)
return launch_request
@router.get("/launch-requests", response_model=list[LaunchRequestRead])

View file

@ -1,5 +1,6 @@
from __future__ import annotations
import uuid
from typing import Any
import httpx
@ -10,6 +11,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.models.fabric_graph import FabricGraphEdge, FabricGraphImport, FabricGraphNode
from api.schemas.fabric_graph import (
FabricGraphActivationRequest,
FabricGraphActivationResult,
FabricGraphEdgeRead,
FabricGraphImportRead,
FabricGraphIngestResult,
@ -19,10 +22,12 @@ from api.schemas.fabric_graph import (
)
from api.services.fabric_graph import (
FabricGraphValidationError,
activate_fabric_graph_import,
ingest_fabric_graph_export,
record_fabric_graph_error,
split_graph_ingest_body,
)
from api.services.repository_aliases import resolve_repository_slug
router = APIRouter(prefix="/fabric", tags=["fabric"])
@ -131,6 +136,34 @@ async def latest_graph_import(
return FabricGraphImportRead.model_validate(import_run)
@router.post(
"/graph-exports/{import_id}/activate",
response_model=FabricGraphActivationResult,
)
async def activate_graph_import(
import_id: uuid.UUID,
body: FabricGraphActivationRequest | None = None,
session: AsyncSession = Depends(get_session),
) -> FabricGraphActivationResult:
request = body or FabricGraphActivationRequest()
try:
import_run, previous_import_id, activated = await activate_fabric_graph_import(
session,
import_id,
requested_by=request.requested_by,
)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
if import_run is None:
raise HTTPException(status_code=404, detail=f"Fabric graph import '{import_id}' not found")
return FabricGraphActivationResult(
import_run=FabricGraphImportRead.model_validate(import_run),
previous_import_id=previous_import_id,
activated=activated,
idempotent=not activated,
)
@router.get("/graph/nodes", response_model=list[FabricGraphNodeRead])
async def list_graph_nodes(
source_repo_slug: str = "railiance-fabric",
@ -157,7 +190,12 @@ async def list_graph_nodes(
if domain:
query = query.where(FabricGraphNode.domain_slug == domain)
if repo:
query = query.where(FabricGraphNode.repo_slug == repo)
resolution = await resolve_repository_slug(session, repo, required=False)
query = query.where(
FabricGraphNode.repo_slug.in_(
resolution.slug_values if resolution else (repo,)
)
)
if canonical_category:
query = query.where(FabricGraphNode.canon_category == canonical_category)
if fabric_id:

View file

@ -0,0 +1,118 @@
from dataclasses import asdict
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.config import settings
from api.schemas.identifier_migration import (
IdentifierMigrationApplySubmit,
SealedProjectionRepairReceiptRead,
SealedProjectionRepairSubmit,
)
from api.services.work_record_identifier_migration import (
IdentifierMigrationError,
apply_repository_identifier_migration,
repair_absent_prederivation_projection,
reverse_repository_identifier_migration,
)
router = APIRouter(prefix="/identifier-migrations", tags=["identifier-migrations"])
@router.post("/repositories/{repo_slug}/apply")
async def apply_identifier_migration(
repo_slug: str,
body: IdentifierMigrationApplySubmit,
session: AsyncSession = Depends(get_session),
) -> dict:
"""Apply one sealed repository-atomic identifier convergence transaction."""
if settings.state_hub_instance_role != "primary":
raise HTTPException(
status_code=409,
detail={
"message": "identifier migration writes require the primary State Hub",
"instance_role": settings.state_hub_instance_role,
"instance_label": settings.state_hub_instance_label,
},
)
if not body.primary_confirmed:
raise HTTPException(status_code=409, detail="explicit primary confirmation is required")
if body.plan.get("plan_sha256") != body.expected_plan_sha256:
raise HTTPException(status_code=409, detail="explicit plan SHA-256 does not match sealed plan")
try:
result = await apply_repository_identifier_migration(session, body.plan, repo_slug)
except IdentifierMigrationError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
from api.routers.workstreams import _invalidate_workplan_index_cache
_invalidate_workplan_index_cache()
return {
"schema": "state-hub.identifier-migration-apply.v1",
"instance_role": settings.state_hub_instance_role,
"instance_label": settings.state_hub_instance_label,
"result": asdict(result),
}
@router.post("/repositories/{repo_slug}/reverse")
async def reverse_identifier_migration(
repo_slug: str,
body: IdentifierMigrationApplySubmit,
session: AsyncSession = Depends(get_session),
) -> dict:
"""Reverse one sealed repository migration after a failed file phase."""
if settings.state_hub_instance_role != "primary":
raise HTTPException(
status_code=409,
detail={
"message": "identifier migration writes require the primary State Hub",
"instance_role": settings.state_hub_instance_role,
"instance_label": settings.state_hub_instance_label,
},
)
if not body.primary_confirmed:
raise HTTPException(status_code=409, detail="explicit primary confirmation is required")
if body.plan.get("plan_sha256") != body.expected_plan_sha256:
raise HTTPException(status_code=409, detail="explicit plan SHA-256 does not match sealed plan")
try:
result = await reverse_repository_identifier_migration(session, body.plan, repo_slug)
except IdentifierMigrationError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
from api.routers.workstreams import _invalidate_workplan_index_cache
_invalidate_workplan_index_cache()
return {
"schema": "state-hub.identifier-migration-reverse.v1",
"instance_role": settings.state_hub_instance_role,
"instance_label": settings.state_hub_instance_label,
"result": asdict(result),
}
@router.post(
"/sealed-projection-repairs",
response_model=SealedProjectionRepairReceiptRead,
)
async def repair_sealed_projection(
body: SealedProjectionRepairSubmit,
session: AsyncSession = Depends(get_session),
) -> dict:
"""Restore one exact pre-derivation workplan unit into an absent projection."""
try:
receipt = await repair_absent_prederivation_projection(
session,
body.plan,
body.repo_slug,
body.unit,
expected_plan_sha256=body.expected_plan_sha256,
source_revision=body.source_revision,
source_fingerprint=body.source_fingerprint,
source_clean=body.source_clean,
source_synchronized=body.source_synchronized,
primary_confirmed=body.primary_confirmed,
projection_identity=body.projection_identity,
)
except IdentifierMigrationError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return asdict(receipt)

173
api/routers/intake.py Normal file
View file

@ -0,0 +1,173 @@
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.models.intake import Intake, IntakeNote, IntakeOutcome, IntakeStatus
from api.models.progress_event import ProgressEvent
from api.schemas.intake import (
IntakeClose,
IntakeCreate,
IntakeNoteCreate,
IntakeRead,
IntakeRoute,
IntakeUpdate,
)
router = APIRouter(prefix="/intakes", tags=["intakes"])
_ALLOWED_ROUTE_FROM = {IntakeStatus.open, IntakeStatus.vetted}
_ALLOWED_CLOSE_FROM = {IntakeStatus.open, IntakeStatus.vetted, IntakeStatus.routed}
def _reject_status(intake: Intake, allowed: set[IntakeStatus], action: str) -> None:
if intake.status not in allowed:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=(
f"Cannot {action} intake in status '{intake.status.value}'; "
f"allowed from: {sorted(s.value for s in allowed)}"
),
)
@router.get("/", response_model=list[IntakeRead])
async def list_intakes(
topic_id: uuid.UUID | None = None,
workplan_id: uuid.UUID | None = None,
repo_id: uuid.UUID | None = None,
status_: IntakeStatus | None = None,
session: AsyncSession = Depends(get_session),
) -> list[Intake]:
q = select(Intake)
if topic_id:
q = q.where(Intake.topic_id == topic_id)
if workplan_id:
q = q.where(Intake.workplan_id == workplan_id)
if repo_id:
q = q.where(Intake.repo_id == repo_id)
if status_:
q = q.where(Intake.status == status_)
q = q.order_by(Intake.created_at)
result = await session.execute(q)
return list(result.scalars().all())
@router.post("/", response_model=IntakeRead, status_code=status.HTTP_201_CREATED)
async def create_intake(
body: IntakeCreate,
session: AsyncSession = Depends(get_session),
) -> Intake:
intake = Intake(**body.model_dump())
session.add(intake)
await session.commit()
await session.refresh(intake)
return intake
@router.get("/{intake_id}", response_model=IntakeRead)
async def get_intake(
intake_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> Intake:
intake = await session.get(Intake, intake_id)
if intake is None:
raise HTTPException(status_code=404, detail="Intake not found")
return intake
@router.patch("/{intake_id}", response_model=IntakeRead)
async def update_intake(
intake_id: uuid.UUID,
body: IntakeUpdate,
session: AsyncSession = Depends(get_session),
) -> Intake:
intake = await session.get(Intake, intake_id)
if intake is None:
raise HTTPException(status_code=404, detail="Intake not found")
for field, value in body.model_dump(exclude_unset=True).items():
setattr(intake, field, value)
await session.commit()
await session.refresh(intake)
return intake
@router.post("/{intake_id}/route", response_model=IntakeRead)
async def route_intake(
intake_id: uuid.UUID,
body: IntakeRoute,
session: AsyncSession = Depends(get_session),
) -> Intake:
"""Move an intake into `routed` — eligible for the promotion transition."""
intake = await session.get(Intake, intake_id)
if intake is None:
raise HTTPException(status_code=404, detail="Intake not found")
_reject_status(intake, _ALLOWED_ROUTE_FROM, "route")
intake.status = IntakeStatus.routed
if body.routed_note:
intake.routed_note = body.routed_note
await session.commit()
await session.refresh(intake)
return intake
@router.post("/{intake_id}/close", response_model=IntakeRead)
async def close_intake(
intake_id: uuid.UUID,
body: IntakeClose,
session: AsyncSession = Depends(get_session),
) -> Intake:
"""Close an intake with an outcome. `outcome=promoted` requires
`promoted_to` (the canonical id of the record it became) this is
normally called by the promotion transition (CUST-WP-0061-T03), not by
hand, but a manual close (declined/absorbed, or a promotion recorded
after the fact) is supported directly."""
intake = await session.get(Intake, intake_id)
if intake is None:
raise HTTPException(status_code=404, detail="Intake not found")
_reject_status(intake, _ALLOWED_CLOSE_FROM, "close")
intake.status = IntakeStatus.closed
intake.outcome = body.outcome
intake.closed_at = datetime.now(tz=timezone.utc)
if body.promoted_to:
intake.promoted_to = body.promoted_to
await session.commit()
await session.refresh(intake)
event = ProgressEvent(
topic_id=intake.topic_id,
workplan_id=intake.workplan_id,
event_type="intake_closed",
summary=f"Intake closed ({body.outcome.value}): {intake.title}",
detail={
"intake_id": str(intake.id),
"outcome": body.outcome.value,
"promoted_to": body.promoted_to,
"note": body.note,
},
)
session.add(event)
await session.commit()
return intake
@router.post("/{intake_id}/notes", response_model=IntakeRead, status_code=status.HTTP_201_CREATED)
async def add_intake_note(
intake_id: uuid.UUID,
body: IntakeNoteCreate,
session: AsyncSession = Depends(get_session),
) -> Intake:
intake = await session.get(Intake, intake_id)
if intake is None:
raise HTTPException(status_code=404, detail="Intake not found")
note = IntakeNote(intake_id=intake.id, author=body.author, content=body.content)
session.add(note)
await session.commit()
await session.refresh(intake)
return intake

View file

@ -8,13 +8,17 @@ from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.models.agent_message import AgentMessage
from api.models.interface_change import InterfaceChange
from api.models.managed_repo import ManagedRepo
from api.models.progress_event import ProgressEvent
from api.schemas.interface_change import (
InterfaceChangeCreate,
InterfaceChangePatch,
InterfaceChangeRead,
)
from api.services.repository_aliases import (
affected_slug_predicate,
canonicalize_repository_slug,
resolve_repository_slug,
)
router = APIRouter(prefix="/interface-changes", tags=["interface-changes"])
@ -32,7 +36,12 @@ async def create_interface_change(
if body.change_type not in _VALID_CHANGE_TYPES:
raise HTTPException(status_code=422, detail=f"change_type must be one of {sorted(_VALID_CHANGE_TYPES)}")
repo = await _repo_by_slug(body.repo_slug, session)
resolution = await resolve_repository_slug(session, body.repo_slug)
repo = resolution.repo
affected_repo_slugs = [
await canonicalize_repository_slug(session, slug)
for slug in body.affected_repo_slugs
]
change = InterfaceChange(
repo_id=repo.id,
interface_type=body.interface_type,
@ -40,7 +49,7 @@ async def create_interface_change(
title=body.title,
description=body.description,
affected_paths=body.affected_paths,
affected_repo_slugs=body.affected_repo_slugs,
affected_repo_slugs=affected_repo_slugs,
planned_for=body.planned_for,
author=body.author,
status="draft",
@ -68,7 +77,11 @@ async def list_interface_changes(
if change_type:
q = q.where(InterfaceChange.change_type == change_type)
if affected_repo:
q = q.where(InterfaceChange.affected_repo_slugs.contains([affected_repo]))
resolution = await resolve_repository_slug(
session, affected_repo, required=False
)
values = resolution.slug_values if resolution else (affected_repo,)
q = q.where(affected_slug_predicate(InterfaceChange.affected_repo_slugs, values))
result = await session.execute(q)
return [InterfaceChangeRead.from_orm_with_slug(c) for c in result.scalars().all()]
@ -94,7 +107,13 @@ async def patch_interface_change(
status_code=409,
detail=f"Cannot edit a change with status '{change.status}'. Only draft records are mutable.",
)
for field, value in body.model_dump(exclude_unset=True).items():
payload = body.model_dump(exclude_unset=True)
if payload.get("affected_repo_slugs") is not None:
payload["affected_repo_slugs"] = [
await canonicalize_repository_slug(session, slug)
for slug in payload["affected_repo_slugs"]
]
for field, value in payload.items():
setattr(change, field, value)
await session.commit()
await session.refresh(change)
@ -119,12 +138,13 @@ async def publish_interface_change(
# Send inbox notifications to agents of affected repos
affected = change.affected_repo_slugs or []
for slug in affected:
target_slug = await canonicalize_repository_slug(session, slug)
paths_summary = ", ".join(change.affected_paths[:5]) if change.affected_paths else "see description"
if len(change.affected_paths) > 5:
paths_summary += f" (+{len(change.affected_paths) - 5} more)"
msg = AgentMessage(
from_agent=change.repo.slug,
to_agent=slug,
to_agent=target_slug,
subject=f"[{change.change_type.upper()}] {change.title}",
body=(
f"**Interface change published by `{change.repo.slug}`**\n\n"
@ -174,12 +194,9 @@ async def resolve_interface_change(
return InterfaceChangeRead.from_orm_with_slug(change)
async def _repo_by_slug(slug: str, session: AsyncSession) -> ManagedRepo:
result = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == slug))
repo = result.scalar_one_or_none()
if repo is None:
raise HTTPException(status_code=404, detail=f"Repo '{slug}' not found")
return repo
async def _repo_by_slug(slug: str, session: AsyncSession):
resolution = await resolve_repository_slug(session, slug)
return resolution.repo
async def _get_or_404(change_id: uuid.UUID, session: AsyncSession) -> InterfaceChange:

View file

@ -1,7 +1,153 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.models.agent_message import AgentMessage
from hub_core.routers.messages import create_messages_router
from api.schemas.agent_message import MessageCreate, MessageRead, MessageReply
from api.services.repository_aliases import (
canonicalize_repository_slug,
resolve_repository_slug,
)
from hub_core.message_identity import resolve_message_reference
from hub_core.models.message_identity_alias import MessageIdentityAlias
router = APIRouter(prefix="/messages", tags=["messages"])
async def _get_message(reference: str, session: AsyncSession) -> AgentMessage:
message_id = await resolve_message_reference(
session, reference, alias_model=MessageIdentityAlias
)
if message_id is None:
raise HTTPException(
status_code=404, detail=f"Message reference {reference!r} not found"
)
message = await session.get(AgentMessage, message_id)
if message is None:
raise HTTPException(
status_code=404, detail=f"Message reference {reference!r} not found"
)
return message
@router.post("/", response_model=MessageRead, status_code=status.HTTP_201_CREATED)
async def send_message(
body: MessageCreate,
session: AsyncSession = Depends(get_session),
) -> AgentMessage:
if body.thread_id and await session.get(AgentMessage, body.thread_id) is None:
raise HTTPException(status_code=404, detail=f"Thread root {body.thread_id} not found")
payload = body.model_dump()
payload["from_agent"] = await canonicalize_repository_slug(session, body.from_agent)
payload["to_agent"] = await canonicalize_repository_slug(session, body.to_agent)
message = AgentMessage(**payload)
session.add(message)
await session.commit()
await session.refresh(message)
return message
@router.get("/", response_model=list[MessageRead])
async def list_messages(
to_agent: str | None = None,
from_agent: str | None = None,
unread_only: bool = False,
limit: int = 50,
session: AsyncSession = Depends(get_session),
) -> list[AgentMessage]:
query = select(AgentMessage).where(AgentMessage.archived_at.is_(None))
if to_agent:
resolution = await resolve_repository_slug(session, to_agent, required=False)
values = resolution.slug_values if resolution else (to_agent,)
query = query.where(
or_(AgentMessage.to_agent.in_(values), AgentMessage.to_agent == "broadcast")
)
if from_agent:
resolution = await resolve_repository_slug(session, from_agent, required=False)
values = resolution.slug_values if resolution else (from_agent,)
query = query.where(AgentMessage.from_agent.in_(values))
if unread_only:
query = query.where(AgentMessage.read_at.is_(None))
result = await session.execute(
query.order_by(AgentMessage.created_at.desc()).limit(limit)
)
return list(result.scalars().all())
@router.get("/thread/{thread_id}", response_model=list[MessageRead])
async def get_thread(
thread_id: str,
session: AsyncSession = Depends(get_session),
) -> list[AgentMessage]:
resolved = await resolve_message_reference(
session, thread_id, alias_model=MessageIdentityAlias
)
if resolved is None:
raise HTTPException(
status_code=404, detail=f"Message reference {thread_id!r} not found"
)
result = await session.execute(
select(AgentMessage)
.where(or_(AgentMessage.id == resolved, AgentMessage.thread_id == resolved))
.order_by(AgentMessage.created_at)
)
return list(result.scalars().all())
@router.patch("/{message_id}/read", response_model=MessageRead)
async def mark_read(
message_id: str,
session: AsyncSession = Depends(get_session),
) -> AgentMessage:
message = await _get_message(message_id, session)
if message.read_at is None:
message.read_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(message)
return message
@router.patch("/{message_id}/archive", response_model=MessageRead)
async def archive_message(
message_id: str,
session: AsyncSession = Depends(get_session),
) -> AgentMessage:
message = await _get_message(message_id, session)
message.archived_at = datetime.now(timezone.utc)
if message.read_at is None:
message.read_at = message.archived_at
await session.commit()
await session.refresh(message)
return message
@router.post(
"/{message_id}/reply",
response_model=MessageRead,
status_code=status.HTTP_201_CREATED,
)
async def reply_to_message(
message_id: str,
body: MessageReply,
session: AsyncSession = Depends(get_session),
) -> AgentMessage:
original = await _get_message(message_id, session)
if original.read_at is None:
original.read_at = datetime.now(timezone.utc)
reply = AgentMessage(
from_agent=await canonicalize_repository_slug(session, body.from_agent),
to_agent=await canonicalize_repository_slug(session, original.from_agent),
subject=f"Re: {original.subject}",
body=body.body,
thread_id=original.thread_id or original.id,
)
session.add(reply)
await session.commit()
await session.refresh(reply)
return reply
router = create_messages_router(get_session, message_model=AgentMessage)
__all__ = ["router"]

13
api/routers/ops_runs.py Normal file
View file

@ -0,0 +1,13 @@
from fastapi import APIRouter
from api.schemas.ops_run import OpsRunProjection
from api.services.ops_run_projection import get_ops_run_projection
router = APIRouter(prefix="/ops-runs", tags=["ops-runs"])
@router.get("/summary", response_model=OpsRunProjection)
async def get_ops_runs_summary(refresh: bool = False) -> OpsRunProjection:
"""Project activity-core queue health; State Hub never claims ops runs."""
return await get_ops_run_projection(refresh=refresh)

View file

@ -8,16 +8,14 @@ from api.database import get_session
from api.models.managed_repo import ManagedRepo
from api.models.repo_goal import RepoGoal, RepoGoalStatus
from api.schemas.repo_goal import RepoGoalCreate, RepoGoalRead, RepoGoalUpdate
from api.services.repository_aliases import resolve_repository_slug
router = APIRouter(prefix="/repo-goals", tags=["repo-goals"])
async def _resolve_repo(repo_slug: str, session: AsyncSession) -> ManagedRepo:
result = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == repo_slug))
repo = result.scalar_one_or_none()
if repo is None:
raise HTTPException(status_code=404, detail=f"Repo '{repo_slug}' not found")
return repo
resolution = await resolve_repository_slug(session, repo_slug)
return resolution.repo
@router.get("/", response_model=list[RepoGoalRead])

View file

@ -11,8 +11,9 @@ from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import case, func, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import noload
from sqlalchemy.orm import joinedload, noload
from api.config import settings
from api.database import get_session
@ -28,6 +29,7 @@ from api.models.domain import Domain
from api.models.interface_change import InterfaceChange
from api.models.managed_repo import ManagedRepo
from api.models.repo_goal import RepoGoal
from api.models.repository_rename import RepositorySlug
from api.models.tpsc import TPSCSnapshot
from api.models.task import Task
from api.models.workplan import Workplan
@ -48,6 +50,18 @@ from api.schemas.managed_repo import (
classification_fields_set,
validate_repo_classification_fields,
)
from api.services.sbom_nexus import SBOMNexusError
from api.services.sbom_nexus import get_json as get_sbom_nexus_json
from api.services.sbom_nexus import reads_from_nexus
from api.services.repository_identity import stage_initial_repository_identity
from api.services.repository_aliases import (
RepositorySlugResolution,
affected_slug_predicate,
repository_resolutions_for_repositories,
repository_resolution_for_id,
resolve_repository_slug,
stale_external_references,
)
from hub_core.routers.repos import create_repos_router
router = APIRouter(prefix="/repos", tags=["repos"])
@ -108,22 +122,25 @@ async def list_repos(
capability_tag: str | None = None,
business_stake: str | None = None,
session: AsyncSession = Depends(get_session),
) -> list[ManagedRepo]:
) -> list[RepoRead]:
"""List repos with optional domain and classification filters."""
response.headers["Cache-Control"] = "max-age=60, stale-while-revalidate=30"
q = (
select(ManagedRepo)
.options(noload(ManagedRepo.goals))
.options(
noload("*"),
joinedload(ManagedRepo.domain).noload("*"),
)
.order_by(ManagedRepo.name)
)
if domain:
domain_result = await session.execute(select(Domain).where(Domain.slug == domain))
domain_obj = domain_result.scalar_one_or_none()
if domain_obj is None:
domain_result = await session.execute(select(Domain.id).where(Domain.slug == domain))
domain_id = domain_result.scalar_one_or_none()
if domain_id is None:
raise HTTPException(status_code=404, detail=f"Domain '{domain}' not found")
q = q.where(
or_(
ManagedRepo.domain_id == domain_obj.id,
ManagedRepo.domain_id == domain_id,
ManagedRepo.secondary_domains.contains([domain]),
)
)
@ -134,20 +151,25 @@ async def list_repos(
if business_stake:
q = q.where(ManagedRepo.business_stake.contains([business_stake]))
result = await session.execute(q)
return list(result.scalars().all())
repositories = list(result.scalars().all())
resolutions = await repository_resolutions_for_repositories(session, repositories)
return await _project_repo_reads(session, repositories, resolutions=resolutions)
@router.post("/", response_model=RepoRead, status_code=status.HTTP_201_CREATED)
async def register_repo(
body: RepoCreate,
session: AsyncSession = Depends(get_session),
) -> ManagedRepo:
) -> RepoRead:
domain_result = await session.execute(select(Domain).where(Domain.slug == body.domain_slug))
domain_obj = domain_result.scalar_one_or_none()
if domain_obj is None:
raise HTTPException(status_code=404, detail=f"Domain '{body.domain_slug}' not found")
existing = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == body.slug))
if existing.scalar_one_or_none():
claimed_slug = await session.execute(
select(RepositorySlug).where(RepositorySlug.slug == body.slug)
)
if existing.scalar_one_or_none() or claimed_slug.scalar_one_or_none():
raise HTTPException(status_code=409, detail=f"Repo slug '{body.slug}' already exists")
payload = body.model_dump()
@ -176,10 +198,19 @@ async def register_repo(
standard_version=body.standard_version,
)
session.add(repo)
await session.commit()
await session.flush()
stage_initial_repository_identity(session, repo)
try:
await session.commit()
except IntegrityError as exc:
await session.rollback()
raise HTTPException(
status_code=409,
detail=f"Repo slug '{body.slug}' is already claimed",
) from exc
await session.refresh(repo)
await _publish_repo_registered(repo, body, domain_obj)
return repo
return (await _project_repo_reads(session, [repo]))[0]
@router.post("/onboard", response_model=RepoOnboardResult)
@ -330,6 +361,7 @@ async def doi_summary(session: AsyncSession = Depends(get_session)) -> list[DoIS
select(ManagedRepo).where(ManagedRepo.status == "active").order_by(ManagedRepo.name)
)
repos = list(repos_result.scalars().all())
sbom_projections = await _sbom_projection_map()
repo_ids = [r.id for r in repos]
id_to_slug = {r.id: r.slug for r in repos}
@ -385,7 +417,7 @@ async def doi_summary(session: AsyncSession = Depends(get_session)) -> list[DoIS
"local_path": repo.local_path,
"remote_url": repo.remote_url,
"host_paths": repo.host_paths or {},
"last_sbom_at": str(repo.last_sbom_at) if repo.last_sbom_at else None,
"last_sbom_at": _projected_last_sbom_at(repo, sbom_projections),
"updated_at": str(repo.updated_at) if repo.updated_at else "",
}
fp = compute_fingerprint(
@ -464,7 +496,9 @@ async def get_repo_doi(
Results are cached by fingerprint. Pass ?force_refresh=true to bypass the cache.
"""
repo = await _get_repo_by_slug(slug, session)
resolution = await resolve_repository_slug(session, slug)
repo = resolution.repo
sbom_projections = await _sbom_projection_map()
domain_result = await session.execute(select(Domain).where(Domain.id == repo.domain_id))
domain_obj = domain_result.scalar_one_or_none()
@ -484,7 +518,7 @@ async def get_repo_doi(
"local_path": repo.local_path,
"remote_url": repo.remote_url,
"host_paths": repo.host_paths or {},
"last_sbom_at": str(repo.last_sbom_at) if repo.last_sbom_at else None,
"last_sbom_at": _projected_last_sbom_at(repo, sbom_projections),
"updated_at": str(repo.updated_at) if repo.updated_at else "",
}
fp = compute_fingerprint(repo_dict, str(tpsc_row.latest) if tpsc_row.latest else None,
@ -497,7 +531,7 @@ async def get_repo_doi(
if not force_refresh and cached and cached.fingerprint == fp and cached.criteria:
return DoIReport(
repo_slug=slug,
repo_slug=repo.slug,
tier=cached.tier,
core_pass=cached.core_pass,
standard_pass=cached.standard_pass,
@ -536,11 +570,11 @@ async def get_repo_doi(
async def get_repo_by_id(
repo_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> ManagedRepo:
) -> RepoRead:
repo = await session.get(ManagedRepo, repo_id)
if repo is None:
raise HTTPException(status_code=404, detail=f"Repo '{repo_id}' not found")
return repo
return (await _project_repo_reads(session, [repo]))[0]
@router.get("/scope-health", response_model=list[RepoScopeHealth])
@ -595,14 +629,32 @@ async def update_repo_with_classification(
slug: str,
body: RepoUpdate,
session: AsyncSession = Depends(get_session),
) -> ManagedRepo:
) -> RepoRead:
"""Patch repo metadata including classification spine fields."""
repo = await _get_repo_by_slug(slug, session)
resolution = await resolve_repository_slug(session, slug)
repo = resolution.repo
payload = body.model_dump(exclude_unset=True)
domain_result = await session.execute(select(Domain).where(Domain.id == repo.domain_id))
domain_obj = domain_result.scalar_one_or_none()
requested_domain_slug = payload.pop("domain_slug", None)
if requested_domain_slug is not None:
domain_result = await session.execute(
select(Domain).where(Domain.slug == requested_domain_slug)
)
domain_obj = domain_result.scalar_one_or_none()
if domain_obj is None:
raise HTTPException(
status_code=404,
detail=f"Domain '{requested_domain_slug}' not found",
)
else:
domain_result = await session.execute(
select(Domain).where(Domain.id == repo.domain_id)
)
domain_obj = domain_result.scalar_one_or_none()
domain_slug = domain_obj.slug if domain_obj else ""
if classification_fields_set(payload):
classification_requested = classification_fields_set(payload)
if classification_requested or (
requested_domain_slug is not None and repo.category is not None
):
merged = {
"category": payload.get("category", repo.category),
"secondary_domains": payload.get("secondary_domains", repo.secondary_domains),
@ -615,31 +667,67 @@ async def update_repo_with_classification(
fields=merged,
require_complete=True,
)
if requested_domain_slug is not None:
repo.domain_id = domain_obj.id
for field, value in payload.items():
setattr(repo, field, value)
await session.commit()
await session.refresh(repo)
return repo
return (await _project_repo_reads(session, [repo], requested=resolution))[0]
@router.get("/{slug}", response_model=RepoRead)
async def get_repo_with_sbom_projection(
slug: str,
session: AsyncSession = Depends(get_session),
) -> RepoRead:
resolution = await resolve_repository_slug(session, slug)
return (
await _project_repo_reads(
session,
[resolution.repo],
requested=resolution,
include_stale_external=True,
)
)[0]
router.include_router(
_core_repo_router(
include_collection_routes=False,
include_lookup_routes=False,
include_slug_routes=False,
)
)
@router.post("/{slug}/paths", response_model=RepoRead)
async def register_repo_path(
slug: str,
body: RepoPathRegister,
session: AsyncSession = Depends(get_session),
) -> RepoRead:
resolution = await resolve_repository_slug(session, slug)
repo = resolution.repo
host_paths = dict(repo.host_paths or {})
host_paths[body.host] = body.path
repo.host_paths = host_paths
await session.commit()
await session.refresh(repo)
return (await _project_repo_reads(session, [repo], requested=resolution))[0]
@router.patch("/{slug}/archive", response_model=RepoRead)
async def archive_repo(
slug: str,
session: AsyncSession = Depends(get_session),
) -> ManagedRepo:
repo = await _get_repo_by_slug(slug, session)
) -> RepoRead:
resolution = await resolve_repository_slug(session, slug)
repo = resolution.repo
repo.status = "archived"
await session.commit()
await session.refresh(repo)
return repo
return (await _project_repo_reads(session, [repo], requested=resolution))[0]
@router.get("/{slug}/dispatch", response_model=RepoDispatch)
@ -653,7 +741,8 @@ async def get_repo_dispatch(
call it at session start to discover what work is pending without needing to
read state-hub summary or scan workplan files manually.
"""
repo = await _get_repo_by_slug(slug, session)
resolution = await resolve_repository_slug(session, slug)
repo = resolution.repo
# Active goal
goal_result = await session.execute(
@ -717,7 +806,9 @@ async def get_repo_dispatch(
ic_result = await session.execute(
select(InterfaceChange).where(
InterfaceChange.status == "published",
InterfaceChange.affected_repo_slugs.contains([slug]),
affected_slug_predicate(
InterfaceChange.affected_repo_slugs, resolution.slug_values
),
).order_by(InterfaceChange.published_at.desc())
)
pending_changes = [
@ -746,7 +837,12 @@ async def get_repo_dispatch(
)
return RepoDispatch(
repo_slug=slug,
repo_slug=resolution.canonical_slug,
requested_slug=resolution.requested_slug,
canonical_slug=resolution.canonical_slug,
slug_status=resolution.slug_status,
aliases=list(resolution.aliases),
stale_external_references=await stale_external_references(session, resolution),
active_goal=active_goal,
active_workplans=dispatch_workstreams,
human_interventions=all_interventions,
@ -771,7 +867,8 @@ async def sync_repo_consistency(
Returns the raw JSON output from consistency_check.py.
Query param ?fix=false to run check-only without writing.
"""
repo = await _get_repo_by_slug(slug, session)
resolution = await resolve_repository_slug(session, slug)
repo = resolution.repo
hostname = socket.gethostname()
host_paths = repo.host_paths or {}
@ -780,13 +877,13 @@ async def sync_repo_consistency(
raise HTTPException(
status_code=503,
detail=(
f"No accessible path for repo '{slug}' on host '{hostname}'. "
f"Register with: POST /repos/{slug}/paths/"
f"No accessible path for repo '{repo.slug}' on host '{hostname}'. "
f"Register with: POST /repos/{repo.slug}/paths/"
),
)
script = Path(__file__).parent.parent.parent / "scripts" / "consistency_check.py"
cmd = [sys.executable, str(script), "--repo", slug, "--json",
cmd = [sys.executable, str(script), "--repo", repo.slug, "--json",
"--api-base", settings.api_base]
if fix:
cmd.append("--fix")
@ -805,11 +902,8 @@ async def sync_repo_consistency(
async def _get_repo_by_slug(slug: str, session: AsyncSession) -> ManagedRepo:
result = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == slug))
repo = result.scalar_one_or_none()
if repo is None:
raise HTTPException(status_code=404, detail=f"Repo '{slug}' not found")
return repo
resolution = await resolve_repository_slug(session, slug)
return resolution.repo
def _repo_doi_dict(repo: ManagedRepo, domain_slug: str | None) -> dict:
@ -822,3 +916,77 @@ def _repo_doi_dict(repo: ManagedRepo, domain_slug: str | None) -> dict:
"last_sbom_at": str(repo.last_sbom_at) if repo.last_sbom_at else None,
"updated_at": str(repo.updated_at) if repo.updated_at else "",
}
async def _sbom_projection_map() -> dict[str, datetime | None]:
if not reads_from_nexus():
return {}
try:
repositories = await get_sbom_nexus_json("/repositories/")
except SBOMNexusError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
return {
repository["slug"]: (
datetime.fromisoformat(repository["last_attempt_at"].replace("Z", "+00:00"))
if repository.get("last_attempt_at")
else None
)
for repository in repositories
}
def _projected_last_sbom_at(
repo: ManagedRepo,
projections: dict[str, datetime | None],
) -> str | None:
if repo.slug in projections:
projected = projections[repo.slug]
return projected.isoformat() if projected else None
return str(repo.last_sbom_at) if repo.last_sbom_at else None
async def _project_repo_reads(
session: AsyncSession,
repositories: list[ManagedRepo],
*,
requested: RepositorySlugResolution | None = None,
resolutions: dict[uuid.UUID, RepositorySlugResolution] | None = None,
include_stale_external: bool = False,
) -> list[RepoRead]:
projections = await _sbom_projection_map()
result: list[RepoRead] = []
for repository in repositories:
resolution = (
requested
if requested is not None and requested.repo.id == repository.id
else (
resolutions[repository.id]
if resolutions is not None
else await repository_resolution_for_id(session, repository)
)
)
read = RepoRead.model_validate(repository)
read = read.model_copy(
update={
"requested_slug": resolution.requested_slug,
"canonical_slug": resolution.canonical_slug,
"slug_status": resolution.slug_status,
"aliases": list(resolution.aliases),
"stale_external_references": (
await stale_external_references(session, resolution)
if include_stale_external
else []
),
}
)
if repository.slug in projections:
read = read.model_copy(
update={
"last_sbom_at": projections[repository.slug],
"sbom_source": "sbom-nexus"
if projections[repository.slug]
else None,
}
)
result.append(read)
return result

View file

@ -0,0 +1,359 @@
from __future__ import annotations
import asyncio
import uuid
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.encoders import jsonable_encoder
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.events import EventEnvelope, publish_event
from api.models.repository_rename import RepositoryRenameOperation
from api.schemas.repository_rename import (
ForgeIdentityVerifyRequest,
RepositoryRenameOperationCreate,
RepositoryRenameOperationRead,
RepositoryRenamePhaseApply,
RepositoryRenamePreflightRead,
RepositoryRenamePreflightRequest,
RepositoryRenameRollbackPreflightRead,
RepositoryRenameRollbackRequest,
RepositoryRenameVerificationRead,
)
from api.services.forge_repository import (
ForgeRepositoryGateway,
get_forge_repository_gateway,
)
from api.services.repository_rename import (
RenameLifecycleError,
apply_phase,
apply_rollback,
build_preflight,
create_operation,
list_operations,
load_operation,
load_operation_by_id,
rollback_preflight,
verify_forge_identity,
verify_operation,
)
router = APIRouter(prefix="/repos/{repo_id}", tags=["repository-renames"])
operation_router = APIRouter(
prefix="/repository-renames", tags=["repository-renames"]
)
def repository_rename_events(
operation: RepositoryRenameOperation,
*,
outcome: str,
no_op: bool = False,
verification: dict[str, Any] | None = None,
) -> list[tuple[str, EventEnvelope]]:
"""Build credential-free lifecycle events from the durable journal."""
telemetry = (operation.evidence or {}).get("telemetry") or {}
phase_durations = telemetry.get("phase_durations_ms") or {}
attributes = {
"operation_id": str(operation.id),
"repo_id": str(operation.repo_id),
"forge_repository_id": operation.expected_forge_repository_id,
"old_slug": operation.old_slug,
"new_slug": operation.new_slug,
"phase": operation.phase,
"actor": operation.actor,
"expected_source_commit": operation.expected_source_commit,
"outcome": outcome,
"no_op": no_op,
"phase_duration_ms": int(phase_durations.get(operation.phase) or 0),
"retries": int(telemetry.get("retries") or 0),
"failures": int(telemetry.get("failures") or 0),
"rollback_attempts": int(telemetry.get("rollback_attempts") or 0),
"rollback_outcome": telemetry.get("rollback_outcome"),
"verification_outcome": (
"passed" if verification and verification.get("ok") else
"failed" if verification else
telemetry.get("verification_outcome", "pending")
),
"error_code": operation.error_code if outcome == "failed" else None,
"evidence_ref": f"repository-renames/operations/{operation.id}",
}
subject = (
"org.statehub.repo.rename.failed"
if outcome == "failed"
else "org.statehub.repo.rename.verified"
if verification is not None
else "org.statehub.repo.rename.rolled_back"
if operation.phase == "rolled-back"
else "org.statehub.repo.rename.phase"
)
events = [(subject, EventEnvelope.new(subject, attributes=attributes))]
if operation.phase == "completed" and outcome == "succeeded":
renamed_subject = "org.statehub.repo.renamed"
events.append(
(renamed_subject, EventEnvelope.new(renamed_subject, attributes=attributes))
)
return events
def _publish_rename_events(
operation: RepositoryRenameOperation,
*,
outcome: str,
no_op: bool = False,
verification: dict[str, Any] | None = None,
) -> None:
for subject, envelope in repository_rename_events(
operation, outcome=outcome, no_op=no_op, verification=verification
):
asyncio.create_task(publish_event(subject, envelope))
def _raise(exc: RenameLifecycleError) -> None:
raise HTTPException(
status_code=exc.status_code,
detail=jsonable_encoder(
{"code": exc.code, "message": str(exc), "details": exc.details}
),
) from exc
def _operation_read(operation: RepositoryRenameOperation, *, no_op: bool = False) -> dict[str, Any]:
return {
"id": operation.id,
"repo_id": operation.repo_id,
"phase": operation.phase,
"old_slug": operation.old_slug,
"new_slug": operation.new_slug,
"expected_forge_repository_id": operation.expected_forge_repository_id,
"expected_source_commit": operation.expected_source_commit,
"expected_default_branch": operation.expected_default_branch,
"actor": operation.actor,
"phase_changed_at": operation.phase_changed_at,
"preflighted_at": operation.preflighted_at,
"preflight_expires_at": operation.preflight_expires_at,
"completed_at": operation.completed_at,
"rolled_back_at": operation.rolled_back_at,
"evidence": operation.evidence,
"error_code": operation.error_code,
"error_message": operation.error_message,
"error_details": operation.error_details,
"error_at": operation.error_at,
"no_op": no_op,
}
@router.post("/forge-identity/verify")
async def verify_repository_forge_identity(
repo_id: uuid.UUID,
body: ForgeIdentityVerifyRequest,
session: AsyncSession = Depends(get_session),
gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway),
) -> dict[str, Any]:
try:
identity = await verify_forge_identity(session, gateway, repo_id, body)
except RenameLifecycleError as exc:
_raise(exc)
return {
"id": identity.id,
"repo_id": identity.repo_id,
"provider": identity.provider,
"forge_instance": identity.forge_instance,
"forge_owner": identity.forge_owner,
"forge_repository_id": identity.forge_repository_id,
"verification_state": identity.verification_state,
"verified_at": identity.verified_at,
"verified_by": identity.verified_by,
"verification_evidence": identity.verification_evidence,
}
@router.post("/rename/preflight", response_model=RepositoryRenamePreflightRead)
async def repository_rename_preflight(
repo_id: uuid.UUID,
body: RepositoryRenamePreflightRequest,
session: AsyncSession = Depends(get_session),
gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway),
) -> dict[str, Any]:
try:
return await build_preflight(session, gateway, repo_id, body)
except RenameLifecycleError as exc:
_raise(exc)
@router.post(
"/rename/operations",
response_model=RepositoryRenameOperationRead,
status_code=status.HTTP_201_CREATED,
)
async def create_repository_rename_operation(
repo_id: uuid.UUID,
body: RepositoryRenameOperationCreate,
session: AsyncSession = Depends(get_session),
gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway),
) -> dict[str, Any]:
try:
operation, no_op = await create_operation(session, gateway, repo_id, body)
except RenameLifecycleError as exc:
_raise(exc)
_publish_rename_events(operation, outcome="succeeded", no_op=no_op)
return _operation_read(operation, no_op=no_op)
@operation_router.get(
"/operations/{operation_id}", response_model=RepositoryRenameOperationRead
)
async def get_repository_rename_operation_by_id(
operation_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> dict[str, Any]:
"""Resolve an operation journal without requiring its repository UUID."""
try:
operation = await load_operation_by_id(session, operation_id)
except RenameLifecycleError as exc:
_raise(exc)
return _operation_read(operation)
@router.get(
"/rename/operations", response_model=list[RepositoryRenameOperationRead]
)
async def list_repository_rename_operations(
repo_id: uuid.UUID,
active_only: bool = False,
session: AsyncSession = Depends(get_session),
) -> list[dict[str, Any]]:
try:
operations = await list_operations(session, repo_id, active_only=active_only)
except RenameLifecycleError as exc:
_raise(exc)
return [_operation_read(operation) for operation in operations]
@router.get(
"/rename/operations/{operation_id}", response_model=RepositoryRenameOperationRead
)
async def get_repository_rename_operation(
repo_id: uuid.UUID,
operation_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> dict[str, Any]:
try:
operation = await load_operation(session, repo_id, operation_id)
except RenameLifecycleError as exc:
_raise(exc)
return _operation_read(operation)
@router.post(
"/rename/operations/{operation_id}/phases/{phase}",
response_model=RepositoryRenameOperationRead,
)
async def apply_repository_rename_phase(
repo_id: uuid.UUID,
operation_id: uuid.UUID,
phase: str,
body: RepositoryRenamePhaseApply,
session: AsyncSession = Depends(get_session),
gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway),
) -> dict[str, Any]:
try:
operation, no_op = await apply_phase(
session, gateway, repo_id, operation_id, phase, body
)
except RenameLifecycleError as exc:
failed = await session.get(RepositoryRenameOperation, operation_id)
if failed is not None:
_publish_rename_events(failed, outcome="failed")
_raise(exc)
_publish_rename_events(operation, outcome="succeeded", no_op=no_op)
return _operation_read(operation, no_op=no_op)
@router.get(
"/rename/operations/{operation_id}/verify",
response_model=RepositoryRenameVerificationRead,
)
async def verify_repository_rename_operation(
repo_id: uuid.UUID,
operation_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway),
) -> dict[str, Any]:
try:
operation = await load_operation(session, repo_id, operation_id)
verification = await verify_operation(session, gateway, operation)
except RenameLifecycleError as exc:
_raise(exc)
_publish_rename_events(
operation,
outcome="succeeded" if verification["ok"] else "failed",
verification=verification,
)
return verification
@router.post(
"/rename/operations/{operation_id}/rollback-preflight",
response_model=RepositoryRenameRollbackPreflightRead,
)
async def preflight_repository_rename_rollback(
repo_id: uuid.UUID,
operation_id: uuid.UUID,
body: RepositoryRenameRollbackRequest,
session: AsyncSession = Depends(get_session),
gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway),
) -> dict[str, Any]:
try:
operation, report = await rollback_preflight(
session,
gateway,
repo_id,
operation_id,
expected_phase=body.expected_phase,
confirmation=body.confirmation,
)
except RenameLifecycleError as exc:
failed = await session.get(RepositoryRenameOperation, operation_id)
if failed is not None:
_publish_rename_events(failed, outcome="failed")
_raise(exc)
_publish_rename_events(operation, outcome="succeeded")
return {
"operation_id": operation.id,
"repo_id": operation.repo_id,
**report,
"operation": _operation_read(operation),
}
@router.post(
"/rename/operations/{operation_id}/rollback",
response_model=RepositoryRenameOperationRead,
)
async def rollback_repository_rename_operation(
repo_id: uuid.UUID,
operation_id: uuid.UUID,
body: RepositoryRenameRollbackRequest,
session: AsyncSession = Depends(get_session),
gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway),
) -> dict[str, Any]:
try:
operation, no_op = await apply_rollback(
session,
gateway,
repo_id,
operation_id,
expected_phase=body.expected_phase,
confirmation=body.confirmation,
)
except RenameLifecycleError as exc:
failed = await session.get(RepositoryRenameOperation, operation_id)
if failed is not None:
_publish_rename_events(failed, outcome="failed")
_raise(exc)
_publish_rename_events(operation, outcome="succeeded", no_op=no_op)
return _operation_read(operation, no_op=no_op)

View file

@ -0,0 +1,190 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.models.review_contract import ReviewContract, ReviewReceipt
from api.schemas.review_contract import (
ReviewAggregateRead,
ReviewContractProject,
ReviewContractRead,
ReviewReceiptRead,
ReviewReceiptSubmit,
)
from api.services.review_contracts import (
active_contract,
aggregate,
digest_document,
normalize_contract,
utc_now,
validate_receipt,
)
router = APIRouter(prefix="/review-contracts", tags=["review-contracts"])
@router.post(
"/projections",
response_model=ReviewContractRead,
status_code=status.HTTP_201_CREATED,
)
async def project_contract(
body: ReviewContractProject,
session: AsyncSession = Depends(get_session),
) -> ReviewContract:
document, contract_digest = normalize_contract(body.contract)
existing = (
await session.execute(
select(ReviewContract).where(
ReviewContract.contract_digest == contract_digest
)
)
).scalar_one_or_none()
if existing:
if (
existing.source_repo != body.source.repo
or existing.source_path != body.source.path
or existing.source_revision != body.source.revision
):
raise HTTPException(
409, "contract digest is already projected from a different source"
)
return existing
contract_key = document["contract_key"]
await session.execute(
update(ReviewContract)
.where(
ReviewContract.contract_key == contract_key, ReviewContract.active.is_(True)
)
.values(active=False)
)
contract = ReviewContract(
contract_key=contract_key,
schema_version="review-contract/v1",
contract_digest=contract_digest,
source_repo=body.source.repo,
source_path=body.source.path,
source_revision=body.source.revision,
document=document,
active=True,
required_for_decision=body.required_for_decision,
decision_id=body.decision_id,
workplan_id=body.workplan_id,
task_id=body.task_id,
projected_at=utc_now(),
)
session.add(contract)
try:
await session.commit()
except IntegrityError as exc:
await session.rollback()
raise HTTPException(
409, "source revision is already projected with different content"
) from exc
await session.refresh(contract)
return contract
@router.get("/", response_model=list[ReviewContractRead])
async def list_contracts(
contract_key: str | None = Query(None),
active: bool | None = Query(None),
decision_id: uuid.UUID | None = Query(None),
session: AsyncSession = Depends(get_session),
) -> list[ReviewContract]:
query = select(ReviewContract).order_by(ReviewContract.projected_at.desc())
if contract_key:
query = query.where(ReviewContract.contract_key == contract_key)
if active is not None:
query = query.where(ReviewContract.active == active)
if decision_id:
query = query.where(ReviewContract.decision_id == decision_id)
return list((await session.execute(query)).scalars())
@router.get("/{contract_key}", response_model=ReviewContractRead)
async def get_active_contract(
contract_key: str,
session: AsyncSession = Depends(get_session),
) -> ReviewContract:
return await active_contract(session, contract_key)
@router.get("/{contract_key}/aggregate", response_model=ReviewAggregateRead)
async def get_aggregate(
contract_key: str,
session: AsyncSession = Depends(get_session),
) -> ReviewAggregateRead:
return await aggregate(session, await active_contract(session, contract_key))
@router.get("/{contract_key}/receipts", response_model=list[ReviewReceiptRead])
async def list_receipts(
contract_key: str,
include_stale: bool = Query(True),
session: AsyncSession = Depends(get_session),
) -> list[ReviewReceipt]:
contract = await active_contract(session, contract_key)
query = (
select(ReviewReceipt)
.join(ReviewContract)
.where(
ReviewContract.contract_key == contract_key
if include_stale
else ReviewReceipt.contract_id == contract.id
)
.order_by(ReviewReceipt.submitted_at)
)
return list((await session.execute(query)).scalars())
@router.post(
"/{contract_key}/receipts",
response_model=ReviewReceiptRead,
status_code=status.HTTP_201_CREATED,
)
async def submit_receipt(
contract_key: str,
body: ReviewReceiptSubmit,
session: AsyncSession = Depends(get_session),
) -> ReviewReceipt:
contract = await active_contract(session, contract_key)
document = validate_receipt(contract, body)
receipt_digest = digest_document(document)
existing = (
await session.execute(
select(ReviewReceipt).where(ReviewReceipt.receipt_digest == receipt_digest)
)
).scalar_one_or_none()
if existing:
return existing
receipt = ReviewReceipt(
contract_id=contract.id,
owner_id=body.owner_id,
actor=body.actor,
disposition=body.disposition,
contract_digest=body.contract_digest,
receipt_digest=receipt_digest,
artifact_hashes=body.artifact_hashes,
assertion_ids=body.assertion_ids,
checks=body.checks,
note=body.note,
source_repo=body.source.repo,
source_path=body.source.path,
source_revision=body.source.revision,
submitted_at=utc_now(),
document=document,
)
session.add(receipt)
try:
await session.commit()
except IntegrityError as exc:
await session.rollback()
raise HTTPException(
409, "receipt source revision already contains different evidence"
) from exc
await session.refresh(receipt)
return receipt

View file

@ -1,7 +1,8 @@
import uuid
import logging
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
@ -18,8 +19,18 @@ from api.schemas.sbom import (
SBOMSnapshotDetail,
SBOMSnapshotRead,
)
from api.services.legacy_meter import identity_from_request, record_legacy_usage
from api.services.sbom_nexus import (
SBOMNexusError,
get_json,
post_json,
reads_from_nexus,
writes_to_nexus,
)
from api.services.repository_aliases import resolve_repository_slug
router = APIRouter(prefix="/sbom", tags=["sbom"])
logger = logging.getLogger(__name__)
_COPYLEFT_PATTERNS = {"GPL", "AGPL", "LGPL", "EUPL", "CDDL", "MPL"}
@ -54,10 +65,37 @@ def _latest_snapshot_ids_subquery():
@router.post("/ingest/")
async def ingest_sbom(
body: SBOMIngest,
request: Request,
session: AsyncSession = Depends(get_session),
) -> dict:
"""Create a new SBOM snapshot for a repo. Previous snapshots are retained."""
repo = await _get_repo_by_slug(body.repo_slug, session)
resolution = await resolve_repository_slug(session, body.repo_slug)
repo = resolution.repo
if writes_to_nexus():
nexus_body = body.model_dump(mode="json")
nexus_body["repo_slug"] = resolution.canonical_slug
payload = await _nexus_post("/sbom/ingest/", body=nexus_body)
try:
snapshot_at = datetime.fromisoformat(
payload["snapshot_at"].replace("Z", "+00:00")
)
result = {
"repo_slug": payload["repo_slug"],
"snapshot_id": payload["snapshot_id"],
"ingested": payload["ingested"],
"snapshot_at": payload["snapshot_at"],
}
except (AttributeError, KeyError, TypeError, ValueError) as exc:
raise HTTPException(
status_code=502,
detail="SBOM Nexus returned an invalid ingest response",
) from exc
repo.last_sbom_at = snapshot_at
repo.sbom_source = "sbom-nexus"
await session.commit()
await _meter_compat(session, request, "POST", "/sbom/ingest/")
return result
now = datetime.now(tz=timezone.utc)
snap = SBOMSnapshot(
@ -90,8 +128,9 @@ async def ingest_sbom(
repo.sbom_source = "manual"
await session.commit()
await _meter_compat(session, request, "POST", "/sbom/ingest/")
return {
"repo_slug": body.repo_slug,
"repo_slug": resolution.canonical_slug,
"snapshot_id": str(snap.id),
"ingested": len(body.entries),
"snapshot_at": now.isoformat(),
@ -100,10 +139,23 @@ async def ingest_sbom(
@router.get("/snapshots/", response_model=list[SBOMSnapshotRead])
async def list_snapshots(
request: Request,
repo_slug: str | None = Query(None),
session: AsyncSession = Depends(get_session),
) -> list[SBOMSnapshotRead]:
"""List SBOM snapshots, newest first. Optionally filter by repo."""
await _meter_compat(session, request, "GET", "/sbom/snapshots/")
if reads_from_nexus():
if repo_slug:
repo_slug = (
await resolve_repository_slug(session, repo_slug)
).canonical_slug
payload = await _nexus_get(
"/sbom/snapshots/",
params={"repo_slug": repo_slug} if repo_slug else None,
)
return await _translate_snapshots(payload, session)
q = select(SBOMSnapshot).order_by(SBOMSnapshot.snapshot_at.desc())
if repo_slug:
repo = await _get_repo_by_slug(repo_slug, session)
@ -115,9 +167,21 @@ async def list_snapshots(
@router.get("/snapshots/{snapshot_id}", response_model=SBOMSnapshotDetail)
async def get_snapshot(
snapshot_id: uuid.UUID,
request: Request,
session: AsyncSession = Depends(get_session),
) -> SBOMSnapshotDetail:
"""Get a snapshot with its full entry list."""
await _meter_compat(session, request, "GET", "/sbom/snapshots/{snapshot_id}")
if reads_from_nexus():
payload = await _nexus_get(f"/sbom/snapshots/{snapshot_id}")
repo_ids = await _local_repo_ids([payload], session)
repo_id = repo_ids[payload["repo_slug"]]
translated = _translate_snapshot(payload, repo_id)
translated["entries"] = [
_translate_entry(entry, repo_id) for entry in payload.get("entries", [])
]
return SBOMSnapshotDetail.model_validate(translated)
snap = await session.get(SBOMSnapshot, snapshot_id)
if snap is None:
raise HTTPException(status_code=404, detail=f"Snapshot '{snapshot_id}' not found")
@ -140,6 +204,7 @@ async def get_snapshot(
@router.get("/")
async def list_sbom_entries(
request: Request,
repo_slug: str | None = Query(None),
ecosystem: Ecosystem | None = Query(None),
license_spdx: str | None = Query(None),
@ -148,6 +213,30 @@ async def list_sbom_entries(
session: AsyncSession = Depends(get_session),
) -> list[SBOMEntryRead]:
"""Return entries from the latest snapshot per repo (default) or filter by repo."""
await _meter_compat(session, request, "GET", "/sbom/")
if reads_from_nexus():
if repo_slug:
repo_slug = (
await resolve_repository_slug(session, repo_slug)
).canonical_slug
params = {
key: value
for key, value in {
"repo_slug": repo_slug,
"ecosystem": ecosystem.value if ecosystem is not None else None,
"license_spdx": license_spdx,
"is_direct": is_direct,
"is_dev": is_dev,
}.items()
if value is not None
}
payload = await _nexus_get("/sbom/", params=params)
repo_ids = await _local_repo_ids(payload, session)
return [
SBOMEntryRead.model_validate(_translate_entry(entry, repo_ids[entry["repo_slug"]]))
for entry in payload
]
if repo_slug:
repo = await _get_repo_by_slug(repo_slug, session)
latest_snap_id_sq = (
@ -177,9 +266,15 @@ async def list_sbom_entries(
@router.get("/report/licences/", response_model=LicenceReport)
async def licence_report(
request: Request,
session: AsyncSession = Depends(get_session),
) -> LicenceReport:
"""Group latest-snapshot SBOM entries by SPDX licence identifier, flag copyleft."""
await _meter_compat(session, request, "GET", "/sbom/report/licences/")
if reads_from_nexus():
payload = await _nexus_get("/sbom/report/licences/")
return LicenceReport.model_validate(payload)
latest_ids_sq = _latest_snapshot_ids_subquery()
rows = await session.execute(
select(SBOMEntry, ManagedRepo.slug)
@ -212,10 +307,20 @@ async def licence_report(
@router.get("/{repo_slug}", response_model=SBOMRepoView)
async def get_repo_sbom(
repo_slug: str,
request: Request,
session: AsyncSession = Depends(get_session),
) -> SBOMRepoView:
"""Return the latest snapshot entries for a specific repo."""
repo = await _get_repo_by_slug(repo_slug, session)
resolution = await resolve_repository_slug(session, repo_slug)
repo = resolution.repo
await _meter_compat(session, request, "GET", "/sbom/{repo_slug}")
if reads_from_nexus():
payload = await _nexus_get(f"/sbom/{resolution.canonical_slug}")
payload["entries"] = [
_translate_entry(entry, repo.id) for entry in payload.get("entries", [])
]
return SBOMRepoView.model_validate(payload)
latest_snap_id_sq = (
select(SBOMSnapshot.id)
.where(SBOMSnapshot.repo_id == repo.id)
@ -230,7 +335,7 @@ async def get_repo_sbom(
)
entries = list(rows.scalars().all())
return SBOMRepoView(
repo_slug=repo_slug,
repo_slug=resolution.canonical_slug,
last_sbom_at=repo.last_sbom_at,
entry_count=len(entries),
entries=[SBOMEntryRead.model_validate(e) for e in entries],
@ -238,8 +343,86 @@ async def get_repo_sbom(
async def _get_repo_by_slug(slug: str, session: AsyncSession) -> ManagedRepo:
result = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == slug))
repo = result.scalar_one_or_none()
if repo is None:
raise HTTPException(status_code=404, detail=f"Repo '{slug}' not found")
return repo
resolution = await resolve_repository_slug(session, slug)
return resolution.repo
async def _nexus_get(path: str, *, params: dict | None = None):
try:
return await get_json(path, params=params)
except SBOMNexusError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
async def _nexus_post(path: str, *, body: dict):
try:
return await post_json(path, body=body)
except SBOMNexusError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
async def _local_repo_ids(items: list[dict], session: AsyncSession) -> dict[str, uuid.UUID]:
slugs = {item.get("repo_slug") for item in items}
if None in slugs:
raise HTTPException(status_code=502, detail="SBOM Nexus response omitted repo_slug")
repo_ids: dict[str, uuid.UUID] = {}
for slug in slugs:
resolution = await resolve_repository_slug(session, slug, required=False)
if resolution is not None:
repo_ids[slug] = resolution.repo.id
missing = sorted(slugs - repo_ids.keys())
if missing:
raise HTTPException(
status_code=502,
detail=f"SBOM Nexus returned repositories absent from State Hub: {', '.join(missing)}",
)
return repo_ids
async def _translate_snapshots(
items: list[dict], session: AsyncSession
) -> list[SBOMSnapshotRead]:
repo_ids = await _local_repo_ids(items, session)
return [
SBOMSnapshotRead.model_validate(_translate_snapshot(item, repo_ids[item["repo_slug"]]))
for item in items
]
def _translate_snapshot(item: dict, repo_id: uuid.UUID) -> dict:
translated = {**item, "repo_id": repo_id}
source = translated.get("source")
if (
translated.get("legacy_id")
and isinstance(source, str)
and source.startswith("state-hub:")
):
translated["source"] = source.removeprefix("state-hub:")
return translated
def _translate_entry(item: dict, repo_id: uuid.UUID) -> dict:
return {**item, "repo_id": repo_id}
async def _meter_compat(
session: AsyncSession,
request: Request,
method: str,
route: str,
) -> None:
"""Meter the compatibility surface without making route success depend on it."""
interface_key = f"rest_api:{method} {route}"
try:
await record_legacy_usage(
session,
interface_key=interface_key,
interface_kind="rest_api",
replacement_ref="sbom-nexus:/sbom/",
owner_component="state-hub.sbom-compat",
replacement_verified=reads_from_nexus(),
identity=identity_from_request(request),
)
except Exception:
await session.rollback()
logger.warning("legacy-meter failed to record %s", interface_key, exc_info=True)

View file

@ -13,7 +13,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from api.database import get_session
from api.models.managed_repo import ManagedRepo
from api.models.service_catalog import (
ServiceCatalog,
ServiceCloud,
@ -22,6 +21,7 @@ from api.models.service_catalog import (
ServiceThirdParty,
)
from api.schemas.service import ServiceCatalogRead, ServiceUpsert
from api.services.repository_aliases import resolve_repository_slug
router = APIRouter(prefix="/services", tags=["services"])
@ -42,6 +42,7 @@ async def list_services(
development_type: str | None = None,
maturity_level: int | None = None,
status: str | None = None,
repo_slug: str | None = None,
session: AsyncSession = Depends(get_session),
) -> list[ServiceCatalog]:
q = select(ServiceCatalog).options(*_WITH_EXTENSIONS)
@ -53,6 +54,11 @@ async def list_services(
q = q.where(ServiceCatalog.maturity_level == maturity_level)
if status:
q = q.where(ServiceCatalog.status == status)
if repo_slug:
resolution = await resolve_repository_slug(session, repo_slug)
q = q.join(ServiceFirstParty).where(
ServiceFirstParty.repo_id == resolution.repo.id
)
q = q.order_by(ServiceCatalog.name.asc())
result = await session.execute(q)
return list(result.scalars().all())
@ -131,12 +137,10 @@ async def _apply_extensions(svc: ServiceCatalog, body: ServiceUpsert, session: A
if body.first_party is not None:
data = body.first_party.model_dump(exclude={"repo_slug"})
if body.first_party.repo_slug and not data.get("repo_id"):
repo = (await session.execute(
select(ManagedRepo).where(ManagedRepo.slug == body.first_party.repo_slug)
)).scalar_one_or_none()
if repo is None:
raise HTTPException(status_code=404, detail=f"Repo '{body.first_party.repo_slug}' not found")
data["repo_id"] = repo.id
resolution = await resolve_repository_slug(
session, body.first_party.repo_slug
)
data["repo_id"] = resolution.repo.id
await _upsert_ext(ServiceFirstParty, svc.id, data, session)

View file

@ -1,13 +1,15 @@
import time
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, Request, Response
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi.responses import JSONResponse
from sqlalchemy import func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import noload, selectinload
from api.config import settings
from api.database import get_session
from api.services.schema_state import schema_state
from api.flow_defs import assertion_result_to_dict, load_flow
from api.models.capability_request import CapabilityRequest
from api.models.contribution import Contribution, ContributionStatus, ContributionType
@ -53,6 +55,9 @@ from api.services.summary_cache import (
get_summary_cache,
register_summary_cache_invalidation,
)
from api.services.ops_run_projection import get_ops_run_projection
from api.services.sbom_nexus import get_json as get_sbom_nexus_json
from api.services.sbom_nexus import reads_from_nexus
def _dual_workplan_refs(
@ -70,6 +75,12 @@ def _dual_workplan_refs(
"workstream_slug": slug,
}
from api.task_status import TERMINAL_TASK_STATUSES, status_value
from api.work_record_flavor import (
RESIDUAL_FLAVOR,
WORK_RECORD_FLAVORS,
is_residual_flavor,
normalize_flavor,
)
from api.workplan_status import (
CLOSED_WORKPLAN_STATUSES,
OPEN_WORKPLAN_STATUSES,
@ -95,12 +106,44 @@ def _summary_cache_headers(
response.headers["Cache-Control"] = "max-age=15, stale-while-revalidate=120"
def _apply_summary_flavor_view(
summary: StateSummary,
*,
include_residuals: bool,
flavor: str | None,
) -> StateSummary:
"""Default views omit residual workplans; totals.residual_open stays honest."""
wanted = normalize_flavor(flavor)
if wanted is not None and 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)}"
),
)
show_residuals = include_residuals or wanted == RESIDUAL_FLAVOR
residual_rows = list(summary.residual_open_workplans)
open_rows = list(summary.open_workplans)
if wanted is not None:
open_rows = [row for row in open_rows if row.flavor == wanted]
residual_rows = [row for row in residual_rows if row.flavor == wanted]
return summary.model_copy(
update={
"open_workplans": open_rows,
"residual_open_workplans": residual_rows if show_residuals else [],
}
)
@router.get("/summary", response_model=StateSummary)
async def get_summary(
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
refresh: bool = False,
include_residuals: bool = Query(False),
flavor: str | None = Query(None),
) -> StateSummary:
revision = await fetch_summary_revision(session)
revision_token = revision.combined_fingerprint()
@ -111,22 +154,38 @@ async def get_summary(
if cache_status == "hit-revision" and cached is not None:
_summary_cache_headers(response, cache_status="hit-revision", revision=revision_token)
return cached
return _apply_summary_flavor_view(
cached.model_copy(update={"ops_runs": await get_ops_run_projection()}),
include_residuals=include_residuals,
flavor=flavor,
)
if cache_status == "progress-section" and cached is not None:
result = await apply_progress_section(session, cached, revision)
_summary_cache_headers(response, cache_status="hit-revision", revision=revision_token)
return result
return _apply_summary_flavor_view(
result.model_copy(update={"ops_runs": await get_ops_run_projection()}),
include_residuals=include_residuals,
flavor=flavor,
)
if cache_status == "stale" and cached is not None:
cache.schedule_refresh(revision)
_summary_cache_headers(response, cache_status="stale", revision=revision_token)
return cached
return _apply_summary_flavor_view(
cached.model_copy(update={"ops_runs": await get_ops_run_projection()}),
include_residuals=include_residuals,
flavor=flavor,
)
result = await build_state_summary(session)
cache.store(result, revision)
_summary_cache_headers(response, cache_status="miss", revision=revision_token)
return result
return _apply_summary_flavor_view(
result.model_copy(update={"ops_runs": await get_ops_run_projection(refresh=force_refresh)}),
include_residuals=include_residuals,
flavor=flavor,
)
async def build_state_summary(session: AsyncSession) -> StateSummary:
@ -331,6 +390,7 @@ async def build_state_summary(session: AsyncSession) -> StateSummary:
),
archived=ws_counts.get("archived", 0),
total=sum(ws_counts.values()),
residual_open=0,
),
tasks=TaskTotals(
wait=task_counts.get(TaskStatus.wait, 0),
@ -363,23 +423,7 @@ async def build_state_summary(session: AsyncSession) -> StateSummary:
)}
contribution_counts = {**contrib_type_counts, **contrib_status_counts}
# Licence risk: copyleft packages in direct prod deps
_COPYLEFT_PATS = ("GPL", "AGPL", "LGPL", "EUPL", "CDDL", "MPL")
copyleft_risk_rows = await session.execute(
select(func.count()).select_from(SBOMEntry)
.where(SBOMEntry.is_direct.is_(True))
.where(SBOMEntry.is_dev.is_(False))
)
# Filter in Python since ILIKE across multiple patterns is verbose in SQLAlchemy
all_direct_prod_rows = await session.execute(
select(SBOMEntry.license_spdx)
.where(SBOMEntry.is_direct.is_(True))
.where(SBOMEntry.is_dev.is_(False))
)
licence_risk_count = sum(
1 for (lic,) in all_direct_prod_rows.all()
if lic and any(pat in lic.upper() for pat in _COPYLEFT_PATS)
)
licence_risk_count, _, _ = await _sbom_metrics(session)
# Open capability requests (non-terminal statuses)
open_cap_req_count = (await session.execute(
@ -408,6 +452,15 @@ async def build_state_summary(session: AsyncSession) -> StateSummary:
)
for w in open_ws
]
relevant_rows = [
row for row, w in zip(open_workplan_rows, open_ws)
if not is_residual_flavor(w.flavor)
]
residual_rows = [
row for row, w in zip(open_workplan_rows, open_ws)
if is_residual_flavor(w.flavor)
]
totals.workstreams.residual_open = len(residual_rows)
result = StateSummary(
generated_at=datetime.now(tz=timezone.utc),
@ -429,7 +482,8 @@ async def build_state_summary(session: AsyncSession) -> StateSummary:
licence_risk_count=licence_risk_count,
open_capability_requests=open_cap_req_count,
ranked_suggestions=ranked_suggestions,
open_workplans=open_workplan_rows,
open_workplans=relevant_rows,
residual_open_workplans=residual_rows,
)
return result
@ -532,6 +586,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
w for w in workstreams_all
if normalize_workplan_status(w.status) in OPEN_WORKPLAN_STATUSES
]
residual_open = sum(1 for w in open_ws if is_residual_flavor(w.flavor))
open_ws_ids = [w.id for w in open_ws]
dep_rows = []
if open_ws_ids:
@ -591,6 +646,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
),
archived=ws_counts.get("archived", 0),
total=sum(ws_counts.values()),
residual_open=residual_open,
),
tasks=TaskTotals(
wait=task_totals_by_status.get("wait", 0),
@ -645,23 +701,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
)}
contribution_counts = {**contrib_type_counts, **contrib_status_counts}
_COPYLEFT_PATS = ("GPL", "AGPL", "LGPL", "EUPL", "CDDL", "MPL")
all_direct_prod_rows = await session.execute(
select(SBOMEntry.license_spdx)
.where(SBOMEntry.is_direct.is_(True))
.where(SBOMEntry.is_dev.is_(False))
)
licence_risk_count = sum(
1 for (lic,) in all_direct_prod_rows.all()
if lic and any(pat in lic.upper() for pat in _COPYLEFT_PATS)
)
snapshot_count, package_total = (await session.execute(
select(
func.count(SBOMSnapshot.id),
func.coalesce(func.sum(SBOMSnapshot.entry_count), 0),
)
)).one()
licence_risk_count, snapshot_count, package_total = await _sbom_metrics(session)
open_cap_req_count = (await session.execute(
select(func.count()).select_from(CapabilityRequest).where(
@ -695,6 +735,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
id=w.id,
title=w.title,
status=normalize_workplan_status(w.status),
flavor=w.flavor,
domain=repo["domain_slug"] if repo else (topic.domain_slug if topic else "unknown"),
repo_label=repo["slug"] if repo else workplan.get("repo_slug", "unassigned"),
workplan_filename=workplan.get("filename"),
@ -741,6 +782,39 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
)
async def _sbom_metrics(session: AsyncSession) -> tuple[int, int, int]:
"""Return compatibility summary metrics from the selected SBOM authority."""
if reads_from_nexus():
snapshots = await get_sbom_nexus_json("/sbom/snapshots/")
report = await get_sbom_nexus_json("/sbom/report/licences/")
return (
int(report.get("copyleft_direct_count") or 0),
len(snapshots),
sum(int(snapshot.get("entry_count") or 0) for snapshot in snapshots),
)
copyleft_patterns = ("GPL", "AGPL", "LGPL", "EUPL", "CDDL", "MPL")
rows = await session.execute(
select(SBOMEntry.license_spdx)
.where(SBOMEntry.is_direct.is_(True))
.where(SBOMEntry.is_dev.is_(False))
)
licence_risk_count = sum(
1
for (licence,) in rows.all()
if licence and any(pattern in licence.upper() for pattern in copyleft_patterns)
)
snapshot_count, package_total = (
await session.execute(
select(
func.count(SBOMSnapshot.id),
func.coalesce(func.sum(SBOMSnapshot.entry_count), 0),
)
)
).one()
return licence_risk_count, int(snapshot_count or 0), int(package_total or 0)
async def _build_domain_summaries(session: AsyncSession) -> list[DomainSummary]:
"""Compute per-domain stats for the state summary."""
domains_rows = await session.execute(
@ -789,7 +863,10 @@ async def _build_domain_summaries(session: AsyncSession) -> list[DomainSummary]:
@router.get("/deps", response_model=list[WorkstreamWithDeps])
async def get_deps(session: AsyncSession = Depends(get_session)) -> list[WorkstreamWithDeps]:
async def get_deps(
session: AsyncSession = Depends(get_session),
include_residuals: bool = Query(False),
) -> list[WorkstreamWithDeps]:
"""Lightweight dep-graph endpoint: open workstreams with their dependency edges only.
Returns the same structure as open_workplans in /state/summary but skips
@ -803,6 +880,8 @@ async def get_deps(session: AsyncSession = Depends(get_session)) -> list[Workstr
.order_by(Workplan.due_date.asc().nullslast(), Workplan.created_at)
)
open_ws = list(open_ws_rows.scalars().all())
if not include_residuals:
open_ws = [w for w in open_ws if not is_residual_flavor(w.flavor)]
open_ws_ids = [w.id for w in open_ws]
dep_rows = []
@ -938,6 +1017,10 @@ async def _derive_next_steps(session: AsyncSession) -> tuple[list[NextStep], lis
if task.id in seen_task_ids:
continue
ws = await session.get(Workplan, decision.workplan_id, options=[noload("*")])
if ws is not None and is_residual_flavor(ws.flavor):
continue
if is_residual_flavor(task.flavor):
continue
domain_slug = await _get_domain_slug_for_workplan(ws, session)
steps.append(NextStep(
type="resolved_decision",
@ -982,6 +1065,7 @@ async def _derive_next_steps(session: AsyncSession) -> tuple[list[NextStep], lis
Workplan.title,
Workplan.slug,
Workplan.topic_id,
Workplan.flavor,
).where(Workplan.id.in_(dep_ws_ids))
)
ws_info = {
@ -990,8 +1074,9 @@ async def _derive_next_steps(session: AsyncSession) -> tuple[list[NextStep], lis
"title": title,
"slug": slug,
"topic_id": topic_id,
"flavor": flavor,
}
for ws_id, status, title, slug, topic_id in ws_rows
for ws_id, status, title, slug, topic_id, flavor in ws_rows
}
ready_from_ws_ids = [
@ -1017,12 +1102,16 @@ async def _derive_next_steps(session: AsyncSession) -> tuple[list[NextStep], lis
for from_ws_id in ready_from_ws_ids:
from_ws = ws_info.get(from_ws_id, {})
if is_residual_flavor(from_ws.get("flavor")):
continue
todo_tasks = todo_by_ws.get(from_ws_id, [])
if not todo_tasks:
continue
task = min(todo_tasks, key=lambda t: (_PRIORITY_RANK.get(t.priority, 99), t.created_at))
if task.id in seen_task_ids:
continue
if is_residual_flavor(task.flavor):
continue
domain_slug = await _get_domain_slug_for_topic(from_ws.get("topic_id"), session)
_blocker_slugs = []
for tid in dep_map[from_ws_id]:
@ -1104,7 +1193,16 @@ async def get_next_steps(session: AsyncSession = Depends(get_session)) -> list[N
async def health_check(session: AsyncSession = Depends(get_session)) -> dict:
try:
await session.execute(text("SELECT 1"))
return {"status": "ok", "db": "connected"}
return {
"status": "ok",
"db": "connected",
# Identity, so a caller can verify it reached the hub it meant to.
"instance_role": settings.state_hub_instance_role,
"instance_label": settings.state_hub_instance_label,
# Surfaced, not merely logged: a schema behind the code is a fault
# the operator must be able to see (STATE-WP-0083-T07).
"schema": await schema_state(session),
}
except Exception as exc:
return JSONResponse(
status_code=503,

View file

@ -1,259 +1,45 @@
import uuid
"""Retired: the suggestion backlog.
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
Slice E1 of the State Hub retirement (`STATE-WP-0079-T05`). The capability was
superseded by the **intake** work-record entity; mutations were retired
2026-07-21 under `CUST-WP-0061-T06`, and the reads were kept live only so the
historical record stayed reachable.
from api.database import get_session
from api.models.domain import Domain
from api.models.suggestion import (
OPEN_SUGGESTION_STAGES,
Suggestion,
SuggestionNote,
SuggestionStage,
)
from api.models.task import Task, TaskPriority, TaskStatus
from api.schemas.suggestion import (
SuggestionBumpRelevance,
SuggestionCreate,
SuggestionDecline,
SuggestionNoteRead,
SuggestionPromote,
SuggestionRead,
SuggestionVet,
)
from api.services.suggestion_relevance import bump_relevance
from api.services.suggestion_wsjf import compute_wsjf, cost_of_delay, suggestion_sort_key
from api.task_status import normalize_task_status
That history is now archived at
`the-custodian/docs/archived-suggestion-backlog.md` all 10 suggestions, 10
notes and 5 relevance bumps, every one closed as `declined` during the intake
migration and none promoted. With a durable record outside this repo, the read
surface has no remaining job, so the whole router answers 410.
The `suggestions`, `suggestion_notes` and `suggestion_relevance_bumps` tables
are deliberately left in place: they are `retire`/`archive` in `SHR-INV-0001`
and are captured by the final dump at `STATE-WP-0079-T06`. Dropping them here
would remove data ahead of the dump for no gain.
"""
from fastapi import APIRouter, HTTPException
router = APIRouter(prefix="/suggestions", tags=["suggestions"])
_ALLOWED_VET_FROM = {SuggestionStage.suggestion}
_ALLOWED_DECLINE_FROM = {SuggestionStage.suggestion, SuggestionStage.requirement}
_ALLOWED_PROMOTE_FROM = {SuggestionStage.requirement}
_DETAIL = (
"suggestions are retired (STATE-WP-0079-T05, slice E1). "
"Use POST /intakes/ for new discovery work. The historical backlog is "
"archived at the-custodian/docs/archived-suggestion-backlog.md — see also "
"the-custodian/intake-legacy-suggestions-migration.md and "
"canon/standards/work-record-types_v0.1.md."
)
async def _resolve_domain_id(slug: str, session: AsyncSession) -> uuid.UUID:
row = await session.execute(
select(Domain.id).where(Domain.slug == slug, Domain.status == "active")
)
domain_id = row.scalar_one_or_none()
if domain_id is None:
valid = [r[0] for r in (await session.execute(
select(Domain.slug).where(Domain.status == "active")
)).all()]
raise HTTPException(
status_code=422,
detail=f"Unknown domain '{slug}'. Valid domains: {sorted(valid)}",
)
return domain_id
def _retired() -> HTTPException:
return HTTPException(status_code=410, detail=_DETAIL)
def _enrich_read(suggestion: Suggestion) -> SuggestionRead:
data = SuggestionRead.model_validate(suggestion)
data.cost_of_delay = cost_of_delay(suggestion)
data.wsjf = compute_wsjf(suggestion)
return data
async def _get_suggestion_or_404(
suggestion_id: uuid.UUID,
session: AsyncSession,
) -> Suggestion:
suggestion = await session.get(Suggestion, suggestion_id)
if suggestion is None:
raise HTTPException(status_code=404, detail="Suggestion not found")
return suggestion
def _reject_stage(suggestion: Suggestion, allowed: set[SuggestionStage], action: str) -> None:
if suggestion.stage not in allowed:
raise HTTPException(
status_code=409,
detail=f"Cannot {action} suggestion in stage '{suggestion.stage.value}'",
)
@router.get("/", response_model=list[SuggestionRead])
async def list_suggestions(
domain: str | None = None,
stage: SuggestionStage | None = None,
include_terminal: bool = Query(False),
rank: str | None = Query(None),
limit: int = Query(100, ge=1, le=500),
session: AsyncSession = Depends(get_session),
) -> list[SuggestionRead]:
q = select(Suggestion)
if domain:
domain_id = await _resolve_domain_id(domain, session)
q = q.where(Suggestion.domain_id == domain_id)
if stage:
q = q.where(Suggestion.stage == stage)
elif not include_terminal:
q = q.where(Suggestion.stage.in_(OPEN_SUGGESTION_STAGES))
result = await session.execute(q)
suggestions = list(result.scalars().all())
if rank == "wsjf":
suggestions.sort(key=suggestion_sort_key)
else:
suggestions.sort(key=lambda s: s.created_at)
return [_enrich_read(s) for s in suggestions[:limit]]
@router.post("/", response_model=SuggestionRead, status_code=status.HTTP_201_CREATED)
async def create_suggestion(
body: SuggestionCreate,
session: AsyncSession = Depends(get_session),
) -> SuggestionRead:
domain_id = await _resolve_domain_id(body.domain, session)
suggestion = Suggestion(
domain_id=domain_id,
topic_id=body.topic_id,
workplan_id=body.workplan_id,
title=body.title,
description=body.description,
origin=body.origin,
origin_ref=body.origin_ref,
base_value=body.base_value,
job_size=body.job_size,
relevance_weight=body.relevance_weight,
)
session.add(suggestion)
await session.commit()
await session.refresh(suggestion)
return _enrich_read(suggestion)
@router.get("/{suggestion_id}", response_model=SuggestionRead)
async def get_suggestion(
suggestion_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> SuggestionRead:
suggestion = await _get_suggestion_or_404(suggestion_id, session)
return _enrich_read(suggestion)
@router.post("/{suggestion_id}/vet", response_model=SuggestionRead)
async def vet_suggestion(
suggestion_id: uuid.UUID,
body: SuggestionVet,
session: AsyncSession = Depends(get_session),
) -> SuggestionRead:
suggestion = await _get_suggestion_or_404(suggestion_id, session)
_reject_stage(suggestion, _ALLOWED_VET_FROM, "vet")
suggestion.stage = SuggestionStage.requirement
if body.base_value is not None:
suggestion.base_value = body.base_value
if body.job_size is not None:
suggestion.job_size = body.job_size
if body.relevance_weight is not None:
suggestion.relevance_weight = body.relevance_weight
if body.workplan_id is not None:
suggestion.workplan_id = body.workplan_id
session.add(
SuggestionNote(
suggestion_id=suggestion.id,
stage=SuggestionStage.requirement.value,
author=body.author,
content=body.note,
)
)
await session.commit()
await session.refresh(suggestion)
return _enrich_read(suggestion)
@router.post("/{suggestion_id}/decline", response_model=SuggestionRead)
async def decline_suggestion(
suggestion_id: uuid.UUID,
body: SuggestionDecline,
session: AsyncSession = Depends(get_session),
) -> SuggestionRead:
suggestion = await _get_suggestion_or_404(suggestion_id, session)
_reject_stage(suggestion, _ALLOWED_DECLINE_FROM, "decline")
suggestion.stage = SuggestionStage.declined
session.add(
SuggestionNote(
suggestion_id=suggestion.id,
stage=SuggestionStage.declined.value,
author=body.author,
content=body.note,
)
)
await session.commit()
await session.refresh(suggestion)
return _enrich_read(suggestion)
@router.post("/{suggestion_id}/promote", response_model=SuggestionRead)
async def promote_suggestion_to_task(
suggestion_id: uuid.UUID,
body: SuggestionPromote,
session: AsyncSession = Depends(get_session),
) -> SuggestionRead:
suggestion = await _get_suggestion_or_404(suggestion_id, session)
_reject_stage(suggestion, _ALLOWED_PROMOTE_FROM, "promote")
if suggestion.workplan_id is None:
raise HTTPException(
status_code=409,
detail="Suggestion must have workplan_id before promotion",
)
task = Task(
workplan_id=suggestion.workplan_id,
title=body.task_title or suggestion.title,
description=body.task_description or suggestion.description,
status=TaskStatus(normalize_task_status(body.task_status)),
priority=TaskPriority(body.task_priority),
)
session.add(task)
await session.flush()
suggestion.stage = SuggestionStage.promoted
suggestion.promoted_task_id = task.id
if body.note:
session.add(
SuggestionNote(
suggestion_id=suggestion.id,
stage=SuggestionStage.promoted.value,
author=body.author,
content=body.note,
)
)
await session.commit()
await session.refresh(suggestion)
return _enrich_read(suggestion)
@router.post("/{suggestion_id}/bump-relevance", response_model=SuggestionRead)
async def bump_suggestion_relevance(
suggestion_id: uuid.UUID,
body: SuggestionBumpRelevance,
session: AsyncSession = Depends(get_session),
) -> SuggestionRead:
suggestion = await _get_suggestion_or_404(suggestion_id, session)
source_key = body.author or "explicit"
await bump_relevance(
session,
suggestion,
source="explicit",
source_key=source_key,
reason=body.reason,
)
await session.commit()
await session.refresh(suggestion)
return _enrich_read(suggestion)
@router.get("/{suggestion_id}/notes", response_model=list[SuggestionNoteRead])
async def list_suggestion_notes(
suggestion_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> list[SuggestionNote]:
await _get_suggestion_or_404(suggestion_id, session)
result = await session.execute(
select(SuggestionNote)
.where(SuggestionNote.suggestion_id == suggestion_id)
.order_by(SuggestionNote.created_at)
)
return list(result.scalars().all())
@router.api_route(
"/{path:path}",
methods=["GET", "POST", "PATCH", "PUT", "DELETE"],
include_in_schema=False,
)
@router.api_route("/", methods=["GET", "POST", "PATCH", "PUT", "DELETE"])
async def suggestions_retired(path: str = "") -> None:
"""Every suggestion route is retired; see module docstring."""
raise _retired()

View file

@ -169,6 +169,8 @@ async def bulk_status_sync(
updated: list[Task] = []
events: list[ProgressEvent] = []
author = body.author or "custodian"
# Cache repo resolution for dual-run writeback (RMGR-WP-0003)
repo_cache: dict = {}
for update in body.updates:
task = tasks_by_id[update.task_id]
previous_status = status_value(task.status)
@ -182,6 +184,32 @@ async def bulk_status_sync(
parent_workstream=ws,
previous_task_status=previous_status,
)
if target_status != previous_status:
try:
from api.models.managed_repo import ManagedRepo
from api.services.repo_manager_dual_run import try_writeback_for_task
from api.services.workplan_files import resolve_repo_path
repo_id = ws.repo_id if ws else None
if repo_id is not None:
if repo_id not in repo_cache:
repo_cache[repo_id] = await session.get(ManagedRepo, repo_id)
repo = repo_cache[repo_id]
if repo is not None:
try_writeback_for_task(
repo_path=resolve_repo_path(repo),
repo_slug=repo.slug,
task_id=str(task.id),
status=target_status,
reason="state-hub bulk-status-sync dual-run",
)
except Exception:
import logging
logging.getLogger(__name__).exception(
"repo-manager dual-run bulk writeback failed for task %s",
update.task_id,
)
event = ProgressEvent(
task_id=task.id,
workplan_id=task.workplan_id,
@ -263,6 +291,33 @@ async def update_task(
parent_workstream=ws,
previous_task_status=previous_status,
)
# RMGR-WP-0002 dual-run: optional checkout writeback via repo-manager
if new_status != previous_status:
try:
from api.models.managed_repo import ManagedRepo
from api.services.repo_manager_dual_run import try_writeback_for_task
from api.services.workplan_files import resolve_repo_path
ws_full = ws or await session.get(Workplan, task.workplan_id)
repo = None
if ws_full and ws_full.repo_id:
repo = await session.get(ManagedRepo, ws_full.repo_id)
if repo is not None:
repo_path = resolve_repo_path(repo)
try_writeback_for_task(
repo_path=repo_path,
repo_slug=repo.slug,
task_id=str(task.id),
status=new_status,
reason="state-hub PATCH /tasks dual-run",
)
except Exception:
# Dual-run must not break native DB update path
import logging
logging.getLogger(__name__).exception(
"repo-manager dual-run writeback failed for task %s", task_id
)
await session.commit()
await session.refresh(task)

View file

@ -13,6 +13,7 @@ from api.models.task import Task
from api.models.token_event import TokenEvent
from api.models.workplan import Workplan
from api.services.legacy_compat import meter_legacy_query_param
from api.services.repository_aliases import resolve_repository_slug
from api.schemas.token_event import (
RepoTokenSummary,
TokenAggregateRow,
@ -288,6 +289,7 @@ async def get_token_summary(
@router.get("/by-repo/", response_model=list[RepoTokenSummary])
async def get_tokens_by_repo(
repo_slug: str | None = None,
measurement_kind: str | None = None,
source_provider: str | None = None,
since: datetime | None = None,
@ -304,6 +306,12 @@ async def get_tokens_by_repo(
Only events that resolve to a repo are included.
"""
requested_repo_id = None
if repo_slug:
requested_repo_id = (
await resolve_repository_slug(session, repo_slug)
).repo.id
# Fetch all events, workstreams, repos in three queries (avoids N+1)
events_result = await session.execute(
_filter_query(
@ -341,6 +349,8 @@ async def get_tokens_by_repo(
rid = resolve_repo_id(e)
if not rid or rid not in repo_map:
continue
if requested_repo_id is not None and rid != requested_repo_id:
continue
if rid not in groups:
groups[rid] = {
"repo_id": rid,

View file

@ -0,0 +1,151 @@
"""Primary-only fast path for one forge-derived repository projection."""
from __future__ import annotations
import asyncio
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.config import settings
from api.database import get_session
from api.models.task import Task
from api.models.workplan import Workplan
from api.models.workplan_dependency import WorkplanDependency
from api.schemas.work_record_projection import (
RepositoryProjectionReconcile,
RepositoryProjectionSnapshot,
)
from api.services.forge_projection import (
ForgeDeriveError,
ForgeUnreadableError,
derive_from_forge,
reset_repository_projection,
)
from api.services.repository_aliases import resolve_repository_slug
router = APIRouter(prefix="/repos", tags=["repository-work-record-projection"])
@router.get(
"/{slug}/work-record-projection/snapshot",
response_model=RepositoryProjectionSnapshot,
)
async def repository_work_record_snapshot(
slug: str,
session: AsyncSession = Depends(get_session),
) -> RepositoryProjectionSnapshot:
"""Return all consistency-check inputs for one repository in one request."""
resolution = await resolve_repository_slug(session, slug)
workplans = list(
(
await session.execute(
select(Workplan)
.where(Workplan.repo_id == resolution.repo.id)
.order_by(Workplan.slug)
)
).scalars()
)
workplan_ids = [workplan.id for workplan in workplans]
tasks: list[Task] = []
dependencies: list[WorkplanDependency] = []
if workplan_ids:
tasks = list(
(
await session.execute(
select(Task)
.where(Task.workplan_id.in_(workplan_ids))
.order_by(Task.workplan_id, Task.id)
)
).scalars()
)
dependencies = list(
(
await session.execute(
select(WorkplanDependency)
.where(WorkplanDependency.from_workplan_id.in_(workplan_ids))
.order_by(
WorkplanDependency.from_workplan_id, WorkplanDependency.id
)
)
).scalars()
)
return RepositoryProjectionSnapshot(
schema="state-hub.repository-projection-snapshot.v1",
repo_slug=resolution.canonical_slug,
repo_id=resolution.repo.id,
workplans=workplans,
tasks=tasks,
dependencies=dependencies,
)
@router.post("/{slug}/work-record-projection/reconcile")
async def reconcile_repository_work_records(
slug: str,
body: RepositoryProjectionReconcile,
session: AsyncSession = Depends(get_session),
) -> dict:
"""Derive an exact pushed commit centrally and apply it transactionally."""
if settings.state_hub_instance_role != "primary":
raise HTTPException(
status_code=409,
detail={
"message": "repository projection writes require the primary State Hub",
"instance_role": settings.state_hub_instance_role,
"instance_label": settings.state_hub_instance_label,
},
)
try:
derived = await asyncio.to_thread(derive_from_forge, slug)
except ForgeUnreadableError as exc:
raise HTTPException(
status_code=424,
detail={
"message": "repository is unreadable from the forge",
"detail": str(exc)[:300],
},
) from exc
except ForgeDeriveError as exc:
raise HTTPException(
status_code=502,
detail={
"message": "forge projection derivation failed",
"detail": str(exc)[:300],
},
) from exc
if derived.commit.lower() != body.expected_commit:
raise HTTPException(
status_code=409,
detail={
"message": "forge default branch is not at the expected commit",
"expected_commit": body.expected_commit,
"derived_commit": derived.commit,
},
)
outcome = await reset_repository_projection(
session,
slug,
acknowledge_retirements=body.acknowledge_retirements,
derived=derived,
)
if outcome.status in {"applied", "noop"} or outcome.released:
await session.commit()
else:
await session.rollback()
from api.routers.workstreams import _invalidate_workplan_index_cache
_invalidate_workplan_index_cache()
return {
"schema": "state-hub.repository-projection-reconcile.v1",
"instance_role": settings.state_hub_instance_role,
"instance_label": settings.state_hub_instance_label,
"expected_commit": body.expected_commit,
"derived_commit": derived.commit,
"outcome": outcome.to_dict(),
}

View file

@ -9,7 +9,7 @@ from typing import Any
import yaml
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
from sqlalchemy import select
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
@ -24,6 +24,11 @@ from api.schemas.workplan import (
)
from api.services.lifecycle import transition_workplan_status
from api.services.legacy_compat import retire_legacy_route
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,
@ -88,6 +93,8 @@ async def _list_workplans(
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)
@ -106,6 +113,19 @@ async def _list_workplans(
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(),
@ -365,6 +385,8 @@ async def list_workplans(
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]:
return await _list_workplans(
@ -374,6 +396,8 @@ async def list_workplans(
status_filter=status,
owner=owner,
slug=slug,
flavor=flavor,
include_residuals=include_residuals,
session=session,
)
@ -411,8 +435,11 @@ async def sync_workplan_bindings(
"""Upsert workstation workplan file bindings for remote API index fallback."""
synced_at = datetime.now(timezone.utc)
updated = 0
requested_ids = {entry.workplan_id for entry in body.bindings}
rows = await session.execute(select(Workplan).where(Workplan.id.in_(requested_ids)))
workplans = {workplan.id: workplan for workplan in rows.scalars().all()}
for entry in body.bindings:
wp = await session.get(Workplan, entry.workplan_id)
wp = workplans.get(entry.workplan_id)
if wp is None:
continue
wp.backing_filename = entry.filename
@ -524,4 +551,4 @@ async def archive_workplan(
workplan_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> Workplan:
return await _archive_workplan(workplan_id=workplan_id, session=session)
return await _archive_workplan(workplan_id=workplan_id, session=session)

View file

@ -9,6 +9,7 @@ from pydantic import AliasChoices, Field
class DecisionCreate(LegacyWorkstreamIdBodyMixin, BaseModel):
id: uuid.UUID | None = None
topic_id: uuid.UUID | None = None
workplan_id: uuid.UUID | None = Field(
default=None,
@ -65,4 +66,4 @@ class DecisionRead(OptionalWorkplanIdCompatMixin, BaseModel):
escalation_note: str | None = None
superseded_by: uuid.UUID | None = None
created_at: datetime
updated_at: datetime
updated_at: datetime

View file

@ -104,3 +104,5 @@ class ExecutionSemantics(BaseModel):
concurrency_modes: dict[str, str]
state_hub_responsibility: list[str]
activity_core_responsibility: list[str]
launch_requests_accepted: bool = False
replacements: dict[str, str] = Field(default_factory=dict)

View file

@ -27,7 +27,10 @@ class FabricGraphSource(BaseModel):
repo: str | None = None
producer: str | None = None
registry: str | None = None
commit: str | None = None
# Accepted-snapshot-set revisions include the algorithm and digest, for
# example ``snapshot-set:sha256:<64 hex chars>``. They are intentionally
# longer than a Git SHA but still bounded before persistence.
commit: str | None = Field(default=None, max_length=255)
path: str | None = None
generation_reason: str | None = None
@ -98,6 +101,18 @@ class FabricGraphAccountingPayload(BaseModel):
valid_until: str | None = None
class FabricGraphDeploymentOverlayPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
deployment_environment: str | None = None
deployment_scenario: str | None = None
routing_authority: str | None = None
access_zone: str | None = None
policy_authority: str | None = None
exposure_class: str | None = None
route_evidence: dict[str, str | int | float | bool | None] = Field(default_factory=dict)
class FabricGraphEvidencePayload(BaseModel):
model_config = ConfigDict(extra="forbid")
@ -154,6 +169,7 @@ class FabricGraphNodePayload(BaseModel):
containment: FabricGraphContainmentPayload | None = None
ownership: FabricGraphOwnershipPayload | None = None
accounting: FabricGraphAccountingPayload | None = None
deployment_overlay: FabricGraphDeploymentOverlayPayload | None = None
evidence: FabricGraphEvidencePayload | None = None
canon_category: str | None = None
canon_anchor: str | None = None
@ -180,6 +196,7 @@ class FabricGraphEdgePayload(BaseModel):
boundary: FabricGraphBoundaryPayload | None = None
utility: FabricGraphUtilityPayload | None = None
accounting: FabricGraphAccountingPayload | None = None
deployment_overlay: FabricGraphDeploymentOverlayPayload | None = None
evidence: FabricGraphEvidencePayload | None = None
attributes: dict[str, Any] = Field(default_factory=dict)
@ -244,6 +261,17 @@ class FabricGraphIngestResult(BaseModel):
edge_count: int
class FabricGraphActivationRequest(BaseModel):
requested_by: str = "operator"
class FabricGraphActivationResult(BaseModel):
import_run: FabricGraphImportRead
previous_import_id: uuid.UUID | None = None
activated: bool
idempotent: bool
class FabricGraphNodeRead(BaseModel):
model_config = ConfigDict(from_attributes=True)

View file

@ -0,0 +1,39 @@
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
class SealedProjectionRepairSubmit(BaseModel):
plan: dict[str, Any]
repo_slug: str = Field(pattern=r"^[a-z0-9][a-z0-9-]{0,99}$")
unit: dict[str, Any]
expected_plan_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
source_revision: str = Field(pattern=r"^[0-9a-f]{40}$")
source_fingerprint: str = Field(pattern=r"^[0-9a-f]{64}$")
source_clean: bool
source_synchronized: bool
primary_confirmed: bool
projection_identity: str = Field(min_length=1, max_length=200)
class SealedProjectionRepairReceiptRead(BaseModel):
model_config = ConfigDict(populate_by_name=True)
schema_version: str = Field(alias="schema")
outcome: str
repo_slug: str
repository_id: str
workplan_record_id: str
workplan_old_id: str
task_records: list[tuple[str, str]]
plan_sha256: str
source_revision: str
source_fingerprint: str
projection_identity: str
observed_at: str
class IdentifierMigrationApplySubmit(BaseModel):
plan: dict[str, Any]
expected_plan_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
primary_confirmed: bool

89
api/schemas/intake.py Normal file
View file

@ -0,0 +1,89 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict, model_validator
from api.models.intake import IntakeLane, IntakeOutcome, IntakeStatus
class IntakeCreate(BaseModel):
id: uuid.UUID | None = None
topic_id: uuid.UUID | None = None
workplan_id: uuid.UUID | None = None
repo_id: uuid.UUID | None = None
title: str
description: str | None = None
lane: IntakeLane = IntakeLane.green
origin: str | None = None
origin_ref: str | None = None
source_repo_path: str | None = None
@model_validator(mode="after")
def scope_required(self) -> "IntakeCreate":
if self.topic_id is None and self.workplan_id is None and self.repo_id is None:
raise ValueError("At least one of topic_id, workplan_id, or repo_id must be set")
return self
class IntakeUpdate(BaseModel):
title: str | None = None
description: str | None = None
lane: IntakeLane | None = None
status: IntakeStatus | None = None
origin: str | None = None
origin_ref: str | None = None
routed_note: str | None = None
class IntakeRoute(BaseModel):
"""Move an intake from open/vetted into routed — the state that makes
it eligible for the promotion transition (CUST-WP-0061-T03)."""
routed_note: str | None = None
class IntakeClose(BaseModel):
outcome: IntakeOutcome
promoted_to: str | None = None
note: str | None = None
@model_validator(mode="after")
def promoted_requires_target(self) -> "IntakeClose":
if self.outcome == IntakeOutcome.promoted and not self.promoted_to:
raise ValueError("outcome=promoted requires promoted_to")
return self
class IntakeNoteCreate(BaseModel):
content: str
author: str | None = None
class IntakeNoteRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
author: str | None = None
content: str
created_at: datetime
class IntakeRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
topic_id: uuid.UUID | None = None
workplan_id: uuid.UUID | None = None
repo_id: uuid.UUID | None = None
title: str
description: str | None = None
lane: IntakeLane
status: IntakeStatus
outcome: IntakeOutcome | None = None
origin: str | None = None
origin_ref: str | None = None
promoted_to: str | None = None
source_repo_path: str | None = None
routed_note: str | None = None
closed_at: datetime | None = None
created_at: datetime
updated_at: datetime
notes: list[IntakeNoteRead] = []

View file

@ -80,6 +80,7 @@ class RepoCreate(CoreRepoCreate, ClassificationFields):
class RepoUpdate(ClassificationFields):
domain_slug: str | None = None
name: str | None = None
local_path: str | None = None
remote_url: str | None = None
@ -113,6 +114,11 @@ class RepoRead(CoreRepoRead, ClassificationFields):
last_state_synced_at: datetime | None = None
created_at: datetime
updated_at: datetime
requested_slug: str | None = None
canonical_slug: str | None = None
slug_status: Literal["canonical", "alias"] = "canonical"
aliases: list[str] = Field(default_factory=list)
stale_external_references: list[dict[str, Any]] = Field(default_factory=list)
class DispatchTask(BaseModel):
@ -157,6 +163,11 @@ class ScopeIssueDetail(BaseModel):
class RepoDispatch(BaseModel):
repo_slug: str
requested_slug: str
canonical_slug: str
slug_status: Literal["canonical", "alias"]
aliases: list[str] = Field(default_factory=list)
stale_external_references: list[dict[str, Any]] = Field(default_factory=list)
active_goal: dict[str, Any] | None
active_workplans: list[DispatchWorkplan]
human_interventions: list[DispatchTask]
@ -176,4 +187,4 @@ class RepoScopeHealth(BaseModel):
local_path: str | None = None
path_available: bool
scope_needs_review: bool
scope_issue_details: list[ScopeIssueDetail]
scope_issue_details: list[ScopeIssueDetail]

27
api/schemas/ops_run.py Normal file
View file

@ -0,0 +1,27 @@
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
class OpsRunProjectionItem(BaseModel):
id: str
definition: str | None = None
target_repo: str | None = None
state: str
lease: dict[str, Any] | None = None
updated_at: datetime | None = None
last_error: str | None = None
class OpsRunProjection(BaseModel):
available: bool = False
stale: bool = False
source: str = "activity-core"
generated_at: datetime
open: int = 0
claimed: int = 0
failed_24h: int = 0
stuck_open_or_claimed: int = 0
items: list[OpsRunProjectionItem] = Field(default_factory=list)
error: str | None = None

View file

@ -0,0 +1,114 @@
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, Field
class EdgeWriteEvidence(BaseModel):
id: str
status: str = "queued"
source_host: str | None = None
path: str | None = None
class RepositoryRenamePreflightRequest(BaseModel):
new_slug: str = Field(min_length=1, max_length=100, pattern=r"^[a-z0-9][a-z0-9-]*$")
queued_edge_writes: list[EdgeWriteEvidence] = Field(default_factory=list)
class RepositoryRenamePreflightRead(BaseModel):
schema_version: Literal["state-hub.repository-rename-preflight.v1"]
repo_id: uuid.UUID
old_slug: str
new_slug: str
safe_to_apply: bool
blockers: list[dict[str, Any]]
warnings: list[dict[str, Any]]
current: dict[str, Any]
target: dict[str, Any]
baselines: dict[str, Any]
active_work: dict[str, Any]
affected: dict[str, Any]
queued_edge_writes: list[dict[str, Any]]
proposed_mutations: list[dict[str, Any]]
retained_history: list[dict[str, Any]]
report_checksum: str
preflight_token: str | None
preflighted_at: datetime
expires_at: datetime | None
class ForgeIdentityVerifyRequest(BaseModel):
provider: Literal["forgejo"] = "forgejo"
forge_instance: str
forge_owner: str
forge_repository_id: int = Field(gt=0)
verified_by: str = Field(min_length=1, max_length=160)
class RepositoryRenameOperationCreate(BaseModel):
operation_id: uuid.UUID | None = None
new_slug: str = Field(min_length=1, max_length=100, pattern=r"^[a-z0-9][a-z0-9-]*$")
preflight_token: str
confirmation: str
actor: str = Field(min_length=1, max_length=160)
queued_edge_writes: list[EdgeWriteEvidence] = Field(default_factory=list)
class RepositoryRenamePhaseApply(BaseModel):
expected_phase: str
confirmation: str
checks: dict[str, bool] = Field(default_factory=dict)
evidence: dict[str, Any] = Field(default_factory=dict)
class RepositoryRenameRollbackRequest(BaseModel):
expected_phase: str
confirmation: str
class RepositoryRenameOperationRead(BaseModel):
id: uuid.UUID
repo_id: uuid.UUID
phase: str
old_slug: str
new_slug: str
expected_forge_repository_id: int
expected_source_commit: str
expected_default_branch: str
actor: str
phase_changed_at: datetime
preflighted_at: datetime | None
preflight_expires_at: datetime | None
completed_at: datetime | None
rolled_back_at: datetime | None
evidence: dict[str, Any]
error_code: str | None
error_message: str | None
error_details: dict[str, Any] | None
error_at: datetime | None
no_op: bool = False
class RepositoryRenameVerificationRead(BaseModel):
operation_id: uuid.UUID
repo_id: uuid.UUID
phase: str
ok: bool
checks: list[dict[str, Any]]
baseline_checksum: str
current_checksum: str
relationship_checksums: dict[str, dict[str, str]]
class RepositoryRenameRollbackPreflightRead(BaseModel):
operation_id: uuid.UUID
repo_id: uuid.UUID
rollback_from_phase: str
safe_to_rollback: bool
blockers: list[dict[str, Any]]
irreversible: list[dict[str, Any]]
operation: RepositoryRenameOperationRead

View file

@ -0,0 +1,96 @@
import uuid
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
class SourceRef(BaseModel):
repo: str = Field(min_length=1, max_length=100)
path: str = Field(min_length=1, max_length=1000)
revision: str = Field(pattern=r"^[0-9a-f]{40,64}$")
class ReviewContractProject(BaseModel):
contract: dict[str, Any]
source: SourceRef
decision_id: uuid.UUID | None = None
workplan_id: uuid.UUID | None = None
task_id: uuid.UUID | None = None
required_for_decision: bool = False
class ReviewContractRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
contract_key: str
schema_version: str
contract_digest: str
source_repo: str
source_path: str
source_revision: str
document: dict[str, Any]
active: bool
required_for_decision: bool
decision_id: uuid.UUID | None
workplan_id: uuid.UUID | None
task_id: uuid.UUID | None
projected_at: datetime
created_at: datetime
updated_at: datetime
class ReviewReceiptSubmit(BaseModel):
owner_id: str = Field(min_length=1, max_length=160)
actor: str = Field(min_length=2, max_length=160)
disposition: Literal["approve", "request_changes"]
contract_digest: str = Field(pattern=r"^[0-9a-f]{64}$")
artifact_hashes: dict[str, str] = Field(default_factory=dict)
assertion_ids: list[str] = Field(default_factory=list)
checks: list[dict[str, Any]] = Field(default_factory=list)
note: str | None = Field(default=None, max_length=2000)
source: SourceRef
class ReviewReceiptRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
contract_id: uuid.UUID
owner_id: str
actor: str
disposition: str
contract_digest: str
receipt_digest: str
artifact_hashes: dict[str, str]
assertion_ids: list[str]
checks: list[dict[str, Any]]
note: str | None
source_repo: str
source_path: str
source_revision: str
submitted_at: datetime
created_at: datetime
class OwnerReviewState(BaseModel):
owner_id: str
status: Literal["missing", "approved", "request_changes", "stale"]
receipt_id: uuid.UUID | None = None
receipt_digest: str | None = None
submitted_at: datetime | None = None
class GateReviewState(BaseModel):
gate_id: str
policy: Literal["all_required"]
owners: list[str]
satisfied: bool
class ReviewAggregateRead(BaseModel):
contract_key: str
contract_digest: str
satisfied: bool
authorizes_execution: Literal[False] = False
owners: list[OwnerReviewState]
gates: list[GateReviewState]

View file

@ -11,6 +11,7 @@ from api.schemas.task import TaskRead
from api.schemas.topic import TopicWithWorkstreams
from api.schemas.suggestion import RankedSuggestionDigest
from api.schemas.workstream import WorkstreamWithDeps
from api.schemas.ops_run import OpsRunProjection
class TopicTotals(BaseModel):
@ -29,6 +30,7 @@ class WorkstreamTotals(BaseModel):
finished: int = 0
archived: int = 0
total: int = 0
residual_open: int = 0
class TaskTotals(BaseModel):
@ -84,18 +86,21 @@ class StateSummary(BaseModel):
blocked_tasks: list[TaskRead] = []
recent_progress: list[ProgressEventRead]
open_workplans: list[WorkstreamWithDeps] = []
residual_open_workplans: list[WorkstreamWithDeps] = []
next_steps: list[NextStep] = []
domains: list[DomainSummary] = []
contribution_counts: dict[str, int] = {}
licence_risk_count: int = 0
open_capability_requests: int = 0
ranked_suggestions: list[RankedSuggestionDigest] = []
ops_runs: OpsRunProjection | None = None
class DashboardWorkplanRow(BaseModel):
id: uuid.UUID
title: str
status: str
flavor: str | None = None
domain: str = "unknown"
repo_label: str = "unassigned"
workplan_filename: str | None = None

View file

@ -7,6 +7,7 @@ from pydantic import BaseModel, ConfigDict, field_validator, model_validator
from api.models.task import TaskPriority, TaskStatus
from api.schemas.compat import WorkplanIdCompatMixin, WorkplanIdCreateMixin
from api.task_status import normalize_task_status
from api.work_record_flavor import WORK_RECORD_FLAVORS, normalize_flavor
class TaskStatusMixin(BaseModel):
@ -18,7 +19,23 @@ class TaskStatusMixin(BaseModel):
return normalize_task_status(value)
class TaskCreate(TaskStatusMixin, WorkplanIdCreateMixin):
class TaskFlavorMixin(BaseModel):
@field_validator("flavor", mode="before", check_fields=False)
@classmethod
def _normalize_flavor(cls, value):
flavor = normalize_flavor(value)
if flavor is not None and flavor not in WORK_RECORD_FLAVORS:
raise ValueError(
f"Unknown work-record flavor {flavor!r}; "
f"expected one of {', '.join(WORK_RECORD_FLAVORS)}"
)
return flavor
class TaskCreate(TaskStatusMixin, TaskFlavorMixin, WorkplanIdCreateMixin):
id: uuid.UUID | None = None
record_id: str | None = None
flavor: str | None = None
title: str
description: str | None = None
status: TaskStatus = TaskStatus.todo
@ -37,8 +54,9 @@ class TaskCreate(TaskStatusMixin, WorkplanIdCreateMixin):
return self
class TaskUpdate(TaskStatusMixin):
class TaskUpdate(TaskStatusMixin, TaskFlavorMixin):
title: str | None = None
flavor: str | None = None
description: str | None = None
status: TaskStatus | None = None
priority: TaskPriority | None = None
@ -99,6 +117,8 @@ class TaskStatusBulkSync(BaseModel):
class TaskRead(TaskStatusMixin, WorkplanIdCompatMixin):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
record_id: str | None = None
flavor: str | None = None
title: str
description: str | None = None
status: TaskStatus

View file

@ -0,0 +1,38 @@
from __future__ import annotations
import re
import uuid
from typing import Literal
from pydantic import BaseModel, Field, field_validator
from api.schemas.task import TaskRead
from api.schemas.workplan import WorkplanRead
from api.schemas.workplan_dependency import WorkplanDependencyRead
class RepositoryProjectionReconcile(BaseModel):
expected_commit: str
acknowledge_retirements: bool = False
@field_validator("expected_commit")
@classmethod
def validate_commit(cls, value: str) -> str:
value = value.strip().lower()
if not re.fullmatch(r"[0-9a-f]{40}", value):
raise ValueError("expected_commit must be a full 40-character Git SHA")
return value
class RepositoryProjectionSnapshot(BaseModel):
"""One bounded read of a repository's complete work-record projection."""
schema_version: Literal["state-hub.repository-projection-snapshot.v1"] = Field(
validation_alias="schema",
serialization_alias="schema",
)
repo_slug: str
repo_id: uuid.UUID
workplans: list[WorkplanRead]
tasks: list[TaskRead]
dependencies: list[WorkplanDependencyRead]

View file

@ -5,6 +5,12 @@ from typing import Literal
from pydantic import BaseModel, ConfigDict, field_validator
from api.schemas.workplan_dependency import WorkplanDepStub
from api.work_record_flavor import (
FLAVOR_PROMOTION_REASONS,
WORK_RECORD_FLAVORS,
normalize_flavor,
normalize_promotion_reason,
)
from api.workplan_status import normalize_workplan_status
WorkplanStatus = Literal[
@ -28,7 +34,43 @@ class WorkplanStatusMixin(BaseModel):
return normalize_workplan_status(value)
class WorkplanCreate(WorkplanStatusMixin):
class WorkplanFlavorMixin(BaseModel):
@field_validator("flavor", mode="before", check_fields=False)
@classmethod
def _normalise_flavor(cls, value):
flavor = normalize_flavor(value)
if flavor is not None and flavor not in WORK_RECORD_FLAVORS:
raise ValueError(
f"Unknown work-record flavor {flavor!r}; "
f"expected one of {', '.join(WORK_RECORD_FLAVORS)}"
)
return flavor
@field_validator("flavor_promotion_reason", mode="before", check_fields=False)
@classmethod
def _normalise_promotion_reason(cls, value):
reason = normalize_promotion_reason(value)
if reason is not None and reason not in FLAVOR_PROMOTION_REASONS:
raise ValueError(
f"Unknown flavor_promotion_reason {reason!r}; "
f"expected one of {', '.join(FLAVOR_PROMOTION_REASONS)}"
)
return reason
@field_validator("flavor_promoted_from", mode="before", check_fields=False)
@classmethod
def _normalise_promoted_from(cls, value):
flavor = normalize_flavor(value)
if flavor is not None and flavor not in WORK_RECORD_FLAVORS:
raise ValueError(
f"Unknown flavor_promoted_from {flavor!r}; "
f"expected one of {', '.join(WORK_RECORD_FLAVORS)}"
)
return flavor
class WorkplanCreate(WorkplanStatusMixin, WorkplanFlavorMixin):
id: uuid.UUID | None = None
repo_id: uuid.UUID
topic_id: uuid.UUID | None = None
slug: str
@ -39,6 +81,9 @@ class WorkplanCreate(WorkplanStatusMixin):
due_date: date | None = None
planning_priority: str | None = None
planning_order: int | None = None
flavor: str | None = None
flavor_promotion_reason: str | None = None
flavor_promoted_from: str | None = None
execution_state: ExecutionState = "manual"
launch_mode: LaunchMode = "manual"
concurrency_mode: ConcurrencyMode = "sequential"
@ -48,7 +93,7 @@ class WorkplanCreate(WorkplanStatusMixin):
repo_goal_id: uuid.UUID | None = None
class WorkplanUpdate(WorkplanStatusMixin):
class WorkplanUpdate(WorkplanStatusMixin, WorkplanFlavorMixin):
title: str | None = None
description: str | None = None
status: WorkplanStatus | None = None
@ -56,6 +101,9 @@ class WorkplanUpdate(WorkplanStatusMixin):
due_date: date | None = None
planning_priority: str | None = None
planning_order: int | None = None
flavor: str | None = None
flavor_promotion_reason: str | None = None
flavor_promoted_from: str | None = None
execution_state: ExecutionState | None = None
launch_mode: LaunchMode | None = None
concurrency_mode: ConcurrencyMode | None = None
@ -94,6 +142,9 @@ class WorkplanRead(WorkplanStatusMixin):
due_date: date | None = None
planning_priority: str | None = None
planning_order: int | None = None
flavor: str | None = None
flavor_promotion_reason: str | None = None
flavor_promoted_from: str | None = None
execution_state: ExecutionState = "manual"
launch_mode: LaunchMode = "manual"
concurrency_mode: ConcurrencyMode = "sequential"
@ -121,4 +172,4 @@ class WorkplanWithDeps(WorkplanWithTaskCounts):
"""WorkplanWithTaskCounts enriched with dependency graph edges."""
depends_on: list[WorkplanDepStub] = []
blocks: list[WorkplanDepStub] = []
blocked_reasons: list[dict] = []
blocked_reasons: list[dict] = []

View file

@ -9,7 +9,7 @@ EXECUTION_STATES = {
"manual": "Not queued for autonomous pickup; humans or agents may still work manually.",
"queued": "Candidate for ordered pickup when dependencies and concurrency allow it.",
"scheduled": "Waiting for an external launch window; State Hub stores the requested time.",
"launching": "A launch request asks for immediate pickup or has been handed off.",
"launching": "Legacy state only; it does not prove that any consumer accepted pickup.",
"paused": "Temporarily held outside the pickup stack.",
"completed": "Execution intent is closed; lifecycle status remains authoritative.",
"cancelled": "Execution intent was cancelled without changing lifecycle status.",
@ -19,7 +19,7 @@ LAUNCH_MODES = {
"manual": "Do not request automation; keep intent visible only.",
"queued": "Place in the prioritized stack for later pickup.",
"scheduled": "Request pickup at or after a selected time.",
"immediate": "Request prompt activity-core or agent pickup.",
"immediate": "Legacy intent only; workplan launch-request pickup is retired.",
}
CONCURRENCY_MODES = {
@ -30,16 +30,26 @@ CONCURRENCY_MODES = {
STATE_HUB_RESPONSIBILITIES = [
"store lifecycle status separately from execution intent",
"rank candidate workplans and expose dependency-aware eligibility",
"record launch requests and handoff metadata durably",
"surface manual, queued, scheduled, and immediate intent to operators",
"preserve historical launch-request and execution-intent rows during retirement",
"reject new workplan launch requests because no pickup consumer exists",
]
ACTIVITY_CORE_RESPONSIBILITIES = [
"own schedules, wakeups, and recurring automation",
"dispatch coding agents and coordinate parallel execution",
"acknowledge, run, and complete launch requests when available",
"claim and complete ops runs created by ActivityDefinition fires",
"do not consume State Hub workplan launch requests",
]
EXECUTION_REPLACEMENTS = {
"POST /execution/launch-requests": (
"Queue work in the authoritative repository file; for recurring or operational "
"fires use an ActivityDefinition and activity-core ops_run"
),
"GET /execution/launch-requests": "GET /ops-runs on activity-core for automation history",
"GET /execution/workplan-stack": "Repo Manager work index or hub-core projection",
"PATCH /execution/workplans/{id}/intent": "Edit the authoritative workplan file",
}
EXECUTION_STATE_RANK = {
"launching": 0,
"queued": 1,

View file

@ -221,6 +221,55 @@ async def ingest_fabric_graph_export(
return import_run, True, False
async def activate_fabric_graph_import(
session: AsyncSession,
import_id: Any,
*,
requested_by: str,
) -> tuple[FabricGraphImport | None, Any | None, bool]:
"""Select a retained valid import as the active read model.
Imports are immutable. Activation only moves the per-source ``is_latest``
marker, which makes cutover rollback fast and avoids re-fetching an older
authority payload during an incident.
"""
result = await session.execute(
select(FabricGraphImport).where(FabricGraphImport.id == import_id)
)
import_run = result.scalar_one_or_none()
if import_run is None:
return None, None, False
if import_run.validation_status != "valid":
raise ValueError("Only a valid Fabric graph import can be activated.")
previous_result = await session.execute(
select(FabricGraphImport).where(
FabricGraphImport.source_repo_slug == import_run.source_repo_slug,
FabricGraphImport.is_latest.is_(True),
)
)
previous = previous_result.scalars().first()
if previous is not None and previous.id == import_run.id:
return import_run, previous.id, False
await _mark_latest(session, import_run)
import_run.last_seen_at = datetime.now(timezone.utc)
await _record_progress(
session,
"Fabric graph read model activation changed.",
{
"source_repo_slug": import_run.source_repo_slug,
"import_id": str(import_run.id),
"previous_import_id": str(previous.id) if previous else None,
"content_hash": import_run.content_hash,
"requested_by": requested_by,
},
)
await session.commit()
await session.refresh(import_run)
return import_run, previous.id if previous else None, True
def validate_fabric_graph_export(payload: dict[str, Any]) -> FabricGraphExportPayload:
try:
export = FabricGraphExportPayload.model_validate(payload)
@ -324,7 +373,10 @@ async def _record_invalid_import(
import_run = FabricGraphImport(
source_repo_slug=source_repo_slug,
source_url=source_url,
source_commit=_source_value(payload, "commit"),
# Invalid payloads are retained for diagnostics, but their
# denormalized provenance must not make the rejection path fail.
# The complete input remains available in ``graph_json``.
source_commit=_bounded_source_value(payload, "commit", max_length=255),
source_path=_source_value(payload, "path"),
api_version=str(payload.get("apiVersion")) if payload.get("apiVersion") else None,
export_kind=str(payload.get("kind")) if payload.get("kind") else None,
@ -620,6 +672,13 @@ def _source_value(payload: dict[str, Any], field: str) -> str | None:
return str(value) if value else None
def _bounded_source_value(
payload: dict[str, Any], field: str, *, max_length: int
) -> str | None:
value = _source_value(payload, field)
return value[:max_length] if value is not None else None
def _parse_datetime(value: Any) -> datetime | None:
if not isinstance(value, str) or not value:
return None

View file

@ -0,0 +1,122 @@
"""Resolve the forge read credential (STATE-WP-0084-T03, MASON-WP-0003-T05).
Three sources, tried in order: a mounted file, an environment variable, and
OpenBao via Kubernetes auth. Production uses the third this cluster has no
agent injector and no secrets-store CSI driver, so the pod authenticates with
its projected ServiceAccount token and reads the KV path itself, which is what
`MASON-WP-0003-T02` built the Kubernetes auth role for. The first two exist so
the code is runnable and testable outside the cluster.
**Absent is a supported state, never an error.** A hub with no credential still
derives every public repository; only private ones become unreadable, and
`STATE-WP-0084-T01` already reports that as its own named condition rather than
as records that stopped deriving. So every failure here no configuration, no
network, OpenBao down, permission denied resolves to `None`. Raising would
convert "cannot read nine repositories" into "the whole pass failed".
"""
from __future__ import annotations
import logging
import os
import time
from pathlib import Path
import httpx
logger = logging.getLogger(__name__)
TOKEN_ENV = "FORGE_READ_TOKEN"
TOKEN_FILE_ENV = "FORGE_READ_TOKEN_FILE"
OPENBAO_ADDR_ENV = "OPENBAO_ADDR"
OPENBAO_ROLE_ENV = "OPENBAO_K8S_ROLE"
OPENBAO_JWT_PATH_ENV = "OPENBAO_K8S_TOKEN_PATH"
OPENBAO_AUTH_MOUNT_ENV = "OPENBAO_K8S_AUTH_MOUNT"
SECRET_PATH_ENV = "FORGE_READ_SECRET_PATH"
SECRET_KEY_ENV = "FORGE_READ_SECRET_KEY"
# Long enough that a fleet reset of 121 repositories does not re-authenticate
# 121 times; short enough that a rotated token is picked up without a redeploy,
# which is what MASON-WP-0003-T02 requires of this lane.
CACHE_TTL_SECONDS = 300.0
_cache: tuple[float, str | None] | None = None
def reset_cache() -> None:
global _cache
_cache = None
def _from_file() -> str | None:
path = os.environ.get(TOKEN_FILE_ENV)
if not path:
return None
try:
return Path(path).read_text(encoding="utf-8").strip() or None
except OSError:
# Deliberately not falling through to the environment: a broken mount
# that silently used a stale value would look like success.
logger.warning("forge credential: token file %s is unreadable", path)
return None
def _from_env() -> str | None:
return (os.environ.get(TOKEN_ENV) or "").strip() or None
def _from_openbao() -> str | None:
addr = (os.environ.get(OPENBAO_ADDR_ENV) or "").strip().rstrip("/")
secret_path = (os.environ.get(SECRET_PATH_ENV) or "").strip().strip("/")
role = (os.environ.get(OPENBAO_ROLE_ENV) or "").strip()
jwt_path = os.environ.get(OPENBAO_JWT_PATH_ENV) or "/var/run/secrets/openbao/token"
mount = (os.environ.get(OPENBAO_AUTH_MOUNT_ENV) or "kubernetes").strip("/")
key = (os.environ.get(SECRET_KEY_ENV) or "token").strip()
if not (addr and secret_path and role):
return None
try:
jwt = Path(jwt_path).read_text(encoding="utf-8").strip()
except OSError:
logger.warning("forge credential: no ServiceAccount token at %s", jwt_path)
return None
try:
with httpx.Client(timeout=10.0) as client:
login = client.post(
f"{addr}/v1/auth/{mount}/login", json={"role": role, "jwt": jwt}
)
login.raise_for_status()
client_token = login.json()["auth"]["client_token"]
read = client.get(
f"{addr}/v1/{secret_path}", headers={"X-Vault-Token": client_token}
)
read.raise_for_status()
data = read.json()["data"]
# KV v2 nests the payload under a second "data"; v1 does not.
if isinstance(data.get("data"), dict):
data = data["data"]
except (httpx.HTTPError, KeyError, ValueError) as exc:
# Never include the response body: a failed KV read can echo content.
logger.warning("forge credential: OpenBao lookup failed (%s)", type(exc).__name__)
return None
value = data.get(key)
if not isinstance(value, str) or not value.strip():
logger.warning("forge credential: key %r absent at the KV path", key)
return None
return value.strip()
def forge_read_token(*, use_cache: bool = True) -> str | None:
"""The forge read credential, or `None` if this instance has none."""
global _cache
now = time.monotonic()
if use_cache and _cache is not None and now - _cache[0] < CACHE_TTL_SECONDS:
return _cache[1]
if os.environ.get(TOKEN_FILE_ENV):
# Configured to use a file means *that* file and nothing else. Falling
# back would let a broken mount quietly resolve to a stale environment
# value that nobody knows is in use.
token = _from_file()
else:
token = _from_env() or _from_openbao()
_cache = (now, token)
return token

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,215 @@
"""Narrow Forgejo repository boundary used by repository rename operations.
The lifecycle service depends on this protocol instead of calling Forgejo
directly. Tests replace it with an in-memory gateway, which makes interruption
and resume tests deterministic and guarantees that API tests cannot rename a
live repository.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Protocol
from urllib.parse import quote, urljoin
import httpx
from api.config import settings
from api.services.forge_credential import forge_read_token
WRITE_TOKEN_ENV = "FORGE_RENAME_TOKEN"
WRITE_TOKEN_FILE_ENV = "FORGE_RENAME_TOKEN_FILE"
class ForgeRepositoryError(RuntimeError):
pass
class ForgeRepositoryUnreadable(ForgeRepositoryError):
pass
class ForgeRepositoryRedirected(ForgeRepositoryUnreadable):
"""Requested coordinate redirects; no credentials are forwarded."""
def __init__(self, source_url: str, location: str):
super().__init__("Forge repository coordinate redirects")
self.target_url = urljoin(source_url, location) if location else None
class ForgeRepositoryConflict(ForgeRepositoryError):
pass
@dataclass(frozen=True)
class ForgeRepositorySnapshot:
repository_id: int
owner: str
name: str
full_name: str
default_branch: str
head_commit: str
clone_url: str | None
html_url: str | None
projection_readable: bool
projection_source_present: bool
projection_entry_count: int | None
def as_dict(self) -> dict:
return {
"repository_id": self.repository_id,
"owner": self.owner,
"name": self.name,
"full_name": self.full_name,
"default_branch": self.default_branch,
"head_commit": self.head_commit,
"clone_url": self.clone_url,
"html_url": self.html_url,
"projection_readable": self.projection_readable,
"projection_source_present": self.projection_source_present,
"projection_entry_count": self.projection_entry_count,
}
class ForgeRepositoryGateway(Protocol):
async def inspect(
self, *, instance: str, owner: str, name: str
) -> ForgeRepositorySnapshot | None: ...
async def rename(
self, *, instance: str, owner: str, old_name: str, new_name: str
) -> ForgeRepositorySnapshot: ...
def _write_token() -> str | None:
path = os.environ.get(WRITE_TOKEN_FILE_ENV)
if path:
try:
return Path(path).read_text(encoding="utf-8").strip() or None
except OSError:
return None
return (os.environ.get(WRITE_TOKEN_ENV) or "").strip() or None
class ForgejoRepositoryGateway:
"""Forgejo v1 API adapter with separate read and rename credentials."""
@staticmethod
def _headers(token: str | None) -> dict[str, str]:
return {"Authorization": f"token {token}"} if token else {}
@staticmethod
def _api(instance: str, owner: str, name: str) -> str:
base = instance.rstrip("/")
return f"{base}/api/v1/repos/{quote(owner, safe='')}/{quote(name, safe='')}"
async def _inspect_with_token(
self, *, instance: str, owner: str, name: str, token: str | None
) -> ForgeRepositorySnapshot | None:
url = self._api(instance, owner, name)
timeout = settings.repository_rename_forge_timeout_seconds
try:
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.get(url, headers=self._headers(token))
if response.status_code in {301, 302, 303, 307, 308}:
raise ForgeRepositoryRedirected(
url, response.headers.get("location", "")
)
if response.status_code == 404:
if token is None:
raise ForgeRepositoryUnreadable(
"Forge returned not-found without an authenticated absence proof"
)
return None
response.raise_for_status()
repo = response.json()
branch_name = str(repo.get("default_branch") or "")
if not branch_name:
raise ForgeRepositoryUnreadable("Forge repository has no default branch")
branch = await client.get(
f"{url}/branches/{quote(branch_name, safe='')}",
headers=self._headers(token),
)
branch.raise_for_status()
branch_data = branch.json()
commit = branch_data.get("commit") or {}
head = str(commit.get("id") or commit.get("sha") or "")
if not head:
raise ForgeRepositoryUnreadable("Forge default branch has no readable head")
projection = await client.get(
f"{url}/contents/workplans",
params={"ref": head},
headers=self._headers(token),
)
source_present = projection.status_code == 200
if projection.status_code not in {200, 404}:
projection.raise_for_status()
entries = projection.json() if source_present else None
except ForgeRepositoryError:
raise
except (httpx.HTTPError, ValueError, KeyError) as exc:
raise ForgeRepositoryUnreadable(
f"Forge repository inspection failed ({type(exc).__name__})"
) from exc
return ForgeRepositorySnapshot(
repository_id=int(repo["id"]),
owner=str((repo.get("owner") or {}).get("login") or owner),
name=str(repo["name"]),
full_name=str(repo.get("full_name") or f"{owner}/{name}"),
default_branch=branch_name,
head_commit=head,
clone_url=repo.get("clone_url"),
html_url=repo.get("html_url"),
projection_readable=True,
projection_source_present=source_present,
projection_entry_count=(len(entries) if isinstance(entries, list) else None),
)
async def inspect(
self, *, instance: str, owner: str, name: str
) -> ForgeRepositorySnapshot | None:
return await self._inspect_with_token(
instance=instance, owner=owner, name=name, token=forge_read_token()
)
async def rename(
self, *, instance: str, owner: str, old_name: str, new_name: str
) -> ForgeRepositorySnapshot:
token = _write_token()
if not token:
raise ForgeRepositoryUnreadable("Forge rename credential is unavailable")
url = self._api(instance, owner, old_name)
try:
async with httpx.AsyncClient(
timeout=settings.repository_rename_forge_timeout_seconds
) as client:
response = await client.patch(
url,
json={"name": new_name},
headers=self._headers(token),
)
if response.status_code in {409, 422}:
raise ForgeRepositoryConflict("Forge rejected the target repository name")
response.raise_for_status()
except ForgeRepositoryError:
raise
except httpx.HTTPError as exc:
raise ForgeRepositoryUnreadable(
f"Forge repository rename failed ({type(exc).__name__})"
) from exc
renamed = await self._inspect_with_token(
instance=instance, owner=owner, name=new_name, token=token
)
if renamed is None:
raise ForgeRepositoryUnreadable("Renamed Forge repository is not readable")
return renamed
_gateway = ForgejoRepositoryGateway()
def get_forge_repository_gateway() -> ForgeRepositoryGateway:
return _gateway

View file

@ -305,7 +305,13 @@ def _summarize_interface(
]
window = _counters(window_buckets)
last_seen = max((bucket.last_seen_at for bucket in buckets), default=None)
retirement_candidate, reason = _retirement_state(interface, window.calls)
retirement_candidate, reason = _retirement_state(
interface,
window.calls,
all_time_calls=all_time.calls,
last_seen_at=last_seen,
window_end=window_end,
)
return LegacyInterfaceSummary(
interface=LegacyInterfaceRead.model_validate(interface),
all_time=all_time,
@ -340,7 +346,34 @@ def _bucket_counts(buckets: list[LegacyInterfaceUsageBucket], bucket_kind: str)
return counts
def _retirement_state(interface: LegacyInterface, window_calls: int) -> tuple[bool, str]:
# Minimum quiet period before an interface may be retired, scaled to how much
# traffic it ever carried. A review window only proves the interface was silent
# for that window; for a high-volume interface that is weak evidence, since a
# monthly or quarterly caller can easily fall outside it. Thresholds are
# (all-time calls, minimum days since last_seen_at).
RETIREMENT_QUIET_LADDER: tuple[tuple[int, int], ...] = (
(10_000, 60),
(100, 30),
(1, 7),
)
def _required_quiet_days(all_time_calls: int) -> int:
"""Days of silence required before retirement, by all-time call volume."""
for threshold, days in RETIREMENT_QUIET_LADDER:
if all_time_calls >= threshold:
return days
return 0
def _retirement_state(
interface: LegacyInterface,
window_calls: int,
*,
all_time_calls: int = 0,
last_seen_at: datetime | None = None,
window_end: datetime | None = None,
) -> tuple[bool, str]:
if interface.status == "retired":
return False, "already retired"
if interface.manual_hold:
@ -351,6 +384,19 @@ def _retirement_state(interface: LegacyInterface, window_calls: int) -> tuple[bo
return False, "replacement not verified"
if window_calls > 0:
return False, f"{window_calls} call(s) in review window"
required_days = _required_quiet_days(all_time_calls)
if required_days and last_seen_at is not None and window_end is not None:
quiet_days = (window_end - _ensure_datetime(last_seen_at)).days
if quiet_days < required_days:
return False, (
f"quiet {quiet_days}d of {required_days}d required "
f"for {all_time_calls} all-time call(s)"
)
return True, (
f"no usage in review window; quiet {quiet_days}d "
f"(>= {required_days}d required for {all_time_calls} all-time call(s))"
)
return True, "no measured usage in review window"

View file

@ -0,0 +1,130 @@
"""Read-only activity-core ops_run projection (STATE-WP-0078)."""
from __future__ import annotations
import asyncio
import time
from datetime import datetime, timedelta, timezone
from typing import Any
import httpx
from api.config import settings
from api.schemas.ops_run import OpsRunProjection, OpsRunProjectionItem
_cache: OpsRunProjection | None = None
_cache_at: float = 0.0
_lock = asyncio.Lock()
def reset_ops_run_projection_cache() -> None:
global _cache, _cache_at
_cache = None
_cache_at = 0.0
def _parse_datetime(value: Any) -> datetime | None:
if not value or not isinstance(value, str):
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
async def _fetch_ops_runs() -> dict[str, Any]:
if not settings.activity_core_url:
raise RuntimeError("ACTIVITY_CORE_URL is not configured")
headers = {}
if settings.activity_core_worker_token:
headers["X-Worker-Token"] = settings.activity_core_worker_token
async with httpx.AsyncClient(
base_url=settings.activity_core_url.rstrip("/"),
timeout=10.0,
follow_redirects=True,
trust_env=False,
headers=headers,
) as client:
response = await client.get("/ops-runs", params={"limit": 200})
response.raise_for_status()
return response.json()
def _project(payload: dict[str, Any], now: datetime) -> OpsRunProjection:
raw_items = payload.get("items") or []
counts = payload.get("counts") or {}
failed_cutoff = now - timedelta(hours=24)
stuck_cutoff = now - timedelta(hours=max(0.1, settings.ops_run_sla_hours))
failed_24h = 0
stuck = 0
items: list[OpsRunProjectionItem] = []
for raw in raw_items:
state = str(raw.get("state") or "unknown")
updated_at = _parse_datetime(raw.get("updated_at"))
created_at = _parse_datetime(raw.get("created_at"))
if state == "failed" and updated_at and updated_at >= failed_cutoff:
failed_24h += 1
if state in {"open", "claimed"} and (created_at or updated_at):
if (created_at or updated_at) < stuck_cutoff:
stuck += 1
result = raw.get("result") if isinstance(raw.get("result"), dict) else {}
last_error = result.get("error") or raw.get("last_error")
if state in {"open", "claimed", "failed"}:
items.append(OpsRunProjectionItem(
id=str(raw.get("id")),
definition=str(raw.get("activity_definition_id")) if raw.get("activity_definition_id") else None,
target_repo=raw.get("target_repo"),
state=state,
lease={
"owner": raw.get("claim_owner"),
"until": raw.get("lease_until"),
"attempt": raw.get("attempt", 0),
} if raw.get("claim_owner") or raw.get("lease_until") else None,
updated_at=updated_at,
last_error=str(last_error) if last_error else None,
))
items.sort(key=lambda item: item.updated_at or datetime.min.replace(tzinfo=timezone.utc), reverse=True)
return OpsRunProjection(
available=True,
generated_at=now,
open=int(counts.get("open", sum(item.state == "open" for item in items))),
claimed=int(counts.get("claimed", sum(item.state == "claimed" for item in items))),
failed_24h=failed_24h,
stuck_open_or_claimed=stuck,
items=items[:50],
)
async def get_ops_run_projection(*, refresh: bool = False) -> OpsRunProjection:
global _cache, _cache_at
now_mono = time.monotonic()
ttl = max(1.0, settings.ops_run_projection_ttl_seconds)
if not refresh and _cache is not None and now_mono - _cache_at < ttl:
return _cache
async with _lock:
now_mono = time.monotonic()
if not refresh and _cache is not None and now_mono - _cache_at < ttl:
return _cache
now = datetime.now(timezone.utc)
try:
projection = _project(await _fetch_ops_runs(), now)
except Exception as exc:
if _cache is not None and _cache.available:
projection = _cache.model_copy(update={
"stale": True,
"error": f"activity-core refresh failed: {exc}",
})
else:
projection = OpsRunProjection(
generated_at=now,
error=f"activity-core unavailable: {exc}",
)
_cache = projection
_cache_at = now_mono
return projection

View file

@ -0,0 +1,544 @@
"""State Hub dual-run adapter → Repo Manager (RMGR-WP-0002/0003 Stage B).
Config file (same as repo-manager): ~/.repo-manager/dual-run.yaml
or RM_DUAL_RUN_CONFIG. Env overrides when set:
RM_WRITEBACK, RM_RECONCILE, RM_WRITEBACK_PUSH, RM_PILOT_REPOS, RM_METER_PATH
Rollback: disable in config / unset env native State Hub path.
"""
from __future__ import annotations
import json
import logging
import os
import subprocess
import sys
import uuid
from functools import lru_cache
from pathlib import Path
from typing import Any
import yaml
logger = logging.getLogger(__name__)
_TRUE = frozenset({"1", "true", "yes", "on"})
_FALSE = frozenset({"0", "false", "no", "off"})
def config_path() -> Path:
raw = os.environ.get("RM_DUAL_RUN_CONFIG", "~/.repo-manager/dual-run.yaml")
return Path(raw).expanduser()
@lru_cache(maxsize=1)
def _load_file_config() -> dict[str, Any]:
path = config_path()
if not path.is_file():
return {}
try:
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except (OSError, yaml.YAMLError):
return {}
return data if isinstance(data, dict) else {}
def reload_config() -> None:
_load_file_config.cache_clear()
def _env_bool(name: str) -> bool | None:
raw = os.environ.get(name)
if raw is None or raw.strip() == "":
return None
v = raw.strip().lower()
if v in _TRUE:
return True
if v in _FALSE:
return False
return None
def _cfg_bool(key: str, env_name: str, default: bool = False) -> bool:
ev = _env_bool(env_name)
if ev is not None:
return ev
cfg = _load_file_config()
if key in cfg:
val = cfg[key]
if isinstance(val, bool):
return val
if isinstance(val, str):
return val.strip().lower() in _TRUE
return default
def writeback_enabled() -> bool:
return _cfg_bool("writeback", "RM_WRITEBACK", False)
def reconcile_enabled() -> bool:
return _cfg_bool("reconcile", "RM_RECONCILE", False)
def writeback_push_enabled() -> bool:
return _cfg_bool("writeback_push", "RM_WRITEBACK_PUSH", False)
def pilot_slugs() -> set[str] | None:
raw = os.environ.get("RM_PILOT_REPOS")
if raw is not None:
raw = raw.strip()
if not raw:
return set()
return {s.strip() for s in raw.split(",") if s.strip()}
cfg = _load_file_config()
if "pilot_repos" not in cfg:
return None
val = cfg["pilot_repos"]
if val is None:
return None
if isinstance(val, str):
if not val.strip():
return set()
return {s.strip() for s in val.split(",") if s.strip()}
if isinstance(val, list):
return {str(s).strip() for s in val if str(s).strip()}
return None
def slug_allowed(slug: str | None) -> bool:
if not slug:
return False
pilots = pilot_slugs()
if pilots is None:
return True
return slug in pilots
def writeback_for_repo(slug: str | None) -> bool:
return writeback_enabled() and slug_allowed(slug)
def reconcile_for_repo(slug: str | None) -> bool:
return reconcile_enabled() and slug_allowed(slug)
def meter_path() -> Path:
raw = os.environ.get("RM_METER_PATH")
if raw:
return Path(raw).expanduser()
cfg = _load_file_config()
if cfg.get("meter_path"):
return Path(str(cfg["meter_path"])).expanduser()
return Path("~/.repo-manager/mutation-meter.jsonl").expanduser()
def record_mutation(
*,
source: str,
kind: str,
repo_slug: str | None,
detail: dict[str, Any] | None = None,
) -> None:
try:
from datetime import UTC, datetime
path = meter_path()
path.parent.mkdir(parents=True, exist_ok=True)
row = {
"ts": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
"source": source,
"kind": kind,
"repo_slug": repo_slug,
"detail": detail or {},
}
with path.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(row, default=str) + "\n")
except OSError:
pass
def run_rmgr(args: list[str], *, timeout: int = 120) -> tuple[int, str, str]:
env = os.environ.copy()
custom = os.environ.get("RMGR_BIN", "").strip()
if custom:
cmd = custom.split() + args
else:
src = os.environ.get("REPO_MANAGER_SRC", "").strip()
sibling = Path(src) if src else (Path.home() / "repo-manager" / "src")
if not sibling.is_dir() and (Path.home() / "repo-manager" / "src").is_dir():
sibling = Path.home() / "repo-manager" / "src"
if sibling.is_dir():
env["PYTHONPATH"] = str(sibling) + (
os.pathsep + env["PYTHONPATH"] if env.get("PYTHONPATH") else ""
)
cmd = [
sys.executable,
"-c",
"from repo_manager.cli import main; import sys; raise SystemExit(main(sys.argv[1:]))",
*args,
]
else:
cmd = ["rmgr", *args]
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
check=False,
env=env,
)
return proc.returncode, proc.stdout, proc.stderr
def rm_update_task_status(
*,
repo_path: str | Path,
task_id: str,
status: str,
repo_slug: str | None = None,
reason: str = "state-hub dual-run",
push: bool | None = None,
correlation_id: str | None = None,
) -> dict[str, Any]:
correlation_id = correlation_id or str(uuid.uuid4())
if push is None:
push = writeback_push_enabled()
args = [
"update-task-status",
"--path",
str(repo_path),
"--task-id",
str(task_id),
"--status",
status,
"--reason",
reason,
"--correlation-id",
correlation_id,
"--idempotency-key",
f"sh-dual-{task_id}-{status}-{correlation_id}",
]
if repo_slug:
args.extend(["--slug", repo_slug])
if push:
args.append("--push")
code, out, err = run_rmgr(args)
try:
result = json.loads(out.strip() or "{}")
except json.JSONDecodeError:
result = {
"status": "failed",
"error": {
"code": "internal",
"message": f"rmgr non-json exit={code} stderr={err!r} stdout={out[:500]!r}",
},
"correlation_id": correlation_id,
}
if code != 0 and result.get("status") not in ("applied", "rejected"):
result.setdefault("status", "failed")
result.setdefault(
"error",
{"code": "internal", "message": f"rmgr exit={code} stderr={err!r}"},
)
return result
def rm_update_workplan(
*,
repo_path: str | Path,
workplan_id: str,
operation: str = "update",
title: str | None = None,
goal: str | None = None,
status: str | None = None,
owner: str | None = None,
domain: str | None = None,
topic_slug: str | None = None,
repo_slug: str | None = None,
reason: str = "state-hub dual-run",
push: bool | None = None,
correlation_id: str | None = None,
confirm_archive: bool = False,
) -> dict[str, Any]:
"""Delegate a file-backed workplan mutation to Repo Manager.
``operation=archive`` is the recoverable counterpart of State Hub's
DELETE workplan route; it never erases repository history.
"""
correlation_id = correlation_id or str(uuid.uuid4())
if operation not in {"create", "update", "archive"}:
return {
"status": "rejected",
"correlation_id": correlation_id,
"error": {"code": "validation_error", "message": f"invalid operation {operation!r}"},
}
if push is None:
push = writeback_push_enabled()
cli_operation = "delete" if operation == "archive" else operation
args = [
"workplan",
cli_operation,
"--path",
str(repo_path),
"--workplan-id",
str(workplan_id),
"--reason",
reason,
"--correlation-id",
correlation_id,
"--idempotency-key",
f"sh-dual-workplan-{operation}-{workplan_id}-{correlation_id}",
]
for flag, value in (
("--title", title),
("--goal", goal),
("--status", status),
("--owner", owner),
("--domain", domain),
("--topic-slug", topic_slug),
("--slug", repo_slug),
):
if value is not None:
args.extend([flag, value])
if operation == "archive" and confirm_archive:
args.append("--confirm")
if push:
args.append("--push")
code, out, err = run_rmgr(args)
try:
result = json.loads(out.strip() or "{}")
except json.JSONDecodeError:
result = {
"status": "failed",
"error": {
"code": "internal",
"message": f"rmgr non-json exit={code} stderr={err!r} stdout={out[:500]!r}",
},
"correlation_id": correlation_id,
}
if code != 0 and result.get("status") not in ("applied", "rejected"):
result.setdefault("status", "failed")
result.setdefault(
"error",
{"code": "internal", "message": f"rmgr exit={code} stderr={err!r}"},
)
return result
def rm_update_register_entry(
*,
repo_path: str | Path,
kind: str,
entry_id: str,
operation: str = "put",
title: str | None = None,
status: str | None = None,
data: dict[str, Any] | None = None,
note: str | None = None,
author: str | None = None,
repo_slug: str | None = None,
reason: str = "state-hub retirement adapter",
push: bool | None = None,
correlation_id: str | None = None,
) -> dict[str, Any]:
"""Delegate register create/update/defer/note to Repo Manager."""
correlation_id = correlation_id or str(uuid.uuid4())
if operation not in {"put", "defer", "note"}:
return {
"status": "rejected",
"correlation_id": correlation_id,
"error": {"code": "validation_error", "message": f"invalid operation {operation!r}"},
}
if push is None:
push = writeback_push_enabled()
args = [
"register",
operation,
"--path",
str(repo_path),
"--kind",
kind,
"--entry-id",
entry_id,
"--reason",
reason,
"--correlation-id",
correlation_id,
"--idempotency-key",
f"sh-dual-register-{operation}-{kind}-{entry_id}-{correlation_id}",
]
for flag, value in (
("--title", title),
("--status", status),
("--note", note),
("--author", author),
("--slug", repo_slug),
):
if value is not None:
args.extend([flag, value])
if data is not None:
args.extend(["--data-json", json.dumps(data, separators=(",", ":"))])
if push:
args.append("--push")
code, out, err = run_rmgr(args)
try:
result = json.loads(out.strip() or "{}")
except json.JSONDecodeError:
result = {
"status": "failed",
"error": {
"code": "internal",
"message": f"rmgr non-json exit={code} stderr={err!r} stdout={out[:500]!r}",
},
"correlation_id": correlation_id,
}
if code != 0 and result.get("status") not in ("applied", "rejected"):
result.setdefault("status", "failed")
result.setdefault(
"error",
{"code": "internal", "message": f"rmgr exit={code} stderr={err!r}"},
)
return result
def rm_scan_sbom(
*,
repo_path: str | Path,
repo_slug: str | None = None,
) -> dict[str, Any]:
"""Derive a versioned SBOM snapshot from repository-owned sources."""
args = ["sbom", "scan", "--path", str(repo_path)]
if repo_slug:
args.extend(["--slug", repo_slug])
code, out, err = run_rmgr(args)
try:
result = json.loads(out.strip() or "{}")
except json.JSONDecodeError:
result = {
"ok": False,
"error": f"rmgr non-json exit={code} stderr={err!r} stdout={out[:500]!r}",
}
if code != 0:
result.setdefault("ok", False)
result.setdefault("error", f"rmgr exit={code} stderr={err!r}")
result["exit_code"] = code
return result
def rm_scaffold(
*,
repo_path: str | Path,
flavor: str,
wp_prefix: str | None = None,
slug: str | None = None,
domain: str = "infotech",
force: bool = False,
commit: bool = False,
) -> dict[str, Any]:
"""Delegate repository scaffolding to ``rmgr scaffold`` (STATE-WP-0080-T02)."""
args = [
"scaffold",
"--path",
str(repo_path),
"--flavor",
flavor,
"--domain",
domain,
]
if slug:
args.extend(["--slug", slug])
if wp_prefix:
args.extend(["--wp-prefix", wp_prefix])
if force:
args.append("--force")
if not commit:
args.append("--no-commit")
code, out, err = run_rmgr(args)
try:
result = json.loads(out.strip() or "{}")
except json.JSONDecodeError:
result = {
"status": "failed",
"error": {
"code": "internal",
"message": f"rmgr non-json exit={code} stderr={err!r} stdout={out[:500]!r}",
},
}
if code != 0 and result.get("status") not in ("applied", "rejected"):
result.setdefault("status", "failed")
result.setdefault(
"error",
{"code": "internal", "message": f"rmgr exit={code} stderr={err!r}"},
)
result["exit_code"] = code
return result
def rm_reconcile(*, repo_path: str | Path, repo_slug: str | None = None) -> dict[str, Any]:
args = ["reconcile", "--path", str(repo_path)]
if repo_slug:
args.extend(["--slug", repo_slug])
code, out, err = run_rmgr(args)
try:
result = json.loads(out.strip() or "{}")
except json.JSONDecodeError:
result = {"ok": False, "error": err or out, "exit_code": code}
result["exit_code"] = code
return result
def try_writeback_for_task(
*,
repo_path: str | Path | None,
repo_slug: str | None,
task_id: str,
status: str,
reason: str = "state-hub dual-run PATCH /tasks",
) -> dict[str, Any] | None:
if not writeback_for_repo(repo_slug):
return None
if not repo_path:
logger.warning("RM dual-run writeback skipped: no repo_path for %s", repo_slug)
return None
path = Path(repo_path)
if not path.is_dir():
logger.warning("RM dual-run writeback skipped: missing path %s", path)
return None
result = rm_update_task_status(
repo_path=path,
task_id=task_id,
status=status,
repo_slug=repo_slug,
reason=reason,
push=writeback_push_enabled(),
)
if result.get("status") == "applied":
record_mutation(
source="repo-manager",
kind="task_status_writeback_via_sh_facade",
repo_slug=repo_slug,
detail={
"task_id": task_id,
"status": status,
"git_sha": (result.get("evidence") or {}).get("git_sha"),
"push_ok": (result.get("evidence") or {}).get("push_ok"),
},
)
else:
logger.warning(
"RM dual-run writeback failed for %s task=%s: %s",
repo_slug,
task_id,
result.get("error") or result,
)
return result

View file

@ -0,0 +1,229 @@
"""Canonical repository identity resolution across current and prior slugs.
The slug registry is the lookup boundary. Historical records deliberately keep
the slug they recorded; callers use ``slug_values`` when they need an identity-
wide read and ``canonical_slug`` when they create a new reference.
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from fastapi import HTTPException
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import noload
from api.models.fabric_graph import FabricGraphEdge, FabricGraphImport, FabricGraphNode
from api.models.managed_repo import ManagedRepo
from api.models.repository_rename import RepositorySlug
@dataclass(frozen=True)
class RepositorySlugResolution:
repo: ManagedRepo
requested_slug: str
canonical_slug: str
slug_status: str
aliases: tuple[str, ...]
source_operation_id: uuid.UUID | None = None
@property
def slug_values(self) -> tuple[str, ...]:
return (self.canonical_slug, *self.aliases)
async def resolve_repository_slug(
session: AsyncSession,
slug: str,
*,
required: bool = True,
) -> RepositorySlugResolution | None:
"""Resolve a current or protected prior slug to one repository UUID."""
record = (
await session.execute(
select(RepositorySlug).where(RepositorySlug.slug == slug)
)
).scalar_one_or_none()
# Compatibility for databases upgraded before the identity backfill has run.
if record is None:
repo = (
await session.execute(select(ManagedRepo).where(ManagedRepo.slug == slug))
).scalar_one_or_none()
if repo is None:
if required:
raise HTTPException(status_code=404, detail=f"Repo '{slug}' not found")
return None
return RepositorySlugResolution(
repo=repo,
requested_slug=slug,
canonical_slug=repo.slug,
slug_status="canonical",
aliases=(),
)
repo = await session.get(ManagedRepo, record.repo_id)
if repo is None:
raise HTTPException(
status_code=409,
detail=f"Repository slug registry entry '{slug}' has no repository",
)
records = list(
(
await session.execute(
select(RepositorySlug)
.where(RepositorySlug.repo_id == repo.id)
.order_by(RepositorySlug.kind.desc(), RepositorySlug.slug)
)
).scalars()
)
canonicals = [item for item in records if item.kind == "canonical"]
if len(canonicals) != 1 or canonicals[0].slug != repo.slug:
raise HTTPException(
status_code=409,
detail=f"Repository '{repo.id}' has inconsistent canonical slug state",
)
return RepositorySlugResolution(
repo=repo,
requested_slug=slug,
canonical_slug=repo.slug,
slug_status=record.kind,
aliases=tuple(item.slug for item in records if item.kind == "alias"),
source_operation_id=record.source_operation_id,
)
async def repository_resolutions_for_repositories(
session: AsyncSession,
repositories: list[ManagedRepo],
) -> dict[uuid.UUID, RepositorySlugResolution]:
"""Resolve a repository collection with one slug-registry query.
Collection callers already hold canonical ``ManagedRepo`` rows. Resolving
every row through :func:`resolve_repository_slug` would issue two identity
queries per repository. Load the complete alias sets once instead while
retaining the pre-registry compatibility behavior.
"""
if not repositories:
return {}
repository_ids = [repository.id for repository in repositories]
records = list(
(
await session.execute(
select(RepositorySlug)
.options(
noload(RepositorySlug.repo),
noload(RepositorySlug.source_operation),
)
.where(RepositorySlug.repo_id.in_(repository_ids))
.order_by(RepositorySlug.repo_id, RepositorySlug.kind.desc(), RepositorySlug.slug)
)
).scalars()
)
records_by_repository: dict[uuid.UUID, list[RepositorySlug]] = {
repository_id: [] for repository_id in repository_ids
}
for record in records:
records_by_repository[record.repo_id].append(record)
resolutions: dict[uuid.UUID, RepositorySlugResolution] = {}
for repository in repositories:
repository_records = records_by_repository[repository.id]
requested_record = next(
(record for record in repository_records if record.slug == repository.slug),
None,
)
if requested_record is None:
# Compatibility for databases upgraded before the identity backfill.
resolutions[repository.id] = RepositorySlugResolution(
repo=repository,
requested_slug=repository.slug,
canonical_slug=repository.slug,
slug_status="canonical",
aliases=(),
)
continue
canonicals = [record for record in repository_records if record.kind == "canonical"]
if len(canonicals) != 1 or canonicals[0].slug != repository.slug:
raise HTTPException(
status_code=409,
detail=f"Repository '{repository.id}' has inconsistent canonical slug state",
)
resolutions[repository.id] = RepositorySlugResolution(
repo=repository,
requested_slug=repository.slug,
canonical_slug=repository.slug,
slug_status=requested_record.kind,
aliases=tuple(
record.slug for record in repository_records if record.kind == "alias"
),
source_operation_id=requested_record.source_operation_id,
)
return resolutions
async def canonicalize_repository_slug(session: AsyncSession, value: str) -> str:
"""Canonicalize a value only when it is a registered repository identity."""
resolution = await resolve_repository_slug(session, value, required=False)
return resolution.canonical_slug if resolution is not None else value
async def repository_resolution_for_id(
session: AsyncSession, repo: ManagedRepo
) -> RepositorySlugResolution:
return await resolve_repository_slug(session, repo.slug) # type: ignore[return-value]
async def stale_external_references(
session: AsyncSession,
resolution: RepositorySlugResolution,
) -> list[dict[str, object]]:
"""Name external projections that still contain an historical slug.
These are handoffs to the projection owner, never implicit rewrite targets.
"""
if not resolution.aliases:
return []
aliases = list(resolution.aliases)
checks = (
(FabricGraphImport, "source_repo_slug"),
(FabricGraphNode, "source_repo_slug"),
(FabricGraphNode, "repo_slug"),
(FabricGraphEdge, "source_repo_slug"),
)
stale: list[dict[str, object]] = []
for model, field_name in checks:
field = getattr(model, field_name)
rows = (
await session.execute(
select(field, func.count()).where(field.in_(aliases)).group_by(field)
)
).all()
for value, count in rows:
stale.append(
{
"owner": "railiance-fabric",
"surface": model.__tablename__,
"field": field_name,
"value": value,
"count": count,
"status": "stale",
"handoff": "owner update or re-ingest required",
}
)
return stale
def affected_slug_predicate(field: object, slugs: tuple[str, ...]):
"""Match a JSONB slug-list against any name in one repository lineage."""
return or_(*(field.contains([slug]) for slug in slugs))

View file

@ -0,0 +1,23 @@
from sqlalchemy.ext.asyncio import AsyncSession
from api.models.managed_repo import ManagedRepo
from api.models.repository_rename import RepositoryForgeIdentity, RepositorySlug
def stage_initial_repository_identity(
session: AsyncSession, repo: ManagedRepo
) -> None:
"""Attach non-guessed identity state to a newly flushed repository row."""
if repo.id is None:
raise ValueError("repository must be flushed before identity records are staged")
session.add_all(
[
RepositoryForgeIdentity(repo_id=repo.id),
RepositorySlug(
repo_id=repo.id,
slug=repo.slug,
kind="canonical",
protected=True,
),
]
)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,336 @@
from __future__ import annotations
import hashlib
import json
from datetime import datetime, timezone
from typing import Any
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.models.review_contract import ReviewContract, ReviewReceipt
from api.schemas.review_contract import (
GateReviewState,
OwnerReviewState,
ReviewAggregateRead,
)
def canonical_json(value: Any) -> bytes:
return json.dumps(
value, sort_keys=True, separators=(",", ":"), ensure_ascii=True
).encode()
def digest_document(value: Any) -> str:
return hashlib.sha256(canonical_json(value)).hexdigest()
def _assertion_id(text: str) -> str:
return "assertion:" + hashlib.sha256(text.encode()).hexdigest()[:20]
def normalize_contract(raw: dict[str, Any]) -> tuple[dict[str, Any], str]:
"""Validate v1 or adapt the WP-0024 prototype without executing its checks."""
digest = digest_document(raw)
if raw.get("interface") == "railiance.owner-review" and raw.get("version") == 1:
owners_raw = raw.get("owners")
task_owners = raw.get("task_owners")
hashes = raw.get("artifact_sha256")
if (
not isinstance(owners_raw, dict)
or not owners_raw
or not isinstance(task_owners, dict)
or not task_owners
):
raise HTTPException(
422, "legacy contract requires non-empty owners and task_owners"
)
if not isinstance(hashes, dict):
raise HTTPException(422, "legacy contract requires artifact_sha256")
owners = []
for owner_id, scope in owners_raw.items():
artifacts = scope.get("artifacts") or []
assertions = scope.get("assertions") or []
checks = scope.get("checks") or []
if (
not artifacts
or not assertions
or not checks
or any(path not in hashes for path in artifacts)
):
raise HTTPException(422, f"{owner_id}: incomplete legacy owner scope")
owners.append(
{
"id": owner_id,
"artifact_ids": artifacts,
"assertions": [
{"id": _assertion_id(text), "statement": text}
for text in assertions
],
"check_ids": checks,
}
)
owner_ids = {item["id"] for item in owners}
gates = []
for gate_id, required in task_owners.items():
if (
not required
or len(required) != len(set(required))
or not set(required) <= owner_ids
):
raise HTTPException(422, f"{gate_id}: invalid legacy owner set")
gates.append({"id": gate_id, "policy": "all_required", "owners": required})
normalized = {
"schema_version": "review-contract/v1",
"contract_key": raw.get("workplan_id"),
"subject": {"kind": "workplan", "id": raw.get("workplan_id")},
"owners": owners,
"gates": gates,
"artifacts": {
path: {"algorithm": "sha256", "digest": value}
for path, value in hashes.items()
},
"allowed_dispositions": [
item.replace("-", "_") for item in (raw.get("decisions") or [])
],
"legacy": {
"interface": raw["interface"],
"version": 1,
"source_digest": digest,
},
}
return validate_v1(normalized), digest
if raw.get("schema_version") != "review-contract/v1":
raise HTTPException(422, "schema_version must be review-contract/v1")
return validate_v1(raw), digest
def validate_v1(doc: dict[str, Any]) -> dict[str, Any]:
contract_key, subject = doc.get("contract_key"), doc.get("subject")
owners, gates, artifacts = doc.get("owners"), doc.get("gates"), doc.get("artifacts")
dispositions = doc.get("allowed_dispositions")
if not isinstance(contract_key, str) or not contract_key:
raise HTTPException(422, "contract_key is required")
if (
not isinstance(subject, dict)
or not subject.get("kind")
or not subject.get("id")
):
raise HTTPException(422, "typed subject kind and id are required")
if (
not isinstance(owners, list)
or not owners
or not isinstance(gates, list)
or not gates
):
raise HTTPException(422, "non-empty owners and gates are required")
if not isinstance(artifacts, dict) or not artifacts:
raise HTTPException(422, "revision-pinned artifacts are required")
if (
set(dispositions or []) != {"approve", "request_changes"}
or len(dispositions) != 2
):
raise HTTPException(
422, "allowed_dispositions must contain approve and request_changes only"
)
owner_ids: set[str] = set()
for owner in owners:
if not isinstance(owner, dict):
raise HTTPException(422, "owner entries must be objects")
owner_id = owner.get("id")
artifact_ids = owner.get("artifact_ids") or []
assertions = owner.get("assertions") or []
checks = owner.get("check_ids") or []
assertion_ids = [
item.get("id") for item in assertions if isinstance(item, dict)
]
if (
not owner_id
or owner_id in owner_ids
or not artifact_ids
or not checks
or not assertion_ids
):
raise HTTPException(
422,
"each owner needs a unique id and non-empty artifact/assertion/check scope",
)
if len(assertion_ids) != len(set(assertion_ids)) or any(
path not in artifacts for path in artifact_ids
):
raise HTTPException(422, f"{owner_id}: invalid assertion or artifact scope")
owner_ids.add(owner_id)
for artifact_id, artifact in artifacts.items():
if (
not isinstance(artifact, dict)
or artifact.get("algorithm") != "sha256"
or not _is_sha256(artifact.get("digest"))
):
raise HTTPException(
422, f"{artifact_id}: only sha256 artifacts are supported"
)
gate_ids: set[str] = set()
for gate in gates:
if not isinstance(gate, dict):
raise HTTPException(422, "gate entries must be objects")
required = gate.get("owners") or []
if (
gate.get("policy") != "all_required"
or not gate.get("id")
or gate["id"] in gate_ids
):
raise HTTPException(
422, "v1 gates require unique ids and all_required policy"
)
if (
not required
or len(required) != len(set(required))
or not set(required) <= owner_ids
):
raise HTTPException(422, f"{gate.get('id')}: gate owner set is invalid")
gate_ids.add(gate["id"])
return doc
def _is_sha256(value: Any) -> bool:
return (
isinstance(value, str)
and len(value) == 64
and all(c in "0123456789abcdef" for c in value)
)
def owner_scope(contract: ReviewContract, owner_id: str) -> dict[str, Any]:
for owner in contract.document["owners"]:
if owner["id"] == owner_id:
return owner
raise HTTPException(422, f"unknown owner: {owner_id}")
def validate_receipt(contract: ReviewContract, body: Any) -> dict[str, Any]:
if body.contract_digest != contract.contract_digest:
raise HTTPException(
409, "contract digest mismatch; re-review the active contract"
)
scope = owner_scope(contract, body.owner_id)
if body.disposition == "request_changes":
if not body.note or not body.note.strip():
raise HTTPException(422, "request_changes requires a note")
else:
expected_artifacts = {
key: contract.document["artifacts"][key]["digest"]
for key in scope["artifact_ids"]
}
if body.artifact_hashes != expected_artifacts:
raise HTTPException(
422, "approval artifact hashes do not exactly match the owner scope"
)
expected_assertions = {item["id"] for item in scope["assertions"]}
if set(body.assertion_ids) != expected_assertions or len(
body.assertion_ids
) != len(expected_assertions):
raise HTTPException(
422, "approval assertions do not exactly match the owner scope"
)
expected_checks = set(scope["check_ids"])
actual_checks = {
item.get("id") for item in body.checks if isinstance(item, dict)
}
if actual_checks != expected_checks or len(body.checks) != len(expected_checks):
raise HTTPException(
422, "approval checks do not exactly match the owner scope"
)
if any(
item.get("passed") is not True or item.get("read_only") is not True
for item in body.checks
):
raise HTTPException(
422, "every approval check must be passed and explicitly read_only"
)
return {
"owner_id": body.owner_id,
"actor": body.actor,
"disposition": body.disposition,
"contract_digest": body.contract_digest,
"artifact_hashes": body.artifact_hashes,
"assertion_ids": body.assertion_ids,
"checks": body.checks,
"note": body.note,
"source": body.source.model_dump(),
}
async def active_contract(session: AsyncSession, contract_key: str) -> ReviewContract:
result = await session.execute(
select(ReviewContract).where(
ReviewContract.contract_key == contract_key, ReviewContract.active.is_(True)
)
)
contract = result.scalar_one_or_none()
if contract is None:
raise HTTPException(404, f"active review contract not found: {contract_key}")
return contract
async def aggregate(
session: AsyncSession, contract: ReviewContract
) -> ReviewAggregateRead:
rows = await session.execute(
select(ReviewReceipt)
.where(ReviewReceipt.contract_id == contract.id)
.order_by(ReviewReceipt.submitted_at.desc(), ReviewReceipt.id.desc())
)
latest: dict[str, ReviewReceipt] = {}
for receipt in rows.scalars():
latest.setdefault(receipt.owner_id, receipt)
prior = await session.execute(
select(ReviewReceipt.owner_id)
.join(ReviewContract)
.where(
ReviewContract.contract_key == contract.contract_key,
ReviewContract.id != contract.id,
)
.distinct()
)
stale_owners = set(prior.scalars())
owner_states, statuses = [], {}
for owner in contract.document["owners"]:
owner_id, receipt = owner["id"], latest.get(owner["id"])
state = (
receipt.disposition.replace("approve", "approved")
if receipt
else ("stale" if owner_id in stale_owners else "missing")
)
statuses[owner_id] = state
owner_states.append(
OwnerReviewState(
owner_id=owner_id,
status=state,
receipt_id=receipt.id if receipt else None,
receipt_digest=receipt.receipt_digest if receipt else None,
submitted_at=receipt.submitted_at if receipt else None,
)
)
gates = [
GateReviewState(
gate_id=gate["id"],
policy="all_required",
owners=gate["owners"],
satisfied=all(statuses[owner] == "approved" for owner in gate["owners"]),
)
for gate in contract.document["gates"]
]
return ReviewAggregateRead(
contract_key=contract.contract_key,
contract_digest=contract.contract_digest,
satisfied=all(gate.satisfied for gate in gates),
authorizes_execution=False,
owners=owner_states,
gates=gates,
)
def utc_now() -> datetime:
return datetime.now(timezone.utc)

View file

@ -0,0 +1,70 @@
"""Read-only client for the reversible SBOM Nexus compatibility facade."""
from __future__ import annotations
from typing import Any
import httpx
from api.config import settings
class SBOMNexusError(RuntimeError):
"""An SBOM Nexus request failed without a usable compatibility response."""
def __init__(self, status_code: int, detail: str):
super().__init__(detail)
self.status_code = status_code
self.detail = detail
def reads_from_nexus() -> bool:
return settings.sbom_nexus_read_mode == "nexus"
def writes_to_nexus() -> bool:
return settings.sbom_nexus_write_mode == "nexus"
async def request_json(
method: str,
path: str,
*,
params: dict[str, Any] | None = None,
body: dict[str, Any] | None = None,
) -> Any:
"""Call Nexus; never fall back silently to legacy storage."""
if not settings.sbom_nexus_url:
raise SBOMNexusError(503, "SBOM Nexus read mode is enabled without SBOM_NEXUS_URL")
try:
async with httpx.AsyncClient(
base_url=settings.sbom_nexus_url.rstrip("/"),
timeout=settings.sbom_nexus_timeout_seconds,
) as client:
response = await client.request(method, path, params=params, json=body)
except httpx.RequestError as exc:
raise SBOMNexusError(502, f"SBOM Nexus is unavailable: {exc.__class__.__name__}") from exc
if response.status_code >= 400:
detail = f"SBOM Nexus returned HTTP {response.status_code}"
try:
payload = response.json()
if isinstance(payload, dict) and isinstance(payload.get("detail"), str):
detail = payload["detail"]
except ValueError:
pass
status_code = 404 if response.status_code == 404 else 502
raise SBOMNexusError(status_code, detail)
try:
return response.json()
except ValueError as exc:
raise SBOMNexusError(502, "SBOM Nexus returned invalid JSON") from exc
async def get_json(path: str, *, params: dict[str, Any] | None = None) -> Any:
return await request_json("GET", path, params=params)
async def post_json(path: str, *, body: dict[str, Any]) -> Any:
return await request_json("POST", path, body=body)

View file

@ -0,0 +1,63 @@
"""Report whether the database schema matches the code (STATE-WP-0083-T07).
Central ran two revisions behind the code it was serving, and `review_contracts`
did not exist there although its migration shipped inside the running image.
Nothing surfaced that: the API starts happily against a schema it was not built
for, and only fails when a request happens to touch the missing table.
A hub that cannot say which schema it is running has the same problem as a
projection that cannot name its source commit it is asserting correctness it
cannot demonstrate.
"""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from typing import Any
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
_MIGRATIONS = Path(__file__).resolve().parents[2] / "migrations"
@lru_cache(maxsize=1)
def code_head_revision() -> str | None:
"""The head revision the shipped migration scripts define.
Read from the migration files rather than the database: this is what the
code expects, and it must be knowable without a working connection.
"""
try:
from alembic.config import Config
from alembic.script import ScriptDirectory
cfg = Config()
cfg.set_main_option("script_location", str(_MIGRATIONS))
heads = ScriptDirectory.from_config(cfg).get_heads()
return heads[0] if len(heads) == 1 else ",".join(sorted(heads)) or None
except Exception:
return None
async def db_revision(session: AsyncSession) -> str | None:
try:
result = await session.execute(text("select version_num from alembic_version"))
return result.scalar()
except Exception:
return None
async def schema_state(session: AsyncSession) -> dict[str, Any]:
applied = await db_revision(session)
expected = code_head_revision()
# "unknown" is deliberately not "ok": an instance that cannot determine its
# own schema state must not report agreement it has not established.
if applied is None or expected is None:
status = "unknown"
elif applied == expected:
status = "ok"
else:
status = "behind"
return {"status": status, "applied": applied, "expected": expected}

View file

@ -27,6 +27,8 @@ from api.models.workplan import Workplan
from api.models.workplan_dependency import WorkplanDependency
from api.schemas.progress_event import ProgressEventRead
from api.schemas.state import StateSummary
from api.services.sbom_nexus import get_json as get_sbom_nexus_json
from api.services.sbom_nexus import reads_from_nexus
logger = logging.getLogger(__name__)
@ -88,9 +90,17 @@ async def fetch_summary_revision(session: AsyncSession) -> SummaryRevision:
if value is not None:
core_parts.append(value)
sbom_at = (
await session.execute(select(func.max(SBOMSnapshot.snapshot_at)))
).scalar_one_or_none()
if reads_from_nexus():
snapshots = await get_sbom_nexus_json("/sbom/snapshots/")
snapshot_times = [
datetime.fromisoformat(item["snapshot_at"].replace("Z", "+00:00"))
for item in snapshots
]
sbom_at = max(snapshot_times, default=None)
else:
sbom_at = (
await session.execute(select(func.max(SBOMSnapshot.snapshot_at)))
).scalar_one_or_none()
progress_at = (
await session.execute(select(func.max(ProgressEvent.created_at)))
@ -285,4 +295,4 @@ async def apply_progress_section(
cache._entry.progress_revision = revision.progress_fingerprint()
else:
cache.store(merged, revision)
return merged
return merged

View file

@ -0,0 +1,214 @@
"""Backfill canonical record ids onto existing task rows (STATE-WP-0083-T06).
Only the repository files hold the mapping. A file task declares both its
canonical id and the projection UUID it was registered under:
```task
id: CUST-WP-0067-T01
state_hub_task_id: "f3608db4-..."
```
so the pairing can be read directly rather than guessed from titles. Anything a
file does not claim is left alone: a task row whose canonical id cannot be
established keeps `record_id` null, and the reset continues to refuse to act on
it. An unknown identity must stay unknown rather than be inferred.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
_TASK_BLOCK_RE = re.compile(r"```task\s*\n(.*?)\n```", re.DOTALL)
_ID_RE = re.compile(r"^id:\s*(\S+)", re.MULTILINE)
_UUID_RE = re.compile(r'state_hub_task_id:\s*"?([0-9a-f-]{36})"?')
_FRONTMATTER_ID_RE = re.compile(r"^id:\s*(\S+)", re.MULTILINE)
# A task id written as a bare "T01" is unique only inside its own workplan.
# Storing it as a canonical identifier makes every workplan's first task share
# one identity — 51 such ids were assigned to 148 rows before this was caught.
_SHORT_TASK_ID_RE = re.compile(r"^T\d+$", re.IGNORECASE)
def qualify_task_id(task_id: str, workplan_id: str | None) -> str | None:
"""Return a canonical task id, or None when identity cannot be established."""
task_id = task_id.strip()
if not _SHORT_TASK_ID_RE.fullmatch(task_id):
return task_id
if not workplan_id:
# Unqualifiable: leaving it unidentified is correct, since an identity
# that is not unique is worse than none.
return None
return f"{workplan_id.strip()}-{task_id.upper()}"
@dataclass
class BackfillReport:
scanned_files: int = 0
pairs_found: int = 0
updated: int = 0
already_set: int = 0
conflicts: list[dict[str, str]] = field(default_factory=list)
unmatched_uuids: int = 0
def to_dict(self) -> dict[str, Any]:
return {
"schema": "state-hub.task-record-id-backfill.v1",
"scanned_files": self.scanned_files,
"pairs_found": self.pairs_found,
"updated": self.updated,
"already_set": self.already_set,
"unmatched_uuids": self.unmatched_uuids,
"conflicts": self.conflicts,
}
def collect_pairs(roots: list[Path]) -> tuple[dict[str, str], BackfillReport]:
"""Map projection UUID -> canonical record id, from workplan files."""
report = BackfillReport()
pairs: dict[str, str] = {}
for root in roots:
wp_dir = root / "workplans"
if not wp_dir.is_dir():
continue
for path in sorted(wp_dir.rglob("*.md")):
if path.name.startswith("."):
continue
try:
text = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
report.scanned_files += 1
head = text.split("---", 2)[1] if text.startswith("---") and text.count("---") >= 2 else ""
wp_match = _FRONTMATTER_ID_RE.search(head)
workplan_id = wp_match.group(1).strip() if wp_match else None
for block in _TASK_BLOCK_RE.finditer(text):
body = block.group(1)
rid = _ID_RE.search(body)
uid = _UUID_RE.search(body)
if not rid or not uid:
continue
record_id = qualify_task_id(rid.group(1), workplan_id)
if record_id is None:
continue
task_uuid = uid.group(1)
prior = pairs.get(task_uuid)
if prior and prior != record_id:
# One UUID claimed by two canonical ids: a duplicate
# registration. Recording it and skipping is the only safe
# option — picking one would fabricate an identity.
report.conflicts.append(
{"uuid": task_uuid, "first": prior, "second": record_id}
)
continue
pairs[task_uuid] = record_id
report.pairs_found = len(pairs)
return pairs, report
async def backfill_task_record_ids(
session: Any, roots: list[Path], *, dry_run: bool = True
) -> BackfillReport:
from sqlalchemy import select
from api.models.task import Task
pairs, report = collect_pairs(roots)
if not pairs:
return report
rows = list((await session.execute(select(Task))).scalars())
by_id = {str(r.id): r for r in rows}
for task_uuid, record_id in pairs.items():
row = by_id.get(task_uuid)
if row is None:
report.unmatched_uuids += 1
continue
if row.record_id == record_id:
report.already_set += 1
continue
if row.record_id and row.record_id != record_id:
report.conflicts.append(
{"uuid": task_uuid, "first": row.record_id, "second": record_id}
)
continue
report.updated += 1
if not dry_run:
row.record_id = record_id
return report
async def backfill_from_forge(
session: Any,
repo_slugs: list[str],
*,
forge_base: str | None = None,
dry_run: bool = True,
) -> BackfillReport:
"""Backfill from repositories cloned out of the forge.
The local-path variant above needs a workstation checkout, which central
does not have and should not depend on: `ADR-012` decision 1 makes the forge
the projection source, and a backfill sourced from someone's laptop would
reintroduce exactly the coupling that ADR removes.
Central can clone the forge directly, so it reads the pairing from the same
place it derives everything else.
"""
import tempfile
from api.services.forge_projection import (
DEFAULT_FORGE_BASE,
ForgeDeriveError,
_run_git,
)
base = forge_base or DEFAULT_FORGE_BASE
report = BackfillReport()
pairs: dict[str, str] = {}
for slug in repo_slugs:
url = f"{base.rstrip('/')}/{slug}.git"
with tempfile.TemporaryDirectory(prefix=f"backfill-{slug}-") as tmp:
try:
_run_git("clone", "--depth", "1", "--quiet", url, tmp)
except (ForgeDeriveError, Exception):
# A repository that cannot be read contributes nothing. It must
# not silently reduce what the rest can identify.
continue
repo_pairs, repo_report = collect_pairs([Path(tmp)])
report.scanned_files += repo_report.scanned_files
report.conflicts.extend(repo_report.conflicts)
for uid, rid in repo_pairs.items():
prior = pairs.get(uid)
if prior and prior != rid:
report.conflicts.append({"uuid": uid, "first": prior, "second": rid})
continue
pairs[uid] = rid
report.pairs_found = len(pairs)
if not pairs:
return report
from sqlalchemy import select
from api.models.task import Task
rows = list((await session.execute(select(Task))).scalars())
by_id = {str(r.id): r for r in rows}
for uid, rid in pairs.items():
row = by_id.get(uid)
if row is None:
report.unmatched_uuids += 1
continue
if row.record_id == rid:
report.already_set += 1
continue
if row.record_id and row.record_id != rid:
report.conflicts.append({"uuid": uid, "first": row.record_id, "second": rid})
continue
report.updated += 1
if not dry_run:
row.record_id = rid
return report

View file

@ -0,0 +1,799 @@
"""Transactional State Hub projection migration for deterministic work-record UUIDs."""
from __future__ import annotations
import hashlib
import json
import re
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from api.models.managed_repo import ManagedRepo
from api.models.task import Task, TaskPriority, TaskStatus
from api.models.work_record_identifier_alias import WorkRecordIdentifierAlias
from api.services.repository_aliases import resolve_repository_slug
from api.models.workplan import Workplan
PLAN_SCHEMA = "repo-manager.identifier-migration-plan.v1"
FLEET_NAMESPACE = "helixforge"
DERIVATION_NAMESPACE_UUID = uuid.UUID("a4058507-5c4a-5a00-ab06-fffa4fb46009")
_RECORD_ID_RE = re.compile(r"^[A-Z][A-Z0-9-]*-WP-[0-9]{4}(?:-T[0-9]{2,})?$")
class IdentifierMigrationError(ValueError):
"""The signed plan or current projection is unsafe to migrate."""
@dataclass(frozen=True)
class IdentifierMigrationResult:
repo_slug: str
plan_sha256: str
direction: str
replacements: int
migrated: int
already_derived: int
assignments_deferred: int
@dataclass(frozen=True)
class SealedProjectionRepairReceipt:
"""Non-secret evidence for one bounded pre-derivation projection repair."""
schema: str
outcome: str
repo_slug: str
repository_id: str
workplan_record_id: str
workplan_old_id: str
task_records: tuple[tuple[str, str], ...]
plan_sha256: str
source_revision: str
source_fingerprint: str
projection_identity: str
observed_at: str
def verify_plan(plan: dict[str, Any]) -> str:
"""Validate the immutable plan envelope and deterministic UUID mappings."""
if plan.get("schema") != PLAN_SCHEMA:
raise IdentifierMigrationError("unsupported identifier migration plan schema")
if plan.get("namespace") != FLEET_NAMESPACE:
raise IdentifierMigrationError(
f"plan namespace must be {FLEET_NAMESPACE!r}"
)
if plan.get("apply_policy") != "all-or-nothing per repository":
raise IdentifierMigrationError("plan does not declare repository-atomic apply")
if plan.get("ready_to_apply") is not True:
raise IdentifierMigrationError("plan is not ready_to_apply")
expected = plan.get("plan_sha256")
if not isinstance(expected, str) or not re.fullmatch(r"[0-9a-f]{64}", expected):
raise IdentifierMigrationError("plan has no valid SHA-256 seal")
unsealed = {key: value for key, value in plan.items() if key != "plan_sha256"}
canonical = json.dumps(unsealed, sort_keys=True, separators=(",", ":")).encode()
actual = hashlib.sha256(canonical).hexdigest()
if actual != expected:
raise IdentifierMigrationError("plan SHA-256 mismatch")
return expected
def _repository_mappings(
plan: dict[str, Any], repo_slug: str
) -> tuple[list[dict[str, Any]], int]:
matches = [item for item in plan.get("repositories", []) if item.get("repo") == repo_slug]
if len(matches) != 1:
raise IdentifierMigrationError(
f"repository {repo_slug!r} must occur exactly once in plan"
)
repository = matches[0]
if repository.get("eligible") is not True or repository.get("atomic_unit") is not True:
raise IdentifierMigrationError(f"repository {repo_slug!r} is not eligible and atomic")
replacements: list[dict[str, Any]] = []
assignments = 0
seen_old: set[uuid.UUID] = set()
seen_new: set[uuid.UUID] = set()
for raw in repository.get("mappings", []):
action = raw.get("action")
if action == "assign":
assignments += 1
continue
if action == "unchanged":
continue
if action != "replace":
raise IdentifierMigrationError(f"unsupported mapping action {action!r}")
if raw.get("repo") != repo_slug:
raise IdentifierMigrationError("mapping repository does not match atomic unit")
kind = raw.get("kind")
record_id = raw.get("record_id")
if kind not in {"workplan", "task"}:
raise IdentifierMigrationError(f"unsupported record kind {kind!r}")
if not isinstance(record_id, str) or not _RECORD_ID_RE.fullmatch(record_id):
raise IdentifierMigrationError(f"noncanonical record id {record_id!r}")
try:
old_id = uuid.UUID(str(raw.get("current_uuid")))
new_id = uuid.UUID(str(raw.get("derived_uuid")))
except (TypeError, ValueError) as exc:
raise IdentifierMigrationError(f"invalid UUID mapping for {record_id}") from exc
expected_new = uuid.uuid5(
DERIVATION_NAMESPACE_UUID,
f"{FLEET_NAMESPACE}\n{record_id}",
)
if new_id != expected_new:
raise IdentifierMigrationError(f"derived UUID mismatch for {record_id}")
if old_id == new_id or old_id in seen_old or new_id in seen_new:
raise IdentifierMigrationError(f"non-unique replacement for {record_id}")
seen_old.add(old_id)
seen_new.add(new_id)
replacements.append(
{
"kind": kind,
"record_id": record_id,
"old_id": old_id,
"new_id": new_id,
}
)
return replacements, assignments
def _repository_entry(plan: dict[str, Any], repo_slug: str) -> dict[str, Any]:
matches = [item for item in plan.get("repositories", []) if item.get("repo") == repo_slug]
if len(matches) != 1:
raise IdentifierMigrationError(
f"repository {repo_slug!r} must occur exactly once in plan"
)
return matches[0]
def _repair_unit_mappings(
plan: dict[str, Any], repo_slug: str, unit: dict[str, Any]
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
replacements, _assignments = _repository_mappings(plan, repo_slug)
workplan = unit.get("workplan")
tasks = unit.get("tasks")
if not isinstance(workplan, dict) or not isinstance(tasks, list):
raise IdentifierMigrationError("repair unit requires one workplan and a task list")
workplan_record_id = workplan.get("record_id")
workplan_matches = [
mapping
for mapping in replacements
if mapping["kind"] == "workplan" and mapping["record_id"] == workplan_record_id
]
if len(workplan_matches) != 1:
raise IdentifierMigrationError(
"repair workplan must occur exactly once as a replacement in the sealed plan"
)
workplan_mapping = workplan_matches[0]
raw_workplan_mapping = next(
raw
for raw in _repository_entry(plan, repo_slug).get("mappings", [])
if raw.get("kind") == "workplan" and raw.get("record_id") == workplan_record_id
)
authoritative_path = raw_workplan_mapping.get("path")
if not isinstance(authoritative_path, str) or not authoritative_path:
raise IdentifierMigrationError("sealed workplan mapping has no authoritative path")
task_record_ids = [task.get("record_id") for task in tasks if isinstance(task, dict)]
if len(task_record_ids) != len(tasks) or len(set(task_record_ids)) != len(tasks):
raise IdentifierMigrationError("repair task record ids must be explicit and unique")
task_mappings = [
mapping
for mapping in replacements
if mapping["kind"] == "task" and mapping["record_id"] in task_record_ids
]
mapped_task_ids = {mapping["record_id"] for mapping in task_mappings}
if mapped_task_ids != set(task_record_ids):
raise IdentifierMigrationError(
"repair tasks do not exactly match replacement mappings in the sealed plan"
)
raw_unit_task_ids = {
raw.get("record_id")
for raw in _repository_entry(plan, repo_slug).get("mappings", [])
if raw.get("kind") == "task"
and raw.get("action") == "replace"
and raw.get("path") == authoritative_path
}
if raw_unit_task_ids != set(task_record_ids):
raise IdentifierMigrationError(
"repair must include every replaced task in the sealed workplan unit"
)
return workplan_mapping, task_mappings
def _uuid_field(value: Any, label: str) -> uuid.UUID:
try:
return uuid.UUID(str(value))
except (TypeError, ValueError) as exc:
raise IdentifierMigrationError(f"invalid UUID for {label}") from exc
def _repair_payloads(
unit: dict[str, Any],
workplan_mapping: dict[str, Any],
task_mappings: list[dict[str, Any]],
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
raw_workplan = unit["workplan"]
if _uuid_field(raw_workplan.get("id"), "workplan") != workplan_mapping["old_id"]:
raise IdentifierMigrationError("repair workplan UUID does not match sealed old UUID")
slug = raw_workplan.get("slug")
title = raw_workplan.get("title")
status = raw_workplan.get("status")
if not isinstance(slug, str) or not slug or not isinstance(title, str) or not title:
raise IdentifierMigrationError("repair workplan requires explicit slug and title")
if status not in {
"proposed", "ready", "active", "blocked", "backlog", "finished", "archived"
}:
raise IdentifierMigrationError(f"unsupported repair workplan status {status!r}")
workplan_payload = {
"id": workplan_mapping["old_id"],
"topic_id": _uuid_field(raw_workplan.get("topic_id"), "workplan topic"),
"slug": slug,
"title": title,
"description": raw_workplan.get("description"),
"status": status,
"owner": raw_workplan.get("owner"),
"planning_priority": raw_workplan.get("planning_priority"),
"planning_order": raw_workplan.get("planning_order"),
}
mappings_by_record = {mapping["record_id"]: mapping for mapping in task_mappings}
task_payloads: list[dict[str, Any]] = []
for raw_task in unit["tasks"]:
mapping = mappings_by_record[raw_task["record_id"]]
if _uuid_field(raw_task.get("id"), raw_task["record_id"]) != mapping["old_id"]:
raise IdentifierMigrationError(
f"repair task UUID does not match sealed old UUID for {raw_task['record_id']}"
)
title = raw_task.get("title")
if not isinstance(title, str) or not title:
raise IdentifierMigrationError(
f"repair task {raw_task['record_id']} requires an explicit title"
)
try:
status_value = TaskStatus(raw_task.get("status", "todo"))
priority_value = TaskPriority(raw_task.get("priority", "medium"))
except ValueError as exc:
raise IdentifierMigrationError(
f"invalid task state for {raw_task['record_id']}"
) from exc
parent_record_id = raw_task.get("parent_record_id")
if parent_record_id is not None and parent_record_id not in mappings_by_record:
raise IdentifierMigrationError(
f"parent task for {raw_task['record_id']} is outside the sealed repair unit"
)
task_payloads.append(
{
"record_id": raw_task["record_id"],
"id": mapping["old_id"],
"title": title,
"description": raw_task.get("description"),
"status": status_value,
"priority": priority_value,
"assignee": raw_task.get("assignee"),
"blocking_reason": raw_task.get("blocking_reason"),
"needs_human": bool(raw_task.get("needs_human", False)),
"intervention_note": raw_task.get("intervention_note"),
"parent_task_id": (
mappings_by_record[parent_record_id]["old_id"]
if parent_record_id is not None
else None
),
}
)
if task_payloads[-1]["needs_human"] and not task_payloads[-1]["intervention_note"]:
raise IdentifierMigrationError(
f"repair task {raw_task['record_id']} needs an intervention note"
)
if (
task_payloads[-1]["status"] == TaskStatus.wait
and task_payloads[-1]["needs_human"]
and not task_payloads[-1]["blocking_reason"]
):
raise IdentifierMigrationError(
f"repair task {raw_task['record_id']} needs a blocking reason"
)
pending = list(task_payloads)
ordered: list[dict[str, Any]] = []
emitted: set[uuid.UUID] = set()
while pending:
ready = [
task
for task in pending
if task["parent_task_id"] is None or task["parent_task_id"] in emitted
]
if not ready:
raise IdentifierMigrationError("repair task parentage contains a cycle")
for task in ready:
pending.remove(task)
ordered.append(task)
emitted.add(task["id"])
return workplan_payload, ordered
def _same_workplan(row: Workplan, expected: dict[str, Any], repo_id: uuid.UUID) -> bool:
fields = (
"id", "topic_id", "slug", "title", "description", "status", "owner",
"planning_priority", "planning_order",
)
return row.repo_id == repo_id and all(
getattr(row, field) == expected[field] for field in fields
)
def _same_task(row: Task, expected: dict[str, Any], workplan_id: uuid.UUID) -> bool:
fields = (
"id", "title", "description", "status", "priority", "assignee",
"blocking_reason", "needs_human", "intervention_note", "parent_task_id",
)
return row.workplan_id == workplan_id and all(
getattr(row, field) == expected[field] for field in fields
)
_TASK_DUPLICATE_FIELDS = (
"workplan_id",
"title",
"description",
"status",
"priority",
"assignee",
"due_date",
"blocking_reason",
"needs_human",
"intervention_note",
"parent_task_id",
)
def _same_duplicate_task(
legacy: Task,
derived: Task,
record_id: str,
) -> bool:
"""Accept only a deterministic duplicate of an otherwise identical legacy row."""
return (
legacy.record_id in {None, record_id}
and derived.record_id == record_id
and all(
getattr(legacy, field) == getattr(derived, field)
for field in _TASK_DUPLICATE_FIELDS
)
)
async def _task_reference_count(session: AsyncSession, task_id: uuid.UUID) -> int:
"""Count every inbound task reference before duplicate coalescence.
Coalescence deliberately refuses referenced legacy rows. Updating references
could collide with relationships already attached to the deterministic row;
a zero-reference proof keeps the repair lossless and reversible.
"""
return int(
await session.scalar(
text(
"SELECT "
"(SELECT count(*) FROM token_events WHERE task_id = :task_id) + "
"(SELECT count(*) FROM tasks WHERE parent_task_id = :task_id) + "
"(SELECT count(*) FROM review_contracts WHERE task_id = :task_id) + "
"(SELECT count(*) FROM progress_events WHERE task_id = :task_id) + "
"(SELECT count(*) FROM capability_requests WHERE blocking_task_id = :task_id) + "
"(SELECT count(*) FROM workplan_dependencies WHERE to_task_id = :task_id) + "
"(SELECT count(*) FROM suggestions WHERE promoted_task_id = :task_id)"
),
{"task_id": task_id},
)
or 0
)
async def repair_absent_prederivation_projection(
session: AsyncSession,
plan: dict[str, Any],
repo_slug: str,
unit: dict[str, Any],
*,
expected_plan_sha256: str,
source_revision: str,
source_fingerprint: str,
source_clean: bool,
source_synchronized: bool,
primary_confirmed: bool,
projection_identity: str,
observed_at: datetime | None = None,
) -> SealedProjectionRepairReceipt:
"""Restore one exact random-ID workplan unit into an absent projection.
This is intentionally separate from ordinary consistency repair. The caller
must independently prove Git cleanliness/synchronization and provide the
authoritative unit parsed from the pinned source revision.
"""
plan_sha256 = verify_plan(plan)
if plan_sha256 != expected_plan_sha256:
raise IdentifierMigrationError("explicit plan SHA-256 does not match sealed plan")
if not primary_confirmed:
raise IdentifierMigrationError("projection repair requires explicit primary confirmation")
if not source_clean or not source_synchronized:
raise IdentifierMigrationError("projection repair requires a clean synchronized source")
if not re.fullmatch(r"[0-9a-f]{40}", source_revision):
raise IdentifierMigrationError("source revision must be a full Git SHA")
if not re.fullmatch(r"[0-9a-f]{64}", source_fingerprint):
raise IdentifierMigrationError("source fingerprint must be SHA-256")
if not projection_identity.strip():
raise IdentifierMigrationError("projection identity is required")
repository_entry = _repository_entry(plan, repo_slug)
if source_revision != repository_entry.get("planned_head_sha"):
raise IdentifierMigrationError("source revision drifted from sealed plan")
if source_fingerprint != repository_entry.get("source_fingerprint"):
raise IdentifierMigrationError("source fingerprint drifted from sealed plan")
workplan_mapping, task_mappings = _repair_unit_mappings(plan, repo_slug, unit)
workplan_payload, task_payloads = _repair_payloads(
unit, workplan_mapping, task_mappings
)
if session.in_transaction():
raise IdentifierMigrationError("projection repair requires a fresh database session")
outcome = "repaired"
async with session.begin():
resolution = await resolve_repository_slug(session, repo_slug, required=False)
if resolution is None:
raise IdentifierMigrationError(f"repository projection is absent: {repo_slug}")
repo = await session.scalar(
select(ManagedRepo)
.where(ManagedRepo.id == resolution.repo.id)
.with_for_update()
)
if repo.topic_id != workplan_payload["topic_id"]:
raise IdentifierMigrationError("repair topic does not match repository projection")
old_ids = [workplan_mapping["old_id"], *(m["old_id"] for m in task_mappings)]
new_workplan = await session.get(Workplan, workplan_mapping["new_id"])
new_tasks = [await session.get(Task, mapping["new_id"]) for mapping in task_mappings]
if new_workplan is not None or any(task is not None for task in new_tasks):
raise IdentifierMigrationError("derived target presence blocks projection repair")
alias_count = await session.scalar(
select(func.count()).select_from(WorkRecordIdentifierAlias).where(
WorkRecordIdentifierAlias.old_id.in_(old_ids)
)
)
if alias_count:
raise IdentifierMigrationError("durable alias presence blocks projection repair")
old_workplan = await session.get(Workplan, workplan_mapping["old_id"])
old_tasks = [await session.get(Task, mapping["old_id"]) for mapping in task_mappings]
present_count = int(old_workplan is not None) + sum(task is not None for task in old_tasks)
expected_count = 1 + len(task_mappings)
if present_count not in {0, expected_count}:
raise IdentifierMigrationError("partial old projection presence blocks repair")
if present_count == expected_count:
if not _same_workplan(old_workplan, workplan_payload, repo.id):
raise IdentifierMigrationError(
"existing old workplan differs from sealed repair unit"
)
expected_tasks = {task["id"]: task for task in task_payloads}
if any(
not _same_task(task, expected_tasks[task.id], workplan_mapping["old_id"])
for task in old_tasks
):
raise IdentifierMigrationError("existing old tasks differ from sealed repair unit")
outcome = "verified_noop"
else:
slug_conflict = await session.scalar(
select(Workplan.id).where(Workplan.slug == workplan_payload["slug"])
)
if slug_conflict is not None:
raise IdentifierMigrationError("workplan slug is already projected")
session.add(
Workplan(repo_id=repo.id, **workplan_payload)
)
await session.flush()
for task_payload in task_payloads:
values = {key: value for key, value in task_payload.items() if key != "record_id"}
session.add(Task(workplan_id=workplan_mapping["old_id"], **values))
await session.flush()
timestamp = observed_at or datetime.now(UTC)
if timestamp.tzinfo is None:
raise IdentifierMigrationError("receipt observation time must be timezone-aware")
return SealedProjectionRepairReceipt(
schema="state-hub.sealed-prederivation-projection-repair-receipt.v1",
outcome=outcome,
repo_slug=repo_slug,
repository_id=str(repo.id),
workplan_record_id=workplan_mapping["record_id"],
workplan_old_id=str(workplan_mapping["old_id"]),
task_records=tuple(
(mapping["record_id"], str(mapping["old_id"])) for mapping in task_mappings
),
plan_sha256=plan_sha256,
source_revision=source_revision,
source_fingerprint=source_fingerprint,
projection_identity=projection_identity,
observed_at=timestamp.astimezone(UTC).isoformat().replace("+00:00", "Z"),
)
async def _assert_projection_preconditions(
session: AsyncSession,
repository_id: uuid.UUID,
repo_slug: str,
replacements: list[dict[str, Any]],
*,
reverse: bool,
) -> dict[str, str]:
states: dict[str, str] = {}
for mapping in replacements:
source_id = mapping["new_id"] if reverse else mapping["old_id"]
target_id = mapping["old_id"] if reverse else mapping["new_id"]
if mapping["kind"] == "workplan":
query = text(
"SELECT workplans.id, workplans.repo_id, workplans.slug "
"FROM workplans WHERE workplans.id = :record_id FOR UPDATE"
)
else:
query = text(
"SELECT tasks.id, workplans.repo_id, tasks.record_id "
"FROM tasks "
"JOIN workplans ON workplans.id = tasks.workplan_id "
"WHERE tasks.id = :record_id FOR UPDATE"
)
source = (await session.execute(query, {"record_id": source_id})).one_or_none()
target = (await session.execute(query, {"record_id": target_id})).one_or_none()
for label, row in (("source", source), ("target", target)):
if row is not None and row.repo_id != repository_id:
raise IdentifierMigrationError(
f"{label} {mapping['kind']} {row.id} belongs to another repository"
)
if reverse:
if source is None:
raise IdentifierMigrationError(
f"source {mapping['kind']} {source_id} is absent from repository {repo_slug}"
)
if target is not None:
raise IdentifierMigrationError(
f"target {mapping['kind']} {target_id} already exists"
)
states[mapping["record_id"]] = "derived_source"
continue
if source is not None and target is not None:
if mapping["kind"] != "task":
raise IdentifierMigrationError(
f"both legacy and derived {mapping['kind']} rows exist for {mapping['record_id']}"
)
legacy_task = await session.scalar(
select(Task).where(Task.id == source_id).with_for_update()
)
derived_task = await session.scalar(
select(Task).where(Task.id == target_id).with_for_update()
)
if (
legacy_task is None
or derived_task is None
or not _same_duplicate_task(
legacy_task, derived_task, mapping["record_id"]
)
):
raise IdentifierMigrationError(
f"legacy and derived task rows differ for {mapping['record_id']}"
)
references = await _task_reference_count(session, source_id)
if references:
raise IdentifierMigrationError(
f"legacy duplicate task has {references} inbound references for "
f"{mapping['record_id']}"
)
states[mapping["record_id"]] = "duplicate_target"
continue
if source is None and target is None:
raise IdentifierMigrationError(
f"neither legacy nor derived {mapping['kind']} row exists for {mapping['record_id']}"
)
if source is not None:
states[mapping["record_id"]] = "legacy_source"
continue
# A pre-existing derived target is safe only when it already represents
# the canonical record from this repository. This is the state produced
# when forge reconciliation reached a partial projection before the
# sealed file migration did.
identity = target.slug if mapping["kind"] == "workplan" else target.record_id
if not isinstance(identity, str) or identity.strip().lower() != mapping["record_id"].lower():
raise IdentifierMigrationError(
f"derived target identity mismatch for {mapping['record_id']}"
)
states[mapping["record_id"]] = "derived_target"
return states
async def apply_repository_identifier_migration(
session: AsyncSession,
plan: dict[str, Any],
repo_slug: str,
) -> IdentifierMigrationResult:
"""Replace one repository's projected UUIDs in one database transaction."""
plan_sha256 = verify_plan(plan)
replacements, assignments = _repository_mappings(plan, repo_slug)
if session.in_transaction():
raise IdentifierMigrationError("migration requires a fresh database session")
async with session.begin():
resolution = await resolve_repository_slug(session, repo_slug, required=False)
if resolution is None:
raise IdentifierMigrationError(f"repository projection is absent: {repo_slug}")
projection_states = await _assert_projection_preconditions(
session, resolution.repo.id, repo_slug, replacements, reverse=False
)
aliases = {
alias.old_id: alias
for alias in (
await session.execute(
select(WorkRecordIdentifierAlias).where(
WorkRecordIdentifierAlias.old_id.in_(
[mapping["old_id"] for mapping in replacements]
)
)
)
).scalars()
}
for mapping in replacements:
alias = aliases.get(mapping["old_id"])
if alias is None:
alias = WorkRecordIdentifierAlias(
old_id=mapping["old_id"],
new_id=mapping["new_id"],
record_kind=mapping["kind"],
record_id=mapping["record_id"],
repo_slug=repo_slug,
namespace=FLEET_NAMESPACE,
plan_sha256=plan_sha256,
)
session.add(alias)
aliases[mapping["old_id"]] = alias
elif (
alias.new_id != mapping["new_id"]
or alias.record_kind != mapping["kind"]
or alias.record_id != mapping["record_id"]
or alias.repo_slug != repo_slug
or alias.namespace != FLEET_NAMESPACE
or alias.plan_sha256 != plan_sha256
or alias.migration_status not in {"reversed", "applied"}
):
raise IdentifierMigrationError(
f"conflicting durable alias for {mapping['old_id']}"
)
await session.flush()
for mapping in (
item
for item in replacements
if projection_states[item["record_id"]] == "duplicate_target"
):
result = await session.execute(
text("DELETE FROM tasks WHERE id = :old_id"),
{"old_id": mapping["old_id"]},
)
if result.rowcount != 1:
raise IdentifierMigrationError(
f"failed to coalesce duplicate {mapping['record_id']}"
)
for kind in ("workplan", "task"):
table = "workplans" if kind == "workplan" else "tasks"
for mapping in (
item
for item in replacements
if item["kind"] == kind
and projection_states[item["record_id"]] == "legacy_source"
):
result = await session.execute(
text(f"UPDATE {table} SET id = :new_id WHERE id = :old_id"),
{"old_id": mapping["old_id"], "new_id": mapping["new_id"]},
)
if result.rowcount != 1:
raise IdentifierMigrationError(
f"failed to replace {mapping['record_id']}"
)
now = datetime.now(UTC)
for alias in aliases.values():
alias.migration_status = "applied"
alias.applied_at = now
alias.reversed_at = None
return IdentifierMigrationResult(
repo_slug=repo_slug,
plan_sha256=plan_sha256,
direction="forward",
replacements=len(replacements),
migrated=sum(
state in {"legacy_source", "duplicate_target"}
for state in projection_states.values()
),
already_derived=sum(
state == "derived_target" for state in projection_states.values()
),
assignments_deferred=assignments,
)
async def reverse_repository_identifier_migration(
session: AsyncSession,
plan: dict[str, Any],
repo_slug: str,
) -> IdentifierMigrationResult:
"""Restore one repository's prior projected UUIDs and retain aliases."""
plan_sha256 = verify_plan(plan)
replacements, assignments = _repository_mappings(plan, repo_slug)
if session.in_transaction():
raise IdentifierMigrationError("migration requires a fresh database session")
async with session.begin():
resolution = await resolve_repository_slug(session, repo_slug, required=False)
if resolution is None:
raise IdentifierMigrationError(f"repository projection is absent: {repo_slug}")
aliases = list(
(
await session.execute(
select(WorkRecordIdentifierAlias).where(
WorkRecordIdentifierAlias.plan_sha256 == plan_sha256,
WorkRecordIdentifierAlias.repo_slug == repo_slug,
)
)
).scalars()
)
aliases_by_old = {alias.old_id: alias for alias in aliases}
if len(aliases_by_old) != len(replacements):
raise IdentifierMigrationError("durable alias set is incomplete")
for mapping in replacements:
alias = aliases_by_old.get(mapping["old_id"])
if (
alias is None
or alias.new_id != mapping["new_id"]
or alias.record_kind != mapping["kind"]
or alias.record_id != mapping["record_id"]
or alias.migration_status != "applied"
):
raise IdentifierMigrationError(
f"durable alias is not applied for {mapping['record_id']}"
)
await _assert_projection_preconditions(
session, resolution.repo.id, repo_slug, replacements, reverse=True
)
for kind in ("task", "workplan"):
table = "tasks" if kind == "task" else "workplans"
for mapping in (item for item in replacements if item["kind"] == kind):
result = await session.execute(
text(f"UPDATE {table} SET id = :old_id WHERE id = :new_id"),
{"old_id": mapping["old_id"], "new_id": mapping["new_id"]},
)
if result.rowcount != 1:
raise IdentifierMigrationError(
f"failed to reverse {mapping['record_id']}"
)
now = datetime.now(UTC)
for alias in aliases:
alias.migration_status = "reversed"
alias.reversed_at = now
return IdentifierMigrationResult(
repo_slug=repo_slug,
plan_sha256=plan_sha256,
direction="reverse",
replacements=len(replacements),
migrated=len(replacements),
already_derived=0,
assignments_deferred=assignments,
)

View file

@ -41,15 +41,16 @@ WRITE_ROUTE_RULES: tuple[WriteRouteRule, ...] = (
WriteRouteRule("POST", r"/decisions", "append", "record decision"),
WriteRouteRule("PATCH", r"/tasks/[^/]+", "replace", "update task"),
WriteRouteRule("POST", r"/tasks/bulk-status-sync", "replace", "bulk task status sync"),
WriteRouteRule(
"POST",
r"/repos/[^/]+/work-record-projection/reconcile",
"replace",
"reconcile one forge-derived repository projection",
),
WriteRouteRule("PATCH", r"/decisions/[^/]+", "replace", "update decision"),
WriteRouteRule("POST", r"/decisions/[^/]+/resolve", "replace", "resolve decision"),
WriteRouteRule("PATCH", r"/workplans/[^/]+", "replace", "update workplan"),
WriteRouteRule("PATCH", r"/workstreams/[^/]+", "replace", "update legacy workstream alias"),
WriteRouteRule("POST", r"/suggestions", "append", "create suggestion"),
WriteRouteRule("POST", r"/suggestions/[^/]+/vet", "replace", "vet suggestion"),
WriteRouteRule("POST", r"/suggestions/[^/]+/decline", "replace", "decline suggestion"),
WriteRouteRule("POST", r"/suggestions/[^/]+/promote", "replace", "promote suggestion to task"),
WriteRouteRule("POST", r"/suggestions/[^/]+/bump-relevance", "append", "bump suggestion relevance"),
)

48
api/work_record_flavor.py Normal file
View file

@ -0,0 +1,48 @@
"""Work-record flavor (STATE-WP-0092).
Flavor is a closed bucket on workplans and tasks, orthogonal to kind and
status. Unset flavor is not residual.
"""
from __future__ import annotations
from typing import Any
WORK_RECORD_FLAVORS: tuple[str, ...] = (
"planning",
"implementation",
"refactoring",
"extension",
"residual",
)
RESIDUAL_FLAVOR = "residual"
FLAVOR_PROMOTION_REASONS: tuple[str, ...] = ("demand", "risk")
_EMPTY = {"", "~", "null", "none", "nil"}
def normalize_flavor(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip().lower()
if text in _EMPTY:
return None
return text
def is_known_flavor(value: Any) -> bool:
flavor = normalize_flavor(value)
return flavor is None or flavor in WORK_RECORD_FLAVORS
def is_residual_flavor(value: Any) -> bool:
return normalize_flavor(value) == RESIDUAL_FLAVOR
def normalize_promotion_reason(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip().lower()
if text in _EMPTY:
return None
return text

View file

@ -18,9 +18,11 @@ import re
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from repository_rename_cli import configure_repo_commands
from statehub_register import run_register as run_statehub_register
STATE_HUB_DIR = Path(__file__).resolve().parent
@ -402,6 +404,8 @@ def cmd_fix_consistency(args: argparse.Namespace) -> None:
cmd.append("--remote")
if args.no_writeback:
cmd.append("--no-writeback")
if getattr(args, "bootstrap_empty_projection", False):
cmd.append("--bootstrap-empty-projection")
if args.archive_closed:
cmd.append("--archive-closed")
if args.archive_workplan:
@ -422,6 +426,53 @@ def cmd_fix_consistency(args: argparse.Namespace) -> None:
sys.exit(exit_code)
def cmd_quality_debt(args: argparse.Namespace) -> None:
"""List DoX quality debt (STATE-WP-0077) — ready without DoR-Ok, etc."""
script = STATE_HUB_DIR / "scripts" / "quality_debt.py"
if not script.exists():
print(f"ERROR: quality_debt.py not found at {script}")
sys.exit(1)
cmd = [sys.executable, str(script)]
if args.repo_path:
cmd.extend(["--repo-path", str(Path(args.repo_path).expanduser().resolve())])
else:
cmd.append("--here")
if args.api_base:
cmd.extend(["--api-base", args.api_base])
if args.no_hub:
cmd.append("--no-hub")
if args.as_json:
cmd.append("--json")
if args.strict:
cmd.append("--strict")
result = subprocess.run(cmd)
sys.exit(result.returncode)
def cmd_promote_intake(args: argparse.Namespace) -> None:
"""Promote a routed intake into a workplan, task, decision, or engagement."""
script = STATE_HUB_DIR / "scripts" / "promote_intake.py"
if not script.exists():
print(f"ERROR: promote_intake.py not found at {script}")
sys.exit(1)
cmd = [
sys.executable, str(script), args.intake_id,
"--to", args.to,
"--repo-path", str(Path(args.repo_path).expanduser().resolve()),
"--repo-slug", args.repo_slug,
"--domain", args.domain,
"--api-base", args.api_base,
]
if args.target_file:
cmd.extend(["--target-file", args.target_file])
if args.workplan_file:
cmd.extend(["--workplan-file", args.workplan_file])
result = subprocess.run(cmd)
sys.exit(result.returncode)
def cmd_create_workstream(args: argparse.Namespace) -> None:
"""Create a workstream under a domain's topic."""
_api_get("/state/health")
@ -508,12 +559,22 @@ def cmd_status(_args: argparse.Namespace) -> None:
"""Quick status: API health + summary totals."""
health = _api_get("/state/health")
print(f"API: {health.get('status', '?')} DB: {health.get('db', '?')}")
# Which instance answered, not merely that one did.
role = health.get("instance_role") or "undeclared"
label = health.get("instance_label")
print(f"Instance: {role}{f' ({label})' if label else ''}")
summary = _api_get("/state/summary")
t = summary["totals"]
print(f"Topics: {t['topics']['active']} active")
print(f"Workstreams: {t['workstreams']['active']} active, {t['workstreams']['blocked']} blocked")
print(f"Tasks: {t['tasks']['in_progress']} in-progress, {t['tasks']['todo']} todo, {t['tasks']['blocked']} blocked")
print(f"Decisions: {t['decisions']['open']} open, {t['decisions']['escalated']} escalated")
topics = t.get("topics", {})
workplans = t.get("workplans") or t.get("workstreams", {})
tasks = t.get("tasks", {})
decisions = t.get("decisions", {})
print(f"API base: {API_BASE}")
print(f"Topics: {topics.get('active', 0)} active")
print(f"Workplans: {workplans.get('active', 0)} active, {workplans.get('blocked', 0)} blocked")
# Task statuses are wait|todo|progress|done|cancel (see workplan-convention.md).
print(f"Tasks: {tasks.get('progress', 0)} in-progress, {tasks.get('todo', 0)} todo, {tasks.get('wait', 0)} waiting")
print(f"Decisions: {decisions.get('open', 0)} open, {decisions.get('escalated', 0)} escalated")
blocking = summary.get("blocking_decisions", [])
if blocking:
print(f"\nBlocking decisions ({len(blocking)}):")
@ -522,6 +583,73 @@ def cmd_status(_args: argparse.Namespace) -> None:
print(f" [{deadline}] {d['title']}")
def _load_json_file(path: str) -> dict:
target = Path(path)
try:
value = json.loads(target.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
print(f"ERROR: invalid JSON file {target}: {exc}")
sys.exit(2)
if not isinstance(value, dict):
print(f"ERROR: {target} must contain a JSON object")
sys.exit(2)
return value
def _git_source(path: str, repo_slug: str | None = None) -> tuple[Path, dict]:
target = Path(path).resolve()
try:
root = Path(subprocess.check_output(
["git", "-C", str(target.parent), "rev-parse", "--show-toplevel"],
text=True,
).strip())
revision = subprocess.check_output(
["git", "-C", str(root), "rev-parse", "HEAD"], text=True
).strip()
relative = target.relative_to(root).as_posix()
except (subprocess.CalledProcessError, ValueError) as exc:
print(f"ERROR: {target} must be inside a Git repository: {exc}")
sys.exit(2)
return target, {"repo": repo_slug or root.name, "path": relative, "revision": revision}
def cmd_review_project(args: argparse.Namespace) -> None:
"""Project a file-authoritative contract wrapper through the direct API."""
target, source = _git_source(args.file, args.source_repo)
contract = _load_json_file(str(target))
payload = contract if "contract" in contract and "source" in contract else {
"contract": contract,
"source": source,
"decision_id": args.decision_id,
"workplan_id": args.workplan_id,
"task_id": args.task_id,
"required_for_decision": args.required_for_decision,
}
print(json.dumps(
_api_post("/review-contracts/projections", payload),
indent=2,
))
def cmd_review_submit(args: argparse.Namespace) -> None:
"""Submit a file-authoritative receipt against an active contract."""
key = urllib.parse.quote(args.contract_key, safe="")
target, source = _git_source(args.file, args.source_repo)
receipt = _load_json_file(str(target))
if "source" not in receipt:
receipt["source"] = source
print(json.dumps(
_api_post(f"/review-contracts/{key}/receipts", receipt),
indent=2,
))
def cmd_review_status(args: argparse.Namespace) -> None:
"""Print the derived owner and gate matrix; never an execution authorization."""
key = urllib.parse.quote(args.contract_key, safe="")
print(json.dumps(_api_get(f"/review-contracts/{key}/aggregate"), indent=2))
def _outbox_store(args):
from api.edge.outbox import OutboxStore, default_outbox_path
@ -601,6 +729,8 @@ def main() -> None:
)
sub = parser.add_subparsers(dest="command", required=True)
configure_repo_commands(sub)
# register
statehub_reg = sub.add_parser(
"register",
@ -608,6 +738,11 @@ def main() -> None:
)
statehub_reg.add_argument("--path", default=os.getcwd(), help="Repo directory (defaults to cwd)")
statehub_reg.add_argument("--domain", default=None, help="State Hub domain slug")
statehub_reg.add_argument(
"--topic",
default=None,
help="Existing active topic slug within the selected domain",
)
statehub_reg.add_argument("--repo-slug", default=None, help="Repo slug (auto-detected if omitted)")
statehub_reg.add_argument("--wp-prefix", default=None, help="Workplan prefix, e.g. STATE-WP")
statehub_reg.add_argument("--description", default=None, help="One-sentence repo description")
@ -673,6 +808,11 @@ def main() -> None:
fix.add_argument("--remote", action="store_true", help="Pull before fixing; requires --repo or --all")
fix.add_argument("--max-seconds", type=int, default=None, help="Wall-clock budget for --remote --all")
fix.add_argument("--no-writeback", action="store_true", help="Disable DB-to-file status writeback")
fix.add_argument(
"--bootstrap-empty-projection",
action="store_true",
help="Rebuild file UUIDs only after proving the repo projection is empty",
)
fix.add_argument("--archive-closed", action="store_true", help="Archive closed root workplans after fixing")
fix.add_argument("--archive-workplan", default=None, help="Archive only the matching workplan id or filename")
fix.add_argument("--archive-date", default=None, help="YYMMDD archive prefix for --archive-closed")
@ -684,6 +824,32 @@ def main() -> None:
help="Preserve checker exit code 2 for warnings-only runs",
)
# quality-debt (STATE-WP-0077)
qdebt = sub.add_parser(
"quality-debt",
help="List DoX quality debt: ready without DoR-Ok, finished without DoD-Ok, intakes without DoC-Ok",
)
qdebt.add_argument("--repo-path", default=None, help="Repo root (default: cwd)")
qdebt.add_argument("--api-base", default=API_BASE, help="State Hub API base URL")
qdebt.add_argument("--no-hub", action="store_true", help="Skip hub intake scan")
qdebt.add_argument("--json", action="store_true", dest="as_json")
qdebt.add_argument("--strict", action="store_true", help="Exit 1 if any debt found")
qdebt.set_defaults(func=cmd_quality_debt)
# promote-intake
promote = sub.add_parser(
"promote-intake",
help="Promote a routed intake into a workplan, task, decision, or engagement (CUST-WP-0061-T03)",
)
promote.add_argument("intake_id", help="UUID of the routed intake")
promote.add_argument("--to", required=True, choices=["workplan", "task", "decision", "engagement"])
promote.add_argument("--repo-path", default=os.getcwd(), help="Local checkout of the target repo")
promote.add_argument("--repo-slug", required=True, help="Registered repo slug")
promote.add_argument("--domain", required=True, help="Market domain slug, e.g. infotech")
promote.add_argument("--target-file", default=None, help="Repo-relative path (required for decision/engagement)")
promote.add_argument("--workplan-file", default=None, help="Repo-relative workplan file path (required for task)")
promote.add_argument("--api-base", default=API_BASE, help="State Hub API base URL")
# create-workstream
cws = sub.add_parser("create-workstream", help="Create a workstream under a domain topic")
cws.add_argument("--domain", required=True, help="Domain slug to create the workstream under")
@ -736,6 +902,26 @@ def main() -> None:
# status
sub.add_parser("status", help="Show State Hub health and summary totals")
# review — file-backed multi-owner review projection
review = sub.add_parser("review", help="Project and inspect multi-owner review evidence")
review_sub = review.add_subparsers(dest="review_command", required=True)
review_project = review_sub.add_parser("project", help="Project an authoritative contract wrapper")
review_project.add_argument("file")
review_project.add_argument("--source-repo", default=None)
review_project.add_argument("--decision-id", default=None)
review_project.add_argument("--workplan-id", default=None)
review_project.add_argument("--task-id", default=None)
review_project.add_argument("--required-for-decision", action="store_true")
review_project.set_defaults(func=cmd_review_project)
review_submit = review_sub.add_parser("submit", help="Submit an authoritative receipt file")
review_submit.add_argument("contract_key")
review_submit.add_argument("file")
review_submit.add_argument("--source-repo", default=None)
review_submit.set_defaults(func=cmd_review_submit)
review_status = review_sub.add_parser("status", help="Print owner and gate status")
review_status.add_argument("contract_key")
review_status.set_defaults(func=cmd_review_status)
# dev up — files-first local hub (CUST-WP-0054-T07)
dev = sub.add_parser("dev", help="Local dev-hub commands")
dev_sub = dev.add_subparsers(dest="dev_command", required=True)
@ -767,6 +953,8 @@ def main() -> None:
cmd_ingest_sbom(args)
elif args.command == "fix-consistency":
cmd_fix_consistency(args)
elif args.command == "promote-intake":
cmd_promote_intake(args)
elif args.command == "create-workstream":
cmd_create_workstream(args)
elif args.command == "create-task":

View file

@ -10,7 +10,9 @@ const _configDir = dirname(fileURLToPath(import.meta.url));
const _modalScript = readFileSync(
join(_configDir, "src/components/improvement-modal.js"), "utf-8"
)
.replace(/^export function /gm, "function ") // strip ES module export
// Strip ES module exports so the file can run as a plain <script>.
.replace(/^export function /gm, "function ")
.replace(/^export const /gm, "const ")
+ "\ninitImprovementModal();\n"; // auto-initialise
export default {
@ -54,6 +56,8 @@ export default {
pages: [
{ name: "Repository DoI", path: "/policy/repo-doi" },
{ name: "Service DoM", path: "/policy/service-dom" },
{ name: "Intake DoC", path: "/policy/intake-doc" },
{ name: "Work-item DoR", path: "/policy/work-item-dor" },
{ name: "Workplan DoD", path: "/policy/workstream-dod" },
],
},
@ -81,7 +85,6 @@ export default {
{ name: "Interventions", path: "/interventions" },
{ name: "Tasks", path: "/tasks" },
{ name: "UI Feedback", path: "/ui-feedback" },
{ name: "Suggestions", path: "/suggestions" },
{ name: "WSJF Triage", path: "/wsjf-triage" },
],
},
@ -102,6 +105,7 @@ export default {
{ name: "Domains", path: "/docs/domains" },
{ name: "Goals", path: "/docs/goals" },
{ name: "Extension Points", path: "/docs/extensions" },
{ name: "Intakes", path: "/docs/intakes" },
{ name: "Inter-Repo Communication", path: "/docs/inter-repo-communication" },
{ name: "Interventions", path: "/docs/interventions" },
{ name: "Live Data", path: "/docs/live-data" },
@ -115,15 +119,16 @@ export default {
{ name: "SBOM", path: "/docs/sbom" },
{ name: "SCOPE.md", path: "/docs/scope" },
{ name: "Service Catalog", path: "/docs/services" },
{ name: "Suggestions (archived)", path: "/docs/suggestions" },
{ name: "Tasks", path: "/docs/tasks" },
{ name: "TPSC", path: "/docs/tpsc" },
{ name: "TPSC — GDPR Maturity", path: "/docs/gdpr-maturity" },
{ name: "Technical Debt", path: "/docs/debt" },
{ name: "Todo", path: "/docs/todo" },
{ name: "Work Records", path: "/docs/work-records" },
{ name: "Workplan Health", path: "/docs/workstream-health-index" },
{ name: "Workplan Lifecycle", path: "/docs/workstream-lifecycle" },
{ name: "Workplans", path: "/docs/workstreams" },
{ name: "Suggestions", path: "/docs/suggestions" },
{ name: "WSJF Triage", path: "/docs/wsjf-triage" },
],
},

View file

@ -12,10 +12,59 @@
* Submissions are stored as technical-debt items with debt_type="dashboard-improvement".
*
* Interaction:
* - Hold Shift cursor changes to crosshair across the entire page
* - Hold Shift 1s cursor changes to copy/highlight mode (shift-wait-click)
* - Shift+click any element (except form controls) opens suggestion modal
*/
/** Domain slug for dashboard-improvement TD records (state-hub lives under infotech). */
export const DEFAULT_IMPROVEMENT_DOMAIN = "infotech";
const DEFAULT_API = "http://127.0.0.1:8000";
const API_STORAGE_KEY = "stateHubApiBase";
/**
* Resolve API base when init is called without an explicit apiBase.
* Mirrors dashboard/src/components/config.js so the modal works when the
* dashboard is not on 127.0.0.1 (remote host, tunnel, storage override).
*/
export function resolveImprovementApiBase({
location = globalThis.location,
storage = globalThis.localStorage,
} = {}) {
const clean = (value) => {
if (typeof value !== "string") return null;
const cleaned = value.trim().replace(/\/+$/, "");
return cleaned || null;
};
try {
if (location?.href) {
const url = new URL(location.href);
for (const name of ["api_base", "apiBase"]) {
const q = clean(url.searchParams.get(name));
if (q) return q;
}
}
} catch { /* ignore */ }
const fromGlobal = clean(globalThis.STATE_HUB_API_BASE);
if (fromGlobal) return fromGlobal;
try {
const fromStorage = clean(storage?.getItem?.(API_STORAGE_KEY));
if (fromStorage) return fromStorage;
} catch { /* ignore */ }
try {
if (location?.href) {
const url = new URL(location.href);
if (!["http:", "https:"].includes(url.protocol)) return DEFAULT_API;
if (url.hostname === "::1" || url.hostname === "[::1]") return DEFAULT_API;
url.port = globalThis.STATE_HUB_API_PORT || "8000";
url.pathname = "";
url.search = "";
url.hash = "";
return url.origin;
}
} catch { /* ignore */ }
return DEFAULT_API;
}
const _STYLE_ID = "improvement-modal-styles";
function _ensureStyles() {
@ -239,10 +288,13 @@ let _initialized = false;
* Safe to call multiple times only the first call takes effect.
*
* @param {object} opts
* @param {string} opts.apiBase State Hub API base URL (default: "http://127.0.0.1:8000")
* @param {string} opts.domain Domain slug for the TD record (default: "custodian")
* @param {string} [opts.apiBase] State Hub API base URL (resolved like config.js if omitted)
* @param {string} [opts.domain] Domain slug for the TD record (default: "infotech")
*/
export function initImprovementModal({ apiBase = "http://127.0.0.1:8000", domain = "custodian" } = {}) {
export function initImprovementModal({
apiBase = resolveImprovementApiBase(),
domain = DEFAULT_IMPROVEMENT_DOMAIN,
} = {}) {
if (_initialized) return;
_initialized = true;
_ensureStyles();
@ -391,7 +443,10 @@ export function initImprovementModal({ apiBase = "http://127.0.0.1:8000", domain
try {
const r = await fetch(`${apiBase}/technical-debt/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: {
"Content-Type": "application/json",
"X-StateHub-Component": "state-hub.dashboard.improvement-modal",
},
body: JSON.stringify(payload),
});
if (r.ok) {
@ -400,7 +455,13 @@ export function initImprovementModal({ apiBase = "http://127.0.0.1:8000", domain
} else {
submitBtn.disabled = false;
submitBtn.textContent = "Submit suggestion";
_toast(`⚠ Submission failed (HTTP ${r.status})`);
let detail = `HTTP ${r.status}`;
try {
const body = await r.json();
if (typeof body?.detail === "string") detail = body.detail;
else if (body?.detail != null) detail = JSON.stringify(body.detail);
} catch { /* keep status-only detail */ }
_toast(`⚠ Submission failed (${detail})`);
}
} catch {
submitBtn.disabled = false;

View file

@ -0,0 +1,22 @@
/**
* Case-insensitive substring match for Overview "Workplans by Repository".
* Matches title, repo, domain, filename, owner, and id.
*
* @param {object} workplan Row from overview workplan_rows
* @param {string} query Free-text filter
* @returns {boolean}
*/
export function workplanMatchesSearch(workplan, query) {
const q = String(query ?? "").trim().toLowerCase();
if (!q) return true;
if (!workplan || typeof workplan !== "object") return false;
const haystack = [
workplan.title,
workplan.repo_label,
workplan.domain,
workplan.workplan_filename,
workplan.owner,
workplan.id,
].filter(Boolean).join("\n").toLowerCase();
return haystack.includes(q);
}

View file

@ -330,7 +330,13 @@ if (filtered.length === 0) {
} else {
display(html`<div class="dec-list">${filtered.map(d => {
const border = STATUS_BORDER[d.status] ?? "#ccc";
const snippet = (d.description || d.rationale || "").slice(0, 200);
const _desc = d.description || "";
const _rat = d.rationale || "";
const _primary = _desc || _rat;
const snippet = _primary.slice(0, 200);
// Expandable when text is truncated, or when both fields exist (the
// collapsed view only shows one of them).
const _needsExpand = _primary.length > 200 || (_desc && _rat && _desc !== _rat);
const due = fmtDate(d.deadline);
const decided = fmtDate(d.decided_at);
const overdue = isOverdue(d.deadline);
@ -375,7 +381,18 @@ if (filtered.length === 0) {
${_isOpen ? html`<button class="dec-resolve-btn" onclick=${onResolve}>Resolve</button>` : ""}
</div>
<div class="dec-title">${d.title}</div>
${snippet ? html`<div class="dec-snippet">${snippet}${snippet.length < (d.description || d.rationale || "").length ? "…" : ""}</div>` : ""}
${_primary ? (_needsExpand
? html`<details class="dec-expand">
<summary>
<span class="dec-snippet dec-snippet-collapsed">${snippet}${_primary.length > 200 ? "…" : ""}</span>
<span class="dec-more"></span>
</summary>
<div class="dec-fulltext">
${_desc ? html`<div class="dec-fulltext-section"><div class="dec-fulltext-label">Description</div><div class="dec-snippet">${_desc}</div></div>` : ""}
${_rat ? html`<div class="dec-fulltext-section"><div class="dec-fulltext-label">Rationale</div><div class="dec-snippet">${_rat}</div></div>` : ""}
</div>
</details>`
: html`<div class="dec-snippet">${_primary}</div>`) : ""}
${d.decided_by ? html`<div class="dec-resolved-by">✓ ${d.decided_by}${decided ? " · " + decided : ""}</div>` : ""}
${d.escalation_note && !["resolved", "superseded"].includes(d.status) ? html`<div class="dec-escalation-note">${d.escalation_note}</div>` : ""}
</div>`;
@ -458,6 +475,18 @@ if (escalated.length > 0) {
.dec-title { font-weight: 600; font-size: 0.95rem; margin-bottom: 0.2rem; }
.dec-snippet { font-size: 0.82rem; color: var(--theme-foreground-muted); line-height: 1.45; white-space: pre-wrap; }
.dec-resolved-by { font-size: 0.78rem; color: #22c55e; margin-top: 0.3rem; }
/* ── Expandable full text ─────────────────────────────────────────────────── */
.dec-expand summary { list-style: none; cursor: pointer; }
.dec-expand summary::-webkit-details-marker { display: none; }
.dec-expand[open] .dec-snippet-collapsed { display: none; }
.dec-more::after { content: "Show full text ▾"; }
.dec-expand[open] .dec-more::after { content: "Show less ▴"; }
.dec-more { display: inline-block; margin-left: 0.35rem; font-size: 0.72rem; font-weight: 600; color: steelblue; white-space: nowrap; }
.dec-more:hover { text-decoration: underline; }
.dec-expand[open] .dec-more { display: block; margin: 0.15rem 0 0.35rem; }
.dec-fulltext-section + .dec-fulltext-section { margin-top: 0.5rem; }
.dec-fulltext-label { font-size: 0.65rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--theme-foreground-faint, #aaa); margin-bottom: 0.1rem; }
.dec-escalation-note { font-size: 0.78rem; color: #b45309; margin-top: 0.3rem; background: #fef3c7; border-radius: 4px; padding: 0.25rem 0.5rem; }
/* ── Resolve button ───────────────────────────────────────────────────────── */

View file

@ -227,8 +227,9 @@ and age in days.
---
*Capability requests are a sanctioned write use case of the State Hub alongside
`resolve_decision`, `get_next_steps`, and the suggestion backlog writes
(`create_suggestion`, `vet_suggestion`, `decline_suggestion`,
`promote_suggestion_to_task`, `bump_suggestion_relevance`). They do not
originate in workplan files —
they are operational coordination.*
`resolve_decision`, `get_next_steps`, and intake capture (`create_intake`,
`route_intake`, `close_intake`; promotion via `statehub promote-intake`). The
legacy suggestion mutation tools (`create_suggestion`, `vet_suggestion`, …)
return HTTP 410. Capability requests do not originate in workplan files —
they are operational coordination. See [Work Records](/docs/work-records) and
[Intakes](/docs/intakes).*

View file

@ -4,10 +4,16 @@ title: Contributions — Reference
# Contributions — Reference
Contributions track **outbound upstream work** — things the Custodian has
identified that belong in a repo it does not own or control. Each contribution
is a structured artifact filed locally in the repo's `contrib/` directory and
registered in the state hub so it is never lost.
Contributions track **outbound upstream work** — things identified for a
repository the fleet does not own or control. Each contribution is a
structured artifact filed locally under `contrib/` and registered in State Hub
so it is not lost.
Contributions are **not** a work-record kind in the fleet registry
(`work-record-types_v0.1.md`). They are a separate hub entity and dashboard
surface used for third-party coordination. Work-record kinds cover internal
coordination (workplan, task, intake, decision, engagement, register-entry).
See [Work Records](/docs/work-records).
---
@ -15,22 +21,26 @@ registered in the state hub so it is never lost.
| Type | Full name | Use when |
|------|-----------|----------|
| `br` | Bug Report | You found a defect in an upstream tool or library |
| `fr` | Feature Request | You need functionality that upstream does not yet provide |
| `ep` | Extension Point | You identified a future enhancement opportunity in upstream code |
| `upr` | Upstream PR | You have written (or are writing) a patch for an upstream repo |
| `br` | Bug Report | Defect in an upstream tool or library |
| `fr` | Feature Request | Functionality upstream does not yet provide |
| `ep` | Extension Point | Future enhancement opportunity in upstream code |
| `upr` | Upstream PR | Patch written (or being written) for an upstream repo |
---
## Flow workstations
## Lifecycle (workstations)
```
Contribution status is evaluated by the task-flow engine
(`flows/contribution.yaml`). The stored field is `status`; flow tooling may
call the same value a **workstation**.
```text
draft → submitted → acknowledged → accepted → merged
↘ ↘
rejected withdrawn
```
| Workstation | Meaning |
| Status | Meaning |
|--------|---------|
| **draft** | Artifact written locally; not yet sent upstream |
| **submitted** | Filed as a GitHub issue, PR, or email — awaiting upstream response |
@ -40,36 +50,42 @@ draft → submitted → acknowledged → accepted → merged
| **rejected** | Upstream declined; record kept for future reference |
| **withdrawn** | We decided not to pursue it |
Contribution movement is evaluated by the task-flow engine. The API asks
whether the target workstation is reachable from the current information
object; if not, it returns structured blocking assertions instead of a generic
"transition not allowed" message. `submitted_at` is stamped automatically when
the contribution reaches `submitted`; `resolved_at` is stamped when it reaches
`merged`, `rejected`, or `withdrawn`.
Reachability is assertion-based. For example, `rejected` is only enterable from
`submitted` or `acknowledged`; `merged` only from `accepted`. If a target is
not reachable, the API returns structured blocking assertions rather than a
generic “transition not allowed” message.
Timestamps: `submitted_at` is stamped when status becomes `submitted`;
`resolved_at` is stamped when status becomes `merged`, `rejected`, or
`withdrawn` (and related terminal paths as implemented on the status patch).
---
## Relation to the Todo classification
Contributions map directly to the **Third-party** class in the inter-repo
communication taxonomy:
Contributions map to the **Third-party** class in the inter-repo communication
taxonomy:
| Todo class | Mechanism |
|------------|-----------|
| Internal | Workplan file + task in this repo's workplan |
| Internal | Workplan file + task in this repo |
| Ecosystem | State hub task with `[repo:<slug>]` prefix |
| **Third-party** | **Contribution artifact in `contrib/` + state hub registration** |
| **Third-party** | **Contribution artifact in `contrib/` + hub registration** |
Contributions in `draft`, `submitted`, or `acknowledged` workstations appear as
open Third-party todos on the [Todo](/todo) page.
Open third-party items are contributions in `draft`, `submitted`, or
`acknowledged`. They appear on the [Todo](/todo) page until the upstream loop
closes (`merged`, `rejected`, or `withdrawn`).
Intakes are **not** third-party contributions: an intake is internal capture of
a spark; a contribution is outbound work aimed at someone elses repo.
---
## File layout
Each artifact lives in the current repo under `contrib/`:
Each artifact lives in the **current** repo under `contrib/`:
```
```text
contrib/
bug-reports/ br-YYYY-MM-DD--<org>--<repo>--<slug>.md
feature-requests/ fr-YYYY-MM-DD--<org>--<repo>--<slug>.md
@ -77,8 +93,8 @@ contrib/
upstream-prs/ upr-YYYY-MM-DD--<org>--<repo>--<slug>.md
```
Templates live in `~/the-custodian/canon/standards/contrib-templates/`.
Convention details: `~/the-custodian/canon/standards/contribution-convention_v0.1.md`.
Templates: `~/the-custodian/canon/standards/contrib-templates/`.
Convention: `~/the-custodian/canon/standards/contribution-convention_v0.1.md`.
---
@ -86,38 +102,52 @@ Convention details: `~/the-custodian/canon/standards/contribution-convention_v0.
**1. Write the artifact file** using the appropriate template.
**2. Register it in the state hub** via MCP:
**2. Register it in the state hub** (MCP):
```
```text
register_contribution(
type = "fr",
title = "Add sidebar TOC injection API",
target_org = "observablehq",
target_repo = "framework",
body_path = "contrib/feature-requests/fr-2026-02-26--observablehq--framework--toc.md",
related_workstream_id = "<uuid>"
type = "fr",
title = "Add sidebar TOC injection API",
target_org = "observablehq",
target_repo = "framework",
body_path = "contrib/feature-requests/fr-2026-02-26--observablehq--framework--toc.md",
related_workplan_id = "<uuid>" # preferred; related_workstream_id still accepted as alias
)
```
**3. Close the loop** when you file it upstream:
REST: `POST /contributions/` with the same fields.
```
**3. Close the loop** when filed upstream — advance via flow tooling or status
patch:
```text
advance_workstation(entity_type="contribution", entity_id="<uuid>", target_workstation="submitted")
```
**4. Keep updating** as upstream responds — `acknowledged`, `accepted`, `merged`.
```bash
curl -X PATCH "http://127.0.0.1:8000/contributions/<uuid>/status" \
-H "Content-Type: application/json" \
-d '{"status": "submitted"}'
```
**4. Keep updating** as upstream responds — `acknowledged`, `accepted`,
`merged` (or `rejected` / `withdrawn`).
Optional links: `related_topic_id`, `related_workplan_id`, `repo_id` (managed
repo that owns the local artifact).
---
## Kanban board
The Contributions page groups artifacts by status column. Only columns with at
least one entry are shown. The **⚠ follow-up banner** appears when any
least one entry are shown. The **follow-up banner** appears when any
contribution has been in `submitted` or `acknowledged` for an extended period
without further movement — a prompt to check in with upstream.
Data source: `GET /contributions/`.
---
*Contributions are append-only. Rejected or withdrawn artifacts are retained as
institutional memory — they explain why certain approaches were tried and
dropped.*
*Contributions are retained after rejection or withdrawal — institutional
memory for why certain approaches were tried and dropped.*

View file

@ -127,7 +127,7 @@ The Goals page groups everything by domain:
Workplans carry an optional `repo_goal_id` field. Setting it traces *why* a workplan exists — which specific repo goal it contributes to. This connection is currently recorded in the DB but is not yet visualised in the Workplans page.
To set the link when creating a workplan through the preferred API, pass `repo_goal_id`. To update an existing one, use `PATCH /workplans/{id}/` with `{"repo_goal_id": "<uuid>"}`. Legacy `create_workstream` and `/workstreams/{id}/` callers remain compatibility-supported while they are metered.
To set the link when creating a workplan through the preferred API, pass `repo_goal_id`. To update an existing one, use `PATCH /workplans/{id}/` with `{"repo_goal_id": "<uuid>"}`. The legacy `create_workstream` tool is removed and `/workstreams/{id}` returns 410; use `create_workplan` and `/workplans/{id}`.
---

View file

@ -0,0 +1,185 @@
---
title: Intakes — Reference
---
# Intakes — Reference
An **intake** is a work-record kind for a **spark**: an idea, finding,
directive, or request that is not yet a workplan, task, decision, or
engagement. Intakes hold early signal until someone vets, routes, and either
promotes or closes them.
Canon: `the-custodian/canon/standards/work-record-types_v0.1.md`
(`kind: intake`, id scheme `{PREFIX}-IN-NNNN`).
Umbrella: [Work Records](/docs/work-records).
Intakes **replace** the retired hub **suggestion** backlog for new work. See
[Suggestions (legacy)](/docs/suggestions).
**Quality:** discovery comprehension is governed by
[Definition of Comprehension (DoC)](/policy/intake-doc) (`DoC-Ok` /
`DoC-Failed` / unassessed). DoC is independent of lifecycle `status` and is
**not** implementation readiness ([DoR](/policy/work-item-dor)). Model:
`docs/work-record-quality-gates.md`.
---
## Lifecycle
```text
open -> vetted -> routed -> closed(promoted | declined | absorbed)
```
| Status | Meaning |
|--------|---------|
| **open** | Captured; not yet fully vetted |
| **vetted** | Reviewed; structure and intent are clear enough to route |
| **routed** | Assigned a destination / owner path; eligible for promotion |
| **closed** | Terminal; requires an **outcome** |
| Outcome (only when `closed`) | Meaning |
|------------------------------|---------|
| **promoted** | Became another work record; `promoted_to` holds the new canonical id |
| **declined** | Explicitly not pursued |
| **absorbed** | Already covered by existing work; no new record needed |
Database constraints (what the API enforces):
- At least one of `topic_id`, `workplan_id`, or `repo_id` is set (scope).
- `closed` requires `outcome`.
- `outcome = promoted` requires `promoted_to`.
Route action: only from `open` or `vetted``routed`.
Close action: from `open`, `vetted`, or `routed``closed`.
---
## Fields that matter
| Field | Role |
|-------|------|
| `title` / `description` | Human-readable content |
| `lane` | Autonomy lane: `green` \| `blue` \| `yellow` \| `orange` \| `red` |
| `status` / `outcome` | Lifecycle |
| `origin` / `origin_ref` | Where the spark came from. **Residuals:** `origin: residual` (or `handoff`) and `origin_ref: <parent-WP-id>` (e.g. `BINKY-WP-0006`). Also e.g. `legacy-suggestion:<uuid>`, mail-triage ids |
| `promoted_to` | Canonical id of the record this became after promotion |
| `source_repo_path` | Repo-relative path of the YAML block / file when file-authored |
| `routed_note` | Free-text routing context |
| `notes` | Append-only note list via `POST /intakes/{id}/notes` |
Identity: hub rows use UUIDv7 primary keys. File-backed blocks use fleet ids
such as `CUST-IN-0004` and receive `state_hub_intake_id` write-back from
`fix-consistency`.
---
## How intakes enter the hub
**1. File-backed (preferred for lasting work)**
Author a schema-valid YAML block in any repo markdown file (canon schemas under
`the-custodian/canon/standards/schemas/work-records/`). Commit, then run
`statehub fix-consistency` so the hub registers the record and writes the UUID
back.
**2. Hub API / MCP (capture path)**
```text
create_intake(title=..., topic_id=... | workplan_id=... | repo_id=..., lane="green", ...)
# REST: POST /intakes/
```
```text
list_intakes(status="open")
route_intake(intake_id, routed_note=...)
close_intake(intake_id, outcome="declined"|"absorbed"|"promoted", promoted_to=...)
```
REST mirrors: `GET/POST /intakes/`, `PATCH /intakes/{id}`,
`POST /intakes/{id}/route`, `POST /intakes/{id}/close`,
`POST /intakes/{id}/notes`.
---
## Promotion (intake → another kind)
Promotion is the sanctioned path from a **routed** intake into a durable
work-record kind:
```text
intake.routed → workplan | task | decision | engagement
```
Implemented by:
```bash
# via CLI (statehub entrypoint wraps scripts/promote_intake.py)
statehub promote-intake <intake-id-or-uuid> --to workplan --repo <path>
statehub promote-intake <id> --to task --workplan-file workplans/….md
statehub promote-intake <id> --to decision --target-file <md>
statehub promote-intake <id> --to engagement --target-file <md>
```
What the promotion transition does today:
1. Validates the intake is **routed**.
2. Writes the target artefact (ADR-001 workplan file, task block, or
decision/engagement YAML block).
3. Sets intake `promoted_to` and closes with `outcome=promoted`.
4. Sets `origin: intake:<…>` on the new record for back-link.
5. Registers / syncs with the hub where a hub entity exists.
**Engagements:** promotion can write the file block; a first-class hub
engagement entity is not present yet — the CLI reports that limitation rather
than inventing silent hub rows.
Closing with `outcome=promoted` by hand is supported for after-the-fact
recording, but the promote CLI is the intended path for both artefacts.
---
## What is not wired yet
These are present limitations of the current hub surface (not aspirations):
| Area | Current behaviour |
|------|-------------------|
| **Dashboard** | No dedicated Intakes page yet; legacy [Suggestions](/suggestions) still reads the old table |
| **Next-steps / summary** | `ranked_suggestions` and open-suggestion signals in `GET /state/summary` and `get_next_steps` still read the **suggestion** table, not intakes |
| **WSJF on intakes** | Suggestion WSJF fields (`base_value`, `relevance`, …) are not part of the intake model |
| **Task-flow YAML** | Workplan, task, and contribution have `flows/*.yaml`; intake advances via dedicated route/close endpoints rather than `advance_workstation` |
Use `GET /intakes/` or MCP `list_intakes` for the live backlog.
---
## Residuals as intakes
When a workplan finishes with leftover work that is not large enough for an
immediate child workplan, author an **intake** with:
```yaml
origin: residual
origin_ref: BINKY-WP-0006 # parent workplan id
lane: green # or blue/yellow/…
status: open # or vetted/routed when ready
```
Promote with `statehub promote-intake` when structure is ready (e.g. to a
workplan). Listing open residual intakes across domains is a State Hub read
concern (`list_intakes` + origin filters; planned `statehub residuals` CLI).
See [Work Records — Residuals](/docs/work-records#residuals-role-not-kind).
## Relation to other kinds
| Kind | Boundary |
|------|----------|
| **workplan / task** | Durable planned work; intake is the pre-plan capture form |
| **decision** | Approval package; an intake may promote *into* a decision |
| **contribution** | Outbound third-party artefact — different entity; not an intake outcome |
| **suggestion (legacy)** | Superseded capture mechanism; do not create new ones |
| **residual** | Not a kind — role of intake/WP/decision with `origin: residual` |
---
*Intakes are a fresh entity (CUST-WP-0061), not a rename of `suggestions`.*

View file

@ -24,6 +24,13 @@ by repository. Each bar is broken into four task-status segments:
| orange | wait |
| light grey | todo |
Above the chart:
- **Mode selector** — lifecycle, health, or recently-changed windows (with
matching workplan counts in each option).
- **Text filter** — case-insensitive substring match on title, repository,
domain, workplan filename, and owner. Useful when many rows share a mode.
The left axis shows the `domain / repository` label once per repository group.
The `done/total` count is printed to the right of each bar. Workplans with no
tasks yet show a grey "— no tasks yet" label.

View file

@ -90,7 +90,7 @@ cd ~/ralph-workplan && ./install.sh --uninstall
```markdown
---
id: WP-0001
id: EXAMPLE-WP-0001
title: "Build a thing"
status: ready
---
@ -100,7 +100,7 @@ Optional description.
## Task: Do the first thing
```task
id: T-01
id: EXAMPLE-WP-0001-T01
status: todo
priority: high
```
@ -108,7 +108,7 @@ priority: high
## Task: Do the second thing
```task
id: T-02
id: EXAMPLE-WP-0001-T02
status: todo
priority: medium
```

Some files were not shown because too many files have changed in this diff Show more