Compare commits

..

No commits in common. "main" and "state-wp-0070-legacy-retirement" have entirely different histories.

325 changed files with 1397 additions and 111013 deletions

View file

@ -42,14 +42,6 @@ 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-09-14 08:15 UTC
**Last synced:** 2026-07-10 13:22 UTC
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
## Active Workstreams
### State Hub retirement strangler and disposition execution
Progress: 4/9 done | workplan_id: `ed077b62-7048-5752-bf65-f90471f45854`
### Workplan terminology phase-2 legacy retirement
Progress: 2/5 done | workplan_id: `9aa92529-6ee8-4b3f-b573-78b0ef8d9788`
**Open tasks:**
- ! 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`
- ► Task: MCP alias removal (Phase 2) `e2b9015a`
- ► Task: Internal dual-key and param alias cleanup `18af98bf`
- · Task: Closeout and fleet gate `dcc81665`
## Inbox Hygiene
**Stale unread:** 2 message(s) older than 3 day(s) — triage at session start.
**Missing thread_id:** 1 unread message(s) lack supersession chains.
**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`
---
## MCP Orientation (when available)

View file

@ -12,13 +12,6 @@ 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,24 +9,11 @@ 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,7 +18,6 @@ __pycache__/
.pytest_cache/
.mypy_cache/
.ruff_cache/
/docs/architecture/.ast_cache/
.coverage
.coverage.*
htmlcov/

View file

@ -13,15 +13,13 @@
## State Hub Integration
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.
The Custodian State Hub tracks work across all domains. Interact via HTTP REST —
there is no MCP server for Codex agents.
| Context | URL |
|---------|-----|
| Local workstation | `http://127.0.0.1:8000` |
| Remote (railiance01, in-cluster) | `http://10.43.68.154:8000` |
| Remote via tunnel | `http://127.0.0.1:18000` |
| 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.
@ -29,46 +27,21 @@ 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 (default summary omits flavor: residual)
# Active workplans for this domain
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" \
@ -124,19 +97,13 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
**Close:**
1. Update workplan file task statuses to reflect progress
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:
2. Log: `POST /progress/` with a summary of what changed
3. After workplan file changes, run:
```bash
uv run --project ~/repo-manager rmgr sync --path . --push
statehub fix-consistency
```
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.
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.
---
@ -209,12 +176,9 @@ 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`, 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.
`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.
**Frontmatter:**
@ -230,7 +194,7 @@ owner: codex
topic_slug: ...
created: "YYYY-MM-DD"
updated: "YYYY-MM-DD"
state_hub_workstream_id: "<uuid>" # deterministic UUIDv5; managed by Repo Manager
state_hub_workstream_id: "<uuid>" # written by fix-consistency — do not edit
---
```
@ -247,7 +211,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>" # deterministic UUIDv5; managed by Repo Manager
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
` ` `
Task description text.
@ -255,19 +219,7 @@ 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 `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`.
2. Run `statehub fix-consistency` locally; ask the operator only if the CLI or
State Hub API is unavailable.

View file

@ -39,9 +39,6 @@ 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,26 +9,6 @@ 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
@ -146,3 +126,4 @@ 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.

167
LICENSE
View file

@ -1,151 +1,16 @@
# Target Revenue Source License
**Version 1.0, Candidate 1 (V1C1)**
---
> **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`.
MIT No Attribution
Copyright <YEAR> <COPYRIGHT HOLDER>
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.

113
Makefile
View file

@ -1,4 +1,4 @@
.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
.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
COMPOSE = docker compose -f infra/docker-compose.yml --env-file .env
PYTHON ?= python3
@ -9,28 +9,19 @@ RAILIANCE_STATE_HUB_RELEASE ?= state-hub
RAILIANCE_STATE_HUB_NAMESPACE ?= state-hub
RAILIANCE_STATE_HUB_CHART ?= deploy/railiance/apps/charts/state-hub
RAILIANCE_STATE_HUB_VALUES ?= deploy/railiance/apps/helm/state-hub-values.yaml
RAILIANCE_STATE_HUB_IMAGE_TAG ?= main-0747b4d
RAILIANCE_STATE_HUB_IMAGE_TAG ?= f2e042a
RAILIANCE_STATE_HUB_PLATFORM_DIR ?= deploy/railiance/platform
RAILIANCE_STATE_HUB_APP_MANIFESTS ?= deploy/railiance/apps/manifests
# Codex/WSL non-login shells may not source ~/.profile; keep uv discoverable.
UV ?= $(shell command -v uv 2>/dev/null || if [ -x "$$HOME/.local/bin/uv" ]; then printf "%s" "$$HOME/.local/bin/uv"; else printf "%s" "uv"; fi)
RMGR ?= $(shell command -v rmgr 2>/dev/null || if [ -x "$$HOME/repo-manager/.venv/bin/rmgr" ]; then printf "%s" "$$HOME/repo-manager/.venv/bin/rmgr"; else printf "%s" "rmgr"; fi)
STATE_HUB_API_BASE ?= http://127.0.0.1:8000
SYNC_PATH ?= .
SYNC_PUSH ?= 1
start:
@echo "# Normal production UI access (run in order)"
@echo "make bridges # connect local :8000 to the railiance01 primary"
@echo "make dashboard # verify the primary, then serve the UI on :3000"
@echo "make sync # push file-backed records and reconcile the exact forge commit"
@echo ""
@echo "# Optional local MCP adapter"
@echo "make mcp-http # local SSE adapter on :8001; not needed by the UI"
@echo ""
@echo "# Local fallback/development only"
@echo "make api # local Postgres + API; conflicts with the production :8000 tunnel"
@echo "make dashboard-local # dashboard against the deliberate local API"
@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"
install:
$(UV) sync
@ -72,80 +63,15 @@ mcp-http:
@fuser -k 8001/tcp 2>/dev/null && echo "Stopped running MCP server" || true
MCP_TRANSPORT=sse MCP_PORT=8001 $(UV) run python mcp_server/server.py
## Require the production identity before serving the normal dashboard. This
## prevents an accidental local `make api` from presenting an empty fallback DB
## as the live State Hub.
check-primary:
@health="$$(curl -fsS --max-time 5 http://127.0.0.1:8000/state/health 2>/dev/null)" || { \
echo "ERROR: State Hub primary is not reachable on 127.0.0.1:8000." >&2; \
echo "Run 'make bridges' first." >&2; \
exit 1; \
}; \
identity="$$(printf '%s' "$$health" | $(PYTHON) -c 'import json, sys; d=json.load(sys.stdin); print("{}/{}".format(d.get("instance_role", ""), d.get("instance_label", "")))')"; \
if [ "$$identity" != "primary/railiance01" ]; then \
echo "ERROR: 127.0.0.1:8000 is '$$identity', not the primary/railiance01 State Hub." >&2; \
echo "Stop the local API, then run 'make bridges'. Use 'make dashboard-local' only for intentional local development." >&2; \
exit 1; \
fi
## Refuse to start the production tunnel when a non-primary API already owns
## port 8000. With no listener, bridge is free to establish the tunnel.
primary-port-clear:
@if health="$$(curl -fsS --max-time 2 http://127.0.0.1:8000/state/health 2>/dev/null)"; then \
identity="$$(printf '%s' "$$health" | $(PYTHON) -c 'import json, sys; d=json.load(sys.stdin); print("{}/{}".format(d.get("instance_role", ""), d.get("instance_label", "")))')"; \
if [ "$$identity" != "primary/railiance01" ]; then \
echo "ERROR: port 8000 is occupied by '$$identity'." >&2; \
echo "Stop the local 'make api' process before starting the production tunnel." >&2; \
exit 1; \
fi; \
fi
dashboard: check-primary
dashboard:
@fuser -k 3000/tcp 2>/dev/null && echo "Stopped running dashboard" || true
$(MAKE) dashboard-install
cd dashboard && npm run dev
## Deliberate local-development dashboard; bypasses the production identity gate.
dashboard-local:
@echo "WARNING: serving the dashboard against the local/fallback API on :8000."
@fuser -k 3000/tcp 2>/dev/null && echo "Stopped running dashboard" || true
$(MAKE) dashboard-install
cd dashboard && npm run dev
## Fast work-record path. Repo Manager assigns only missing deterministic IDs,
## verifies the primary/railliance01 bridge, and asks central to derive the
## exact pushed Forgejo commit in one transactional request.
sync:
$(RMGR) sync --path "$(SYNC_PATH)" --api-base "$(STATE_HUB_API_BASE)" $(if $(filter 1 true yes,$(SYNC_PUSH)),--push,)
check: check-primary
@echo "State Hub primary/railiance01 is healthy."
check-local:
check:
curl -sf http://127.0.0.1:8000/state/health | python3 -m json.tool
# CUST-WP-0067-T09. The chart ships a copy of the-custodian canon allowed-values
# because a container has no checkout to read it from. Regenerate after any
# canon vocabulary change; the -check target fails on drift so a release cannot
# validate classification against a stale vocabulary.
sync-classification-allowed:
python3 scripts/sync_classification_allowed.py
check-classification-allowed:
python3 scripts/sync_classification_allowed.py --check
# STATE-WP-0091: read-only surge-headroom check. Does not start Helm.
# Live: make railiance-state-hub-headroom
# Fixtures: make railiance-state-hub-headroom HEADROOM_FIXTURE=tests/fixtures/release_headroom/rev63-105m.json
HEADROOM_FIXTURE ?=
railiance-state-hub-headroom:
@if [ -n "$(HEADROOM_FIXTURE)" ]; then \
$(PYTHON) scripts/release_headroom_preflight.py --fixture "$(HEADROOM_FIXTURE)"; \
else \
$(PYTHON) scripts/release_headroom_preflight.py --kubectl "$(KUBECTL)"; \
fi
railiance-state-hub-render: check-classification-allowed
railiance-state-hub-render:
$(HELM) template $(RAILIANCE_STATE_HUB_RELEASE) $(RAILIANCE_STATE_HUB_CHART) \
--namespace $(RAILIANCE_STATE_HUB_NAMESPACE) \
-f $(RAILIANCE_STATE_HUB_VALUES) \
@ -200,7 +126,7 @@ benchmark-summary-cache:
## ops-bridge managed tunnels
## Requires ops-bridge: bridge is at /home/worsch/.local/bin/bridge
tunnels-up: primary-port-clear
tunnels-up:
bridge up
tunnels-status:
@ -213,7 +139,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: primary-port-clear
bridges:
@echo "==> Bringing up all tunnels..."
bridge up
@echo ""
@ -223,10 +149,9 @@ bridges: primary-port-clear
@echo "==> Checking tunnel health..."
bridge check
## Start (or restart) the LOCAL FALLBACK backend — db + migrate + uvicorn.
## This replaces anything on :8000, including the production State Hub tunnel.
## Start (or restart) the full backend — db + migrate + uvicorn.
## Stops uvicorn on :8000 if already running, then starts fresh.
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; \
@ -255,14 +180,6 @@ 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,15 +64,11 @@ 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 in the railiance01-hosted k3s
cluster. Workstation port `8000` reaches it through the ops-bridge
`state-hub-primary` tunnel. See
**Production:** the primary State Hub API runs on coulombcore-k3s. 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
@ -83,11 +79,7 @@ Repository rename operations use the phased, UUID-preserving workflow in
- Python 3.12+ with `uv` (`pip install uv`)
- Node.js 18+ (dashboard only)
### 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`.
### First-time
```bash
cd /home/worsch/state-hub
@ -97,51 +89,27 @@ make install # uv sync
make db # docker compose up postgres
make migrate # alembic upgrade head
make seed # insert 6 canonical topics
make api # LOCAL fallback: db + migrate + uvicorn :8000
make api # db + migrate + uvicorn :8000 (restarts if running)
```
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:
### Dashboard
```bash
make bridges # connect workstation :8000 to primary/railiance01
make dashboard # verify the primary identity, then serve http://127.0.0.1:3000
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 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.
### Start Everything
The MCP adapter is optional and is not required by the dashboard:
To start all the infrastructure on separate consoles do:
```bash
make mcp-http # optional local SSE adapter on :8001
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
```
### 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
@ -159,22 +127,17 @@ 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 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` | Start 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` | Start the local fallback API; replaces the production tunnel on `:8000` |
| `make api` | `db` + wait + `migrate` + `uvicorn` (restarts if running) |
| `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` | 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 dashboard` | Install deps if needed, then start Observable dev server (restarts if running) |
| `make check` | `curl /state/health` |
| `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) |
@ -256,7 +219,7 @@ use `/state/health`, not `/state/summary`.
|--------|-----------|
| `/topics` | CRUD (soft-delete: `archived`) |
| `/workplans` | Preferred CRUD surface for repo-backed workplans (soft-delete: `archived`) |
| `/workstreams` | Retired REST routes (410); use `/workplans`. Rejected calls remain metered |
| `/workstreams` | Legacy compatibility CRUD surface; usage is recorded by legacy-meter |
| `/tasks` | CRUD (soft-delete: `cancel`); `PATCH` updates status |
| `/decisions` | CRUD (soft-delete: `superseded`); auto-escalation |
| `/progress` | `GET` list + `POST` append — no DELETE |
@ -264,8 +227,7 @@ use `/state/health`, not `/state/summary`.
| `/state/summary` | Full snapshot |
| `/state/health` | DB connectivity check |
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
See `docs/workplan-terminology-transition.md` for the workstream-to-workplan
compatibility policy and retirement criteria.
---
@ -323,7 +285,7 @@ Prints API health, totals, and any blocking decisions.
### What `register-project` does
1. Verifies the API is reachable
1. Verifies the API is reachable (fails fast with `make api` hint)
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,10 +2,9 @@
## One-Liner
State Hub is the transitional coordination service for repo-anchored workplans,
State Hub is the local-first coordination service for repo-anchored workplans,
tasks, decisions, progress events, repo classification and metadata, MCP
tooling, and dashboard telemetry. Its primary runtime is on railiance01; local
repository files remain authoritative for repository-owned work records.
tooling, and dashboard telemetry.
## In Scope
@ -24,16 +23,13 @@ repository files remain authoritative for repository-owned work records.
| Repo | Role |
| --- | --- |
| `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` |
| `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 |
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

View file

@ -1,365 +0,0 @@
# 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,16 +9,8 @@ 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"
@ -78,21 +70,12 @@ 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}. "
"Set REPO_CLASSIFICATION_ALLOWED_PATH when running without a "
"the-custodian checkout (for example in a container)."
f"{_PRIMARY_ALLOWED}, {_RAILIANCE_ALLOWED}, or {_FALLBACK_ALLOWED}"
)

View file

@ -1,5 +1,3 @@
from typing import Literal
from pydantic_settings import BaseSettings, SettingsConfigDict
@ -12,36 +10,9 @@ 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, intake, ops_runs, progress, state, suggestions, tasks, technical_debt, topics, workstreams, workstream_dependencies
from api.routers import decisions, extension_points, 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,10 +23,6 @@ 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):
@ -111,9 +107,6 @@ 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)
@ -121,7 +114,6 @@ 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)
@ -141,10 +133,7 @@ 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,13 +10,6 @@ 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
@ -41,13 +34,6 @@ 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,
@ -68,7 +54,6 @@ __all__ = [
"WorkstreamDependency",
"Task", "TaskStatus", "TaskPriority",
"Decision", "DecisionType", "DecisionStatus",
"Intake", "IntakeNote", "IntakeLane", "IntakeStatus", "IntakeOutcome",
"ProgressEvent",
"ExtensionPoint", "EPStatus",
"TechnicalDebt", "TDStatus",
@ -88,8 +73,5 @@ __all__ = [
"FabricGraphImport", "FabricGraphNode", "FabricGraphEdge",
"LegacyInterface", "LegacyInterfaceUsageBucket",
"WriteIdempotencyKey",
"WorkRecordIdentifierAlias",
"ReviewContract", "ReviewReceipt",
"RepositoryForgeIdentity", "RepositoryRenameOperation", "RepositorySlug",
"Suggestion", "SuggestionNote", "SuggestionRelevanceBump", "SuggestionStage",
]
]

View file

@ -1,5 +1,3 @@
import os
import time
import uuid
from datetime import datetime
@ -26,28 +24,3 @@ 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", onupdate="CASCADE"),
ForeignKey("workplans.id", ondelete="SET NULL"),
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", onupdate="CASCADE"),
ForeignKey("workplans.id", ondelete="SET NULL"),
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", onupdate="CASCADE"),
ForeignKey("tasks.id", ondelete="SET NULL"),
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 DateTime, Enum, ForeignKey, String, Text
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
@ -48,9 +48,7 @@ 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", onupdate="CASCADE"),
nullable=True,
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), 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,10 +37,7 @@ 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", onupdate="CASCADE"),
nullable=True,
index=True,
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="RESTRICT"), 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,9 +45,7 @@ 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", onupdate="CASCADE"),
nullable=True,
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), 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(255), nullable=True, index=True)
source_commit: Mapped[str | None] = mapped_column(String(80), 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)

View file

@ -1,129 +0,0 @@
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,21 +53,6 @@ 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,16 +20,10 @@ 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", onupdate="CASCADE"),
nullable=True,
index=True,
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="RESTRICT"), 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,
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="RESTRICT"), 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

@ -1,292 +0,0 @@
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

@ -1,128 +0,0 @@
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,9 +36,7 @@ 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", onupdate="CASCADE"),
nullable=True,
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True
)
title: Mapped[str] = mapped_column(String(500), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
@ -63,9 +61,7 @@ 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", onupdate="CASCADE"),
nullable=True,
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
)
domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821
@ -121,4 +117,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,18 +31,8 @@ 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", onupdate="CASCADE"),
nullable=False,
index=True,
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="RESTRICT"), 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(
@ -57,9 +47,7 @@ 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", onupdate="CASCADE"),
nullable=True,
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
)
workplan: Mapped["Workplan"] = relationship("Workplan", back_populates="tasks") # noqa: F821

View file

@ -77,9 +77,7 @@ 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", onupdate="CASCADE"),
nullable=True,
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True
)
domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821

View file

@ -1,16 +1,9 @@
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
@ -32,16 +25,10 @@ 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", onupdate="CASCADE"),
nullable=True,
index=True,
UUID(as_uuid=True), ForeignKey("tasks.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,
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), 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

@ -1,44 +0,0 @@
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,9 +39,6 @@ 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),
@ -56,16 +53,6 @@ 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", onupdate="CASCADE"),
ForeignKey("workplans.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
to_workplan_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("workplans.id", ondelete="CASCADE", onupdate="CASCADE"),
ForeignKey("workplans.id", ondelete="CASCADE"),
nullable=True,
index=True,
)
to_task_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("tasks.id", ondelete="CASCADE", onupdate="CASCADE"),
ForeignKey("tasks.id", ondelete="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", onupdate="CASCADE"),
ForeignKey("workplans.id", ondelete="CASCADE"),
nullable=False,
index=True,
)

View file

@ -1,7 +1,7 @@
import re
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@ -11,6 +11,7 @@ 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 (
@ -21,15 +22,12 @@ 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
# ---------------------------------------------------------------------------
@ -396,102 +394,12 @@ def _check_transition(current: str, target: str) -> None:
)
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,
router = create_capability_catalog_router(
get_session,
domain_model=Domain,
repo_model=ManagedRepo,
catalog_model=CapabilityCatalog,
)
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,
@ -524,4 +432,4 @@ router.include_router(
after_dispute=_notify_on_dispute,
after_reroute=_notify_on_reroute,
)
)
)

View file

@ -6,6 +6,7 @@ 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
@ -13,12 +14,9 @@ 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 = (
@ -156,25 +154,6 @@ 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,8 +43,6 @@ 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,
)
@ -163,27 +161,37 @@ async def workplan_stack(
@router.post(
"/launch-requests",
status_code=status.HTTP_410_GONE,
response_model=LaunchRequestRead,
status_code=status.HTTP_201_CREATED,
)
async def create_launch_request(
request: Request,
response: Response,
body: LaunchRequestCreate,
session: AsyncSession = Depends(get_session),
) -> 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."
),
) -> 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,
)
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,6 +1,5 @@
from __future__ import annotations
import uuid
from typing import Any
import httpx
@ -11,8 +10,6 @@ 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,
@ -22,12 +19,10 @@ 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"])
@ -136,34 +131,6 @@ 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",
@ -190,12 +157,7 @@ async def list_graph_nodes(
if domain:
query = query.where(FabricGraphNode.domain_slug == domain)
if repo:
resolution = await resolve_repository_slug(session, repo, required=False)
query = query.where(
FabricGraphNode.repo_slug.in_(
resolution.slug_values if resolution else (repo,)
)
)
query = query.where(FabricGraphNode.repo_slug == repo)
if canonical_category:
query = query.where(FabricGraphNode.canon_category == canonical_category)
if fabric_id:

View file

@ -1,118 +0,0 @@
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)

View file

@ -1,173 +0,0 @@
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,17 +8,13 @@ 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"])
@ -36,12 +32,7 @@ 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)}")
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
]
repo = await _repo_by_slug(body.repo_slug, session)
change = InterfaceChange(
repo_id=repo.id,
interface_type=body.interface_type,
@ -49,7 +40,7 @@ async def create_interface_change(
title=body.title,
description=body.description,
affected_paths=body.affected_paths,
affected_repo_slugs=affected_repo_slugs,
affected_repo_slugs=body.affected_repo_slugs,
planned_for=body.planned_for,
author=body.author,
status="draft",
@ -77,11 +68,7 @@ async def list_interface_changes(
if change_type:
q = q.where(InterfaceChange.change_type == change_type)
if 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))
q = q.where(InterfaceChange.affected_repo_slugs.contains([affected_repo]))
result = await session.execute(q)
return [InterfaceChangeRead.from_orm_with_slug(c) for c in result.scalars().all()]
@ -107,13 +94,7 @@ async def patch_interface_change(
status_code=409,
detail=f"Cannot edit a change with status '{change.status}'. Only draft records are mutable.",
)
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():
for field, value in body.model_dump(exclude_unset=True).items():
setattr(change, field, value)
await session.commit()
await session.refresh(change)
@ -138,13 +119,12 @@ 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=target_slug,
to_agent=slug,
subject=f"[{change.change_type.upper()}] {change.title}",
body=(
f"**Interface change published by `{change.repo.slug}`**\n\n"
@ -194,9 +174,12 @@ async def resolve_interface_change(
return InterfaceChangeRead.from_orm_with_slug(change)
async def _repo_by_slug(slug: str, session: AsyncSession):
resolution = await resolve_repository_slug(session, slug)
return resolution.repo
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 _get_or_404(change_id: uuid.UUID, session: AsyncSession) -> InterfaceChange:

View file

@ -1,153 +1,7 @@
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 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
from hub_core.routers.messages import create_messages_router
router = create_messages_router(get_session, message_model=AgentMessage)
__all__ = ["router"]

View file

@ -1,13 +0,0 @@
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,14 +8,16 @@ 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:
resolution = await resolve_repository_slug(session, repo_slug)
return resolution.repo
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
@router.get("/", response_model=list[RepoGoalRead])

View file

@ -11,9 +11,8 @@ 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 joinedload, noload
from sqlalchemy.orm import noload
from api.config import settings
from api.database import get_session
@ -29,7 +28,6 @@ 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
@ -50,18 +48,6 @@ 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"])
@ -122,25 +108,22 @@ async def list_repos(
capability_tag: str | None = None,
business_stake: str | None = None,
session: AsyncSession = Depends(get_session),
) -> list[RepoRead]:
) -> list[ManagedRepo]:
"""List repos with optional domain and classification filters."""
response.headers["Cache-Control"] = "max-age=60, stale-while-revalidate=30"
q = (
select(ManagedRepo)
.options(
noload("*"),
joinedload(ManagedRepo.domain).noload("*"),
)
.options(noload(ManagedRepo.goals))
.order_by(ManagedRepo.name)
)
if domain:
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:
domain_result = await session.execute(select(Domain).where(Domain.slug == domain))
domain_obj = domain_result.scalar_one_or_none()
if domain_obj is None:
raise HTTPException(status_code=404, detail=f"Domain '{domain}' not found")
q = q.where(
or_(
ManagedRepo.domain_id == domain_id,
ManagedRepo.domain_id == domain_obj.id,
ManagedRepo.secondary_domains.contains([domain]),
)
)
@ -151,25 +134,20 @@ async def list_repos(
if business_stake:
q = q.where(ManagedRepo.business_stake.contains([business_stake]))
result = await session.execute(q)
repositories = list(result.scalars().all())
resolutions = await repository_resolutions_for_repositories(session, repositories)
return await _project_repo_reads(session, repositories, resolutions=resolutions)
return list(result.scalars().all())
@router.post("/", response_model=RepoRead, status_code=status.HTTP_201_CREATED)
async def register_repo(
body: RepoCreate,
session: AsyncSession = Depends(get_session),
) -> RepoRead:
) -> ManagedRepo:
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))
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():
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail=f"Repo slug '{body.slug}' already exists")
payload = body.model_dump()
@ -198,19 +176,10 @@ async def register_repo(
standard_version=body.standard_version,
)
session.add(repo)
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.commit()
await session.refresh(repo)
await _publish_repo_registered(repo, body, domain_obj)
return (await _project_repo_reads(session, [repo]))[0]
return repo
@router.post("/onboard", response_model=RepoOnboardResult)
@ -361,7 +330,6 @@ 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}
@ -417,7 +385,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": _projected_last_sbom_at(repo, sbom_projections),
"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 "",
}
fp = compute_fingerprint(
@ -496,9 +464,7 @@ async def get_repo_doi(
Results are cached by fingerprint. Pass ?force_refresh=true to bypass the cache.
"""
resolution = await resolve_repository_slug(session, slug)
repo = resolution.repo
sbom_projections = await _sbom_projection_map()
repo = await _get_repo_by_slug(slug, session)
domain_result = await session.execute(select(Domain).where(Domain.id == repo.domain_id))
domain_obj = domain_result.scalar_one_or_none()
@ -518,7 +484,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": _projected_last_sbom_at(repo, sbom_projections),
"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 "",
}
fp = compute_fingerprint(repo_dict, str(tpsc_row.latest) if tpsc_row.latest else None,
@ -531,7 +497,7 @@ async def get_repo_doi(
if not force_refresh and cached and cached.fingerprint == fp and cached.criteria:
return DoIReport(
repo_slug=repo.slug,
repo_slug=slug,
tier=cached.tier,
core_pass=cached.core_pass,
standard_pass=cached.standard_pass,
@ -570,11 +536,11 @@ async def get_repo_doi(
async def get_repo_by_id(
repo_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> RepoRead:
) -> ManagedRepo:
repo = await session.get(ManagedRepo, repo_id)
if repo is None:
raise HTTPException(status_code=404, detail=f"Repo '{repo_id}' not found")
return (await _project_repo_reads(session, [repo]))[0]
return repo
@router.get("/scope-health", response_model=list[RepoScopeHealth])
@ -629,32 +595,14 @@ async def update_repo_with_classification(
slug: str,
body: RepoUpdate,
session: AsyncSession = Depends(get_session),
) -> RepoRead:
) -> ManagedRepo:
"""Patch repo metadata including classification spine fields."""
resolution = await resolve_repository_slug(session, slug)
repo = resolution.repo
repo = await _get_repo_by_slug(slug, session)
payload = body.model_dump(exclude_unset=True)
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_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 ""
classification_requested = classification_fields_set(payload)
if classification_requested or (
requested_domain_slug is not None and repo.category is not None
):
if classification_fields_set(payload):
merged = {
"category": payload.get("category", repo.category),
"secondary_domains": payload.get("secondary_domains", repo.secondary_domains),
@ -667,67 +615,31 @@ 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 (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]
return repo
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),
) -> RepoRead:
resolution = await resolve_repository_slug(session, slug)
repo = resolution.repo
) -> ManagedRepo:
repo = await _get_repo_by_slug(slug, session)
repo.status = "archived"
await session.commit()
await session.refresh(repo)
return (await _project_repo_reads(session, [repo], requested=resolution))[0]
return repo
@router.get("/{slug}/dispatch", response_model=RepoDispatch)
@ -741,8 +653,7 @@ 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.
"""
resolution = await resolve_repository_slug(session, slug)
repo = resolution.repo
repo = await _get_repo_by_slug(slug, session)
# Active goal
goal_result = await session.execute(
@ -806,9 +717,7 @@ async def get_repo_dispatch(
ic_result = await session.execute(
select(InterfaceChange).where(
InterfaceChange.status == "published",
affected_slug_predicate(
InterfaceChange.affected_repo_slugs, resolution.slug_values
),
InterfaceChange.affected_repo_slugs.contains([slug]),
).order_by(InterfaceChange.published_at.desc())
)
pending_changes = [
@ -837,12 +746,7 @@ async def get_repo_dispatch(
)
return RepoDispatch(
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),
repo_slug=slug,
active_goal=active_goal,
active_workplans=dispatch_workstreams,
human_interventions=all_interventions,
@ -867,8 +771,7 @@ async def sync_repo_consistency(
Returns the raw JSON output from consistency_check.py.
Query param ?fix=false to run check-only without writing.
"""
resolution = await resolve_repository_slug(session, slug)
repo = resolution.repo
repo = await _get_repo_by_slug(slug, session)
hostname = socket.gethostname()
host_paths = repo.host_paths or {}
@ -877,13 +780,13 @@ async def sync_repo_consistency(
raise HTTPException(
status_code=503,
detail=(
f"No accessible path for repo '{repo.slug}' on host '{hostname}'. "
f"Register with: POST /repos/{repo.slug}/paths/"
f"No accessible path for repo '{slug}' on host '{hostname}'. "
f"Register with: POST /repos/{slug}/paths/"
),
)
script = Path(__file__).parent.parent.parent / "scripts" / "consistency_check.py"
cmd = [sys.executable, str(script), "--repo", repo.slug, "--json",
cmd = [sys.executable, str(script), "--repo", slug, "--json",
"--api-base", settings.api_base]
if fix:
cmd.append("--fix")
@ -902,8 +805,11 @@ async def sync_repo_consistency(
async def _get_repo_by_slug(slug: str, session: AsyncSession) -> ManagedRepo:
resolution = await resolve_repository_slug(session, slug)
return resolution.repo
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
def _repo_doi_dict(repo: ManagedRepo, domain_slug: str | None) -> dict:
@ -916,77 +822,3 @@ 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

@ -1,359 +0,0 @@
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

@ -1,190 +0,0 @@
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,8 +1,7 @@
import uuid
import logging
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
@ -19,18 +18,8 @@ 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"}
@ -65,37 +54,10 @@ 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."""
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
repo = await _get_repo_by_slug(body.repo_slug, session)
now = datetime.now(tz=timezone.utc)
snap = SBOMSnapshot(
@ -128,9 +90,8 @@ async def ingest_sbom(
repo.sbom_source = "manual"
await session.commit()
await _meter_compat(session, request, "POST", "/sbom/ingest/")
return {
"repo_slug": resolution.canonical_slug,
"repo_slug": body.repo_slug,
"snapshot_id": str(snap.id),
"ingested": len(body.entries),
"snapshot_at": now.isoformat(),
@ -139,23 +100,10 @@ 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)
@ -167,21 +115,9 @@ 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")
@ -204,7 +140,6 @@ 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),
@ -213,30 +148,6 @@ 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 = (
@ -266,15 +177,9 @@ 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)
@ -307,20 +212,10 @@ 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."""
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)
repo = await _get_repo_by_slug(repo_slug, session)
latest_snap_id_sq = (
select(SBOMSnapshot.id)
.where(SBOMSnapshot.repo_id == repo.id)
@ -335,7 +230,7 @@ async def get_repo_sbom(
)
entries = list(rows.scalars().all())
return SBOMRepoView(
repo_slug=resolution.canonical_slug,
repo_slug=repo_slug,
last_sbom_at=repo.last_sbom_at,
entry_count=len(entries),
entries=[SBOMEntryRead.model_validate(e) for e in entries],
@ -343,86 +238,8 @@ async def get_repo_sbom(
async def _get_repo_by_slug(slug: str, session: AsyncSession) -> ManagedRepo:
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)
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

View file

@ -13,6 +13,7 @@ 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,
@ -21,7 +22,6 @@ 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,7 +42,6 @@ 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)
@ -54,11 +53,6 @@ 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())
@ -137,10 +131,12 @@ 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"):
resolution = await resolve_repository_slug(
session, body.first_party.repo_slug
)
data["repo_id"] = resolution.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
await _upsert_ext(ServiceFirstParty, svc.id, data, session)

View file

@ -1,15 +1,13 @@
import time
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi import APIRouter, Depends, 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
@ -55,9 +53,6 @@ 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(
@ -75,12 +70,6 @@ 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,
@ -106,44 +95,12 @@ 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()
@ -154,38 +111,22 @@ 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 _apply_summary_flavor_view(
cached.model_copy(update={"ops_runs": await get_ops_run_projection()}),
include_residuals=include_residuals,
flavor=flavor,
)
return cached
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 _apply_summary_flavor_view(
result.model_copy(update={"ops_runs": await get_ops_run_projection()}),
include_residuals=include_residuals,
flavor=flavor,
)
return result
if cache_status == "stale" and cached is not None:
cache.schedule_refresh(revision)
_summary_cache_headers(response, cache_status="stale", revision=revision_token)
return _apply_summary_flavor_view(
cached.model_copy(update={"ops_runs": await get_ops_run_projection()}),
include_residuals=include_residuals,
flavor=flavor,
)
return cached
result = await build_state_summary(session)
cache.store(result, revision)
_summary_cache_headers(response, cache_status="miss", revision=revision_token)
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,
)
return result
async def build_state_summary(session: AsyncSession) -> StateSummary:
@ -390,7 +331,6 @@ 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),
@ -423,7 +363,23 @@ async def build_state_summary(session: AsyncSession) -> StateSummary:
)}
contribution_counts = {**contrib_type_counts, **contrib_status_counts}
licence_risk_count, _, _ = await _sbom_metrics(session)
# 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)
)
# Open capability requests (non-terminal statuses)
open_cap_req_count = (await session.execute(
@ -452,15 +408,6 @@ 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),
@ -482,8 +429,7 @@ 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=relevant_rows,
residual_open_workplans=residual_rows,
open_workplans=open_workplan_rows,
)
return result
@ -586,7 +532,6 @@ 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:
@ -646,7 +591,6 @@ 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),
@ -701,7 +645,23 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
)}
contribution_counts = {**contrib_type_counts, **contrib_status_counts}
licence_risk_count, snapshot_count, package_total = await _sbom_metrics(session)
_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()
open_cap_req_count = (await session.execute(
select(func.count()).select_from(CapabilityRequest).where(
@ -735,7 +695,6 @@ 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"),
@ -782,39 +741,6 @@ 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(
@ -863,10 +789,7 @@ 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),
include_residuals: bool = Query(False),
) -> list[WorkstreamWithDeps]:
async def get_deps(session: AsyncSession = Depends(get_session)) -> 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
@ -880,8 +803,6 @@ async def get_deps(
.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 = []
@ -1017,10 +938,6 @@ 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",
@ -1065,7 +982,6 @@ 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 = {
@ -1074,9 +990,8 @@ 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, flavor in ws_rows
for ws_id, status, title, slug, topic_id in ws_rows
}
ready_from_ws_ids = [
@ -1102,16 +1017,12 @@ 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]:
@ -1193,16 +1104,7 @@ 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",
# 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),
}
return {"status": "ok", "db": "connected"}
except Exception as exc:
return JSONResponse(
status_code=503,

View file

@ -1,45 +1,259 @@
"""Retired: the suggestion backlog.
import uuid
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 fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
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
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
router = APIRouter(prefix="/suggestions", tags=["suggestions"])
_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."
)
_ALLOWED_VET_FROM = {SuggestionStage.suggestion}
_ALLOWED_DECLINE_FROM = {SuggestionStage.suggestion, SuggestionStage.requirement}
_ALLOWED_PROMOTE_FROM = {SuggestionStage.requirement}
def _retired() -> HTTPException:
return HTTPException(status_code=410, detail=_DETAIL)
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
@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()
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())

View file

@ -169,8 +169,6 @@ 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)
@ -184,32 +182,6 @@ 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,
@ -291,33 +263,6 @@ 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,7 +13,6 @@ 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,
@ -289,7 +288,6 @@ 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,
@ -306,12 +304,6 @@ 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(
@ -349,8 +341,6 @@ 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

@ -1,151 +0,0 @@
"""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 or_, select
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
@ -24,11 +24,6 @@ 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,
@ -93,8 +88,6 @@ 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)
@ -113,19 +106,6 @@ 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(),
@ -385,8 +365,6 @@ 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(
@ -396,8 +374,6 @@ async def list_workplans(
status_filter=status,
owner=owner,
slug=slug,
flavor=flavor,
include_residuals=include_residuals,
session=session,
)
@ -435,11 +411,8 @@ 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 = workplans.get(entry.workplan_id)
wp = await session.get(Workplan, entry.workplan_id)
if wp is None:
continue
wp.backing_filename = entry.filename
@ -551,4 +524,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,7 +9,6 @@ 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,
@ -66,4 +65,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,5 +104,3 @@ 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,10 +27,7 @@ class FabricGraphSource(BaseModel):
repo: str | None = None
producer: str | None = None
registry: 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)
commit: str | None = None
path: str | None = None
generation_reason: str | None = None
@ -101,18 +98,6 @@ 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")
@ -169,7 +154,6 @@ 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
@ -196,7 +180,6 @@ 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)
@ -261,17 +244,6 @@ 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

@ -1,39 +0,0 @@
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

View file

@ -1,89 +0,0 @@
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,7 +80,6 @@ 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
@ -114,11 +113,6 @@ 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):
@ -163,11 +157,6 @@ 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]
@ -187,4 +176,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]

View file

@ -1,27 +0,0 @@
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

@ -1,114 +0,0 @@
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

@ -1,96 +0,0 @@
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,7 +11,6 @@ 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):
@ -30,7 +29,6 @@ class WorkstreamTotals(BaseModel):
finished: int = 0
archived: int = 0
total: int = 0
residual_open: int = 0
class TaskTotals(BaseModel):
@ -86,21 +84,18 @@ 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,7 +7,6 @@ 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):
@ -19,23 +18,7 @@ class TaskStatusMixin(BaseModel):
return normalize_task_status(value)
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
class TaskCreate(TaskStatusMixin, WorkplanIdCreateMixin):
title: str
description: str | None = None
status: TaskStatus = TaskStatus.todo
@ -54,9 +37,8 @@ class TaskCreate(TaskStatusMixin, TaskFlavorMixin, WorkplanIdCreateMixin):
return self
class TaskUpdate(TaskStatusMixin, TaskFlavorMixin):
class TaskUpdate(TaskStatusMixin):
title: str | None = None
flavor: str | None = None
description: str | None = None
status: TaskStatus | None = None
priority: TaskPriority | None = None
@ -117,8 +99,6 @@ 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

@ -1,38 +0,0 @@
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,12 +5,6 @@ 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[
@ -34,43 +28,7 @@ class WorkplanStatusMixin(BaseModel):
return normalize_workplan_status(value)
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
class WorkplanCreate(WorkplanStatusMixin):
repo_id: uuid.UUID
topic_id: uuid.UUID | None = None
slug: str
@ -81,9 +39,6 @@ class WorkplanCreate(WorkplanStatusMixin, WorkplanFlavorMixin):
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"
@ -93,7 +48,7 @@ class WorkplanCreate(WorkplanStatusMixin, WorkplanFlavorMixin):
repo_goal_id: uuid.UUID | None = None
class WorkplanUpdate(WorkplanStatusMixin, WorkplanFlavorMixin):
class WorkplanUpdate(WorkplanStatusMixin):
title: str | None = None
description: str | None = None
status: WorkplanStatus | None = None
@ -101,9 +56,6 @@ class WorkplanUpdate(WorkplanStatusMixin, WorkplanFlavorMixin):
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
@ -142,9 +94,6 @@ 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"
@ -172,4 +121,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": "Legacy state only; it does not prove that any consumer accepted pickup.",
"launching": "A launch request asks for immediate pickup or has been handed off.",
"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": "Legacy intent only; workplan launch-request pickup is retired.",
"immediate": "Request prompt activity-core or agent pickup.",
}
CONCURRENCY_MODES = {
@ -30,26 +30,16 @@ CONCURRENCY_MODES = {
STATE_HUB_RESPONSIBILITIES = [
"store lifecycle status separately from execution intent",
"rank candidate workplans and expose dependency-aware eligibility",
"preserve historical launch-request and execution-intent rows during retirement",
"reject new workplan launch requests because no pickup consumer exists",
"record launch requests and handoff metadata durably",
"surface manual, queued, scheduled, and immediate intent to operators",
]
ACTIVITY_CORE_RESPONSIBILITIES = [
"own schedules, wakeups, and recurring automation",
"claim and complete ops runs created by ActivityDefinition fires",
"do not consume State Hub workplan launch requests",
"dispatch coding agents and coordinate parallel execution",
"acknowledge, run, and complete launch requests when available",
]
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,55 +221,6 @@ 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)
@ -373,10 +324,7 @@ async def _record_invalid_import(
import_run = FabricGraphImport(
source_repo_slug=source_repo_slug,
source_url=source_url,
# 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_commit=_source_value(payload, "commit"),
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,
@ -672,13 +620,6 @@ 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

@ -1,122 +0,0 @@
"""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

@ -1,215 +0,0 @@
"""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,13 +305,7 @@ 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,
all_time_calls=all_time.calls,
last_seen_at=last_seen,
window_end=window_end,
)
retirement_candidate, reason = _retirement_state(interface, window.calls)
return LegacyInterfaceSummary(
interface=LegacyInterfaceRead.model_validate(interface),
all_time=all_time,
@ -346,34 +340,7 @@ def _bucket_counts(buckets: list[LegacyInterfaceUsageBucket], bucket_kind: str)
return counts
# 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]:
def _retirement_state(interface: LegacyInterface, window_calls: int) -> tuple[bool, str]:
if interface.status == "retired":
return False, "already retired"
if interface.manual_hold:
@ -384,19 +351,6 @@ def _retirement_state(
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

@ -1,130 +0,0 @@
"""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

@ -1,544 +0,0 @@
"""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

@ -1,229 +0,0 @@
"""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

@ -1,23 +0,0 @@
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

@ -1,336 +0,0 @@
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

@ -1,70 +0,0 @@
"""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

@ -1,63 +0,0 @@
"""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,8 +27,6 @@ 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__)
@ -90,17 +88,9 @@ async def fetch_summary_revision(session: AsyncSession) -> SummaryRevision:
if value is not None:
core_parts.append(value)
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()
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)))
@ -295,4 +285,4 @@ async def apply_progress_section(
cache._entry.progress_revision = revision.progress_fingerprint()
else:
cache.store(merged, revision)
return merged
return merged

View file

@ -1,214 +0,0 @@
"""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

@ -1,799 +0,0 @@
"""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,16 +41,15 @@ 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"),
)

View file

@ -1,48 +0,0 @@
"""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,11 +18,9 @@ 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
@ -404,8 +402,6 @@ 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:
@ -426,53 +422,6 @@ 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")
@ -559,22 +508,12 @@ 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"]
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")
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")
blocking = summary.get("blocking_decisions", [])
if blocking:
print(f"\nBlocking decisions ({len(blocking)}):")
@ -583,73 +522,6 @@ 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
@ -729,8 +601,6 @@ def main() -> None:
)
sub = parser.add_subparsers(dest="command", required=True)
configure_repo_commands(sub)
# register
statehub_reg = sub.add_parser(
"register",
@ -738,11 +608,6 @@ 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")
@ -808,11 +673,6 @@ 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")
@ -824,32 +684,6 @@ 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")
@ -902,26 +736,6 @@ 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)
@ -953,8 +767,6 @@ 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,9 +10,7 @@ const _configDir = dirname(fileURLToPath(import.meta.url));
const _modalScript = readFileSync(
join(_configDir, "src/components/improvement-modal.js"), "utf-8"
)
// Strip ES module exports so the file can run as a plain <script>.
.replace(/^export function /gm, "function ")
.replace(/^export const /gm, "const ")
.replace(/^export function /gm, "function ") // strip ES module export
+ "\ninitImprovementModal();\n"; // auto-initialise
export default {
@ -56,8 +54,6 @@ 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" },
],
},
@ -85,6 +81,7 @@ 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" },
],
},
@ -105,7 +102,6 @@ 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" },
@ -119,16 +115,15 @@ 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,59 +12,10 @@
* Submissions are stored as technical-debt items with debt_type="dashboard-improvement".
*
* Interaction:
* - Hold Shift 1s cursor changes to copy/highlight mode (shift-wait-click)
* - Hold Shift cursor changes to crosshair across the entire page
* - 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() {
@ -288,13 +239,10 @@ 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 (resolved like config.js if omitted)
* @param {string} [opts.domain] Domain slug for the TD record (default: "infotech")
* @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")
*/
export function initImprovementModal({
apiBase = resolveImprovementApiBase(),
domain = DEFAULT_IMPROVEMENT_DOMAIN,
} = {}) {
export function initImprovementModal({ apiBase = "http://127.0.0.1:8000", domain = "custodian" } = {}) {
if (_initialized) return;
_initialized = true;
_ensureStyles();
@ -443,10 +391,7 @@ export function initImprovementModal({
try {
const r = await fetch(`${apiBase}/technical-debt/`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-StateHub-Component": "state-hub.dashboard.improvement-modal",
},
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (r.ok) {
@ -455,13 +400,7 @@ export function initImprovementModal({
} else {
submitBtn.disabled = false;
submitBtn.textContent = "Submit suggestion";
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})`);
_toast(`⚠ Submission failed (HTTP ${r.status})`);
}
} catch {
submitBtn.disabled = false;

View file

@ -1,22 +0,0 @@
/**
* 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,13 +330,7 @@ if (filtered.length === 0) {
} else {
display(html`<div class="dec-list">${filtered.map(d => {
const border = STATUS_BORDER[d.status] ?? "#ccc";
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 snippet = (d.description || d.rationale || "").slice(0, 200);
const due = fmtDate(d.deadline);
const decided = fmtDate(d.decided_at);
const overdue = isOverdue(d.deadline);
@ -381,18 +375,7 @@ if (filtered.length === 0) {
${_isOpen ? html`<button class="dec-resolve-btn" onclick=${onResolve}>Resolve</button>` : ""}
</div>
<div class="dec-title">${d.title}</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>`) : ""}
${snippet ? html`<div class="dec-snippet">${snippet}${snippet.length < (d.description || d.rationale || "").length ? "…" : ""}</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>`;
@ -475,18 +458,6 @@ 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,9 +227,8 @@ and age in days.
---
*Capability requests are a sanctioned write use case of the State Hub alongside
`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).*
`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.*

View file

@ -4,16 +4,10 @@ title: Contributions — Reference
# Contributions — Reference
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).
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.
---
@ -21,26 +15,22 @@ See [Work Records](/docs/work-records).
| Type | Full name | Use when |
|------|-----------|----------|
| `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 |
| `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 |
---
## Lifecycle (workstations)
## Flow 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
```
| Status | Meaning |
| Workstation | Meaning |
|--------|---------|
| **draft** | Artifact written locally; not yet sent upstream |
| **submitted** | Filed as a GitHub issue, PR, or email — awaiting upstream response |
@ -50,42 +40,36 @@ draft → submitted → acknowledged → accepted → merged
| **rejected** | Upstream declined; record kept for future reference |
| **withdrawn** | We decided not to pursue it |
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).
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`.
---
## Relation to the Todo classification
Contributions map to the **Third-party** class in the inter-repo communication
taxonomy:
Contributions map directly to the **Third-party** class in the inter-repo
communication taxonomy:
| Todo class | Mechanism |
|------------|-----------|
| Internal | Workplan file + task in this repo |
| Internal | Workplan file + task in this repo's workplan |
| Ecosystem | State hub task with `[repo:<slug>]` prefix |
| **Third-party** | **Contribution artifact in `contrib/` + hub registration** |
| **Third-party** | **Contribution artifact in `contrib/` + state hub registration** |
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.
Contributions in `draft`, `submitted`, or `acknowledged` workstations appear as
open Third-party todos on the [Todo](/todo) page.
---
## 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
@ -93,8 +77,8 @@ contrib/
upstream-prs/ upr-YYYY-MM-DD--<org>--<repo>--<slug>.md
```
Templates: `~/the-custodian/canon/standards/contrib-templates/`.
Convention: `~/the-custodian/canon/standards/contribution-convention_v0.1.md`.
Templates live in `~/the-custodian/canon/standards/contrib-templates/`.
Convention details: `~/the-custodian/canon/standards/contribution-convention_v0.1.md`.
---
@ -102,52 +86,38 @@ Convention: `~/the-custodian/canon/standards/contribution-convention_v0.1.md`.
**1. Write the artifact file** using the appropriate template.
**2. Register it in the state hub** (MCP):
**2. Register it in the state hub** via 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_workplan_id = "<uuid>" # preferred; related_workstream_id still accepted as alias
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>"
)
```
REST: `POST /contributions/` with the same fields.
**3. Close the loop** when you file it upstream:
**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")
```
```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).
**4. Keep updating** as upstream responds — `acknowledged`, `accepted`, `merged`.
---
## 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 retained after rejection or withdrawal — institutional
memory for why certain approaches were tried and dropped.*
*Contributions are append-only. Rejected or withdrawn artifacts are retained as
institutional memory — they explain 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>"}`. The legacy `create_workstream` tool is removed and `/workstreams/{id}` returns 410; use `create_workplan` and `/workplans/{id}`.
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.
---

View file

@ -1,185 +0,0 @@
---
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,13 +24,6 @@ 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: EXAMPLE-WP-0001
id: WP-0001
title: "Build a thing"
status: ready
---
@ -100,7 +100,7 @@ Optional description.
## Task: Do the first thing
```task
id: EXAMPLE-WP-0001-T01
id: T-01
status: todo
priority: high
```
@ -108,7 +108,7 @@ priority: high
## Task: Do the second thing
```task
id: EXAMPLE-WP-0001-T02
id: T-02
status: todo
priority: medium
```

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