diff --git a/.claude/rules/session-protocol.md b/.claude/rules/session-protocol.md index 5bcebbc..d7f51bb 100644 --- a/.claude/rules/session-protocol.md +++ b/.claude/rules/session-protocol.md @@ -42,6 +42,14 @@ ls workplans/ For each file with `status: ready`, `active`, or `blocked`, note pending `wait`/`todo`/`progress` tasks. +Optional quality debt (STATE-WP-0077): +```bash +statehub quality-debt --repo-path . +``` +Prefer **DoR-Ok** before heavy implementation on ready plans; **DoC-Ok** before +confident intake promote; record with `quality_dor` / `quality_doc` / +`quality_dod` fields — see `docs/work-record-quality-gates.md`. + **Step 4 — Present brief** 1. **Active workplans** for `infotech` — title, task counts, blocking decisions diff --git a/.custodian-brief.md b/.custodian-brief.md index 7c62258..853aa59 100644 --- a/.custodian-brief.md +++ b/.custodian-brief.md @@ -2,25 +2,25 @@ # Custodian Brief — state-hub **Domain:** infotech -**Last synced:** 2026-07-10 13:22 UTC +**Last synced:** 2026-09-14 08:15 UTC **State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)* ## Active Workstreams -### Workplan terminology phase-2 legacy retirement -Progress: 2/5 done | workplan_id: `9aa92529-6ee8-4b3f-b573-78b0ef8d9788` +### State Hub retirement strangler and disposition execution +Progress: 4/9 done | workplan_id: `ed077b62-7048-5752-bf65-f90471f45854` **Open tasks:** -- ► Task: MCP alias removal (Phase 2) `e2b9015a` -- ► Task: Internal dual-key and param alias cleanup `18af98bf` -- · Task: Closeout and fleet gate `dcc81665` +- ! Execute move/replace slices `3a454438` +- ! Retire legacy surfaces `f6cf8c01` +- ! Stabilization window and archive prep `9230c99b` +- ! Attribute and finish migrating remaining legacy readers `2ac16837` +- ! Gate the production inbox reader switch `da1d9a95` ## Inbox Hygiene -**Stale unread:** 4 message(s) older than 3 day(s) — triage at session start. -**Missing thread_id:** 4 unread message(s) lack supersession chains. -- ! activity-core: Optional pattern suggestion: standardized deployment evidence handoff `10d37752` -- ! inter-hub: Suggestion: document cross-repo coordination in State Hub suggestions handling `6e8510b1` +**Stale unread:** 2 message(s) older than 3 day(s) — triage at session start. +**Missing thread_id:** 1 unread message(s) lack supersession chains. --- ## MCP Orientation (when available) diff --git a/.env.example b/.env.example index 3c94cbf..7d011c3 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,13 @@ PGADMIN_PASSWORD=admin # API API_BASE=http://127.0.0.1:8000 +# Read-only activity-core ops_run projection (STATE-WP-0078). +ACTIVITY_CORE_URL=http://127.0.0.1:8010 +# Inject from OpenBao/ESO in production; never commit the value. +ACTIVITY_CORE_WORKER_TOKEN= +OPS_RUN_PROJECTION_TTL_SECONDS=15 +OPS_RUN_SLA_HOURS=1 + # Gitea (for gitea_inventory.py) GITEA_URL=http://92.205.130.254:32166 GITEA_TOKEN= diff --git a/.forgejo/workflows/image.yaml b/.forgejo/workflows/image.yaml index 3dc9af2..d99cd16 100644 --- a/.forgejo/workflows/image.yaml +++ b/.forgejo/workflows/image.yaml @@ -9,11 +9,24 @@ on: push: branches: - main + # Must list every path the Dockerfile COPYs, or a change ships to git + # without ever reaching the image. This previously covered only api/**, + # so edits to mcp_server/ and — more seriously — migrations/ produced no + # rebuild (CUST-WP-0067-T08). paths: - ".forgejo/workflows/image.yaml" - "Dockerfile" + - "alembic.ini" - "api/**" + - "flows/**" + - "mcp_server/**" + - "migrations/**" + - "policies/**" + - "prompts/**" - "pyproject.toml" + - "scripts/**" + - "task_flow_engine/**" + - "templates/**" - "uv.lock" workflow_dispatch: diff --git a/.gitignore b/.gitignore index 4b8a2da..4c0f900 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ __pycache__/ .pytest_cache/ .mypy_cache/ .ruff_cache/ +/docs/architecture/.ast_cache/ .coverage .coverage.* htmlcov/ diff --git a/AGENTS.md b/AGENTS.md index 1daf1e5..2bbd630 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,13 +13,15 @@ ## State Hub Integration -The Custodian State Hub tracks work across all domains. Interact via HTTP REST — -there is no MCP server for Codex agents. +The Custodian State Hub tracks work across all domains. Codex uses HTTP REST and +the `statehub` CLI by default. MCP is opt-in because the current Codex MCP bridge +adds severe call latency; the full administrative MCP surface remains available +to clients that need it. | Context | URL | |---------|-----| | Local workstation | `http://127.0.0.1:8000` | -| Remote via tunnel | `http://127.0.0.1:18000` | +| Remote (railiance01, in-cluster) | `http://10.43.68.154:8000` | | Optional local edge relay | http://127.0.0.1:18080 | When an operator has enabled the edge relay, set API_BASE to the relay URL. @@ -27,21 +29,46 @@ Queueable writes return an explicit queued receipt if the central hub is unreachable. Treat that as pending local evidence, then ask the operator to run statehub outbox status/replay after connectivity returns. +Codex workspace-write sandboxes need network access enabled to reach the host's +loopback listener. Bootstrap this once with `make -C ~/state-hub configure-codex` +and restart Codex. The canonical REST health endpoint is `/state/health`, not +`/health`. If a sandboxed loopback probe fails, retry it with escalated execution +before declaring State Hub unavailable; a managed Codex permission profile may +still enforce isolated networking. Experimental MCP can be enabled explicitly +with `make -C ~/state-hub configure-codex WITH_MCP=1`. + ### Orient at session start ```bash # Offline brief — works without hub connection cat .custodian-brief.md -# Active workplans for this domain +# Active workplans for this domain (default summary omits flavor: residual) curl -s "http://127.0.0.1:8000/workplans/?topic_id=cee7bedf-2b48-46ef-8601-006474f2ad7a&status=active" \ | python3 -m json.tool +# Residuals are not implementation demand unless promoted: +# GET /state/summary (open_workplans omits flavor=residual) +# GET /workplans/?flavor=residual # Check inbox curl -s "http://127.0.0.1:8000/messages/?to_agent=state-hub&unread_only=true" \ | python3 -m json.tool + +# Optional: DoX quality debt (ready without DoR-Ok, finished without DoD-Ok, …) +statehub quality-debt --repo-path . ``` +### Definition quality (DoC / DoR / DoD) + +Lifecycle `status` is independent of quality badges. Prefer: + +- **DoC-Ok** on intakes before confident promote (`quality_doc` or note) +- **DoR-Ok** on workplans/tasks before heavy implementation (`quality_dor`) +- **DoD-Ok** when claiming quality-complete finish (`quality_dod`) + +Recording form and examples: `docs/work-record-quality-gates.md`. +Policies: `policies/intake-doc.md`, `work-item-dor.md`, `workstream-dod.md`. + Mark a message read: ```bash curl -s -X PATCH "http://127.0.0.1:8000/messages//read" \ @@ -97,13 +124,19 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/" \ **Close:** 1. Update workplan file task statuses to reflect progress -2. Log: `POST /progress/` with a summary of what changed -3. After workplan file changes, run: +2. If finishing a workplan: hand off **residuals** as live work records first + (intake with `origin: residual` + `origin_ref: `, or a next workplan / + decision / engagement). Do not park leftovers only in prose or `SCOPE.md`. + Canon: `the-custodian/canon/standards/work-record-types_v0.1.md` § Residuals. +3. Log: `POST /progress/` with a summary of what changed (name handoff ids) +4. After workplan file changes, run the fast authoritative projection path: ```bash - statehub fix-consistency + uv run --project ~/repo-manager rmgr sync --path . --push ``` - Coding agents should run this directly; ask the operator only if the CLI or - State Hub API is unavailable. This syncs task status from files into the hub DB. + This assigns only missing deterministic identifiers, verifies the pushed + Forgejo commit, verifies `primary/railliance01`, and reconciles the repository + in one request. A queued result is pending evidence; retry after connectivity + returns. Run `statehub fix-consistency` separately when a deep audit is needed. --- @@ -176,9 +209,12 @@ read/cache/index layer that rebuilds from files. the completion/archive date; the frontmatter `id` does not change. **Ad Hoc Tasks:** small opportunistic fixes discovered during a session use -`workplans/ADHOC-YYYY-MM-DD.md` with task ids `ADHOC-YYYY-MM-DD-T01`, etc. Use -this only for low-risk work completed directly; create a normal workplan for -anything needing analysis, design, approval, dependencies, or multiple phases. +`workplans/ADHOC-YYYY-MM-DD.md`, workplan id +`STATE-WP-ADHOC-YYYY-MM-DD`, and task ids +`STATE-WP-ADHOC-YYYY-MM-DD-T01`, etc. Unqualified historic `ADHOC-*` ids are +grandfathered and must not be copied into new records. Use this only for +low-risk work completed directly; create a normal workplan for anything needing +analysis, design, approval, dependencies, or multiple phases. **Frontmatter:** @@ -194,7 +230,7 @@ owner: codex topic_slug: ... created: "YYYY-MM-DD" updated: "YYYY-MM-DD" -state_hub_workstream_id: "" # written by fix-consistency — do not edit +state_hub_workstream_id: "" # deterministic UUIDv5; managed by Repo Manager --- ``` @@ -211,7 +247,7 @@ derived health labels, not frontmatter statuses. id: STATE-WP-NNNN-T01 status: wait | todo | progress | done | cancel priority: high | medium | low -state_hub_task_id: "" # written by fix-consistency — do not edit +state_hub_task_id: "" # deterministic UUIDv5; managed by Repo Manager ` ` ` Task description text. @@ -219,7 +255,19 @@ Task description text. Status progression: `todo` → `progress` → `done`; use `wait` for waiting/blocked work and `cancel` for stopped work. +**Residuals when finishing:** actionable leftovers become live work records +before `status: finished` — usually an intake (`origin: residual`, +`origin_ref: STATE-WP-NNNN`) or a spawned workplan. Residual is a *role*, +not a kind. Fleet list lives on State Hub, not in `SCOPE.md`. + To create a new workplan: 1. Write the file following the format above -2. Run `statehub fix-consistency` locally; ask the operator only if the CLI or - State Hub API is unavailable. +2. Run `uv run --project ~/repo-manager rmgr sync --path . --push`. +3. Use `statehub fix-consistency` only for a separate deep consistency audit. + +## Repository / cluster boundary + +Workstation agents commit and push to Forgejo. Cluster infrastructure reads a +verified Forge commit into ephemeral storage. Neither side reads or writes the +other side's checkout. `local_path` and `host_paths` are host observations, not +central storage configuration. See `docs/adr/ADR-002-forge-repository-boundary.md`. diff --git a/Dockerfile b/Dockerfile index b1cfbd3..5833f0f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,6 +39,9 @@ COPY policies/ ./policies/ COPY prompts/ ./prompts/ COPY scripts/ ./scripts/ COPY task_flow_engine/ ./task_flow_engine/ +# ACTIVITY-WP-0021 / ROS hourly: domain-digest.md must exist at +# templates/recently-on-scope/ for /recently-on-scope/hourly rendering. +COPY templates/ ./templates/ EXPOSE 8000 diff --git a/INTENT.md b/INTENT.md index 18b9cfa..de2fa78 100644 --- a/INTENT.md +++ b/INTENT.md @@ -9,6 +9,26 @@ updated: "2026-05-17" > This file explains why State Hub exists, where its authority begins and ends, > and why it is now separate from the-custodian. +## Retirement status + +State Hub is a legacy service in active retirement planning. It remains +operational during the migration, but it is not the target home for new +cross-domain capabilities. + +The replacement program is coordinated in +`/home/worsch/prj-state-hub-retirement`, whose `GOAL.md` defines the migration +and retirement gates. Repository representation, file-backed record indexing, +consistency reconciliation, and governed repository changes are moving toward +`/home/worsch/repo-manager`. Cross-domain identity, communication, interaction, +state projections, telemetry, and domain-extension composition are moving +toward the consolidated `hub-core` framework. + +Until the retirement gates are met, State Hub remains authoritative only where +the current contracts and repository-backed workflows require it. Changes made +during this period should preserve compatibility, enable extraction, reduce +scope, or address operational risk; they should not establish new permanent +architectural ownership here. + --- ## Why it exists @@ -126,4 +146,3 @@ visible rather than fail opaquely when one component is unavailable. **Narrow authority.** State Hub coordinates and exposes state; it does not make irreversible human decisions or become the owner of every adjacent system. - diff --git a/LICENSE b/LICENSE index a4e9dc9..7e08e28 100644 --- a/LICENSE +++ b/LICENSE @@ -1,16 +1,151 @@ -MIT No Attribution +# Target Revenue Source License -Copyright +**Version 1.0, Candidate 1 (V1C1)** -Permission is hereby granted, free of charge, to any person obtaining a copy of this -software and associated documentation files (the "Software"), to deal in the Software -without restriction, including without limitation the rights to use, copy, modify, -merge, publish, distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so. +--- -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, -INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +> **PRELIMINARY CANDIDATE — SUBJECT TO CHANGE — NOT FINAL** +> +> This repository is governed by the Target Revenue Source License +> (TRSL), Version 1, Candidate 1. This is the framework's first working +> candidate, adopted as the org's preliminary operating license across +> all repos (maintainer decision, 2026-07-29) during the build/alpha +> stage — see `SCOPE.md` §1 and Appendix A of the canonical text (link +> below) for the alpha/beta risk-acceptance decision this reflects and +> what it does and does not mean. Full specialist legal review is +> explicitly deferred until the framework moves out of beta. +> +> **Canonical source, full candidate-status banner, and Appendix A +> (non-normative candidate notes tracking every open item):** the +> `coulomb` org's `target-revenue` repository, +> `specs/TargetRevenueSourceLicense-V1C1.md` — this file is the operative +> legal text (Preamble through Section 11) only; the canonical document +> is authoritative if this copy and that document ever diverge. + +--- + +## Preamble + +This Target Revenue Source License ("**License**") governs the Software identified in the applicable Phase Manifest. It implements the Target Revenue Framework: a defined development Phase accumulates Development Credit and Remission Credit against an immutable Initial Target until the Milestone Release automatically and irrevocably converts to a declared permissive Future License. + +Commercial beneficiaries fund the creation and early availability of a software improvement; once the declared target is satisfied, the governed release becomes permissively open source. + +## 1. Definitions + +Capitalized terms used in this License have the meanings given below. Where a term is also defined in the Phase Manifest or Target Ledger for a specific Phase, the Phase Manifest and Target Ledger govern the *values* (amounts, dates, identifiers) and this License governs the *legal effect* of those values — the two must not be read as conflicting definitions of the same concept. + +**"Commercial Entitlement"** means a right, purchased or otherwise granted under a Commercial Use Agreement, to make Commercial Use of the Software during a Phase. + +**"Commercial Use"** means billing, invoicing, or otherwise charging any customer a fee, subscription, license fee, or other consideration for or in connection with use of the Software, at any time before the Conversion Event for the applicable Phase, regardless of whether the person or organization billed would otherwise qualify for Noncommercial Use. Commercial Use occurs by virtue of such billing alone, whether or not the resulting payment is registered with the Trust Service; in particular, billing a customer for pre-conversion use of the Software without recording the corresponding payment in the applicable Phase's Target Ledger is Commercial Use without a valid Commercial Entitlement — a violation of Section 3, addressed under Section 7 and, where applicable, the Enforcement Network described in the canonical repository's `specs/EnforcementNetworkConcept.md`. + +**"Commercial Use Agreement"** means the separate agreement, referenced by the applicable Phase Manifest, under which a Commercial Entitlement is purchased or granted. This License does not itself set pricing, metering, or payment terms — those are governed by the Commercial Use Agreement. + +**"Conversion Event"** means the moment the Outstanding Target for a Phase reaches zero, as computed from the Phase Manifest and Target Ledger per the Target Ledger Specification. The Conversion Event occurs automatically and is not conditioned on any declaration, attestation, or other act by the Licensor or any Trust Service. + +**"Development Credit"** means the portion of a collected and settled payment explicitly allocated toward satisfying the Initial Target of a specific Phase, as recorded in that Phase's Target Ledger. + +**"Future License"** means the permissive license identified in the applicable Phase Manifest, being either the MIT License or the Apache License, Version 2.0, which applies to the Milestone Release upon the Conversion Event. + +**"Initial Target"** means the immutable monetary target declared for a Phase in its Phase Manifest. + +**"Licensor"** means **Binky Hedgehog GmbH**, the party that publishes the Phase Manifest and holds the rights necessary to grant this License and the Future License for the Milestone Release. + +**"Milestone Release"** means the precisely identified software release designated in the applicable Phase Manifest, identified by an immutable source revision, release artifact, or cryptographic digest. + +**"Noncommercial Use"** means use of the Software for personal purposes, private study, hobby or amateur projects; use by any charitable organization, educational institution, public research organization, or government institution acting in a non-revenue-generating capacity; or other use of a materially similar character. + +**"Outstanding Target"** means, at any time, `max(0, Initial Target − cumulative Development Credit − cumulative Remission Credit)` for a Phase, as computed from that Phase's Target Ledger. + +**"Phase"** means a bounded development undertaking governed by one Initial Target, one Milestone Release, one degeneration policy, and one Future License declaration, as declared in a Phase Manifest. + +**"Phase Manifest"** means the published, immutable declaration identifying a Phase, its Milestone Release, Initial Target, Future License, degeneration policy, and Target Ledger location, as specified in the Phase Manifest Specification. + +**"Remission Credit"** means a transparent, non-revenue reduction of a Phase's Outstanding Target, generated under that Phase's published degeneration policy and recorded in the Target Ledger. + +**"Settled Payment"** means a payment that has cleared through its payment processor and is no longer subject to reversal in the ordinary course (chargeback, dispute, or equivalent), as further specified by the applicable Commercial Use Agreement or monetization extension. + +**"Software"** means the source code, object code, and associated documentation of the Milestone Release identified in the applicable Phase Manifest. + +**"Target Ledger"** means the append-only record of Development Credit, Remission Credit, and correction entries for a Phase, as specified in the Target Ledger Specification. + +**"You"** or **"Licensee"** means the individual or entity exercising rights under this License. + +## 2. Grant of Rights for Noncommercial Use + +Subject to the terms of this License, the Licensor grants You a worldwide, royalty-free, non-exclusive license, during the applicable Phase, to: + +(a) use, reproduce, and study the Software for any Noncommercial Use; + +(b) modify the Software and create derivative works of it for any Noncommercial Use; and + +(c) redistribute the Software and Your modifications, in source or object form, for any Noncommercial Use, provided that You include this License, unmodified, with any such redistribution, and that You do not remove or alter any copyright, patent, trademark, or attribution notices contained in the Software. + +This grant does not extend to Commercial Use. Commercial Use requires a Commercial Entitlement under Section 3. + +## 3. Commercial Use + +You may not make Commercial Use of the Software during the applicable Phase unless You hold a valid, current Commercial Entitlement under a Commercial Use Agreement with the Licensor covering the applicable Phase. A Commercial Entitlement granted under one Phase's Commercial Use Agreement does not extend to a later Phase's Milestone Release unless the Commercial Use Agreement expressly says so. + +This Section 3 states the existence and boundary of the commercial-use restriction. It does not itself set pricing, invoicing, metering, audit rights, or payment terms — those are governed exclusively by the applicable Commercial Use Agreement. + +## 4. Patent License + +Subject to the terms of this License, each contributor to the Software grants You, during the applicable Phase and solely to the extent of rights granted under Sections 2 and 3, a perpetual (subject to the termination below), worldwide, non-exclusive, no-charge, royalty-free patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Software, limited to those patent claims licensable by that contributor that are necessarily infringed by their contribution(s) alone or by combination of their contribution(s) with the Software. + +If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Software or a contribution incorporated within it constitutes direct or contributory patent infringement, then any patent licenses granted to You under this Section 4 for the Software shall terminate as of the date such litigation is filed. + +## 5. Automatic Conversion to the Future License + +**5.1 Automatic effect.** Upon the Conversion Event for a Phase, the rights and restrictions in Sections 3 (Commercial Use) of this License, as they apply to that Phase's Milestone Release, terminate automatically. In their place, the Milestone Release is licensed under the Future License identified in that Phase's Phase Manifest, effective as of the Conversion Event, without any further act, declaration, or attestation required by the Licensor, any Trust Service, or any other party. + +**5.2 Irrevocability.** Once a valid Conversion Event has occurred for a Phase, no subsequent refund, chargeback, accounting correction, dispute, or termination of this License for an unrelated breach shall revoke, suspend, or otherwise impair the Future License grant for that Phase's Milestone Release. Any shortfall or dispute arising after a Conversion Event is a commercial or accounting matter between the relevant parties and does not reinstate a commercial-use restriction over already-converted Software. + +**5.3 Prior freedom preserved.** A later Phase covering subsequent improvements to the Software does not restrict, withdraw, or otherwise affect the rights granted under the Future License for an earlier Phase's already-converted Milestone Release. + +**5.4 Evidence, not cause.** A Trust Service may publish a Conversion Attestation documenting a Conversion Event. Such an attestation is evidence that the Conversion Event occurred; it is not a condition of, and its absence or delay does not postpone, the automatic effect described in Section 5.1. Any person may independently verify whether a Conversion Event has occurred directly from the Phase Manifest and Target Ledger. + +## 6. Successive Phases + +The Licensor may declare a new Phase covering subsequent improvements to the Software following a Milestone Release's Conversion Event. Each Phase is independently governed by its own Phase Manifest, Initial Target, degeneration policy, and Target Ledger. Nothing in a later Phase's Phase Manifest may be construed to reduce or withdraw rights already granted under Section 5 for an earlier Phase's Milestone Release. + +## 7. Term and Termination + +**7.1 Term.** This License applies to the Software for the duration of the applicable Phase, and, for the Milestone Release, indefinitely following that Phase's Conversion Event under the Future License. + +**7.2 Termination for breach.** If You breach Section 3 (Commercial Use) or Section 2(c) (redistribution notice requirement), the Licensor may terminate this License as to You. Before such termination becomes effective, the Licensor shall provide You written notice of the breach; if You cure the breach within thirty (30) days of that notice, this License continues in effect. A second breach of the same provision within twelve (12) months may be terminated immediately without a further cure opportunity. + +**7.3 Effect of termination.** Termination under this Section 7 affects only Your rights under Sections 2 and 3 for the Phase in which the breach occurred. It does not affect any rights already vested under Section 5 (Automatic Conversion) for a Milestone Release whose Conversion Event has already occurred, per Section 5.2. + +**7.4 Public record of breach and resolution.** The Licensor shall cause the Trust Service to publish, as part of the public record for the affected Phase, notice of: (a) any breach notice issued under Section 7.2, stating the general nature of the breach and the date of notice; (b) whether the breach was cured within the applicable cure period, and the date of cure; and (c) any termination determination made under this Section 7, including its effective date and scope. This public record exists to give the ecosystem a transparent, verifiable conformity signal for the Phase, distinct from and in addition to the Development Credit and Remission Credit facts already published under Section 5.4 and the Target Ledger Specification. + +A breach that You dispute, and that has not been finally determined, shall be recorded as **alleged**; it shall be recorded as **determined** only once the cure period has run without cure, or the dispute has been resolved against You under the applicable Commercial Use Agreement's dispute process, if any. The Trust Service shall update the record promptly upon resolution in either direction. Recording an alleged or determined breach under this Section 7.4 is a ministerial act of publishing the Licensor's determination (or a dispute process's outcome); it does not give the Trust Service discretionary authority to decide whether a breach occurred, consistent with Section 5.4's evidence-not-cause principle. + +Whether, and under what conditions, the public record identifies a Commercial Entitlement holder by name is governed exclusively by the applicable Commercial Use Agreement, which the Licensor and that Commercial Entitlement holder negotiate and agree to directly. This License does not itself set a naming default. Where no Commercial Use Agreement addresses the question, or where the affected party has no Commercial Use Agreement at all (for example, a Section 2(c) breach by a Noncommercial Use licensee), the public record states the Phase and breach category only, without naming the party. + +## 8. Disclaimer of Warranty + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. THE LICENSOR DOES NOT WARRANT THAT THE SOFTWARE WILL BE ERROR-FREE OR THAT ANY PHASE WILL REACH ITS CONVERSION EVENT. + +## 9. Limitation of Liability + +IN NO EVENT SHALL THE LICENSOR OR ANY CONTRIBUTOR BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE, EXCEPT TO THE EXTENT SUCH LIMITATION IS PROHIBITED BY APPLICABLE LAW. + +## 10. Trademarks + +This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary attribution. + +## 11. General Provisions + +**11.1 Governing law and venue.** Adopted for alpha/beta 2026-07-29: disputes arising under this License shall be resolved by binding arbitration, seated at a neutral, arbitration-mature venue (Singapore or London are the two candidates concretely supported by current research), rather than by litigation in a national court. The specific arbitral institution and substantive governing law remain a per-deployment blank pending final selection; they are not fixed by this candidate. See the canonical repository's `history/260729-TRSL-Jurisdiction-Synthesis.md` §2. + +**11.2 Severability.** If any provision of this License is held unenforceable, the remaining provisions remain in full force, and the unenforceable provision shall be reformed to the minimum extent necessary to make it enforceable. + +**11.3 No waiver.** Failure to enforce any provision of this License is not a waiver of future enforcement of that or any other provision. + +**11.4 Entire agreement (as to licensing).** This License, together with the applicable Phase Manifest and, where applicable, the Commercial Use Agreement, constitutes the entire agreement between You and the Licensor regarding the Software's licensing terms. Operations, service, and consulting arrangements are governed by separate agreements, if any, and are not part of this License. + +**11.5 Definitions control.** Marketing materials, documentation, or other non-normative communications about the Software must not describe pre-Conversion-Event Software as "Open Source," "free software," or "open core." Pre-conversion Noncommercial Use is **source-available**; pre-conversion Commercial Use requires a **Commercial Entitlement**; only post-conversion Software may be described as Open Source, under the Future License. + +--- + +**No Phase is currently declared for this repository under this License.** Until a Phase Manifest is published and registered with the Trust Service for a Milestone Release in this repository, Sections 2–7 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`. diff --git a/Makefile b/Makefile index 887a37c..52c2458 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install install-cli dashboard-install dashboard-check db db-tools migrate seed api dashboard check test test-python clean register-project register-codex-project register-mcp bootstrap-env dev-hub edge-relay mcp-profile validate-adr add-domain rename-domain add-repo list-repos register-path register-from-classification register-from-classification-all cleanup-stale tunnels-up tunnels-status tunnels-check bridges install-hooks install-hooks-all gitea-inventory token-reconcile railiance-state-hub-render railiance-state-hub-client-dry-run railiance-state-hub-server-dry-run +.PHONY: start install install-cli dashboard-install dashboard-check db db-tools migrate seed api dashboard dashboard-local sync check-primary primary-port-clear check check-local test test-python clean register-project register-codex-project register-mcp configure-codex bootstrap-env dev-hub edge-relay mcp-profile validate-adr add-domain rename-domain add-repo list-repos register-path register-from-classification register-from-classification-all cleanup-stale tunnels-up tunnels-status tunnels-check bridges install-hooks install-hooks-all gitea-inventory token-reconcile railiance-state-hub-render railiance-state-hub-client-dry-run railiance-state-hub-server-dry-run railiance-state-hub-headroom COMPOSE = docker compose -f infra/docker-compose.yml --env-file .env PYTHON ?= python3 @@ -9,19 +9,28 @@ RAILIANCE_STATE_HUB_RELEASE ?= state-hub RAILIANCE_STATE_HUB_NAMESPACE ?= state-hub RAILIANCE_STATE_HUB_CHART ?= deploy/railiance/apps/charts/state-hub RAILIANCE_STATE_HUB_VALUES ?= deploy/railiance/apps/helm/state-hub-values.yaml -RAILIANCE_STATE_HUB_IMAGE_TAG ?= f2e042a +RAILIANCE_STATE_HUB_IMAGE_TAG ?= main-0747b4d RAILIANCE_STATE_HUB_PLATFORM_DIR ?= deploy/railiance/platform RAILIANCE_STATE_HUB_APP_MANIFESTS ?= deploy/railiance/apps/manifests # Codex/WSL non-login shells may not source ~/.profile; keep uv discoverable. UV ?= $(shell command -v uv 2>/dev/null || if [ -x "$$HOME/.local/bin/uv" ]; then printf "%s" "$$HOME/.local/bin/uv"; else printf "%s" "uv"; fi) +RMGR ?= $(shell command -v rmgr 2>/dev/null || if [ -x "$$HOME/repo-manager/.venv/bin/rmgr" ]; then printf "%s" "$$HOME/repo-manager/.venv/bin/rmgr"; else printf "%s" "rmgr"; fi) +STATE_HUB_API_BASE ?= http://127.0.0.1:8000 +SYNC_PATH ?= . +SYNC_PUSH ?= 1 start: - @echo "# run in different terminals" - @echo "make db # docker compose up postgres" - @echo "make api # start backend api" - @echo "make mcp-http # start state-hub mcp service" - @echo "make dashboard # Observable dev server on :3000" - @echo "make bridges # Set up ssh bridges for cross machines access" + @echo "# Normal production UI access (run in order)" + @echo "make bridges # connect local :8000 to the railiance01 primary" + @echo "make dashboard # verify the primary, then serve the UI on :3000" + @echo "make sync # push file-backed records and reconcile the exact forge commit" + @echo "" + @echo "# Optional local MCP adapter" + @echo "make mcp-http # local SSE adapter on :8001; not needed by the UI" + @echo "" + @echo "# Local fallback/development only" + @echo "make api # local Postgres + API; conflicts with the production :8000 tunnel" + @echo "make dashboard-local # dashboard against the deliberate local API" install: $(UV) sync @@ -63,15 +72,80 @@ mcp-http: @fuser -k 8001/tcp 2>/dev/null && echo "Stopped running MCP server" || true MCP_TRANSPORT=sse MCP_PORT=8001 $(UV) run python mcp_server/server.py -dashboard: +## Require the production identity before serving the normal dashboard. This +## prevents an accidental local `make api` from presenting an empty fallback DB +## as the live State Hub. +check-primary: + @health="$$(curl -fsS --max-time 5 http://127.0.0.1:8000/state/health 2>/dev/null)" || { \ + echo "ERROR: State Hub primary is not reachable on 127.0.0.1:8000." >&2; \ + echo "Run 'make bridges' first." >&2; \ + exit 1; \ + }; \ + identity="$$(printf '%s' "$$health" | $(PYTHON) -c 'import json, sys; d=json.load(sys.stdin); print("{}/{}".format(d.get("instance_role", ""), d.get("instance_label", "")))')"; \ + if [ "$$identity" != "primary/railiance01" ]; then \ + echo "ERROR: 127.0.0.1:8000 is '$$identity', not the primary/railiance01 State Hub." >&2; \ + echo "Stop the local API, then run 'make bridges'. Use 'make dashboard-local' only for intentional local development." >&2; \ + exit 1; \ + fi + +## Refuse to start the production tunnel when a non-primary API already owns +## port 8000. With no listener, bridge is free to establish the tunnel. +primary-port-clear: + @if health="$$(curl -fsS --max-time 2 http://127.0.0.1:8000/state/health 2>/dev/null)"; then \ + identity="$$(printf '%s' "$$health" | $(PYTHON) -c 'import json, sys; d=json.load(sys.stdin); print("{}/{}".format(d.get("instance_role", ""), d.get("instance_label", "")))')"; \ + if [ "$$identity" != "primary/railiance01" ]; then \ + echo "ERROR: port 8000 is occupied by '$$identity'." >&2; \ + echo "Stop the local 'make api' process before starting the production tunnel." >&2; \ + exit 1; \ + fi; \ + fi + +dashboard: check-primary @fuser -k 3000/tcp 2>/dev/null && echo "Stopped running dashboard" || true $(MAKE) dashboard-install cd dashboard && npm run dev -check: +## Deliberate local-development dashboard; bypasses the production identity gate. +dashboard-local: + @echo "WARNING: serving the dashboard against the local/fallback API on :8000." + @fuser -k 3000/tcp 2>/dev/null && echo "Stopped running dashboard" || true + $(MAKE) dashboard-install + cd dashboard && npm run dev + +## Fast work-record path. Repo Manager assigns only missing deterministic IDs, +## verifies the primary/railliance01 bridge, and asks central to derive the +## exact pushed Forgejo commit in one transactional request. +sync: + $(RMGR) sync --path "$(SYNC_PATH)" --api-base "$(STATE_HUB_API_BASE)" $(if $(filter 1 true yes,$(SYNC_PUSH)),--push,) + +check: check-primary + @echo "State Hub primary/railiance01 is healthy." + +check-local: curl -sf http://127.0.0.1:8000/state/health | python3 -m json.tool -railiance-state-hub-render: +# CUST-WP-0067-T09. The chart ships a copy of the-custodian canon allowed-values +# because a container has no checkout to read it from. Regenerate after any +# canon vocabulary change; the -check target fails on drift so a release cannot +# validate classification against a stale vocabulary. +sync-classification-allowed: + python3 scripts/sync_classification_allowed.py + +check-classification-allowed: + python3 scripts/sync_classification_allowed.py --check + +# STATE-WP-0091: read-only surge-headroom check. Does not start Helm. +# Live: make railiance-state-hub-headroom +# Fixtures: make railiance-state-hub-headroom HEADROOM_FIXTURE=tests/fixtures/release_headroom/rev63-105m.json +HEADROOM_FIXTURE ?= +railiance-state-hub-headroom: + @if [ -n "$(HEADROOM_FIXTURE)" ]; then \ + $(PYTHON) scripts/release_headroom_preflight.py --fixture "$(HEADROOM_FIXTURE)"; \ + else \ + $(PYTHON) scripts/release_headroom_preflight.py --kubectl "$(KUBECTL)"; \ + fi + +railiance-state-hub-render: check-classification-allowed $(HELM) template $(RAILIANCE_STATE_HUB_RELEASE) $(RAILIANCE_STATE_HUB_CHART) \ --namespace $(RAILIANCE_STATE_HUB_NAMESPACE) \ -f $(RAILIANCE_STATE_HUB_VALUES) \ @@ -126,7 +200,7 @@ benchmark-summary-cache: ## ops-bridge managed tunnels ## Requires ops-bridge: bridge is at /home/worsch/.local/bin/bridge -tunnels-up: +tunnels-up: primary-port-clear bridge up tunnels-status: @@ -139,7 +213,7 @@ tunnels-check: ## Ensure all ops-bridge tunnels are up and healthy. ## Brings up any stopped/stale tunnels, shows final status, exits non-zero if anything is still down. -bridges: +bridges: primary-port-clear @echo "==> Bringing up all tunnels..." bridge up @echo "" @@ -149,9 +223,10 @@ bridges: @echo "==> Checking tunnel health..." bridge check -## Start (or restart) the full backend — db + migrate + uvicorn. -## Stops uvicorn on :8000 if already running, then starts fresh. +## Start (or restart) the LOCAL FALLBACK backend — db + migrate + uvicorn. +## This replaces anything on :8000, including the production State Hub tunnel. api: db + @echo "WARNING: starting the local fallback API; this is not the railiance01 primary." @echo "Waiting for postgres..."; \ for i in 1 2 3 4 5 6 7 8 9 10; do \ nc -z 127.0.0.1 5432 2>/dev/null && break; \ @@ -180,6 +255,14 @@ register-mcp: $(if $(API_BASE),--api-base "$(API_BASE)",) \ $(if $(DRY_RUN),--dry-run,) +## Configure Codex workspace-write networking for local State Hub access. +configure-codex: + scripts/configure-codex.sh \ + $(if $(CODEX_HOME),--codex-home "$(CODEX_HOME)",) \ + $(if $(API_BASE),--api-base "$(API_BASE)",) \ + $(if $(WITH_MCP),--with-mcp,) \ + $(if $(DRY_RUN),--dry-run,) + ## Bootstrap a new operator/collaborator environment. Optional: make bootstrap-env ARGS="--install-missing" bootstrap-env: scripts/bootstrap-env.sh $(ARGS) diff --git a/README.md b/README.md index 8dbe5b6..7193dbd 100644 --- a/README.md +++ b/README.md @@ -64,11 +64,15 @@ then run consistency sync. All services bind to `127.0.0.1` only — nothing exposed to the network. -**Production:** the primary State Hub API runs on coulombcore-k3s. Workstation -port `8000` reaches it through the ops-bridge `state-hub-primary` tunnel. See +**Production:** the primary State Hub API runs in the railiance01-hosted k3s +cluster. Workstation port `8000` reaches it through the ops-bridge +`state-hub-primary` tunnel. See [`docs/cluster-operating-model.md`](docs/cluster-operating-model.md) for access, rollback, backups, and pragmatic limitations. +Repository rename operations use the phased, UUID-preserving workflow in +[`docs/repository-rename-operations.md`](docs/repository-rename-operations.md). + --- ## Setup @@ -79,7 +83,11 @@ rollback, backups, and pragmatic limitations. - Python 3.12+ with `uv` (`pip install uv`) - Node.js 18+ (dashboard only) -### First-time +### First-time local development setup + +The local database and API are retained for development and disaster fallback. +They are not the normal production access path and conflict with the production +tunnel on port `8000`. ```bash cd /home/worsch/state-hub @@ -89,27 +97,51 @@ make install # uv sync make db # docker compose up postgres make migrate # alembic upgrade head make seed # insert 6 canonical topics -make api # db + migrate + uvicorn :8000 (restarts if running) +make api # LOCAL fallback: db + migrate + uvicorn :8000 ``` -### Dashboard +For a dashboard against that deliberate local backend, use +`make dashboard-local`. + +### Production dashboard (normal operation) + +The dashboard remains a workstation process, but its API is the primary State +Hub on railiance01. Start the tunnel first, then start the dashboard: ```bash -make dashboard # installs dashboard deps if needed, then Observable dev server on :3000 -make dashboard-check # installs deps if needed, then runs Observable build +make bridges # connect workstation :8000 to primary/railiance01 +make dashboard # verify the primary identity, then serve http://127.0.0.1:3000 ``` -### Start Everything +`make dashboard` refuses to start if port `8000` is unreachable or identifies +itself as anything other than `primary/railiance01`. This prevents the empty +local fallback database from being mistaken for production. -To start all the infrastructure on separate consoles do: +The MCP adapter is optional and is not required by the dashboard: ```bash -make db # docker compose up postgres -make mcp-http # start state-hub mcp service -make dashboard # Observable dev server on :3000 -make bridges # Set up ssh bridges for cross machines access +make mcp-http # optional local SSE adapter on :8001 ``` +### Fast work-record synchronization + +Workplan files remain authoritative. Repo Manager assigns missing UUIDv5 +identifiers locally, pushes the file commit, and asks the primary State Hub to +derive that exact commit from Forgejo in one transactional request: + +```bash +make bridges +make sync +``` + +The sync refuses an uncommitted or behind branch and will not write to a local +empty database: the API must identify itself as `primary/railliance01`. If the +primary is unavailable, Repo Manager writes an explicit pending receipt under +the checkout's local `.git/repo-manager/` state; rerunning `make sync` safely +replays the current pushed state. + +Use `statehub fix-consistency` only for the broader consistency/quality audit. +It is no longer the normal registration and task-update path. ### CLI @@ -127,17 +159,22 @@ custodian register-project # register cwd as a Custodian project |--------|-------------| | `make install` | `uv sync` — install Python deps + entry points | | `make install-cli` | Symlink `custodian` to `~/.local/bin` | -| `make db` | Start postgres container | +| `make start` | Print the production UI and local fallback startup paths | +| `make bridges` | Connect managed tunnels, including workstation `:8000` to the railiance01 primary | +| `make db` | Start the local fallback/development Postgres container | | `make db-tools` | Start postgres + pgadmin (http://127.0.0.1:5050) | | `make migrate` | `alembic upgrade head` | | `make seed` | Insert 6 canonical topics (legacy bootstrap) | | `make register-from-classification REPO=slug` | Upsert repo from `.repo-classification.yaml` | | `make register-from-classification-all` | Bulk reclassify all repos with classification files | -| `make api` | `db` + wait + `migrate` + `uvicorn` (restarts if running) | +| `make api` | Start the local fallback API; replaces the production tunnel on `:8000` | | `make dashboard-install` | Install dashboard npm deps from `dashboard/package-lock.json` | | `make dashboard-check` | Build the Observable dashboard as a smoke/regression check | -| `make dashboard` | Install deps if needed, then start Observable dev server (restarts if running) | -| `make check` | `curl /state/health` | +| `make dashboard` | Verify `primary/railiance01`, then start the Observable dashboard | +| `make dashboard-local` | Start the dashboard against an intentional local/fallback API | +| `make sync` | Push missing deterministic IDs and reconcile this repo from the exact Forgejo commit | +| `make check` | Require a healthy `primary/railiance01` on workstation `:8000` | +| `make check-local` | Print `/state/health` without enforcing production identity | | `make test` | Python test suite plus `make dashboard-check` | | `make register-project DOMAIN=x PROJECT_PATH=y` | Register a project | | `make clean` | `docker compose down -v` (destroys DB volume) | @@ -219,7 +256,7 @@ use `/state/health`, not `/state/summary`. |--------|-----------| | `/topics` | CRUD (soft-delete: `archived`) | | `/workplans` | Preferred CRUD surface for repo-backed workplans (soft-delete: `archived`) | -| `/workstreams` | Legacy compatibility CRUD surface; usage is recorded by legacy-meter | +| `/workstreams` | Retired REST routes (410); use `/workplans`. Rejected calls remain metered | | `/tasks` | CRUD (soft-delete: `cancel`); `PATCH` updates status | | `/decisions` | CRUD (soft-delete: `superseded`); auto-escalation | | `/progress` | `GET` list + `POST` append — no DELETE | @@ -227,7 +264,8 @@ use `/state/health`, not `/state/summary`. | `/state/summary` | Full snapshot | | `/state/health` | DB connectivity check | -See `docs/workplan-terminology-transition.md` for the workstream-to-workplan +See `docs/work-record-flavor.md` for flavor buckets and residual +default-exclusion (STATE-WP-0092). See `docs/workplan-terminology-transition.md` for the workstream-to-workplan compatibility policy and retirement criteria. --- @@ -285,7 +323,7 @@ Prints API health, totals, and any blocking decisions. ### What `register-project` does -1. Verifies the API is reachable (fails fast with `make api` hint) +1. Verifies the API is reachable 2. Looks up the topic ID for the domain via `/topics/?status=active` 3. Checks that `state-hub` is in `~/.claude.json` 4. Writes `$PROJECT_PATH/CLAUDE.md` from `scripts/project_claude_md.template` diff --git a/SCOPE.md b/SCOPE.md index 51a2cbb..87155e2 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -2,9 +2,10 @@ ## One-Liner -State Hub is the local-first coordination service for repo-anchored workplans, +State Hub is the transitional coordination service for repo-anchored workplans, tasks, decisions, progress events, repo classification and metadata, MCP -tooling, and dashboard telemetry. +tooling, and dashboard telemetry. Its primary runtime is on railiance01; local +repository files remain authoritative for repository-owned work records. ## In Scope @@ -23,13 +24,16 @@ tooling, and dashboard telemetry. | Repo | Role | | --- | --- | -| `hub-core` | Shared library — State Hub mounts router factories and MCP from `hub_core` | -| `state-hub` | This repo — dev coordination host, workplan file authority | -| `core-hub` | Production framework — `/api/v2` at `hub.coulomb.social`; does not replace State Hub dev MCP | +| `hub-core` | Shared library and selected target runtime for capabilities retained after State Hub retirement | +| `state-hub` | This repo — transitional coordination runtime and read projection; repository files retain work-record authority | +| `core-hub` | Production `/api/v2` compatibility runtime on railiance01 during consolidation into `hub-core` | Canon: `/home/worsch/the-custodian/docs/hub-ecosystem-architecture.md` State Hub does **not** implement Inter-Hub `/api/v2` routes; those belong to core-hub. +New permanent cross-domain capability should land in its disposition owner, not +expand State Hub. The retirement program is governed by +`prj-state-hub-retirement`. ## Out Of Scope diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md new file mode 100644 index 0000000..ba57372 --- /dev/null +++ b/WORK-RECORDS.md @@ -0,0 +1,365 @@ +# Work Records — state-hub + +> Generated by `statehub fix-consistency` (CUST-WP-0061-T04, work-record +> stage 3). Do not edit by hand — edit the source file/block listed for +> each record and re-run fix-consistency to refresh this index. Archived +> workplans are omitted; closed decisions/intakes/engagements stay listed +> so recently-resolved work is still visible. [auto] + +| Kind | ID | Status | Lane | Source | +| --- | --- | --- | --- | --- | +| workplan | STATE-WP-ADHOC-2026-06-04 | finished | — | workplans/ADHOC-2026-06-04.md | +| workplan | STATE-WP-ADHOC-2026-07-01 | finished | — | workplans/ADHOC-2026-07-01.md | +| workplan | STATE-WP-ADHOC-2026-08-08 | finished | — | workplans/ADHOC-2026-08-08.md | +| workplan | STATE-WP-ADHOC-2026-08-23 | finished | — | workplans/ADHOC-2026-08-23.md | +| workplan | STATE-WP-ADHOC-2026-08-30 | finished | — | workplans/ADHOC-2026-08-30.md | +| workplan | STATE-WP-ADHOC-2026-09-04 | finished | — | workplans/ADHOC-2026-09-04.md | +| workplan | CUST-WP-0003 | finished | — | workplans/CUST-WP-0003-whi-kpi-card.md | +| workplan | CUST-WP-0012 | finished | — | workplans/CUST-WP-0012-multi-user-onboarding.md | +| workplan | CUST-WP-0038 | backlog | — | workplans/CUST-WP-0038-state-hub-threephoenix-ha.md | +| workplan | CUST-WP-0042 | finished | — | workplans/CUST-WP-0042-workplan-state-model-cleanup.md | +| workplan | STATE-WP-0043 | finished | — | workplans/STATE-WP-0043-dashboard-ui-experience.md | +| workplan | STATE-WP-0044 | finished | — | workplans/STATE-WP-0044-recently-on-scope-digest.md | +| workplan | STATE-WP-0045 | finished | — | workplans/STATE-WP-0045-token-measurement-accuracy.md | +| workplan | STATE-WP-0046 | finished | — | workplans/STATE-WP-0046-workplan-id-normalization.md | +| workplan | STATE-WP-0047 | finished | — | workplans/STATE-WP-0047-lifecycle-assertions-and-renormalization.md | +| workplan | STATE-WP-0048 | finished | — | workplans/STATE-WP-0048-ui-state-change-reconciliation.md | +| workplan | STATE-WP-0049 | finished | — | workplans/STATE-WP-0049-workplan-execution-queue.md | +| workplan | STATE-WP-0050 | finished | — | workplans/STATE-WP-0050-railiance-fabric-graph-read-model-ingest.md | +| workplan | STATE-WP-0051 | finished | — | workplans/STATE-WP-0051-financial-fabric-read-model-adaptation.md | +| workplan | STATE-WP-0052 | finished | — | workplans/STATE-WP-0052-task-state-canon-adaptation.md | +| workplan | STATE-WP-0053 | finished | — | workplans/STATE-WP-0053-wsjf-triage-review-page.md | +| workplan | STATE-WP-0054 | finished | — | workplans/STATE-WP-0054-workplan-terminology-transition-legacy-meter.md | +| workplan | STATE-WP-0055 | finished | — | workplans/STATE-WP-0055-statehub-register-cli.md | +| workplan | STATE-WP-0056 | finished | — | workplans/STATE-WP-0056-dashboard-loading-robustness.md | +| workplan | STATE-WP-0057 | finished | — | workplans/STATE-WP-0057-overview-workstream-stage-counts.md | +| workplan | STATE-WP-0058 | finished | — | workplans/STATE-WP-0058-agent-skill-hub-interaction.md | +| workplan | STATE-WP-0059 | finished | — | workplans/STATE-WP-0059-mcp-write-layer-reliability.md | +| workplan | STATE-WP-0060 | finished | — | workplans/STATE-WP-0060-fix-consistency-cross-flavor.md | +| workplan | STATE-WP-0062 | finished | — | workplans/STATE-WP-0062-service-catalog-two-dimensions.md | +| workplan | STATE-WP-0063 | finished | — | workplans/STATE-WP-0063-weekend-automation-repair.md | +| workplan | STATE-WP-0064 | finished | — | workplans/STATE-WP-0064-statehub-consistency-sync-railiance01.md | +| workplan | STATE-WP-0065 | finished | — | workplans/STATE-WP-0065-repo-anchored-classification-spine.md | +| workplan | STATE-WP-0066 | finished | — | workplans/STATE-WP-0066-state-summary-revision-cache.md | +| workplan | STATE-WP-0067 | finished | — | workplans/STATE-WP-0067-attached-repo-agent-normalization.md | +| workplan | STATE-WP-0068 | finished | — | workplans/STATE-WP-0068-offline-write-buffer-and-edge-relay.md | +| workplan | STATE-WP-0071 | finished | — | workplans/STATE-WP-0071-railiance01-workstream-caller-deploy.md | +| workplan | STATE-WP-0072 | finished | — | workplans/STATE-WP-0072-workstream-caller-remediation.md | +| workplan | STATE-WP-0073 | finished | — | workplans/STATE-WP-0073-legacy-meter-decline-and-remote-audit.md | +| workplan | STATE-WP-0074 | finished | — | workplans/STATE-WP-0074-hub-ecosystem-boundary-alignment.md | +| workplan | STATE-WP-0075 | finished | — | workplans/STATE-WP-0075-workstream-route-410-stranglers.md | +| workplan | STATE-WP-0076 | finished | — | workplans/STATE-WP-0076-definition-of-ready-and-comprehension.md | +| workplan | STATE-WP-0077 | finished | — | workplans/STATE-WP-0077-dox-assessment-recording-and-soft-visibility.md | +| workplan | STATE-WP-0078 | finished | — | workplans/STATE-WP-0078-ops-run-read-projection.md | +| workplan | STATE-WP-0079 | blocked | — | workplans/STATE-WP-0079-retirement-strangler.md | +| workplan | STATE-WP-0080 | finished | — | workplans/STATE-WP-0080-register-project-flavor-awareness.md | +| workplan | STATE-WP-0081 | finished | — | workplans/STATE-WP-0081-cluster-self-sufficiency-and-registrar.md | +| workplan | STATE-WP-0082 | finished | — | workplans/STATE-WP-0082-multi-owner-review-contracts-and-receipts.md | +| workplan | STATE-WP-0083 | finished | — | workplans/STATE-WP-0083-forge-derived-projection-reset.md | +| workplan | STATE-WP-0084 | finished | — | workplans/STATE-WP-0084-forge-read-for-private-repositories.md | +| workplan | STATE-WP-0085 | finished | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md | +| workplan | STATE-WP-0086 | finished | — | workplans/STATE-WP-0086-fast-forge-projection-reconcile.md | +| workplan | STATE-WP-0087 | finished | — | workplans/STATE-WP-0087-bounded-repository-collection-projection.md | +| workplan | STATE-WP-0088 | finished | — | workplans/STATE-WP-0088-preflight-signing-runtime-acceptance.md | +| workplan | STATE-WP-0089 | finished | — | workplans/STATE-WP-0089-rename-redirect-recovery.md | +| workplan | STATE-WP-0090 | finished | — | workplans/STATE-WP-0090-projection-task-field-convergence.md | +| workplan | STATE-WP-0091 | finished | — | workplans/STATE-WP-0091-release-headroom.md | +| workplan | STATE-WP-0092 | finished | — | workplans/STATE-WP-0092-workplan-flavor-and-depends-on-policy.md | +| task | STATE-WP-ADHOC-2026-06-04-T01 | done | — | workplans/ADHOC-2026-06-04.md | +| task | STATE-WP-ADHOC-2026-07-01-T01 | done | — | workplans/ADHOC-2026-07-01.md | +| task | STATE-WP-ADHOC-2026-07-01-T02 | done | — | workplans/ADHOC-2026-07-01.md | +| task | STATE-WP-ADHOC-2026-08-08-T01 | done | — | workplans/ADHOC-2026-08-08.md | +| task | STATE-WP-ADHOC-2026-08-08-T02 | done | — | workplans/ADHOC-2026-08-08.md | +| task | STATE-WP-ADHOC-2026-08-08-T03 | done | — | workplans/ADHOC-2026-08-08.md | +| task | STATE-WP-ADHOC-2026-08-08-T04 | done | — | workplans/ADHOC-2026-08-08.md | +| task | STATE-WP-ADHOC-2026-08-23-T01 | done | — | workplans/ADHOC-2026-08-23.md | +| task | STATE-WP-ADHOC-2026-08-30-T01 | done | — | workplans/ADHOC-2026-08-30.md | +| task | STATE-WP-ADHOC-2026-08-30-T02 | done | — | workplans/ADHOC-2026-08-30.md | +| task | STATE-WP-ADHOC-2026-09-04-T01 | done | — | workplans/ADHOC-2026-09-04.md | +| task | CUST-WP-0003-T01 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md | +| task | CUST-WP-0003-T02 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md | +| task | CUST-WP-0003-T03 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md | +| task | CUST-WP-0003-T04 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md | +| task | CUST-WP-0003-T05 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md | +| task | CUST-WP-0003-T06 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md | +| task | CUST-WP-0003-T07 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md | +| task | CUST-WP-0003-T08 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md | +| task | CUST-WP-0003-T09 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md | +| task | CUST-WP-0012-T01 | done | — | workplans/CUST-WP-0012-multi-user-onboarding.md | +| task | CUST-WP-0012-T02 | done | — | workplans/CUST-WP-0012-multi-user-onboarding.md | +| task | CUST-WP-0012-T03 | done | — | workplans/CUST-WP-0012-multi-user-onboarding.md | +| task | CUST-WP-0012-T04 | done | — | workplans/CUST-WP-0012-multi-user-onboarding.md | +| task | CUST-WP-0012-T05 | done | — | workplans/CUST-WP-0012-multi-user-onboarding.md | +| task | CUST-WP-0012-T06 | done | — | workplans/CUST-WP-0012-multi-user-onboarding.md | +| task | CUST-WP-0038-T01 | todo | — | workplans/CUST-WP-0038-state-hub-threephoenix-ha.md | +| task | CUST-WP-0038-T02 | todo | — | workplans/CUST-WP-0038-state-hub-threephoenix-ha.md | +| task | CUST-WP-0038-T03 | todo | — | workplans/CUST-WP-0038-state-hub-threephoenix-ha.md | +| task | CUST-WP-0038-T04 | todo | — | workplans/CUST-WP-0038-state-hub-threephoenix-ha.md | +| task | CUST-WP-0038-T05 | todo | — | workplans/CUST-WP-0038-state-hub-threephoenix-ha.md | +| task | CUST-WP-0038-T06 | todo | — | workplans/CUST-WP-0038-state-hub-threephoenix-ha.md | +| task | CUST-WP-0038-T07 | todo | — | workplans/CUST-WP-0038-state-hub-threephoenix-ha.md | +| task | CUST-WP-0038-T08 | todo | — | workplans/CUST-WP-0038-state-hub-threephoenix-ha.md | +| task | CUST-WP-0042-T01 | done | — | workplans/CUST-WP-0042-workplan-state-model-cleanup.md | +| task | CUST-WP-0042-T02 | done | — | workplans/CUST-WP-0042-workplan-state-model-cleanup.md | +| task | CUST-WP-0042-T03 | done | — | workplans/CUST-WP-0042-workplan-state-model-cleanup.md | +| task | CUST-WP-0042-T04 | done | — | workplans/CUST-WP-0042-workplan-state-model-cleanup.md | +| task | CUST-WP-0042-T05 | done | — | workplans/CUST-WP-0042-workplan-state-model-cleanup.md | +| task | CUST-WP-0042-T06 | done | — | workplans/CUST-WP-0042-workplan-state-model-cleanup.md | +| task | CUST-WP-0042-T07 | done | — | workplans/CUST-WP-0042-workplan-state-model-cleanup.md | +| task | CUST-WP-0042-T08 | done | — | workplans/CUST-WP-0042-workplan-state-model-cleanup.md | +| task | STATE-WP-0043-T01 | done | — | workplans/STATE-WP-0043-dashboard-ui-experience.md | +| task | STATE-WP-0043-T02 | done | — | workplans/STATE-WP-0043-dashboard-ui-experience.md | +| task | STATE-WP-0043-T03 | done | — | workplans/STATE-WP-0043-dashboard-ui-experience.md | +| task | STATE-WP-0043-T04 | done | — | workplans/STATE-WP-0043-dashboard-ui-experience.md | +| task | STATE-WP-0043-T05 | done | — | workplans/STATE-WP-0043-dashboard-ui-experience.md | +| task | STATE-WP-0043-T06 | done | — | workplans/STATE-WP-0043-dashboard-ui-experience.md | +| task | STATE-WP-0043-T07 | done | — | workplans/STATE-WP-0043-dashboard-ui-experience.md | +| task | STATE-WP-0043-T08 | done | — | workplans/STATE-WP-0043-dashboard-ui-experience.md | +| task | STATE-WP-0044-T01 | done | — | workplans/STATE-WP-0044-recently-on-scope-digest.md | +| task | STATE-WP-0044-T02 | done | — | workplans/STATE-WP-0044-recently-on-scope-digest.md | +| task | STATE-WP-0044-T03 | done | — | workplans/STATE-WP-0044-recently-on-scope-digest.md | +| task | STATE-WP-0044-T04 | done | — | workplans/STATE-WP-0044-recently-on-scope-digest.md | +| task | STATE-WP-0044-T05 | done | — | workplans/STATE-WP-0044-recently-on-scope-digest.md | +| task | STATE-WP-0044-T06 | done | — | workplans/STATE-WP-0044-recently-on-scope-digest.md | +| task | STATE-WP-0045-T01 | done | — | workplans/STATE-WP-0045-token-measurement-accuracy.md | +| task | STATE-WP-0045-T02 | done | — | workplans/STATE-WP-0045-token-measurement-accuracy.md | +| task | STATE-WP-0045-T03 | done | — | workplans/STATE-WP-0045-token-measurement-accuracy.md | +| task | STATE-WP-0045-T04 | done | — | workplans/STATE-WP-0045-token-measurement-accuracy.md | +| task | STATE-WP-0045-T05 | done | — | workplans/STATE-WP-0045-token-measurement-accuracy.md | +| task | STATE-WP-0045-T06 | done | — | workplans/STATE-WP-0045-token-measurement-accuracy.md | +| task | STATE-WP-0045-T07 | done | — | workplans/STATE-WP-0045-token-measurement-accuracy.md | +| task | STATE-WP-0045-T08 | done | — | workplans/STATE-WP-0045-token-measurement-accuracy.md | +| task | STATE-WP-0046-T01 | done | — | workplans/STATE-WP-0046-workplan-id-normalization.md | +| task | STATE-WP-0046-T02 | done | — | workplans/STATE-WP-0046-workplan-id-normalization.md | +| task | STATE-WP-0046-T03 | done | — | workplans/STATE-WP-0046-workplan-id-normalization.md | +| task | STATE-WP-0046-T04 | done | — | workplans/STATE-WP-0046-workplan-id-normalization.md | +| task | STATE-WP-0046-T05 | done | — | workplans/STATE-WP-0046-workplan-id-normalization.md | +| task | STATE-WP-0046-T06 | done | — | workplans/STATE-WP-0046-workplan-id-normalization.md | +| task | STATE-WP-0047-T01 | done | — | workplans/STATE-WP-0047-lifecycle-assertions-and-renormalization.md | +| task | STATE-WP-0047-T02 | done | — | workplans/STATE-WP-0047-lifecycle-assertions-and-renormalization.md | +| task | STATE-WP-0047-T03 | done | — | workplans/STATE-WP-0047-lifecycle-assertions-and-renormalization.md | +| task | STATE-WP-0047-T04 | done | — | workplans/STATE-WP-0047-lifecycle-assertions-and-renormalization.md | +| task | STATE-WP-0047-T05 | done | — | workplans/STATE-WP-0047-lifecycle-assertions-and-renormalization.md | +| task | STATE-WP-0047-T06 | done | — | workplans/STATE-WP-0047-lifecycle-assertions-and-renormalization.md | +| task | STATE-WP-0047-T07 | done | — | workplans/STATE-WP-0047-lifecycle-assertions-and-renormalization.md | +| task | STATE-WP-0048-T01 | done | — | workplans/STATE-WP-0048-ui-state-change-reconciliation.md | +| task | STATE-WP-0048-T02 | done | — | workplans/STATE-WP-0048-ui-state-change-reconciliation.md | +| task | STATE-WP-0048-T03 | done | — | workplans/STATE-WP-0048-ui-state-change-reconciliation.md | +| task | STATE-WP-0048-T04 | done | — | workplans/STATE-WP-0048-ui-state-change-reconciliation.md | +| task | STATE-WP-0048-T05 | done | — | workplans/STATE-WP-0048-ui-state-change-reconciliation.md | +| task | STATE-WP-0048-T06 | done | — | workplans/STATE-WP-0048-ui-state-change-reconciliation.md | +| task | STATE-WP-0048-T07 | done | — | workplans/STATE-WP-0048-ui-state-change-reconciliation.md | +| task | STATE-WP-0049-T01 | done | — | workplans/STATE-WP-0049-workplan-execution-queue.md | +| task | STATE-WP-0049-T02 | done | — | workplans/STATE-WP-0049-workplan-execution-queue.md | +| task | STATE-WP-0049-T03 | done | — | workplans/STATE-WP-0049-workplan-execution-queue.md | +| task | STATE-WP-0049-T04 | done | — | workplans/STATE-WP-0049-workplan-execution-queue.md | +| task | STATE-WP-0049-T05 | done | — | workplans/STATE-WP-0049-workplan-execution-queue.md | +| task | STATE-WP-0049-T06 | done | — | workplans/STATE-WP-0049-workplan-execution-queue.md | +| task | STATE-WP-0049-T07 | done | — | workplans/STATE-WP-0049-workplan-execution-queue.md | +| task | STATE-WP-0050-T01 | done | — | workplans/STATE-WP-0050-railiance-fabric-graph-read-model-ingest.md | +| task | STATE-WP-0050-T02 | done | — | workplans/STATE-WP-0050-railiance-fabric-graph-read-model-ingest.md | +| task | STATE-WP-0050-T03 | done | — | workplans/STATE-WP-0050-railiance-fabric-graph-read-model-ingest.md | +| task | STATE-WP-0050-T04 | done | — | workplans/STATE-WP-0050-railiance-fabric-graph-read-model-ingest.md | +| task | STATE-WP-0051-T01 | done | — | workplans/STATE-WP-0051-financial-fabric-read-model-adaptation.md | +| task | STATE-WP-0051-T02 | done | — | workplans/STATE-WP-0051-financial-fabric-read-model-adaptation.md | +| task | STATE-WP-0051-T03 | done | — | workplans/STATE-WP-0051-financial-fabric-read-model-adaptation.md | +| task | STATE-WP-0051-T04 | done | — | workplans/STATE-WP-0051-financial-fabric-read-model-adaptation.md | +| task | STATE-WP-0051-T05 | done | — | workplans/STATE-WP-0051-financial-fabric-read-model-adaptation.md | +| task | STATE-WP-0051-T06 | done | — | workplans/STATE-WP-0051-financial-fabric-read-model-adaptation.md | +| task | STATE-WP-0052-T01 | done | — | workplans/STATE-WP-0052-task-state-canon-adaptation.md | +| task | STATE-WP-0052-T02 | done | — | workplans/STATE-WP-0052-task-state-canon-adaptation.md | +| task | STATE-WP-0052-T03 | done | — | workplans/STATE-WP-0052-task-state-canon-adaptation.md | +| task | STATE-WP-0052-T04 | done | — | workplans/STATE-WP-0052-task-state-canon-adaptation.md | +| task | STATE-WP-0052-T05 | done | — | workplans/STATE-WP-0052-task-state-canon-adaptation.md | +| task | STATE-WP-0052-T06 | done | — | workplans/STATE-WP-0052-task-state-canon-adaptation.md | +| task | STATE-WP-0052-T07 | done | — | workplans/STATE-WP-0052-task-state-canon-adaptation.md | +| task | STATE-WP-0052-T08 | done | — | workplans/STATE-WP-0052-task-state-canon-adaptation.md | +| task | STATE-WP-0052-T09 | done | — | workplans/STATE-WP-0052-task-state-canon-adaptation.md | +| task | STATE-WP-0052-T10 | done | — | workplans/STATE-WP-0052-task-state-canon-adaptation.md | +| task | STATE-WP-0053-T01 | done | — | workplans/STATE-WP-0053-wsjf-triage-review-page.md | +| task | STATE-WP-0053-T02 | done | — | workplans/STATE-WP-0053-wsjf-triage-review-page.md | +| task | STATE-WP-0053-T03 | done | — | workplans/STATE-WP-0053-wsjf-triage-review-page.md | +| task | STATE-WP-0053-T04 | done | — | workplans/STATE-WP-0053-wsjf-triage-review-page.md | +| task | STATE-WP-0053-T05 | done | — | workplans/STATE-WP-0053-wsjf-triage-review-page.md | +| task | STATE-WP-0053-T06 | done | — | workplans/STATE-WP-0053-wsjf-triage-review-page.md | +| task | STATE-WP-0054-T01 | done | — | workplans/STATE-WP-0054-workplan-terminology-transition-legacy-meter.md | +| task | STATE-WP-0054-T02 | done | — | workplans/STATE-WP-0054-workplan-terminology-transition-legacy-meter.md | +| task | STATE-WP-0054-T03 | done | — | workplans/STATE-WP-0054-workplan-terminology-transition-legacy-meter.md | +| task | STATE-WP-0054-T04 | done | — | workplans/STATE-WP-0054-workplan-terminology-transition-legacy-meter.md | +| task | STATE-WP-0054-T05 | done | — | workplans/STATE-WP-0054-workplan-terminology-transition-legacy-meter.md | +| task | STATE-WP-0054-T06 | done | — | workplans/STATE-WP-0054-workplan-terminology-transition-legacy-meter.md | +| task | STATE-WP-0054-T07 | done | — | workplans/STATE-WP-0054-workplan-terminology-transition-legacy-meter.md | +| task | STATE-WP-0054-T08 | done | — | workplans/STATE-WP-0054-workplan-terminology-transition-legacy-meter.md | +| task | STATE-WP-0055-T01 | done | — | workplans/STATE-WP-0055-statehub-register-cli.md | +| task | STATE-WP-0056-T01 | done | — | workplans/STATE-WP-0056-dashboard-loading-robustness.md | +| task | STATE-WP-0056-T02 | done | — | workplans/STATE-WP-0056-dashboard-loading-robustness.md | +| task | STATE-WP-0056-T03 | done | — | workplans/STATE-WP-0056-dashboard-loading-robustness.md | +| task | STATE-WP-0056-T04 | done | — | workplans/STATE-WP-0056-dashboard-loading-robustness.md | +| task | STATE-WP-0056-T05 | done | — | workplans/STATE-WP-0056-dashboard-loading-robustness.md | +| task | STATE-WP-0056-T06 | done | — | workplans/STATE-WP-0056-dashboard-loading-robustness.md | +| task | STATE-WP-0056-T07 | done | — | workplans/STATE-WP-0056-dashboard-loading-robustness.md | +| task | STATE-WP-0056-T08 | done | — | workplans/STATE-WP-0056-dashboard-loading-robustness.md | +| task | STATE-WP-0057-T01 | done | — | workplans/STATE-WP-0057-overview-workstream-stage-counts.md | +| task | STATE-WP-0057-T02 | done | — | workplans/STATE-WP-0057-overview-workstream-stage-counts.md | +| task | STATE-WP-0057-T03 | done | — | workplans/STATE-WP-0057-overview-workstream-stage-counts.md | +| task | STATE-WP-0057-T04 | done | — | workplans/STATE-WP-0057-overview-workstream-stage-counts.md | +| task | STATE-WP-0057-T05 | done | — | workplans/STATE-WP-0057-overview-workstream-stage-counts.md | +| task | STATE-WP-0057-T06 | done | — | workplans/STATE-WP-0057-overview-workstream-stage-counts.md | +| task | STATE-WP-0058-T01 | done | — | workplans/STATE-WP-0058-agent-skill-hub-interaction.md | +| task | STATE-WP-0058-T02 | done | — | workplans/STATE-WP-0058-agent-skill-hub-interaction.md | +| task | STATE-WP-0058-T03 | done | — | workplans/STATE-WP-0058-agent-skill-hub-interaction.md | +| task | STATE-WP-0059-T01 | done | — | workplans/STATE-WP-0059-mcp-write-layer-reliability.md | +| task | STATE-WP-0059-T02 | done | — | workplans/STATE-WP-0059-mcp-write-layer-reliability.md | +| task | STATE-WP-0059-T03 | done | — | workplans/STATE-WP-0059-mcp-write-layer-reliability.md | +| task | STATE-WP-0060-T01 | done | — | workplans/STATE-WP-0060-fix-consistency-cross-flavor.md | +| task | STATE-WP-0060-T02 | done | — | workplans/STATE-WP-0060-fix-consistency-cross-flavor.md | +| task | STATE-WP-0060-T03 | done | — | workplans/STATE-WP-0060-fix-consistency-cross-flavor.md | +| task | STATE-WP-0062-T01 | done | — | workplans/STATE-WP-0062-service-catalog-two-dimensions.md | +| task | STATE-WP-0062-T02 | done | — | workplans/STATE-WP-0062-service-catalog-two-dimensions.md | +| task | STATE-WP-0062-T03 | done | — | workplans/STATE-WP-0062-service-catalog-two-dimensions.md | +| task | STATE-WP-0062-T04 | done | — | workplans/STATE-WP-0062-service-catalog-two-dimensions.md | +| task | STATE-WP-0062-T05 | done | — | workplans/STATE-WP-0062-service-catalog-two-dimensions.md | +| task | STATE-WP-0063-T01 | done | — | workplans/STATE-WP-0063-weekend-automation-repair.md | +| task | STATE-WP-0063-T02 | done | — | workplans/STATE-WP-0063-weekend-automation-repair.md | +| task | STATE-WP-0063-T03 | done | — | workplans/STATE-WP-0063-weekend-automation-repair.md | +| task | STATE-WP-0063-T04 | done | — | workplans/STATE-WP-0063-weekend-automation-repair.md | +| task | STATE-WP-0063-T05 | done | — | workplans/STATE-WP-0063-weekend-automation-repair.md | +| task | STATE-WP-0064-T01 | done | — | workplans/STATE-WP-0064-statehub-consistency-sync-railiance01.md | +| task | STATE-WP-0064-T02 | done | — | workplans/STATE-WP-0064-statehub-consistency-sync-railiance01.md | +| task | STATE-WP-0064-T03 | done | — | workplans/STATE-WP-0064-statehub-consistency-sync-railiance01.md | +| task | STATE-WP-0064-T04 | done | — | workplans/STATE-WP-0064-statehub-consistency-sync-railiance01.md | +| task | STATE-WP-0064-T05 | done | — | workplans/STATE-WP-0064-statehub-consistency-sync-railiance01.md | +| task | STATE-WP-0065-T01 | done | — | workplans/STATE-WP-0065-repo-anchored-classification-spine.md | +| task | STATE-WP-0065-T02 | done | — | workplans/STATE-WP-0065-repo-anchored-classification-spine.md | +| task | STATE-WP-0065-T03 | done | — | workplans/STATE-WP-0065-repo-anchored-classification-spine.md | +| task | STATE-WP-0065-T04 | done | — | workplans/STATE-WP-0065-repo-anchored-classification-spine.md | +| task | STATE-WP-0066-T01 | done | — | workplans/STATE-WP-0066-state-summary-revision-cache.md | +| task | STATE-WP-0066-T02 | done | — | workplans/STATE-WP-0066-state-summary-revision-cache.md | +| task | STATE-WP-0066-T03 | done | — | workplans/STATE-WP-0066-state-summary-revision-cache.md | +| task | STATE-WP-0066-T04 | done | — | workplans/STATE-WP-0066-state-summary-revision-cache.md | +| task | STATE-WP-0066-T05 | done | — | workplans/STATE-WP-0066-state-summary-revision-cache.md | +| task | STATE-WP-0066-T06 | done | — | workplans/STATE-WP-0066-state-summary-revision-cache.md | +| task | STATE-WP-0066-T07 | done | — | workplans/STATE-WP-0066-state-summary-revision-cache.md | +| task | STATE-WP-0066-T08 | done | — | workplans/STATE-WP-0066-state-summary-revision-cache.md | +| task | STATE-WP-0067-T01 | done | — | workplans/STATE-WP-0067-attached-repo-agent-normalization.md | +| task | STATE-WP-0067-T02 | done | — | workplans/STATE-WP-0067-attached-repo-agent-normalization.md | +| task | STATE-WP-0067-T03 | done | — | workplans/STATE-WP-0067-attached-repo-agent-normalization.md | +| task | STATE-WP-0067-T04 | done | — | workplans/STATE-WP-0067-attached-repo-agent-normalization.md | +| task | STATE-WP-0067-T05 | done | — | workplans/STATE-WP-0067-attached-repo-agent-normalization.md | +| task | STATE-WP-0067-T06 | done | — | workplans/STATE-WP-0067-attached-repo-agent-normalization.md | +| task | STATE-WP-0068-T01 | done | — | workplans/STATE-WP-0068-offline-write-buffer-and-edge-relay.md | +| task | STATE-WP-0068-T02 | done | — | workplans/STATE-WP-0068-offline-write-buffer-and-edge-relay.md | +| task | STATE-WP-0068-T03 | done | — | workplans/STATE-WP-0068-offline-write-buffer-and-edge-relay.md | +| task | STATE-WP-0068-T04 | done | — | workplans/STATE-WP-0068-offline-write-buffer-and-edge-relay.md | +| task | STATE-WP-0068-T05 | done | — | workplans/STATE-WP-0068-offline-write-buffer-and-edge-relay.md | +| task | STATE-WP-0068-T06 | done | — | workplans/STATE-WP-0068-offline-write-buffer-and-edge-relay.md | +| task | STATE-WP-0068-T07 | done | — | workplans/STATE-WP-0068-offline-write-buffer-and-edge-relay.md | +| task | STATE-WP-0068-T08 | done | — | workplans/STATE-WP-0068-offline-write-buffer-and-edge-relay.md | +| task | STATE-WP-0068-T09 | done | — | workplans/STATE-WP-0068-offline-write-buffer-and-edge-relay.md | +| task | STATE-WP-0071-T01 | done | — | workplans/STATE-WP-0071-railiance01-workstream-caller-deploy.md | +| task | STATE-WP-0071-T02 | done | — | workplans/STATE-WP-0071-railiance01-workstream-caller-deploy.md | +| task | STATE-WP-0071-T03 | done | — | workplans/STATE-WP-0071-railiance01-workstream-caller-deploy.md | +| task | STATE-WP-0071-T04 | done | — | workplans/STATE-WP-0071-railiance01-workstream-caller-deploy.md | +| task | STATE-WP-0071-T05 | done | — | workplans/STATE-WP-0071-railiance01-workstream-caller-deploy.md | +| task | STATE-WP-0071-T06 | done | — | workplans/STATE-WP-0071-railiance01-workstream-caller-deploy.md | +| task | STATE-WP-0072-T01 | done | — | workplans/STATE-WP-0072-workstream-caller-remediation.md | +| task | STATE-WP-0072-T02 | done | — | workplans/STATE-WP-0072-workstream-caller-remediation.md | +| task | STATE-WP-0072-T03 | done | — | workplans/STATE-WP-0072-workstream-caller-remediation.md | +| task | STATE-WP-0072-T04 | done | — | workplans/STATE-WP-0072-workstream-caller-remediation.md | +| task | STATE-WP-0073-T01 | done | — | workplans/STATE-WP-0073-legacy-meter-decline-and-remote-audit.md | +| task | STATE-WP-0073-T02 | done | — | workplans/STATE-WP-0073-legacy-meter-decline-and-remote-audit.md | +| task | STATE-WP-0073-T03 | done | — | workplans/STATE-WP-0073-legacy-meter-decline-and-remote-audit.md | +| task | STATE-WP-0073-T04 | done | — | workplans/STATE-WP-0073-legacy-meter-decline-and-remote-audit.md | +| task | STATE-WP-0073-T05 | cancel | — | workplans/STATE-WP-0073-legacy-meter-decline-and-remote-audit.md | +| task | STATE-WP-0074-T01 | done | — | workplans/STATE-WP-0074-hub-ecosystem-boundary-alignment.md | +| task | STATE-WP-0074-T02 | done | — | workplans/STATE-WP-0074-hub-ecosystem-boundary-alignment.md | +| task | STATE-WP-0074-T03 | done | — | workplans/STATE-WP-0074-hub-ecosystem-boundary-alignment.md | +| task | STATE-WP-0074-T04 | done | — | workplans/STATE-WP-0074-hub-ecosystem-boundary-alignment.md | +| task | STATE-WP-0074-T05 | done | — | workplans/STATE-WP-0074-hub-ecosystem-boundary-alignment.md | +| task | STATE-WP-0075-T01 | done | — | workplans/STATE-WP-0075-workstream-route-410-stranglers.md | +| task | STATE-WP-0075-T02 | done | — | workplans/STATE-WP-0075-workstream-route-410-stranglers.md | +| task | STATE-WP-0075-T03 | done | — | workplans/STATE-WP-0075-workstream-route-410-stranglers.md | +| task | STATE-WP-0075-T04 | done | — | workplans/STATE-WP-0075-workstream-route-410-stranglers.md | +| task | STATE-WP-0076-T01 | done | — | workplans/STATE-WP-0076-definition-of-ready-and-comprehension.md | +| task | STATE-WP-0076-T02 | done | — | workplans/STATE-WP-0076-definition-of-ready-and-comprehension.md | +| task | STATE-WP-0076-T03 | done | — | workplans/STATE-WP-0076-definition-of-ready-and-comprehension.md | +| task | STATE-WP-0076-T04 | done | — | workplans/STATE-WP-0076-definition-of-ready-and-comprehension.md | +| task | STATE-WP-0076-T05 | cancel | — | workplans/STATE-WP-0076-definition-of-ready-and-comprehension.md | +| task | STATE-WP-0076-T06 | cancel | — | workplans/STATE-WP-0076-definition-of-ready-and-comprehension.md | +| task | STATE-WP-0077-T01 | done | — | workplans/STATE-WP-0077-dox-assessment-recording-and-soft-visibility.md | +| task | STATE-WP-0077-T02 | done | — | workplans/STATE-WP-0077-dox-assessment-recording-and-soft-visibility.md | +| task | STATE-WP-0077-T03 | done | — | workplans/STATE-WP-0077-dox-assessment-recording-and-soft-visibility.md | +| task | STATE-WP-0077-T04 | done | — | workplans/STATE-WP-0077-dox-assessment-recording-and-soft-visibility.md | +| task | STATE-WP-0078-T01 | done | — | workplans/STATE-WP-0078-ops-run-read-projection.md | +| task | STATE-WP-0078-T02 | done | — | workplans/STATE-WP-0078-ops-run-read-projection.md | +| task | STATE-WP-0078-T03 | done | — | workplans/STATE-WP-0078-ops-run-read-projection.md | +| task | STATE-WP-0079-T01 | done | — | workplans/STATE-WP-0079-retirement-strangler.md | +| task | STATE-WP-0079-T02 | done | — | workplans/STATE-WP-0079-retirement-strangler.md | +| task | STATE-WP-0079-T03 | done | — | workplans/STATE-WP-0079-retirement-strangler.md | +| task | STATE-WP-0079-T04 | wait | — | workplans/STATE-WP-0079-retirement-strangler.md | +| task | STATE-WP-0079-T05 | wait | — | workplans/STATE-WP-0079-retirement-strangler.md | +| task | STATE-WP-0079-T06 | wait | — | workplans/STATE-WP-0079-retirement-strangler.md | +| task | STATE-WP-0079-T07 | wait | — | workplans/STATE-WP-0079-retirement-strangler.md | +| task | STATE-WP-0079-T08 | done | — | workplans/STATE-WP-0079-retirement-strangler.md | +| task | STATE-WP-0079-T09 | wait | — | workplans/STATE-WP-0079-retirement-strangler.md | +| task | STATE-WP-0080-T01 | done | — | workplans/STATE-WP-0080-register-project-flavor-awareness.md | +| task | STATE-WP-0080-T02 | done | — | workplans/STATE-WP-0080-register-project-flavor-awareness.md | +| task | STATE-WP-0080-T03 | done | — | workplans/STATE-WP-0080-register-project-flavor-awareness.md | +| task | STATE-WP-0080-T04 | cancel | — | workplans/STATE-WP-0080-register-project-flavor-awareness.md | +| task | STATE-WP-0080-T05 | cancel | — | workplans/STATE-WP-0080-register-project-flavor-awareness.md | +| task | STATE-WP-0080-T06 | cancel | — | workplans/STATE-WP-0080-register-project-flavor-awareness.md | +| task | STATE-WP-0080-T07 | cancel | — | workplans/STATE-WP-0080-register-project-flavor-awareness.md | +| task | STATE-WP-0080-T08 | cancel | — | workplans/STATE-WP-0080-register-project-flavor-awareness.md | +| task | STATE-WP-0081-T01 | cancel | — | workplans/STATE-WP-0081-cluster-self-sufficiency-and-registrar.md | +| task | STATE-WP-0081-T02 | cancel | — | workplans/STATE-WP-0081-cluster-self-sufficiency-and-registrar.md | +| task | STATE-WP-0081-T03 | done | — | workplans/STATE-WP-0081-cluster-self-sufficiency-and-registrar.md | +| task | STATE-WP-0081-T04 | done | — | workplans/STATE-WP-0081-cluster-self-sufficiency-and-registrar.md | +| task | STATE-WP-0081-T05 | done | — | workplans/STATE-WP-0081-cluster-self-sufficiency-and-registrar.md | +| task | STATE-WP-0081-T06 | done | — | workplans/STATE-WP-0081-cluster-self-sufficiency-and-registrar.md | +| task | STATE-WP-0081-T07 | done | — | workplans/STATE-WP-0081-cluster-self-sufficiency-and-registrar.md | +| task | STATE-WP-0081-T08 | done | — | workplans/STATE-WP-0081-cluster-self-sufficiency-and-registrar.md | +| task | STATE-WP-0082-T01 | done | — | workplans/STATE-WP-0082-multi-owner-review-contracts-and-receipts.md | +| task | STATE-WP-0082-T02 | done | — | workplans/STATE-WP-0082-multi-owner-review-contracts-and-receipts.md | +| task | STATE-WP-0082-T03 | done | — | workplans/STATE-WP-0082-multi-owner-review-contracts-and-receipts.md | +| task | STATE-WP-0082-T04 | done | — | workplans/STATE-WP-0082-multi-owner-review-contracts-and-receipts.md | +| task | STATE-WP-0082-T05 | done | — | workplans/STATE-WP-0082-multi-owner-review-contracts-and-receipts.md | +| task | STATE-WP-0082-T06 | done | — | workplans/STATE-WP-0082-multi-owner-review-contracts-and-receipts.md | +| task | STATE-WP-0083-T01 | done | — | workplans/STATE-WP-0083-forge-derived-projection-reset.md | +| task | STATE-WP-0083-T02 | done | — | workplans/STATE-WP-0083-forge-derived-projection-reset.md | +| task | STATE-WP-0083-T03 | done | — | workplans/STATE-WP-0083-forge-derived-projection-reset.md | +| task | STATE-WP-0083-T04 | done | — | workplans/STATE-WP-0083-forge-derived-projection-reset.md | +| task | STATE-WP-0083-T05 | done | — | workplans/STATE-WP-0083-forge-derived-projection-reset.md | +| task | STATE-WP-0083-T06 | done | — | workplans/STATE-WP-0083-forge-derived-projection-reset.md | +| task | STATE-WP-0083-T07 | done | — | workplans/STATE-WP-0083-forge-derived-projection-reset.md | +| task | STATE-WP-0083-T08 | done | — | workplans/STATE-WP-0083-forge-derived-projection-reset.md | +| task | STATE-WP-0084-T01 | done | — | workplans/STATE-WP-0084-forge-read-for-private-repositories.md | +| task | STATE-WP-0084-T02 | done | — | workplans/STATE-WP-0084-forge-read-for-private-repositories.md | +| task | STATE-WP-0084-T03 | done | — | workplans/STATE-WP-0084-forge-read-for-private-repositories.md | +| task | STATE-WP-0084-T04 | done | — | workplans/STATE-WP-0084-forge-read-for-private-repositories.md | +| task | STATE-WP-0085-T01 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md | +| task | STATE-WP-0085-T02 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md | +| task | STATE-WP-0085-T03 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md | +| task | STATE-WP-0085-T04 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md | +| task | STATE-WP-0085-T05 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md | +| task | STATE-WP-0085-T06 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md | +| task | STATE-WP-0085-T07 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md | +| task | STATE-WP-0085-T08 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md | +| task | STATE-WP-0085-T09 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md | +| task | STATE-WP-0086-T01 | done | — | workplans/STATE-WP-0086-fast-forge-projection-reconcile.md | +| task | STATE-WP-0086-T02 | done | — | workplans/STATE-WP-0086-fast-forge-projection-reconcile.md | +| task | STATE-WP-0086-T03 | done | — | workplans/STATE-WP-0086-fast-forge-projection-reconcile.md | +| task | STATE-WP-0087-T01 | done | — | workplans/STATE-WP-0087-bounded-repository-collection-projection.md | +| task | STATE-WP-0088-T01 | done | — | workplans/STATE-WP-0088-preflight-signing-runtime-acceptance.md | +| task | STATE-WP-0088-T02 | done | — | workplans/STATE-WP-0088-preflight-signing-runtime-acceptance.md | +| task | STATE-WP-0089-T01 | done | — | workplans/STATE-WP-0089-rename-redirect-recovery.md | +| task | STATE-WP-0089-T02 | done | — | workplans/STATE-WP-0089-rename-redirect-recovery.md | +| task | STATE-WP-0090-T01 | done | — | workplans/STATE-WP-0090-projection-task-field-convergence.md | +| task | STATE-WP-0090-T02 | done | — | workplans/STATE-WP-0090-projection-task-field-convergence.md | +| task | STATE-WP-0090-T03 | done | — | workplans/STATE-WP-0090-projection-task-field-convergence.md | +| task | STATE-WP-0091-T01 | done | — | workplans/STATE-WP-0091-release-headroom.md | +| task | STATE-WP-0091-T02 | done | — | workplans/STATE-WP-0091-release-headroom.md | +| task | STATE-WP-0092-T01 | done | — | workplans/STATE-WP-0092-workplan-flavor-and-depends-on-policy.md | +| task | STATE-WP-0092-T02 | done | — | workplans/STATE-WP-0092-workplan-flavor-and-depends-on-policy.md | +| task | STATE-WP-0092-T03 | done | — | workplans/STATE-WP-0092-workplan-flavor-and-depends-on-policy.md | +| task | STATE-WP-0092-T04 | done | — | workplans/STATE-WP-0092-workplan-flavor-and-depends-on-policy.md | +| task | STATE-WP-0092-T05 | done | — | workplans/STATE-WP-0092-workplan-flavor-and-depends-on-policy.md | diff --git a/api/classification.py b/api/classification.py index a2955bb..43aef85 100644 --- a/api/classification.py +++ b/api/classification.py @@ -9,8 +9,16 @@ import re from dataclasses import dataclass, field from pathlib import Path +import os + import yaml +# Explicit override first — every other candidate below is a developer +# workstation path, so in a container none of them exist and classification +# validation fails outright. Set REPO_CLASSIFICATION_ALLOWED_PATH in any +# deployment that does not carry a the-custodian checkout (CUST-WP-0067-T04). +_ENV_ALLOWED = os.environ.get("REPO_CLASSIFICATION_ALLOWED_PATH") + # Workstation checkout, railiance01 fleet checkout, then state-hub sibling fallback. _PRIMARY_ALLOWED = Path( "/home/worsch/the-custodian/canon/standards/repo-classification.allowed.yaml" @@ -70,12 +78,21 @@ class ClassificationData: def _allowed_path() -> Path: + if _ENV_ALLOWED: + candidate = Path(_ENV_ALLOWED) + if candidate.is_file(): + return candidate + raise FileNotFoundError( + f"REPO_CLASSIFICATION_ALLOWED_PATH is set to {candidate}, which is not a file" + ) for candidate in (_PRIMARY_ALLOWED, _RAILIANCE_ALLOWED, _FALLBACK_ALLOWED): if candidate.is_file(): return candidate raise FileNotFoundError( "repo-classification.allowed.yaml not found at " - f"{_PRIMARY_ALLOWED}, {_RAILIANCE_ALLOWED}, or {_FALLBACK_ALLOWED}" + f"{_PRIMARY_ALLOWED}, {_RAILIANCE_ALLOWED}, or {_FALLBACK_ALLOWED}. " + "Set REPO_CLASSIFICATION_ALLOWED_PATH when running without a " + "the-custodian checkout (for example in a container)." ) diff --git a/api/config.py b/api/config.py index 14d7798..05a0c36 100644 --- a/api/config.py +++ b/api/config.py @@ -1,3 +1,5 @@ +from typing import Literal + from pydantic_settings import BaseSettings, SettingsConfigDict @@ -10,9 +12,36 @@ class Settings(BaseSettings): database_url: str = "postgresql+asyncpg://custodian:changeme@127.0.0.1:5432/custodian" api_base: str = "http://127.0.0.1:8000" + # What this instance claims to be. Deliberately defaults to "unknown": + # an instance that has not declared itself is not the primary, and callers + # that need the authoritative hub must be able to tell the difference. + # Answering on a port is not evidence of authority — that assumption cost + # seven weeks of onboarding (CUST-WP-0067-T03, ADR-010). + state_hub_instance_role: Literal["primary", "cache", "unknown"] = "unknown" + # Env vars are STATE_HUB_INSTANCE_ROLE / STATE_HUB_INSTANCE_LABEL — the + # field name *is* the binding, so renaming either breaks the deployment + # silently. Covered by tests/test_instance_identity.py. + # Free-form label to name *which* instance answered, e.g. "railiance01". + state_hub_instance_label: str | None = None debug: bool = False state_hub_report_dir: str = "reports/recently-on-scope" state_hub_markitect_cli_path: str | None = None + activity_core_url: str | None = None + activity_core_worker_token: str | None = None + ops_run_projection_ttl_seconds: float = 15.0 + ops_run_sla_hours: float = 1.0 + sbom_nexus_url: str | None = None + sbom_nexus_read_mode: Literal["legacy", "nexus"] = "legacy" + sbom_nexus_write_mode: Literal["legacy", "nexus"] = "legacy" + sbom_nexus_timeout_seconds: float = 5.0 + # Repository renames are central-only, compare-and-set operations. The + # secret signs short-lived, non-persistent preflight evidence; it must be + # supplied by the deployment (normally through OpenBao), never recorded in + # State Hub. With no secret the read-only report remains available but no + # mutation token can be issued. + repository_rename_preflight_secret: str | None = None + repository_rename_preflight_ttl_seconds: int = 900 + repository_rename_forge_timeout_seconds: float = 10.0 settings = Settings() diff --git a/api/main.py b/api/main.py index db63efc..fecd89c 100644 --- a/api/main.py +++ b/api/main.py @@ -12,7 +12,7 @@ from starlette.responses import Response as StarletteResponse from api.database import engine from api.events import shutdown_publisher from api.services.write_idempotency import WriteIdempotencyMiddleware -from api.routers import decisions, extension_points, progress, state, suggestions, tasks, technical_debt, topics, workstreams, workstream_dependencies +from api.routers import decisions, extension_points, intake, ops_runs, progress, state, suggestions, tasks, technical_debt, topics, workstreams, workstream_dependencies from api.routers import domains, repos, contributions, sbom, policy, domain_goals, repo_goals, messages, capability_requests, tpsc, services from api.routers import token_events from api.routers import interface_changes @@ -23,6 +23,10 @@ from api.routers import reconciliation from api.routers import execution from api.routers import fabric from api.routers import legacy_meter +from api.routers import review_contracts +from api.routers import identifier_migrations +from api.routers import repository_renames +from api.routers import work_record_projection class ETagMiddleware(BaseHTTPMiddleware): @@ -107,6 +111,9 @@ app.include_router(recently_on_scope.hourly_router) app.include_router(recently_on_scope.router) app.include_router(consistency_sweep.router) app.include_router(repos.router) +app.include_router(repository_renames.router) +app.include_router(repository_renames.operation_router) +app.include_router(work_record_projection.router) app.include_router(topics.router) app.include_router(workstreams.router) app.include_router(workstreams.workplan_router) @@ -114,6 +121,7 @@ app.include_router(workstream_dependencies.router) app.include_router(workstream_dependencies.workplan_router) app.include_router(tasks.router) app.include_router(decisions.router) +app.include_router(intake.router) app.include_router(extension_points.router) app.include_router(technical_debt.router) app.include_router(progress.router) @@ -133,7 +141,10 @@ app.include_router(reconciliation.router) app.include_router(execution.router) app.include_router(fabric.router) app.include_router(legacy_meter.router) +app.include_router(review_contracts.router) +app.include_router(identifier_migrations.router) app.include_router(state.router) +app.include_router(ops_runs.router) app.include_router(policy.router) diff --git a/api/models/__init__.py b/api/models/__init__.py index 522c3b0..1f8b110 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -10,6 +10,13 @@ from api.models.workstream import Workstream from api.models.workstream_dependency import WorkstreamDependency from api.models.task import Task, TaskStatus, TaskPriority from api.models.decision import Decision, DecisionType, DecisionStatus +from api.models.intake import ( + Intake, + IntakeNote, + IntakeLane, + IntakeStatus, + IntakeOutcome, +) from api.models.progress_event import ProgressEvent from api.models.extension_point import ExtensionPoint, EPStatus from api.models.technical_debt import TechnicalDebt, TDStatus @@ -34,6 +41,13 @@ from api.models.workplan_launch_request import WorkplanLaunchRequest from api.models.fabric_graph import FabricGraphImport, FabricGraphNode, FabricGraphEdge from api.models.legacy_meter import LegacyInterface, LegacyInterfaceUsageBucket from api.models.write_idempotency_key import WriteIdempotencyKey +from api.models.work_record_identifier_alias import WorkRecordIdentifierAlias +from api.models.review_contract import ReviewContract, ReviewReceipt +from api.models.repository_rename import ( + RepositoryForgeIdentity, + RepositoryRenameOperation, + RepositorySlug, +) from api.models.suggestion import ( Suggestion, SuggestionNote, @@ -54,6 +68,7 @@ __all__ = [ "WorkstreamDependency", "Task", "TaskStatus", "TaskPriority", "Decision", "DecisionType", "DecisionStatus", + "Intake", "IntakeNote", "IntakeLane", "IntakeStatus", "IntakeOutcome", "ProgressEvent", "ExtensionPoint", "EPStatus", "TechnicalDebt", "TDStatus", @@ -73,5 +88,8 @@ __all__ = [ "FabricGraphImport", "FabricGraphNode", "FabricGraphEdge", "LegacyInterface", "LegacyInterfaceUsageBucket", "WriteIdempotencyKey", + "WorkRecordIdentifierAlias", + "ReviewContract", "ReviewReceipt", + "RepositoryForgeIdentity", "RepositoryRenameOperation", "RepositorySlug", "Suggestion", "SuggestionNote", "SuggestionRelevanceBump", "SuggestionStage", -] \ No newline at end of file +] diff --git a/api/models/base.py b/api/models/base.py index 4dfbd1c..578c4d5 100644 --- a/api/models/base.py +++ b/api/models/base.py @@ -1,3 +1,5 @@ +import os +import time import uuid from datetime import datetime @@ -24,3 +26,28 @@ class TimestampMixin: def new_uuid() -> uuid.UUID: return uuid.uuid4() + + +def new_uuid7() -> uuid.UUID: + """Generate a UUIDv7 (RFC 9562): 48-bit big-endian ms timestamp, version + and variant bits, remaining bits random. Time-sortable, so primary keys + generated with this helper order chronologically without a separate + created_at index lookup — the identity layering canon + (work-record-types_v0.1.md) calls this out as the primary internal key + for new work-record entities. + + Dependency-free (no uuid7 in stdlib before Python 3.14, no third-party + lib added for a ~15-line, non-cryptographic layout). + """ + unix_ts_ms = int(time.time() * 1000) + rand = int.from_bytes(os.urandom(10), "big") + rand_a = (rand >> 62) & 0x0FFF # top 12 bits of the 80 random bits + rand_b = rand & 0x3FFFFFFFFFFFFFFF # bottom 62 bits + value = ( + (unix_ts_ms << 80) + | (0x7 << 76) # version 7 + | (rand_a << 64) + | (0x2 << 62) # variant 10 + | rand_b + ) + return uuid.UUID(int=value) diff --git a/api/models/capability_request.py b/api/models/capability_request.py index 8117375..7fdaba1 100644 --- a/api/models/capability_request.py +++ b/api/models/capability_request.py @@ -33,7 +33,7 @@ class CapabilityRequest(Base, TimestampMixin): ) requesting_workplan_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), - ForeignKey("workplans.id", ondelete="SET NULL"), + ForeignKey("workplans.id", ondelete="SET NULL", onupdate="CASCADE"), nullable=True, ) requesting_agent: Mapped[str] = mapped_column(String(100), nullable=False) @@ -47,7 +47,7 @@ class CapabilityRequest(Base, TimestampMixin): ) fulfilling_workplan_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), - ForeignKey("workplans.id", ondelete="SET NULL"), + ForeignKey("workplans.id", ondelete="SET NULL", onupdate="CASCADE"), nullable=True, ) fulfilling_agent: Mapped[str | None] = mapped_column(String(100), nullable=True) @@ -55,7 +55,7 @@ class CapabilityRequest(Base, TimestampMixin): # Links blocking_task_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), - ForeignKey("tasks.id", ondelete="SET NULL"), + ForeignKey("tasks.id", ondelete="SET NULL", onupdate="CASCADE"), nullable=True, ) catalog_entry_id: Mapped[uuid.UUID | None] = mapped_column( diff --git a/api/models/contribution.py b/api/models/contribution.py index 46b934e..0596784 100644 --- a/api/models/contribution.py +++ b/api/models/contribution.py @@ -2,7 +2,7 @@ import enum import uuid from datetime import datetime -from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String, Text +from sqlalchemy import DateTime, Enum, ForeignKey, String, Text from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -48,7 +48,9 @@ class Contribution(Base, TimestampMixin): UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True ) related_workplan_id: Mapped[uuid.UUID | None] = mapped_column( - UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True + UUID(as_uuid=True), + ForeignKey("workplans.id", ondelete="SET NULL", onupdate="CASCADE"), + nullable=True, ) repo_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True diff --git a/api/models/decision.py b/api/models/decision.py index 08ae0ed..1e9a2c6 100644 --- a/api/models/decision.py +++ b/api/models/decision.py @@ -37,7 +37,10 @@ class Decision(Base, TimestampMixin): UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=True, index=True ) workplan_id: Mapped[uuid.UUID | None] = mapped_column( - UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="RESTRICT"), nullable=True, index=True + UUID(as_uuid=True), + ForeignKey("workplans.id", ondelete="RESTRICT", onupdate="CASCADE"), + nullable=True, + index=True, ) title: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/api/models/extension_point.py b/api/models/extension_point.py index 84f6c2a..532ade4 100644 --- a/api/models/extension_point.py +++ b/api/models/extension_point.py @@ -45,7 +45,9 @@ class ExtensionPoint(Base, TimestampMixin): UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True ) workplan_id: Mapped[uuid.UUID | None] = mapped_column( - UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True + UUID(as_uuid=True), + ForeignKey("workplans.id", ondelete="SET NULL", onupdate="CASCADE"), + nullable=True, ) domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821 diff --git a/api/models/fabric_graph.py b/api/models/fabric_graph.py index 850616c..746447b 100644 --- a/api/models/fabric_graph.py +++ b/api/models/fabric_graph.py @@ -23,7 +23,7 @@ class FabricGraphImport(Base, TimestampMixin): ) source_repo_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True) source_url: Mapped[str | None] = mapped_column(Text, nullable=True) - source_commit: Mapped[str | None] = mapped_column(String(80), nullable=True, index=True) + source_commit: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True) source_path: Mapped[str | None] = mapped_column(Text, nullable=True) api_version: Mapped[str | None] = mapped_column(String(100), nullable=True) schema_version: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True) diff --git a/api/models/intake.py b/api/models/intake.py new file mode 100644 index 0000000..a9cc84f --- /dev/null +++ b/api/models/intake.py @@ -0,0 +1,129 @@ +import enum +import uuid +from datetime import datetime + +from sqlalchemy import CheckConstraint, DateTime, Enum, ForeignKey, String, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.sql import func + +from api.models.base import Base, TimestampMixin, new_uuid7 + + +class IntakeLane(str, enum.Enum): + green = "green" + blue = "blue" + yellow = "yellow" + orange = "orange" + red = "red" + + +class IntakeStatus(str, enum.Enum): + open = "open" + vetted = "vetted" + routed = "routed" + closed = "closed" + + +class IntakeOutcome(str, enum.Enum): + promoted = "promoted" + declined = "declined" + absorbed = "absorbed" + + +OPEN_INTAKE_STATUSES = (IntakeStatus.open, IntakeStatus.vetted, IntakeStatus.routed) + + +class Intake(Base, TimestampMixin): + """A `kind: intake` work record — a spark: idea, finding, directive, or + request, per canon/standards/work-record-types_v0.1.md. Lifecycle: + open -> vetted -> routed -> closed(promoted|declined|absorbed). + + Fresh entity per the founder-reviewed architecture draft (2026-07-20, + WorkOrchestrationArchitectureDraft.md §8 item 6): not a rename/reuse of + the legacy `suggestions` table. + """ + + __tablename__ = "intakes" + __table_args__ = ( + CheckConstraint( + "topic_id IS NOT NULL OR workplan_id IS NOT NULL OR repo_id IS NOT NULL", + name="ck_intakes_topic_or_workplan_or_repo", + ), + CheckConstraint( + "(status != 'closed') OR (outcome IS NOT NULL)", + name="ck_intakes_closed_requires_outcome", + ), + CheckConstraint( + "(outcome != 'promoted') OR (promoted_to IS NOT NULL)", + name="ck_intakes_promoted_requires_promoted_to", + ), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid7 + ) + topic_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True, index=True + ) + workplan_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("workplans.id", ondelete="SET NULL", onupdate="CASCADE"), + nullable=True, + index=True, + ) + repo_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True, index=True + ) + title: Mapped[str] = mapped_column(String(500), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + lane: Mapped[IntakeLane] = mapped_column( + Enum(IntakeLane, name="intakelane"), nullable=False, default=IntakeLane.green + ) + status: Mapped[IntakeStatus] = mapped_column( + Enum(IntakeStatus, name="intakestatus"), + nullable=False, + default=IntakeStatus.open, + index=True, + ) + outcome: Mapped[IntakeOutcome | None] = mapped_column( + Enum(IntakeOutcome, name="intakeoutcome"), nullable=True + ) + origin: Mapped[str | None] = mapped_column(String(200), nullable=True) + origin_ref: Mapped[str | None] = mapped_column(String(200), nullable=True, index=True) + promoted_to: Mapped[str | None] = mapped_column(String(200), nullable=True) + source_repo_path: Mapped[str | None] = mapped_column( + Text, nullable=True, doc="Repo-relative path of the source file this record was authored in." + ) + routed_note: Mapped[str | None] = mapped_column(Text, nullable=True) + closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + topic: Mapped["Topic | None"] = relationship("Topic", lazy="selectin") # noqa: F821 + workplan: Mapped["Workplan | None"] = relationship("Workplan", lazy="selectin") # noqa: F821 + repo: Mapped["ManagedRepo | None"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821 + notes: Mapped[list["IntakeNote"]] = relationship( + "IntakeNote", + back_populates="intake", + lazy="selectin", + order_by="IntakeNote.created_at", + cascade="all, delete-orphan", + ) + + +class IntakeNote(Base): + __tablename__ = "intake_notes" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=new_uuid7) + intake_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("intakes.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + author: Mapped[str | None] = mapped_column(String(100), nullable=True) + content: Mapped[str] = mapped_column(Text, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + intake: Mapped["Intake"] = relationship("Intake", back_populates="notes") diff --git a/api/models/managed_repo.py b/api/models/managed_repo.py index edf4034..993e236 100644 --- a/api/models/managed_repo.py +++ b/api/models/managed_repo.py @@ -53,6 +53,21 @@ class ManagedRepo(Base, TimestampMixin): "RepoGoal", back_populates="repo", lazy="selectin" ) + forge_identity: Mapped["RepositoryForgeIdentity | None"] = relationship( # noqa: F821 + "RepositoryForgeIdentity", + back_populates="repo", + lazy="selectin", + uselist=False, + ) + + slug_records: Mapped[list["RepositorySlug"]] = relationship( # noqa: F821 + "RepositorySlug", back_populates="repo", lazy="selectin" + ) + + rename_operations: Mapped[list["RepositoryRenameOperation"]] = relationship( # noqa: F821 + "RepositoryRenameOperation", back_populates="repo", lazy="selectin" + ) + @property def domain_slug(self) -> str: return self.domain.slug if self.domain is not None else "" diff --git a/api/models/progress_event.py b/api/models/progress_event.py index ca11ffd..b73fe60 100644 --- a/api/models/progress_event.py +++ b/api/models/progress_event.py @@ -20,10 +20,16 @@ class ProgressEvent(Base): UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=True, index=True ) workplan_id: Mapped[uuid.UUID | None] = mapped_column( - UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="RESTRICT"), nullable=True, index=True + UUID(as_uuid=True), + ForeignKey("workplans.id", ondelete="RESTRICT", onupdate="CASCADE"), + nullable=True, + index=True, ) task_id: Mapped[uuid.UUID | None] = mapped_column( - UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="RESTRICT"), nullable=True, index=True + UUID(as_uuid=True), + ForeignKey("tasks.id", ondelete="RESTRICT", onupdate="CASCADE"), + nullable=True, + index=True, ) decision_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("decisions.id", ondelete="RESTRICT"), nullable=True, index=True diff --git a/api/models/repository_rename.py b/api/models/repository_rename.py new file mode 100644 index 0000000..b27a92c --- /dev/null +++ b/api/models/repository_rename.py @@ -0,0 +1,292 @@ +import uuid +from datetime import datetime + +from sqlalchemy import ( + BigInteger, + Boolean, + CheckConstraint, + DateTime, + ForeignKey, + ForeignKeyConstraint, + Index, + String, + Text, + UniqueConstraint, + text, +) +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from api.models.base import Base, TimestampMixin, new_uuid + + +FORGE_IDENTITY_STATES = ("unverified", "verified") +REPOSITORY_SLUG_KINDS = ("canonical", "alias") +REPOSITORY_RENAME_PHASES = ( + "draft", + "preflighted", + "forge-renamed", + "statehub-rebound", + "source-synced", + "consumers-verified", + "completed", + "rollback-preflight", + "rolled-back", +) +TERMINAL_REPOSITORY_RENAME_PHASES = ("completed", "rolled-back") + + +class RepositoryForgeIdentity(Base, TimestampMixin): + """One explicitly verified (or explicitly unverified) Forge identity per repo.""" + + __tablename__ = "repository_forge_identities" + __table_args__ = ( + UniqueConstraint("repo_id", name="uq_repository_forge_identities_repo_id"), + UniqueConstraint( + "id", + "repo_id", + "verification_state", + name="uq_repository_forge_identity_verified_ref", + ), + UniqueConstraint( + "provider", + "forge_instance", + "forge_owner", + "forge_repository_id", + name="uq_repository_forge_identity_tuple", + ), + CheckConstraint( + "verification_state IN ('unverified', 'verified')", + name="ck_repository_forge_identity_state", + ), + CheckConstraint( + "forge_repository_id IS NULL OR forge_repository_id > 0", + name="ck_repository_forge_id_positive", + ), + CheckConstraint( + "verification_state != 'verified' OR " + "(provider IS NOT NULL AND forge_instance IS NOT NULL " + "AND forge_owner IS NOT NULL AND forge_repository_id IS NOT NULL " + "AND verified_at IS NOT NULL AND verified_by IS NOT NULL)", + name="ck_repository_forge_verified_complete", + ), + CheckConstraint( + "verification_state = 'verified' OR " + "(verified_at IS NULL AND verified_by IS NULL)", + name="ck_repository_forge_unverified_has_no_attestation", + ), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + repo_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("managed_repos.id", ondelete="RESTRICT"), + nullable=False, + ) + provider: Mapped[str | None] = mapped_column(String(40), nullable=True) + forge_instance: Mapped[str | None] = mapped_column(String(255), nullable=True) + forge_owner: Mapped[str | None] = mapped_column(String(255), nullable=True) + forge_repository_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + verification_state: Mapped[str] = mapped_column( + String(20), nullable=False, default="unverified", server_default="unverified" + ) + verified_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + verified_by: Mapped[str | None] = mapped_column(String(160), nullable=True) + verification_evidence: Mapped[dict] = mapped_column( + JSONB, nullable=False, default=dict, server_default="{}" + ) + + repo: Mapped["ManagedRepo"] = relationship( # noqa: F821 + "ManagedRepo", back_populates="forge_identity", lazy="selectin" + ) + rename_operations: Mapped[list["RepositoryRenameOperation"]] = relationship( + "RepositoryRenameOperation", + back_populates="forge_identity", + lazy="selectin", + viewonly=True, + ) + + +class RepositoryRenameOperation(Base, TimestampMixin): + """Append-preserving journal for one phased canonical-coordinate change.""" + + __tablename__ = "repository_rename_operations" + __table_args__ = ( + CheckConstraint( + "phase IN ('draft', 'preflighted', 'forge-renamed', " + "'statehub-rebound', 'source-synced', 'consumers-verified', " + "'completed', 'rollback-preflight', 'rolled-back')", + name="ck_repository_rename_phase", + ), + CheckConstraint( + "old_slug != new_slug", name="ck_repository_rename_distinct_slugs" + ), + CheckConstraint( + "expected_forge_repository_id > 0", + name="ck_repository_rename_forge_id_positive", + ), + CheckConstraint( + "forge_identity_state = 'verified'", + name="ck_repository_rename_verified_identity", + ), + CheckConstraint( + "phase = 'draft' OR preflighted_at IS NOT NULL", + name="ck_repository_rename_preflight_timestamp", + ), + CheckConstraint( + "phase != 'completed' OR completed_at IS NOT NULL", + name="ck_repository_rename_completed_timestamp", + ), + CheckConstraint( + "phase != 'rolled-back' OR rolled_back_at IS NOT NULL", + name="ck_repository_rename_rollback_timestamp", + ), + Index( + "uq_repository_rename_active_repo", + "repo_id", + unique=True, + postgresql_where=text("phase NOT IN ('completed', 'rolled-back')"), + ), + Index( + "uq_repository_rename_active_new_slug", + "new_slug", + unique=True, + postgresql_where=text("phase NOT IN ('completed', 'rolled-back')"), + ), + Index( + "ix_repository_rename_phase", "phase" + ), + ForeignKeyConstraint( + ["forge_identity_id", "repo_id", "forge_identity_state"], + [ + "repository_forge_identities.id", + "repository_forge_identities.repo_id", + "repository_forge_identities.verification_state", + ], + name="fk_repository_rename_verified_identity", + ondelete="RESTRICT", + ), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + repo_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("managed_repos.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + forge_identity_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), nullable=False + ) + forge_identity_state: Mapped[str] = mapped_column( + String(20), nullable=False, default="verified", server_default="verified" + ) + expected_provider: Mapped[str] = mapped_column(String(40), nullable=False) + expected_forge_instance: Mapped[str] = mapped_column(String(255), nullable=False) + expected_forge_owner: Mapped[str] = mapped_column(String(255), nullable=False) + expected_forge_repository_id: Mapped[int] = mapped_column( + BigInteger, nullable=False + ) + expected_source_commit: Mapped[str] = mapped_column(String(64), nullable=False) + expected_default_branch: Mapped[str] = mapped_column(String(255), nullable=False) + old_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + new_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + old_coordinates: Mapped[dict] = mapped_column(JSONB, nullable=False) + new_coordinates: Mapped[dict] = mapped_column(JSONB, nullable=False) + phase: Mapped[str] = mapped_column( + String(32), nullable=False, default="draft", server_default="draft" + ) + actor: Mapped[str] = mapped_column(String(160), nullable=False) + phase_changed_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False + ) + preflighted_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + preflight_expires_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + completed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + rolled_back_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + evidence: Mapped[dict] = mapped_column( + JSONB, nullable=False, default=dict, server_default="{}" + ) + error_code: Mapped[str | None] = mapped_column(String(80), nullable=True) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + error_details: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + error_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + repo: Mapped["ManagedRepo"] = relationship( # noqa: F821 + "ManagedRepo", back_populates="rename_operations", lazy="selectin" + ) + forge_identity: Mapped[RepositoryForgeIdentity] = relationship( + "RepositoryForgeIdentity", + back_populates="rename_operations", + lazy="selectin", + viewonly=True, + ) + slugs: Mapped[list["RepositorySlug"]] = relationship( + "RepositorySlug", back_populates="source_operation", lazy="selectin" + ) + + +class RepositorySlug(Base, TimestampMixin): + """Global uniqueness boundary for current and protected prior slugs.""" + + __tablename__ = "repository_slugs" + __table_args__ = ( + UniqueConstraint("slug", name="uq_repository_slugs_slug"), + CheckConstraint( + "kind IN ('canonical', 'alias')", name="ck_repository_slug_kind" + ), + CheckConstraint( + "kind != 'alias' OR protected", + name="ck_repository_slug_alias_protected", + ), + Index( + "uq_repository_slugs_one_canonical", + "repo_id", + unique=True, + postgresql_where=text("kind = 'canonical'"), + ), + Index("ix_repository_slugs_repo_kind", "repo_id", "kind"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + repo_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("managed_repos.id", ondelete="RESTRICT"), + nullable=False, + ) + slug: Mapped[str] = mapped_column(String(100), nullable=False) + kind: Mapped[str] = mapped_column(String(16), nullable=False) + protected: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=True, server_default="true" + ) + source_operation_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("repository_rename_operations.id", ondelete="RESTRICT"), + nullable=True, + index=True, + ) + + repo: Mapped["ManagedRepo"] = relationship( # noqa: F821 + "ManagedRepo", back_populates="slug_records", lazy="selectin" + ) + source_operation: Mapped[RepositoryRenameOperation | None] = relationship( + "RepositoryRenameOperation", back_populates="slugs", lazy="selectin" + ) diff --git a/api/models/review_contract.py b/api/models/review_contract.py new file mode 100644 index 0000000..392235e --- /dev/null +++ b/api/models/review_contract.py @@ -0,0 +1,128 @@ +import uuid +from datetime import datetime + +from sqlalchemy import ( + Boolean, + DateTime, + ForeignKey, + Index, + String, + Text, + UniqueConstraint, +) +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from api.models.base import Base, TimestampMixin, new_uuid7 + + +class ReviewContract(Base, TimestampMixin): + """Replaceable projection of one authoritative review-contract revision.""" + + __tablename__ = "review_contracts" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid7 + ) + contract_key: Mapped[str] = mapped_column(String(160), nullable=False, index=True) + schema_version: Mapped[str] = mapped_column(String(40), nullable=False) + contract_digest: Mapped[str] = mapped_column( + String(64), nullable=False, unique=True + ) + source_repo: Mapped[str] = mapped_column(String(100), nullable=False) + source_path: Mapped[str] = mapped_column(Text, nullable=False) + source_revision: Mapped[str] = mapped_column(String(64), nullable=False) + document: Mapped[dict] = mapped_column(JSONB, nullable=False) + active: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=True, index=True + ) + required_for_decision: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False + ) + decision_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("decisions.id", ondelete="RESTRICT"), + nullable=True, + index=True, + ) + workplan_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("workplans.id", ondelete="RESTRICT", onupdate="CASCADE"), + nullable=True, + index=True, + ) + task_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("tasks.id", ondelete="RESTRICT", onupdate="CASCADE"), + nullable=True, + index=True, + ) + projected_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False + ) + + receipts: Mapped[list["ReviewReceipt"]] = relationship( + "ReviewReceipt", back_populates="contract", lazy="selectin" + ) + + __table_args__ = ( + UniqueConstraint( + "contract_key", + "source_repo", + "source_path", + "source_revision", + name="uq_review_contract_source_revision", + ), + Index("ix_review_contract_key_active", "contract_key", "active"), + ) + + +class ReviewReceipt(Base, TimestampMixin): + """Immutable evidence submitted against an exact contract digest.""" + + __tablename__ = "review_receipts" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid7 + ) + contract_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("review_contracts.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + owner_id: Mapped[str] = mapped_column(String(160), nullable=False, index=True) + actor: Mapped[str] = mapped_column(String(160), nullable=False) + disposition: Mapped[str] = mapped_column(String(32), nullable=False) + contract_digest: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + receipt_digest: Mapped[str] = mapped_column(String(64), nullable=False, unique=True) + artifact_hashes: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + assertion_ids: Mapped[list] = mapped_column(JSONB, nullable=False, default=list) + checks: Mapped[list] = mapped_column(JSONB, nullable=False, default=list) + note: Mapped[str | None] = mapped_column(Text, nullable=True) + source_repo: Mapped[str] = mapped_column(String(100), nullable=False) + source_path: Mapped[str] = mapped_column(Text, nullable=False) + source_revision: Mapped[str] = mapped_column(String(64), nullable=False) + submitted_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, index=True + ) + document: Mapped[dict] = mapped_column(JSONB, nullable=False) + + contract: Mapped[ReviewContract] = relationship( + "ReviewContract", back_populates="receipts" + ) + + __table_args__ = ( + UniqueConstraint( + "source_repo", + "source_path", + "source_revision", + name="uq_review_receipt_source_revision", + ), + Index( + "ix_review_receipt_contract_owner_time", + "contract_id", + "owner_id", + "submitted_at", + ), + ) diff --git a/api/models/suggestion.py b/api/models/suggestion.py index df5a791..7b46abc 100644 --- a/api/models/suggestion.py +++ b/api/models/suggestion.py @@ -36,7 +36,9 @@ class Suggestion(Base, TimestampMixin): UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True ) workplan_id: Mapped[uuid.UUID | None] = mapped_column( - UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True + UUID(as_uuid=True), + ForeignKey("workplans.id", ondelete="SET NULL", onupdate="CASCADE"), + nullable=True, ) title: Mapped[str] = mapped_column(String(500), nullable=False) description: Mapped[str | None] = mapped_column(Text, nullable=True) @@ -61,7 +63,9 @@ class Suggestion(Base, TimestampMixin): Float, nullable=False, default=1.0, server_default="1" ) promoted_task_id: Mapped[uuid.UUID | None] = mapped_column( - UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True + UUID(as_uuid=True), + ForeignKey("tasks.id", ondelete="SET NULL", onupdate="CASCADE"), + nullable=True, ) domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821 @@ -117,4 +121,4 @@ class SuggestionRelevanceBump(Base): reason: Mapped[str | None] = mapped_column(Text, nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False - ) \ No newline at end of file + ) diff --git a/api/models/task.py b/api/models/task.py index ecdb35b..b91d14e 100644 --- a/api/models/task.py +++ b/api/models/task.py @@ -31,8 +31,18 @@ class Task(Base, TimestampMixin): UUID(as_uuid=True), primary_key=True, default=new_uuid ) workplan_id: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="RESTRICT"), nullable=False, index=True + UUID(as_uuid=True), + ForeignKey("workplans.id", ondelete="RESTRICT", onupdate="CASCADE"), + nullable=False, + index=True, ) + # Canonical work-record identifier, e.g. "CUST-WP-0067-T01" (ADR-007). + # Nullable because rows created before STATE-WP-0083-T06 have none; only the + # repository files know the mapping. Without it a task can be matched only by + # title, so renaming a heading looks like one task vanishing and another + # appearing. + record_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True) + flavor: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) title: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[str | None] = mapped_column(Text, nullable=True) status: Mapped[TaskStatus] = mapped_column( @@ -47,7 +57,9 @@ class Task(Base, TimestampMixin): needs_human: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True) intervention_note: Mapped[str | None] = mapped_column(Text, nullable=True) parent_task_id: Mapped[uuid.UUID | None] = mapped_column( - UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True + UUID(as_uuid=True), + ForeignKey("tasks.id", ondelete="SET NULL", onupdate="CASCADE"), + nullable=True, ) workplan: Mapped["Workplan"] = relationship("Workplan", back_populates="tasks") # noqa: F821 diff --git a/api/models/technical_debt.py b/api/models/technical_debt.py index d501f60..334bf6a 100644 --- a/api/models/technical_debt.py +++ b/api/models/technical_debt.py @@ -77,7 +77,9 @@ class TechnicalDebt(Base, TimestampMixin): UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True ) workplan_id: Mapped[uuid.UUID | None] = mapped_column( - UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True + UUID(as_uuid=True), + ForeignKey("workplans.id", ondelete="SET NULL", onupdate="CASCADE"), + nullable=True, ) domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821 diff --git a/api/models/token_event.py b/api/models/token_event.py index cc85825..f1dfe79 100644 --- a/api/models/token_event.py +++ b/api/models/token_event.py @@ -1,9 +1,16 @@ import uuid from datetime import datetime - from typing import Any -from sqlalchemy import DateTime, Float, ForeignKey, Integer, Text, UniqueConstraint, func +from sqlalchemy import ( + DateTime, + Float, + ForeignKey, + Integer, + Text, + UniqueConstraint, + func, +) from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -25,10 +32,16 @@ class TokenEvent(Base): UUID(as_uuid=True), primary_key=True, default=new_uuid ) task_id: Mapped[uuid.UUID | None] = mapped_column( - UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True, index=True + UUID(as_uuid=True), + ForeignKey("tasks.id", ondelete="SET NULL", onupdate="CASCADE"), + nullable=True, + index=True, ) workplan_id: Mapped[uuid.UUID | None] = mapped_column( - UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True, index=True + UUID(as_uuid=True), + ForeignKey("workplans.id", ondelete="SET NULL", onupdate="CASCADE"), + nullable=True, + index=True, ) repo_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True, index=True diff --git a/api/models/work_record_identifier_alias.py b/api/models/work_record_identifier_alias.py new file mode 100644 index 0000000..23f389c --- /dev/null +++ b/api/models/work_record_identifier_alias.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import CheckConstraint, DateTime, String, UniqueConstraint +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from api.models.base import Base, TimestampMixin, new_uuid + + +class WorkRecordIdentifierAlias(Base, TimestampMixin): + """Durable provenance for a canonical work-record UUID replacement.""" + + __tablename__ = "work_record_identifier_aliases" + __table_args__ = ( + CheckConstraint( + "record_kind IN ('workplan', 'task')", + name="ck_work_record_identifier_aliases_kind", + ), + CheckConstraint( + "migration_status IN ('prepared', 'applied', 'reversed')", + name="ck_work_record_identifier_aliases_status", + ), + UniqueConstraint("old_id", name="uq_work_record_identifier_aliases_old_id"), + UniqueConstraint("new_id", name="uq_work_record_identifier_aliases_new_id"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + old_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False) + new_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False) + record_kind: Mapped[str] = mapped_column(String(20), nullable=False) + record_id: Mapped[str] = mapped_column(String(160), nullable=False, index=True) + repo_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + namespace: Mapped[str] = mapped_column(String(64), nullable=False) + plan_sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + migration_status: Mapped[str] = mapped_column( + String(20), nullable=False, default="prepared", server_default="prepared" + ) + applied_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + reversed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/api/models/workplan.py b/api/models/workplan.py index 7fcf51f..8006b28 100644 --- a/api/models/workplan.py +++ b/api/models/workplan.py @@ -39,6 +39,9 @@ class Workplan(Base, TimestampMixin): queue_rank: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) execution_group: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True) scheduled_for: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + flavor: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) + flavor_promotion_reason: Mapped[str | None] = mapped_column(String(32), nullable=True) + flavor_promoted_from: Mapped[str | None] = mapped_column(String(32), nullable=True) repo_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), @@ -53,6 +56,16 @@ class Workplan(Base, TimestampMixin): index=True, ) backing_filename: Mapped[str | None] = mapped_column(String(255), nullable=True) + # Projection provenance and retirement (ADR-012 decisions 2 and 7). + # A retired record is one the forge no longer derives. It is not deleted: + # hub-native records reference it with RESTRICT and must survive. It is not + # `archived` either — that says the owner closed the work, which is a + # different fact. + projection_retired_at: Mapped["datetime | None"] = mapped_column( # noqa: F821 + DateTime(timezone=True), nullable=True, index=True + ) + projection_retired_reason: Mapped[str | None] = mapped_column(String(255), nullable=True) + derived_from_commit: Mapped[str | None] = mapped_column(String(40), nullable=True) backing_relative_path: Mapped[str | None] = mapped_column(Text, nullable=True) backing_archived: Mapped[bool | None] = mapped_column(nullable=True) backing_synced_at: Mapped[datetime | None] = mapped_column( diff --git a/api/models/workplan_dependency.py b/api/models/workplan_dependency.py index 4ec5391..d74b6e0 100644 --- a/api/models/workplan_dependency.py +++ b/api/models/workplan_dependency.py @@ -45,19 +45,19 @@ class WorkplanDependency(Base, TimestampMixin): ) from_workplan_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), - ForeignKey("workplans.id", ondelete="CASCADE"), + ForeignKey("workplans.id", ondelete="CASCADE", onupdate="CASCADE"), nullable=False, index=True, ) to_workplan_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), - ForeignKey("workplans.id", ondelete="CASCADE"), + ForeignKey("workplans.id", ondelete="CASCADE", onupdate="CASCADE"), nullable=True, index=True, ) to_task_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), - ForeignKey("tasks.id", ondelete="CASCADE"), + ForeignKey("tasks.id", ondelete="CASCADE", onupdate="CASCADE"), nullable=True, index=True, ) @@ -72,4 +72,4 @@ class WorkplanDependency(Base, TimestampMixin): to_workplan: Mapped["Workplan | None"] = relationship( # noqa: F821 "Workplan", foreign_keys=[to_workplan_id] ) - to_task: Mapped["Task | None"] = relationship("Task", foreign_keys=[to_task_id]) # noqa: F821 \ No newline at end of file + to_task: Mapped["Task | None"] = relationship("Task", foreign_keys=[to_task_id]) # noqa: F821 diff --git a/api/models/workplan_launch_request.py b/api/models/workplan_launch_request.py index dec994d..284bd37 100644 --- a/api/models/workplan_launch_request.py +++ b/api/models/workplan_launch_request.py @@ -15,7 +15,7 @@ class WorkplanLaunchRequest(Base, TimestampMixin): ) workplan_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), - ForeignKey("workplans.id", ondelete="CASCADE"), + ForeignKey("workplans.id", ondelete="CASCADE", onupdate="CASCADE"), nullable=False, index=True, ) diff --git a/api/routers/capability_requests.py b/api/routers/capability_requests.py index 3192004..271ed41 100644 --- a/api/routers/capability_requests.py +++ b/api/routers/capability_requests.py @@ -1,7 +1,7 @@ import re import uuid from datetime import datetime, timezone -from fastapi import HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -11,7 +11,6 @@ from api.models.agent_message import AgentMessage from api.models.capability_catalog import CapabilityCatalog from api.models.capability_request import CapabilityRequest from api.models.domain import Domain -from api.models.managed_repo import ManagedRepo from api.models.task import Task from api.services.suggestion_relevance import bump_matching_for_capability_request from api.schemas.capability_request import ( @@ -22,12 +21,15 @@ from api.schemas.capability_request import ( CapabilityRequestRead, CapabilityRequestReroute, CapabilityRequestStatusPatch, + CatalogCreate, + CatalogPatch, + CatalogRead, ) from hub_core.routers.capabilities import ( - create_capability_catalog_router, create_capability_request_read_router, create_capability_request_write_router, ) +from api.services.repository_aliases import resolve_repository_slug # --------------------------------------------------------------------------- @@ -394,12 +396,102 @@ def _check_transition(current: str, target: str) -> None: ) -router = create_capability_catalog_router( - get_session, - domain_model=Domain, - repo_model=ManagedRepo, - catalog_model=CapabilityCatalog, +router = APIRouter(tags=["capability-requests"]) + + +async def _catalog_domain(slug: str, session: AsyncSession) -> Domain: + domain = ( + await session.execute(select(Domain).where(Domain.slug == slug)) + ).scalar_one_or_none() + if domain is None: + raise HTTPException(status_code=404, detail=f"Domain '{slug}' not found") + return domain + + +@router.post( + "/capability-catalog/", + response_model=CatalogRead, + status_code=status.HTTP_201_CREATED, ) +async def create_catalog_entry( + body: CatalogCreate, + session: AsyncSession = Depends(get_session), +) -> CapabilityCatalog: + domain = await _catalog_domain(body.domain, session) + repo_id = None + if body.repo_slug: + repo_id = (await resolve_repository_slug(session, body.repo_slug)).repo.id + entry = CapabilityCatalog( + domain_id=domain.id, + repo_id=repo_id, + capability_type=body.capability_type, + title=body.title, + description=body.description, + keywords=body.keywords, + ) + session.add(entry) + try: + await session.commit() + except Exception: + await session.rollback() + raise HTTPException( + status_code=409, + detail=( + f"Catalog entry '{body.title}' for type '{body.capability_type}' " + f"already exists in domain '{body.domain}'" + ), + ) + await session.refresh(entry) + return entry + + +@router.get("/capability-catalog/", response_model=list[CatalogRead]) +async def list_catalog( + domain: str | None = Query(None), + capability_type: str | None = Query(None), + status_filter: str | None = Query(None, alias="status"), + repo_slug: str | None = Query(None), + session: AsyncSession = Depends(get_session), +) -> list[CapabilityCatalog]: + query = select(CapabilityCatalog).order_by(CapabilityCatalog.created_at.desc()) + if domain: + query = query.where( + CapabilityCatalog.domain_id == (await _catalog_domain(domain, session)).id + ) + if capability_type: + query = query.where(CapabilityCatalog.capability_type == capability_type) + if repo_slug: + query = query.where( + CapabilityCatalog.repo_id + == (await resolve_repository_slug(session, repo_slug)).repo.id + ) + if status_filter and status_filter != "all": + query = query.where(CapabilityCatalog.status == status_filter) + elif not status_filter: + query = query.where(CapabilityCatalog.status == "active") + return list((await session.execute(query)).scalars().all()) + + +@router.patch("/capability-catalog/{entry_id}", response_model=CatalogRead) +async def patch_catalog_entry( + entry_id: uuid.UUID, + body: CatalogPatch, + session: AsyncSession = Depends(get_session), +) -> CapabilityCatalog: + entry = await session.get(CapabilityCatalog, entry_id) + if entry is None: + raise HTTPException(status_code=404, detail=f"Catalog entry '{entry_id}' not found") + if body.repo_slug is not None: + entry.repo_id = (await resolve_repository_slug(session, body.repo_slug)).repo.id + for field in ("description", "keywords", "status"): + value = getattr(body, field) + if value is not None: + setattr(entry, field, value) + await session.commit() + await session.refresh(entry) + return entry + + router.include_router( create_capability_request_read_router( get_session, @@ -432,4 +524,4 @@ router.include_router( after_dispute=_notify_on_dispute, after_reroute=_notify_on_reroute, ) -) \ No newline at end of file +) diff --git a/api/routers/decisions.py b/api/routers/decisions.py index 9e777b7..069e9a0 100644 --- a/api/routers/decisions.py +++ b/api/routers/decisions.py @@ -6,7 +6,6 @@ from pathlib import Path from fastapi import APIRouter, Depends, HTTPException, Request, Response, status -logger = logging.getLogger(__name__) from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -14,9 +13,12 @@ from api.database import get_session from api.events import EventEnvelope, publish_event from api.models.decision import Decision, DecisionStatus, DecisionType from api.models.progress_event import ProgressEvent +from api.models.review_contract import ReviewContract from api.schemas.decision import DecisionCreate, DecisionRead, DecisionResolve, DecisionUpdate from api.services.legacy_compat import meter_legacy_body_from_model, meter_legacy_query_param +from api.services.review_contracts import aggregate +logger = logging.getLogger(__name__) router = APIRouter(prefix="/decisions", tags=["decisions"]) _FINANCIAL_LEGAL_KEYWORDS = ( @@ -154,6 +156,25 @@ async def resolve_decision_action( if decision.status == DecisionStatus.resolved: raise HTTPException(status_code=409, detail="Decision already resolved") + review_rows = await session.execute( + select(ReviewContract).where( + ReviewContract.decision_id == decision.id, + ReviewContract.active.is_(True), + ReviewContract.required_for_decision.is_(True), + ) + ) + for contract in review_rows.scalars(): + review_state = await aggregate(session, contract) + if not review_state.satisfied: + raise HTTPException( + status_code=409, + detail={ + "message": "required multi-owner review is not satisfied", + "contract_key": contract.contract_key, + "contract_digest": contract.contract_digest, + }, + ) + decision.status = DecisionStatus.resolved decision.decision_type = DecisionType.made decision.rationale = body.rationale diff --git a/api/routers/execution.py b/api/routers/execution.py index 9f8adbc..c55a519 100644 --- a/api/routers/execution.py +++ b/api/routers/execution.py @@ -21,9 +21,9 @@ from api.services.execution_queue import ( ACTIVITY_CORE_RESPONSIBILITIES, CONCURRENCY_MODES, EXECUTION_STATES, + EXECUTION_REPLACEMENTS, LAUNCH_MODES, STATE_HUB_RESPONSIBILITIES, - execution_state_for_launch, queue_sort_key, workplan_blockers, ) @@ -43,6 +43,8 @@ async def execution_semantics() -> ExecutionSemantics: concurrency_modes=CONCURRENCY_MODES, state_hub_responsibility=STATE_HUB_RESPONSIBILITIES, activity_core_responsibility=ACTIVITY_CORE_RESPONSIBILITIES, + launch_requests_accepted=False, + replacements=EXECUTION_REPLACEMENTS, ) @@ -161,37 +163,27 @@ async def workplan_stack( @router.post( "/launch-requests", - response_model=LaunchRequestRead, - status_code=status.HTTP_201_CREATED, + status_code=status.HTTP_410_GONE, ) async def create_launch_request( + request: Request, + response: Response, body: LaunchRequestCreate, session: AsyncSession = Depends(get_session), -) -> WorkplanLaunchRequest: - ws = await session.get(Workplan, body.workplan_id) - if ws is None: - raise HTTPException(status_code=404, detail="Workplan not found") - - launch_request = WorkplanLaunchRequest( - workplan_id=ws.id, - requested_by=body.requested_by, - requested_actor=body.requested_actor, - launch_mode=body.launch_mode, - concurrency_mode=body.concurrency_mode, - priority=body.priority or ws.planning_priority, - repo_id=body.repo_id or ws.repo_id, - branch_preference=body.branch_preference, - immediate_pickup=body.immediate_pickup, - notes=body.notes, - request_metadata=body.request_metadata, +) -> None: + del body # Request shape stays documented while the retired route returns 410. + await retire_legacy_route( + session=session, + request=request, + response=response, + interface_key="rest_api:POST /execution/launch-requests", + replacement_ref="repo file queue or activity-core ActivityDefinition + ops_run", + detail=( + "State Hub workplan launch requests are retired: no consumer picks up these rows. " + "Queue development work in the authoritative repository file; use an " + "ActivityDefinition and activity-core ops_run only for recurring or operational fires." + ), ) - ws.launch_mode = body.launch_mode - ws.concurrency_mode = body.concurrency_mode - ws.execution_state = execution_state_for_launch(body.launch_mode, body.immediate_pickup) - session.add(launch_request) - await session.commit() - await session.refresh(launch_request) - return launch_request @router.get("/launch-requests", response_model=list[LaunchRequestRead]) diff --git a/api/routers/fabric.py b/api/routers/fabric.py index c389342..98e704b 100644 --- a/api/routers/fabric.py +++ b/api/routers/fabric.py @@ -1,5 +1,6 @@ from __future__ import annotations +import uuid from typing import Any import httpx @@ -10,6 +11,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from api.database import get_session from api.models.fabric_graph import FabricGraphEdge, FabricGraphImport, FabricGraphNode from api.schemas.fabric_graph import ( + FabricGraphActivationRequest, + FabricGraphActivationResult, FabricGraphEdgeRead, FabricGraphImportRead, FabricGraphIngestResult, @@ -19,10 +22,12 @@ from api.schemas.fabric_graph import ( ) from api.services.fabric_graph import ( FabricGraphValidationError, + activate_fabric_graph_import, ingest_fabric_graph_export, record_fabric_graph_error, split_graph_ingest_body, ) +from api.services.repository_aliases import resolve_repository_slug router = APIRouter(prefix="/fabric", tags=["fabric"]) @@ -131,6 +136,34 @@ async def latest_graph_import( return FabricGraphImportRead.model_validate(import_run) +@router.post( + "/graph-exports/{import_id}/activate", + response_model=FabricGraphActivationResult, +) +async def activate_graph_import( + import_id: uuid.UUID, + body: FabricGraphActivationRequest | None = None, + session: AsyncSession = Depends(get_session), +) -> FabricGraphActivationResult: + request = body or FabricGraphActivationRequest() + try: + import_run, previous_import_id, activated = await activate_fabric_graph_import( + session, + import_id, + requested_by=request.requested_by, + ) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + if import_run is None: + raise HTTPException(status_code=404, detail=f"Fabric graph import '{import_id}' not found") + return FabricGraphActivationResult( + import_run=FabricGraphImportRead.model_validate(import_run), + previous_import_id=previous_import_id, + activated=activated, + idempotent=not activated, + ) + + @router.get("/graph/nodes", response_model=list[FabricGraphNodeRead]) async def list_graph_nodes( source_repo_slug: str = "railiance-fabric", @@ -157,7 +190,12 @@ async def list_graph_nodes( if domain: query = query.where(FabricGraphNode.domain_slug == domain) if repo: - query = query.where(FabricGraphNode.repo_slug == repo) + resolution = await resolve_repository_slug(session, repo, required=False) + query = query.where( + FabricGraphNode.repo_slug.in_( + resolution.slug_values if resolution else (repo,) + ) + ) if canonical_category: query = query.where(FabricGraphNode.canon_category == canonical_category) if fabric_id: diff --git a/api/routers/identifier_migrations.py b/api/routers/identifier_migrations.py new file mode 100644 index 0000000..95cd647 --- /dev/null +++ b/api/routers/identifier_migrations.py @@ -0,0 +1,118 @@ +from dataclasses import asdict + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.config import settings +from api.schemas.identifier_migration import ( + IdentifierMigrationApplySubmit, + SealedProjectionRepairReceiptRead, + SealedProjectionRepairSubmit, +) +from api.services.work_record_identifier_migration import ( + IdentifierMigrationError, + apply_repository_identifier_migration, + repair_absent_prederivation_projection, + reverse_repository_identifier_migration, +) + +router = APIRouter(prefix="/identifier-migrations", tags=["identifier-migrations"]) + + +@router.post("/repositories/{repo_slug}/apply") +async def apply_identifier_migration( + repo_slug: str, + body: IdentifierMigrationApplySubmit, + session: AsyncSession = Depends(get_session), +) -> dict: + """Apply one sealed repository-atomic identifier convergence transaction.""" + if settings.state_hub_instance_role != "primary": + raise HTTPException( + status_code=409, + detail={ + "message": "identifier migration writes require the primary State Hub", + "instance_role": settings.state_hub_instance_role, + "instance_label": settings.state_hub_instance_label, + }, + ) + if not body.primary_confirmed: + raise HTTPException(status_code=409, detail="explicit primary confirmation is required") + if body.plan.get("plan_sha256") != body.expected_plan_sha256: + raise HTTPException(status_code=409, detail="explicit plan SHA-256 does not match sealed plan") + try: + result = await apply_repository_identifier_migration(session, body.plan, repo_slug) + except IdentifierMigrationError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + from api.routers.workstreams import _invalidate_workplan_index_cache + + _invalidate_workplan_index_cache() + return { + "schema": "state-hub.identifier-migration-apply.v1", + "instance_role": settings.state_hub_instance_role, + "instance_label": settings.state_hub_instance_label, + "result": asdict(result), + } + + +@router.post("/repositories/{repo_slug}/reverse") +async def reverse_identifier_migration( + repo_slug: str, + body: IdentifierMigrationApplySubmit, + session: AsyncSession = Depends(get_session), +) -> dict: + """Reverse one sealed repository migration after a failed file phase.""" + if settings.state_hub_instance_role != "primary": + raise HTTPException( + status_code=409, + detail={ + "message": "identifier migration writes require the primary State Hub", + "instance_role": settings.state_hub_instance_role, + "instance_label": settings.state_hub_instance_label, + }, + ) + if not body.primary_confirmed: + raise HTTPException(status_code=409, detail="explicit primary confirmation is required") + if body.plan.get("plan_sha256") != body.expected_plan_sha256: + raise HTTPException(status_code=409, detail="explicit plan SHA-256 does not match sealed plan") + try: + result = await reverse_repository_identifier_migration(session, body.plan, repo_slug) + except IdentifierMigrationError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + from api.routers.workstreams import _invalidate_workplan_index_cache + + _invalidate_workplan_index_cache() + return { + "schema": "state-hub.identifier-migration-reverse.v1", + "instance_role": settings.state_hub_instance_role, + "instance_label": settings.state_hub_instance_label, + "result": asdict(result), + } + + +@router.post( + "/sealed-projection-repairs", + response_model=SealedProjectionRepairReceiptRead, +) +async def repair_sealed_projection( + body: SealedProjectionRepairSubmit, + session: AsyncSession = Depends(get_session), +) -> dict: + """Restore one exact pre-derivation workplan unit into an absent projection.""" + try: + receipt = await repair_absent_prederivation_projection( + session, + body.plan, + body.repo_slug, + body.unit, + expected_plan_sha256=body.expected_plan_sha256, + source_revision=body.source_revision, + source_fingerprint=body.source_fingerprint, + source_clean=body.source_clean, + source_synchronized=body.source_synchronized, + primary_confirmed=body.primary_confirmed, + projection_identity=body.projection_identity, + ) + except IdentifierMigrationError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + return asdict(receipt) diff --git a/api/routers/intake.py b/api/routers/intake.py new file mode 100644 index 0000000..2301205 --- /dev/null +++ b/api/routers/intake.py @@ -0,0 +1,173 @@ +import uuid +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.models.intake import Intake, IntakeNote, IntakeOutcome, IntakeStatus +from api.models.progress_event import ProgressEvent +from api.schemas.intake import ( + IntakeClose, + IntakeCreate, + IntakeNoteCreate, + IntakeRead, + IntakeRoute, + IntakeUpdate, +) + +router = APIRouter(prefix="/intakes", tags=["intakes"]) + +_ALLOWED_ROUTE_FROM = {IntakeStatus.open, IntakeStatus.vetted} +_ALLOWED_CLOSE_FROM = {IntakeStatus.open, IntakeStatus.vetted, IntakeStatus.routed} + + +def _reject_status(intake: Intake, allowed: set[IntakeStatus], action: str) -> None: + if intake.status not in allowed: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=( + f"Cannot {action} intake in status '{intake.status.value}'; " + f"allowed from: {sorted(s.value for s in allowed)}" + ), + ) + + +@router.get("/", response_model=list[IntakeRead]) +async def list_intakes( + topic_id: uuid.UUID | None = None, + workplan_id: uuid.UUID | None = None, + repo_id: uuid.UUID | None = None, + status_: IntakeStatus | None = None, + session: AsyncSession = Depends(get_session), +) -> list[Intake]: + q = select(Intake) + if topic_id: + q = q.where(Intake.topic_id == topic_id) + if workplan_id: + q = q.where(Intake.workplan_id == workplan_id) + if repo_id: + q = q.where(Intake.repo_id == repo_id) + if status_: + q = q.where(Intake.status == status_) + q = q.order_by(Intake.created_at) + result = await session.execute(q) + return list(result.scalars().all()) + + +@router.post("/", response_model=IntakeRead, status_code=status.HTTP_201_CREATED) +async def create_intake( + body: IntakeCreate, + session: AsyncSession = Depends(get_session), +) -> Intake: + intake = Intake(**body.model_dump()) + session.add(intake) + await session.commit() + await session.refresh(intake) + return intake + + +@router.get("/{intake_id}", response_model=IntakeRead) +async def get_intake( + intake_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> Intake: + intake = await session.get(Intake, intake_id) + if intake is None: + raise HTTPException(status_code=404, detail="Intake not found") + return intake + + +@router.patch("/{intake_id}", response_model=IntakeRead) +async def update_intake( + intake_id: uuid.UUID, + body: IntakeUpdate, + session: AsyncSession = Depends(get_session), +) -> Intake: + intake = await session.get(Intake, intake_id) + if intake is None: + raise HTTPException(status_code=404, detail="Intake not found") + for field, value in body.model_dump(exclude_unset=True).items(): + setattr(intake, field, value) + await session.commit() + await session.refresh(intake) + return intake + + +@router.post("/{intake_id}/route", response_model=IntakeRead) +async def route_intake( + intake_id: uuid.UUID, + body: IntakeRoute, + session: AsyncSession = Depends(get_session), +) -> Intake: + """Move an intake into `routed` — eligible for the promotion transition.""" + intake = await session.get(Intake, intake_id) + if intake is None: + raise HTTPException(status_code=404, detail="Intake not found") + _reject_status(intake, _ALLOWED_ROUTE_FROM, "route") + + intake.status = IntakeStatus.routed + if body.routed_note: + intake.routed_note = body.routed_note + await session.commit() + await session.refresh(intake) + return intake + + +@router.post("/{intake_id}/close", response_model=IntakeRead) +async def close_intake( + intake_id: uuid.UUID, + body: IntakeClose, + session: AsyncSession = Depends(get_session), +) -> Intake: + """Close an intake with an outcome. `outcome=promoted` requires + `promoted_to` (the canonical id of the record it became) — this is + normally called by the promotion transition (CUST-WP-0061-T03), not by + hand, but a manual close (declined/absorbed, or a promotion recorded + after the fact) is supported directly.""" + intake = await session.get(Intake, intake_id) + if intake is None: + raise HTTPException(status_code=404, detail="Intake not found") + _reject_status(intake, _ALLOWED_CLOSE_FROM, "close") + + intake.status = IntakeStatus.closed + intake.outcome = body.outcome + intake.closed_at = datetime.now(tz=timezone.utc) + if body.promoted_to: + intake.promoted_to = body.promoted_to + await session.commit() + await session.refresh(intake) + + event = ProgressEvent( + topic_id=intake.topic_id, + workplan_id=intake.workplan_id, + event_type="intake_closed", + summary=f"Intake closed ({body.outcome.value}): {intake.title}", + detail={ + "intake_id": str(intake.id), + "outcome": body.outcome.value, + "promoted_to": body.promoted_to, + "note": body.note, + }, + ) + session.add(event) + await session.commit() + + return intake + + +@router.post("/{intake_id}/notes", response_model=IntakeRead, status_code=status.HTTP_201_CREATED) +async def add_intake_note( + intake_id: uuid.UUID, + body: IntakeNoteCreate, + session: AsyncSession = Depends(get_session), +) -> Intake: + intake = await session.get(Intake, intake_id) + if intake is None: + raise HTTPException(status_code=404, detail="Intake not found") + note = IntakeNote(intake_id=intake.id, author=body.author, content=body.content) + session.add(note) + await session.commit() + await session.refresh(intake) + return intake diff --git a/api/routers/interface_changes.py b/api/routers/interface_changes.py index d4985a3..078557e 100644 --- a/api/routers/interface_changes.py +++ b/api/routers/interface_changes.py @@ -8,13 +8,17 @@ from sqlalchemy.ext.asyncio import AsyncSession from api.database import get_session from api.models.agent_message import AgentMessage from api.models.interface_change import InterfaceChange -from api.models.managed_repo import ManagedRepo from api.models.progress_event import ProgressEvent from api.schemas.interface_change import ( InterfaceChangeCreate, InterfaceChangePatch, InterfaceChangeRead, ) +from api.services.repository_aliases import ( + affected_slug_predicate, + canonicalize_repository_slug, + resolve_repository_slug, +) router = APIRouter(prefix="/interface-changes", tags=["interface-changes"]) @@ -32,7 +36,12 @@ async def create_interface_change( if body.change_type not in _VALID_CHANGE_TYPES: raise HTTPException(status_code=422, detail=f"change_type must be one of {sorted(_VALID_CHANGE_TYPES)}") - repo = await _repo_by_slug(body.repo_slug, session) + resolution = await resolve_repository_slug(session, body.repo_slug) + repo = resolution.repo + affected_repo_slugs = [ + await canonicalize_repository_slug(session, slug) + for slug in body.affected_repo_slugs + ] change = InterfaceChange( repo_id=repo.id, interface_type=body.interface_type, @@ -40,7 +49,7 @@ async def create_interface_change( title=body.title, description=body.description, affected_paths=body.affected_paths, - affected_repo_slugs=body.affected_repo_slugs, + affected_repo_slugs=affected_repo_slugs, planned_for=body.planned_for, author=body.author, status="draft", @@ -68,7 +77,11 @@ async def list_interface_changes( if change_type: q = q.where(InterfaceChange.change_type == change_type) if affected_repo: - q = q.where(InterfaceChange.affected_repo_slugs.contains([affected_repo])) + resolution = await resolve_repository_slug( + session, affected_repo, required=False + ) + values = resolution.slug_values if resolution else (affected_repo,) + q = q.where(affected_slug_predicate(InterfaceChange.affected_repo_slugs, values)) result = await session.execute(q) return [InterfaceChangeRead.from_orm_with_slug(c) for c in result.scalars().all()] @@ -94,7 +107,13 @@ async def patch_interface_change( status_code=409, detail=f"Cannot edit a change with status '{change.status}'. Only draft records are mutable.", ) - for field, value in body.model_dump(exclude_unset=True).items(): + payload = body.model_dump(exclude_unset=True) + if payload.get("affected_repo_slugs") is not None: + payload["affected_repo_slugs"] = [ + await canonicalize_repository_slug(session, slug) + for slug in payload["affected_repo_slugs"] + ] + for field, value in payload.items(): setattr(change, field, value) await session.commit() await session.refresh(change) @@ -119,12 +138,13 @@ async def publish_interface_change( # Send inbox notifications to agents of affected repos affected = change.affected_repo_slugs or [] for slug in affected: + target_slug = await canonicalize_repository_slug(session, slug) paths_summary = ", ".join(change.affected_paths[:5]) if change.affected_paths else "see description" if len(change.affected_paths) > 5: paths_summary += f" (+{len(change.affected_paths) - 5} more)" msg = AgentMessage( from_agent=change.repo.slug, - to_agent=slug, + to_agent=target_slug, subject=f"[{change.change_type.upper()}] {change.title}", body=( f"**Interface change published by `{change.repo.slug}`**\n\n" @@ -174,12 +194,9 @@ async def resolve_interface_change( return InterfaceChangeRead.from_orm_with_slug(change) -async def _repo_by_slug(slug: str, session: AsyncSession) -> ManagedRepo: - result = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == slug)) - repo = result.scalar_one_or_none() - if repo is None: - raise HTTPException(status_code=404, detail=f"Repo '{slug}' not found") - return repo +async def _repo_by_slug(slug: str, session: AsyncSession): + resolution = await resolve_repository_slug(session, slug) + return resolution.repo async def _get_or_404(change_id: uuid.UUID, session: AsyncSession) -> InterfaceChange: diff --git a/api/routers/messages.py b/api/routers/messages.py index 6da5630..d8334cb 100644 --- a/api/routers/messages.py +++ b/api/routers/messages.py @@ -1,7 +1,153 @@ +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import or_, select +from sqlalchemy.ext.asyncio import AsyncSession + from api.database import get_session from api.models.agent_message import AgentMessage -from hub_core.routers.messages import create_messages_router +from api.schemas.agent_message import MessageCreate, MessageRead, MessageReply +from api.services.repository_aliases import ( + canonicalize_repository_slug, + resolve_repository_slug, +) +from hub_core.message_identity import resolve_message_reference +from hub_core.models.message_identity_alias import MessageIdentityAlias + +router = APIRouter(prefix="/messages", tags=["messages"]) + + +async def _get_message(reference: str, session: AsyncSession) -> AgentMessage: + message_id = await resolve_message_reference( + session, reference, alias_model=MessageIdentityAlias + ) + if message_id is None: + raise HTTPException( + status_code=404, detail=f"Message reference {reference!r} not found" + ) + message = await session.get(AgentMessage, message_id) + if message is None: + raise HTTPException( + status_code=404, detail=f"Message reference {reference!r} not found" + ) + return message + + +@router.post("/", response_model=MessageRead, status_code=status.HTTP_201_CREATED) +async def send_message( + body: MessageCreate, + session: AsyncSession = Depends(get_session), +) -> AgentMessage: + if body.thread_id and await session.get(AgentMessage, body.thread_id) is None: + raise HTTPException(status_code=404, detail=f"Thread root {body.thread_id} not found") + payload = body.model_dump() + payload["from_agent"] = await canonicalize_repository_slug(session, body.from_agent) + payload["to_agent"] = await canonicalize_repository_slug(session, body.to_agent) + message = AgentMessage(**payload) + session.add(message) + await session.commit() + await session.refresh(message) + return message + + +@router.get("/", response_model=list[MessageRead]) +async def list_messages( + to_agent: str | None = None, + from_agent: str | None = None, + unread_only: bool = False, + limit: int = 50, + session: AsyncSession = Depends(get_session), +) -> list[AgentMessage]: + query = select(AgentMessage).where(AgentMessage.archived_at.is_(None)) + if to_agent: + resolution = await resolve_repository_slug(session, to_agent, required=False) + values = resolution.slug_values if resolution else (to_agent,) + query = query.where( + or_(AgentMessage.to_agent.in_(values), AgentMessage.to_agent == "broadcast") + ) + if from_agent: + resolution = await resolve_repository_slug(session, from_agent, required=False) + values = resolution.slug_values if resolution else (from_agent,) + query = query.where(AgentMessage.from_agent.in_(values)) + if unread_only: + query = query.where(AgentMessage.read_at.is_(None)) + result = await session.execute( + query.order_by(AgentMessage.created_at.desc()).limit(limit) + ) + return list(result.scalars().all()) + + +@router.get("/thread/{thread_id}", response_model=list[MessageRead]) +async def get_thread( + thread_id: str, + session: AsyncSession = Depends(get_session), +) -> list[AgentMessage]: + resolved = await resolve_message_reference( + session, thread_id, alias_model=MessageIdentityAlias + ) + if resolved is None: + raise HTTPException( + status_code=404, detail=f"Message reference {thread_id!r} not found" + ) + result = await session.execute( + select(AgentMessage) + .where(or_(AgentMessage.id == resolved, AgentMessage.thread_id == resolved)) + .order_by(AgentMessage.created_at) + ) + return list(result.scalars().all()) + + +@router.patch("/{message_id}/read", response_model=MessageRead) +async def mark_read( + message_id: str, + session: AsyncSession = Depends(get_session), +) -> AgentMessage: + message = await _get_message(message_id, session) + if message.read_at is None: + message.read_at = datetime.now(timezone.utc) + await session.commit() + await session.refresh(message) + return message + + +@router.patch("/{message_id}/archive", response_model=MessageRead) +async def archive_message( + message_id: str, + session: AsyncSession = Depends(get_session), +) -> AgentMessage: + message = await _get_message(message_id, session) + message.archived_at = datetime.now(timezone.utc) + if message.read_at is None: + message.read_at = message.archived_at + await session.commit() + await session.refresh(message) + return message + + +@router.post( + "/{message_id}/reply", + response_model=MessageRead, + status_code=status.HTTP_201_CREATED, +) +async def reply_to_message( + message_id: str, + body: MessageReply, + session: AsyncSession = Depends(get_session), +) -> AgentMessage: + original = await _get_message(message_id, session) + if original.read_at is None: + original.read_at = datetime.now(timezone.utc) + reply = AgentMessage( + from_agent=await canonicalize_repository_slug(session, body.from_agent), + to_agent=await canonicalize_repository_slug(session, original.from_agent), + subject=f"Re: {original.subject}", + body=body.body, + thread_id=original.thread_id or original.id, + ) + session.add(reply) + await session.commit() + await session.refresh(reply) + return reply -router = create_messages_router(get_session, message_model=AgentMessage) __all__ = ["router"] diff --git a/api/routers/ops_runs.py b/api/routers/ops_runs.py new file mode 100644 index 0000000..7f0d283 --- /dev/null +++ b/api/routers/ops_runs.py @@ -0,0 +1,13 @@ +from fastapi import APIRouter + +from api.schemas.ops_run import OpsRunProjection +from api.services.ops_run_projection import get_ops_run_projection + + +router = APIRouter(prefix="/ops-runs", tags=["ops-runs"]) + + +@router.get("/summary", response_model=OpsRunProjection) +async def get_ops_runs_summary(refresh: bool = False) -> OpsRunProjection: + """Project activity-core queue health; State Hub never claims ops runs.""" + return await get_ops_run_projection(refresh=refresh) diff --git a/api/routers/repo_goals.py b/api/routers/repo_goals.py index f836b91..601684e 100644 --- a/api/routers/repo_goals.py +++ b/api/routers/repo_goals.py @@ -8,16 +8,14 @@ from api.database import get_session from api.models.managed_repo import ManagedRepo from api.models.repo_goal import RepoGoal, RepoGoalStatus from api.schemas.repo_goal import RepoGoalCreate, RepoGoalRead, RepoGoalUpdate +from api.services.repository_aliases import resolve_repository_slug router = APIRouter(prefix="/repo-goals", tags=["repo-goals"]) async def _resolve_repo(repo_slug: str, session: AsyncSession) -> ManagedRepo: - result = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == repo_slug)) - repo = result.scalar_one_or_none() - if repo is None: - raise HTTPException(status_code=404, detail=f"Repo '{repo_slug}' not found") - return repo + resolution = await resolve_repository_slug(session, repo_slug) + return resolution.repo @router.get("/", response_model=list[RepoGoalRead]) diff --git a/api/routers/repos.py b/api/routers/repos.py index c9c9e9f..af88a8c 100644 --- a/api/routers/repos.py +++ b/api/routers/repos.py @@ -11,8 +11,9 @@ from pathlib import Path from fastapi import APIRouter, Depends, HTTPException, Response, status from sqlalchemy import case, func, or_, select +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import noload +from sqlalchemy.orm import joinedload, noload from api.config import settings from api.database import get_session @@ -28,6 +29,7 @@ from api.models.domain import Domain from api.models.interface_change import InterfaceChange from api.models.managed_repo import ManagedRepo from api.models.repo_goal import RepoGoal +from api.models.repository_rename import RepositorySlug from api.models.tpsc import TPSCSnapshot from api.models.task import Task from api.models.workplan import Workplan @@ -48,6 +50,18 @@ from api.schemas.managed_repo import ( classification_fields_set, validate_repo_classification_fields, ) +from api.services.sbom_nexus import SBOMNexusError +from api.services.sbom_nexus import get_json as get_sbom_nexus_json +from api.services.sbom_nexus import reads_from_nexus +from api.services.repository_identity import stage_initial_repository_identity +from api.services.repository_aliases import ( + RepositorySlugResolution, + affected_slug_predicate, + repository_resolutions_for_repositories, + repository_resolution_for_id, + resolve_repository_slug, + stale_external_references, +) from hub_core.routers.repos import create_repos_router router = APIRouter(prefix="/repos", tags=["repos"]) @@ -108,22 +122,25 @@ async def list_repos( capability_tag: str | None = None, business_stake: str | None = None, session: AsyncSession = Depends(get_session), -) -> list[ManagedRepo]: +) -> list[RepoRead]: """List repos with optional domain and classification filters.""" response.headers["Cache-Control"] = "max-age=60, stale-while-revalidate=30" q = ( select(ManagedRepo) - .options(noload(ManagedRepo.goals)) + .options( + noload("*"), + joinedload(ManagedRepo.domain).noload("*"), + ) .order_by(ManagedRepo.name) ) if domain: - domain_result = await session.execute(select(Domain).where(Domain.slug == domain)) - domain_obj = domain_result.scalar_one_or_none() - if domain_obj is None: + domain_result = await session.execute(select(Domain.id).where(Domain.slug == domain)) + domain_id = domain_result.scalar_one_or_none() + if domain_id is None: raise HTTPException(status_code=404, detail=f"Domain '{domain}' not found") q = q.where( or_( - ManagedRepo.domain_id == domain_obj.id, + ManagedRepo.domain_id == domain_id, ManagedRepo.secondary_domains.contains([domain]), ) ) @@ -134,20 +151,25 @@ async def list_repos( if business_stake: q = q.where(ManagedRepo.business_stake.contains([business_stake])) result = await session.execute(q) - return list(result.scalars().all()) + repositories = list(result.scalars().all()) + resolutions = await repository_resolutions_for_repositories(session, repositories) + return await _project_repo_reads(session, repositories, resolutions=resolutions) @router.post("/", response_model=RepoRead, status_code=status.HTTP_201_CREATED) async def register_repo( body: RepoCreate, session: AsyncSession = Depends(get_session), -) -> ManagedRepo: +) -> RepoRead: domain_result = await session.execute(select(Domain).where(Domain.slug == body.domain_slug)) domain_obj = domain_result.scalar_one_or_none() if domain_obj is None: raise HTTPException(status_code=404, detail=f"Domain '{body.domain_slug}' not found") existing = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == body.slug)) - if existing.scalar_one_or_none(): + claimed_slug = await session.execute( + select(RepositorySlug).where(RepositorySlug.slug == body.slug) + ) + if existing.scalar_one_or_none() or claimed_slug.scalar_one_or_none(): raise HTTPException(status_code=409, detail=f"Repo slug '{body.slug}' already exists") payload = body.model_dump() @@ -176,10 +198,19 @@ async def register_repo( standard_version=body.standard_version, ) session.add(repo) - await session.commit() + await session.flush() + stage_initial_repository_identity(session, repo) + try: + await session.commit() + except IntegrityError as exc: + await session.rollback() + raise HTTPException( + status_code=409, + detail=f"Repo slug '{body.slug}' is already claimed", + ) from exc await session.refresh(repo) await _publish_repo_registered(repo, body, domain_obj) - return repo + return (await _project_repo_reads(session, [repo]))[0] @router.post("/onboard", response_model=RepoOnboardResult) @@ -330,6 +361,7 @@ async def doi_summary(session: AsyncSession = Depends(get_session)) -> list[DoIS select(ManagedRepo).where(ManagedRepo.status == "active").order_by(ManagedRepo.name) ) repos = list(repos_result.scalars().all()) + sbom_projections = await _sbom_projection_map() repo_ids = [r.id for r in repos] id_to_slug = {r.id: r.slug for r in repos} @@ -385,7 +417,7 @@ async def doi_summary(session: AsyncSession = Depends(get_session)) -> list[DoIS "local_path": repo.local_path, "remote_url": repo.remote_url, "host_paths": repo.host_paths or {}, - "last_sbom_at": str(repo.last_sbom_at) if repo.last_sbom_at else None, + "last_sbom_at": _projected_last_sbom_at(repo, sbom_projections), "updated_at": str(repo.updated_at) if repo.updated_at else "", } fp = compute_fingerprint( @@ -464,7 +496,9 @@ async def get_repo_doi( Results are cached by fingerprint. Pass ?force_refresh=true to bypass the cache. """ - repo = await _get_repo_by_slug(slug, session) + resolution = await resolve_repository_slug(session, slug) + repo = resolution.repo + sbom_projections = await _sbom_projection_map() domain_result = await session.execute(select(Domain).where(Domain.id == repo.domain_id)) domain_obj = domain_result.scalar_one_or_none() @@ -484,7 +518,7 @@ async def get_repo_doi( "local_path": repo.local_path, "remote_url": repo.remote_url, "host_paths": repo.host_paths or {}, - "last_sbom_at": str(repo.last_sbom_at) if repo.last_sbom_at else None, + "last_sbom_at": _projected_last_sbom_at(repo, sbom_projections), "updated_at": str(repo.updated_at) if repo.updated_at else "", } fp = compute_fingerprint(repo_dict, str(tpsc_row.latest) if tpsc_row.latest else None, @@ -497,7 +531,7 @@ async def get_repo_doi( if not force_refresh and cached and cached.fingerprint == fp and cached.criteria: return DoIReport( - repo_slug=slug, + repo_slug=repo.slug, tier=cached.tier, core_pass=cached.core_pass, standard_pass=cached.standard_pass, @@ -536,11 +570,11 @@ async def get_repo_doi( async def get_repo_by_id( repo_id: uuid.UUID, session: AsyncSession = Depends(get_session), -) -> ManagedRepo: +) -> RepoRead: repo = await session.get(ManagedRepo, repo_id) if repo is None: raise HTTPException(status_code=404, detail=f"Repo '{repo_id}' not found") - return repo + return (await _project_repo_reads(session, [repo]))[0] @router.get("/scope-health", response_model=list[RepoScopeHealth]) @@ -595,14 +629,32 @@ async def update_repo_with_classification( slug: str, body: RepoUpdate, session: AsyncSession = Depends(get_session), -) -> ManagedRepo: +) -> RepoRead: """Patch repo metadata including classification spine fields.""" - repo = await _get_repo_by_slug(slug, session) + resolution = await resolve_repository_slug(session, slug) + repo = resolution.repo payload = body.model_dump(exclude_unset=True) - domain_result = await session.execute(select(Domain).where(Domain.id == repo.domain_id)) - domain_obj = domain_result.scalar_one_or_none() + requested_domain_slug = payload.pop("domain_slug", None) + if requested_domain_slug is not None: + domain_result = await session.execute( + select(Domain).where(Domain.slug == requested_domain_slug) + ) + domain_obj = domain_result.scalar_one_or_none() + if domain_obj is None: + raise HTTPException( + status_code=404, + detail=f"Domain '{requested_domain_slug}' not found", + ) + else: + domain_result = await session.execute( + select(Domain).where(Domain.id == repo.domain_id) + ) + domain_obj = domain_result.scalar_one_or_none() domain_slug = domain_obj.slug if domain_obj else "" - if classification_fields_set(payload): + classification_requested = classification_fields_set(payload) + if classification_requested or ( + requested_domain_slug is not None and repo.category is not None + ): merged = { "category": payload.get("category", repo.category), "secondary_domains": payload.get("secondary_domains", repo.secondary_domains), @@ -615,31 +667,67 @@ async def update_repo_with_classification( fields=merged, require_complete=True, ) + if requested_domain_slug is not None: + repo.domain_id = domain_obj.id for field, value in payload.items(): setattr(repo, field, value) await session.commit() await session.refresh(repo) - return repo + return (await _project_repo_reads(session, [repo], requested=resolution))[0] + + +@router.get("/{slug}", response_model=RepoRead) +async def get_repo_with_sbom_projection( + slug: str, + session: AsyncSession = Depends(get_session), +) -> RepoRead: + resolution = await resolve_repository_slug(session, slug) + return ( + await _project_repo_reads( + session, + [resolution.repo], + requested=resolution, + include_stale_external=True, + ) + )[0] router.include_router( _core_repo_router( include_collection_routes=False, include_lookup_routes=False, + include_slug_routes=False, ) ) +@router.post("/{slug}/paths", response_model=RepoRead) +async def register_repo_path( + slug: str, + body: RepoPathRegister, + session: AsyncSession = Depends(get_session), +) -> RepoRead: + resolution = await resolve_repository_slug(session, slug) + repo = resolution.repo + host_paths = dict(repo.host_paths or {}) + host_paths[body.host] = body.path + repo.host_paths = host_paths + await session.commit() + await session.refresh(repo) + return (await _project_repo_reads(session, [repo], requested=resolution))[0] + + @router.patch("/{slug}/archive", response_model=RepoRead) async def archive_repo( slug: str, session: AsyncSession = Depends(get_session), -) -> ManagedRepo: - repo = await _get_repo_by_slug(slug, session) +) -> RepoRead: + resolution = await resolve_repository_slug(session, slug) + repo = resolution.repo repo.status = "archived" await session.commit() await session.refresh(repo) - return repo + return (await _project_repo_reads(session, [repo], requested=resolution))[0] @router.get("/{slug}/dispatch", response_model=RepoDispatch) @@ -653,7 +741,8 @@ async def get_repo_dispatch( call it at session start to discover what work is pending without needing to read state-hub summary or scan workplan files manually. """ - repo = await _get_repo_by_slug(slug, session) + resolution = await resolve_repository_slug(session, slug) + repo = resolution.repo # Active goal goal_result = await session.execute( @@ -717,7 +806,9 @@ async def get_repo_dispatch( ic_result = await session.execute( select(InterfaceChange).where( InterfaceChange.status == "published", - InterfaceChange.affected_repo_slugs.contains([slug]), + affected_slug_predicate( + InterfaceChange.affected_repo_slugs, resolution.slug_values + ), ).order_by(InterfaceChange.published_at.desc()) ) pending_changes = [ @@ -746,7 +837,12 @@ async def get_repo_dispatch( ) return RepoDispatch( - repo_slug=slug, + repo_slug=resolution.canonical_slug, + requested_slug=resolution.requested_slug, + canonical_slug=resolution.canonical_slug, + slug_status=resolution.slug_status, + aliases=list(resolution.aliases), + stale_external_references=await stale_external_references(session, resolution), active_goal=active_goal, active_workplans=dispatch_workstreams, human_interventions=all_interventions, @@ -771,7 +867,8 @@ async def sync_repo_consistency( Returns the raw JSON output from consistency_check.py. Query param ?fix=false to run check-only without writing. """ - repo = await _get_repo_by_slug(slug, session) + resolution = await resolve_repository_slug(session, slug) + repo = resolution.repo hostname = socket.gethostname() host_paths = repo.host_paths or {} @@ -780,13 +877,13 @@ async def sync_repo_consistency( raise HTTPException( status_code=503, detail=( - f"No accessible path for repo '{slug}' on host '{hostname}'. " - f"Register with: POST /repos/{slug}/paths/" + f"No accessible path for repo '{repo.slug}' on host '{hostname}'. " + f"Register with: POST /repos/{repo.slug}/paths/" ), ) script = Path(__file__).parent.parent.parent / "scripts" / "consistency_check.py" - cmd = [sys.executable, str(script), "--repo", slug, "--json", + cmd = [sys.executable, str(script), "--repo", repo.slug, "--json", "--api-base", settings.api_base] if fix: cmd.append("--fix") @@ -805,11 +902,8 @@ async def sync_repo_consistency( async def _get_repo_by_slug(slug: str, session: AsyncSession) -> ManagedRepo: - result = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == slug)) - repo = result.scalar_one_or_none() - if repo is None: - raise HTTPException(status_code=404, detail=f"Repo '{slug}' not found") - return repo + resolution = await resolve_repository_slug(session, slug) + return resolution.repo def _repo_doi_dict(repo: ManagedRepo, domain_slug: str | None) -> dict: @@ -822,3 +916,77 @@ def _repo_doi_dict(repo: ManagedRepo, domain_slug: str | None) -> dict: "last_sbom_at": str(repo.last_sbom_at) if repo.last_sbom_at else None, "updated_at": str(repo.updated_at) if repo.updated_at else "", } + + +async def _sbom_projection_map() -> dict[str, datetime | None]: + if not reads_from_nexus(): + return {} + try: + repositories = await get_sbom_nexus_json("/repositories/") + except SBOMNexusError as exc: + raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc + return { + repository["slug"]: ( + datetime.fromisoformat(repository["last_attempt_at"].replace("Z", "+00:00")) + if repository.get("last_attempt_at") + else None + ) + for repository in repositories + } + + +def _projected_last_sbom_at( + repo: ManagedRepo, + projections: dict[str, datetime | None], +) -> str | None: + if repo.slug in projections: + projected = projections[repo.slug] + return projected.isoformat() if projected else None + return str(repo.last_sbom_at) if repo.last_sbom_at else None + + +async def _project_repo_reads( + session: AsyncSession, + repositories: list[ManagedRepo], + *, + requested: RepositorySlugResolution | None = None, + resolutions: dict[uuid.UUID, RepositorySlugResolution] | None = None, + include_stale_external: bool = False, +) -> list[RepoRead]: + projections = await _sbom_projection_map() + result: list[RepoRead] = [] + for repository in repositories: + resolution = ( + requested + if requested is not None and requested.repo.id == repository.id + else ( + resolutions[repository.id] + if resolutions is not None + else await repository_resolution_for_id(session, repository) + ) + ) + read = RepoRead.model_validate(repository) + read = read.model_copy( + update={ + "requested_slug": resolution.requested_slug, + "canonical_slug": resolution.canonical_slug, + "slug_status": resolution.slug_status, + "aliases": list(resolution.aliases), + "stale_external_references": ( + await stale_external_references(session, resolution) + if include_stale_external + else [] + ), + } + ) + if repository.slug in projections: + read = read.model_copy( + update={ + "last_sbom_at": projections[repository.slug], + "sbom_source": "sbom-nexus" + if projections[repository.slug] + else None, + } + ) + result.append(read) + return result diff --git a/api/routers/repository_renames.py b/api/routers/repository_renames.py new file mode 100644 index 0000000..cfdef31 --- /dev/null +++ b/api/routers/repository_renames.py @@ -0,0 +1,359 @@ +from __future__ import annotations + +import asyncio +import uuid +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.encoders import jsonable_encoder +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.events import EventEnvelope, publish_event +from api.models.repository_rename import RepositoryRenameOperation +from api.schemas.repository_rename import ( + ForgeIdentityVerifyRequest, + RepositoryRenameOperationCreate, + RepositoryRenameOperationRead, + RepositoryRenamePhaseApply, + RepositoryRenamePreflightRead, + RepositoryRenamePreflightRequest, + RepositoryRenameRollbackPreflightRead, + RepositoryRenameRollbackRequest, + RepositoryRenameVerificationRead, +) +from api.services.forge_repository import ( + ForgeRepositoryGateway, + get_forge_repository_gateway, +) +from api.services.repository_rename import ( + RenameLifecycleError, + apply_phase, + apply_rollback, + build_preflight, + create_operation, + list_operations, + load_operation, + load_operation_by_id, + rollback_preflight, + verify_forge_identity, + verify_operation, +) + + +router = APIRouter(prefix="/repos/{repo_id}", tags=["repository-renames"]) +operation_router = APIRouter( + prefix="/repository-renames", tags=["repository-renames"] +) + + +def repository_rename_events( + operation: RepositoryRenameOperation, + *, + outcome: str, + no_op: bool = False, + verification: dict[str, Any] | None = None, +) -> list[tuple[str, EventEnvelope]]: + """Build credential-free lifecycle events from the durable journal.""" + telemetry = (operation.evidence or {}).get("telemetry") or {} + phase_durations = telemetry.get("phase_durations_ms") or {} + attributes = { + "operation_id": str(operation.id), + "repo_id": str(operation.repo_id), + "forge_repository_id": operation.expected_forge_repository_id, + "old_slug": operation.old_slug, + "new_slug": operation.new_slug, + "phase": operation.phase, + "actor": operation.actor, + "expected_source_commit": operation.expected_source_commit, + "outcome": outcome, + "no_op": no_op, + "phase_duration_ms": int(phase_durations.get(operation.phase) or 0), + "retries": int(telemetry.get("retries") or 0), + "failures": int(telemetry.get("failures") or 0), + "rollback_attempts": int(telemetry.get("rollback_attempts") or 0), + "rollback_outcome": telemetry.get("rollback_outcome"), + "verification_outcome": ( + "passed" if verification and verification.get("ok") else + "failed" if verification else + telemetry.get("verification_outcome", "pending") + ), + "error_code": operation.error_code if outcome == "failed" else None, + "evidence_ref": f"repository-renames/operations/{operation.id}", + } + subject = ( + "org.statehub.repo.rename.failed" + if outcome == "failed" + else "org.statehub.repo.rename.verified" + if verification is not None + else "org.statehub.repo.rename.rolled_back" + if operation.phase == "rolled-back" + else "org.statehub.repo.rename.phase" + ) + events = [(subject, EventEnvelope.new(subject, attributes=attributes))] + if operation.phase == "completed" and outcome == "succeeded": + renamed_subject = "org.statehub.repo.renamed" + events.append( + (renamed_subject, EventEnvelope.new(renamed_subject, attributes=attributes)) + ) + return events + + +def _publish_rename_events( + operation: RepositoryRenameOperation, + *, + outcome: str, + no_op: bool = False, + verification: dict[str, Any] | None = None, +) -> None: + for subject, envelope in repository_rename_events( + operation, outcome=outcome, no_op=no_op, verification=verification + ): + asyncio.create_task(publish_event(subject, envelope)) + + +def _raise(exc: RenameLifecycleError) -> None: + raise HTTPException( + status_code=exc.status_code, + detail=jsonable_encoder( + {"code": exc.code, "message": str(exc), "details": exc.details} + ), + ) from exc + + +def _operation_read(operation: RepositoryRenameOperation, *, no_op: bool = False) -> dict[str, Any]: + return { + "id": operation.id, + "repo_id": operation.repo_id, + "phase": operation.phase, + "old_slug": operation.old_slug, + "new_slug": operation.new_slug, + "expected_forge_repository_id": operation.expected_forge_repository_id, + "expected_source_commit": operation.expected_source_commit, + "expected_default_branch": operation.expected_default_branch, + "actor": operation.actor, + "phase_changed_at": operation.phase_changed_at, + "preflighted_at": operation.preflighted_at, + "preflight_expires_at": operation.preflight_expires_at, + "completed_at": operation.completed_at, + "rolled_back_at": operation.rolled_back_at, + "evidence": operation.evidence, + "error_code": operation.error_code, + "error_message": operation.error_message, + "error_details": operation.error_details, + "error_at": operation.error_at, + "no_op": no_op, + } + + +@router.post("/forge-identity/verify") +async def verify_repository_forge_identity( + repo_id: uuid.UUID, + body: ForgeIdentityVerifyRequest, + session: AsyncSession = Depends(get_session), + gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway), +) -> dict[str, Any]: + try: + identity = await verify_forge_identity(session, gateway, repo_id, body) + except RenameLifecycleError as exc: + _raise(exc) + return { + "id": identity.id, + "repo_id": identity.repo_id, + "provider": identity.provider, + "forge_instance": identity.forge_instance, + "forge_owner": identity.forge_owner, + "forge_repository_id": identity.forge_repository_id, + "verification_state": identity.verification_state, + "verified_at": identity.verified_at, + "verified_by": identity.verified_by, + "verification_evidence": identity.verification_evidence, + } + + +@router.post("/rename/preflight", response_model=RepositoryRenamePreflightRead) +async def repository_rename_preflight( + repo_id: uuid.UUID, + body: RepositoryRenamePreflightRequest, + session: AsyncSession = Depends(get_session), + gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway), +) -> dict[str, Any]: + try: + return await build_preflight(session, gateway, repo_id, body) + except RenameLifecycleError as exc: + _raise(exc) + + +@router.post( + "/rename/operations", + response_model=RepositoryRenameOperationRead, + status_code=status.HTTP_201_CREATED, +) +async def create_repository_rename_operation( + repo_id: uuid.UUID, + body: RepositoryRenameOperationCreate, + session: AsyncSession = Depends(get_session), + gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway), +) -> dict[str, Any]: + try: + operation, no_op = await create_operation(session, gateway, repo_id, body) + except RenameLifecycleError as exc: + _raise(exc) + _publish_rename_events(operation, outcome="succeeded", no_op=no_op) + return _operation_read(operation, no_op=no_op) + + +@operation_router.get( + "/operations/{operation_id}", response_model=RepositoryRenameOperationRead +) +async def get_repository_rename_operation_by_id( + operation_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> dict[str, Any]: + """Resolve an operation journal without requiring its repository UUID.""" + try: + operation = await load_operation_by_id(session, operation_id) + except RenameLifecycleError as exc: + _raise(exc) + return _operation_read(operation) + + +@router.get( + "/rename/operations", response_model=list[RepositoryRenameOperationRead] +) +async def list_repository_rename_operations( + repo_id: uuid.UUID, + active_only: bool = False, + session: AsyncSession = Depends(get_session), +) -> list[dict[str, Any]]: + try: + operations = await list_operations(session, repo_id, active_only=active_only) + except RenameLifecycleError as exc: + _raise(exc) + return [_operation_read(operation) for operation in operations] + + +@router.get( + "/rename/operations/{operation_id}", response_model=RepositoryRenameOperationRead +) +async def get_repository_rename_operation( + repo_id: uuid.UUID, + operation_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> dict[str, Any]: + try: + operation = await load_operation(session, repo_id, operation_id) + except RenameLifecycleError as exc: + _raise(exc) + return _operation_read(operation) + + +@router.post( + "/rename/operations/{operation_id}/phases/{phase}", + response_model=RepositoryRenameOperationRead, +) +async def apply_repository_rename_phase( + repo_id: uuid.UUID, + operation_id: uuid.UUID, + phase: str, + body: RepositoryRenamePhaseApply, + session: AsyncSession = Depends(get_session), + gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway), +) -> dict[str, Any]: + try: + operation, no_op = await apply_phase( + session, gateway, repo_id, operation_id, phase, body + ) + except RenameLifecycleError as exc: + failed = await session.get(RepositoryRenameOperation, operation_id) + if failed is not None: + _publish_rename_events(failed, outcome="failed") + _raise(exc) + _publish_rename_events(operation, outcome="succeeded", no_op=no_op) + return _operation_read(operation, no_op=no_op) + + +@router.get( + "/rename/operations/{operation_id}/verify", + response_model=RepositoryRenameVerificationRead, +) +async def verify_repository_rename_operation( + repo_id: uuid.UUID, + operation_id: uuid.UUID, + session: AsyncSession = Depends(get_session), + gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway), +) -> dict[str, Any]: + try: + operation = await load_operation(session, repo_id, operation_id) + verification = await verify_operation(session, gateway, operation) + except RenameLifecycleError as exc: + _raise(exc) + _publish_rename_events( + operation, + outcome="succeeded" if verification["ok"] else "failed", + verification=verification, + ) + return verification + + +@router.post( + "/rename/operations/{operation_id}/rollback-preflight", + response_model=RepositoryRenameRollbackPreflightRead, +) +async def preflight_repository_rename_rollback( + repo_id: uuid.UUID, + operation_id: uuid.UUID, + body: RepositoryRenameRollbackRequest, + session: AsyncSession = Depends(get_session), + gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway), +) -> dict[str, Any]: + try: + operation, report = await rollback_preflight( + session, + gateway, + repo_id, + operation_id, + expected_phase=body.expected_phase, + confirmation=body.confirmation, + ) + except RenameLifecycleError as exc: + failed = await session.get(RepositoryRenameOperation, operation_id) + if failed is not None: + _publish_rename_events(failed, outcome="failed") + _raise(exc) + _publish_rename_events(operation, outcome="succeeded") + return { + "operation_id": operation.id, + "repo_id": operation.repo_id, + **report, + "operation": _operation_read(operation), + } + + +@router.post( + "/rename/operations/{operation_id}/rollback", + response_model=RepositoryRenameOperationRead, +) +async def rollback_repository_rename_operation( + repo_id: uuid.UUID, + operation_id: uuid.UUID, + body: RepositoryRenameRollbackRequest, + session: AsyncSession = Depends(get_session), + gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway), +) -> dict[str, Any]: + try: + operation, no_op = await apply_rollback( + session, + gateway, + repo_id, + operation_id, + expected_phase=body.expected_phase, + confirmation=body.confirmation, + ) + except RenameLifecycleError as exc: + failed = await session.get(RepositoryRenameOperation, operation_id) + if failed is not None: + _publish_rename_events(failed, outcome="failed") + _raise(exc) + _publish_rename_events(operation, outcome="succeeded", no_op=no_op) + return _operation_read(operation, no_op=no_op) diff --git a/api/routers/review_contracts.py b/api/routers/review_contracts.py new file mode 100644 index 0000000..19d2bd8 --- /dev/null +++ b/api/routers/review_contracts.py @@ -0,0 +1,190 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import select, update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.models.review_contract import ReviewContract, ReviewReceipt +from api.schemas.review_contract import ( + ReviewAggregateRead, + ReviewContractProject, + ReviewContractRead, + ReviewReceiptRead, + ReviewReceiptSubmit, +) +from api.services.review_contracts import ( + active_contract, + aggregate, + digest_document, + normalize_contract, + utc_now, + validate_receipt, +) + +router = APIRouter(prefix="/review-contracts", tags=["review-contracts"]) + + +@router.post( + "/projections", + response_model=ReviewContractRead, + status_code=status.HTTP_201_CREATED, +) +async def project_contract( + body: ReviewContractProject, + session: AsyncSession = Depends(get_session), +) -> ReviewContract: + document, contract_digest = normalize_contract(body.contract) + existing = ( + await session.execute( + select(ReviewContract).where( + ReviewContract.contract_digest == contract_digest + ) + ) + ).scalar_one_or_none() + if existing: + if ( + existing.source_repo != body.source.repo + or existing.source_path != body.source.path + or existing.source_revision != body.source.revision + ): + raise HTTPException( + 409, "contract digest is already projected from a different source" + ) + return existing + contract_key = document["contract_key"] + await session.execute( + update(ReviewContract) + .where( + ReviewContract.contract_key == contract_key, ReviewContract.active.is_(True) + ) + .values(active=False) + ) + contract = ReviewContract( + contract_key=contract_key, + schema_version="review-contract/v1", + contract_digest=contract_digest, + source_repo=body.source.repo, + source_path=body.source.path, + source_revision=body.source.revision, + document=document, + active=True, + required_for_decision=body.required_for_decision, + decision_id=body.decision_id, + workplan_id=body.workplan_id, + task_id=body.task_id, + projected_at=utc_now(), + ) + session.add(contract) + try: + await session.commit() + except IntegrityError as exc: + await session.rollback() + raise HTTPException( + 409, "source revision is already projected with different content" + ) from exc + await session.refresh(contract) + return contract + + +@router.get("/", response_model=list[ReviewContractRead]) +async def list_contracts( + contract_key: str | None = Query(None), + active: bool | None = Query(None), + decision_id: uuid.UUID | None = Query(None), + session: AsyncSession = Depends(get_session), +) -> list[ReviewContract]: + query = select(ReviewContract).order_by(ReviewContract.projected_at.desc()) + if contract_key: + query = query.where(ReviewContract.contract_key == contract_key) + if active is not None: + query = query.where(ReviewContract.active == active) + if decision_id: + query = query.where(ReviewContract.decision_id == decision_id) + return list((await session.execute(query)).scalars()) + + +@router.get("/{contract_key}", response_model=ReviewContractRead) +async def get_active_contract( + contract_key: str, + session: AsyncSession = Depends(get_session), +) -> ReviewContract: + return await active_contract(session, contract_key) + + +@router.get("/{contract_key}/aggregate", response_model=ReviewAggregateRead) +async def get_aggregate( + contract_key: str, + session: AsyncSession = Depends(get_session), +) -> ReviewAggregateRead: + return await aggregate(session, await active_contract(session, contract_key)) + + +@router.get("/{contract_key}/receipts", response_model=list[ReviewReceiptRead]) +async def list_receipts( + contract_key: str, + include_stale: bool = Query(True), + session: AsyncSession = Depends(get_session), +) -> list[ReviewReceipt]: + contract = await active_contract(session, contract_key) + query = ( + select(ReviewReceipt) + .join(ReviewContract) + .where( + ReviewContract.contract_key == contract_key + if include_stale + else ReviewReceipt.contract_id == contract.id + ) + .order_by(ReviewReceipt.submitted_at) + ) + return list((await session.execute(query)).scalars()) + + +@router.post( + "/{contract_key}/receipts", + response_model=ReviewReceiptRead, + status_code=status.HTTP_201_CREATED, +) +async def submit_receipt( + contract_key: str, + body: ReviewReceiptSubmit, + session: AsyncSession = Depends(get_session), +) -> ReviewReceipt: + contract = await active_contract(session, contract_key) + document = validate_receipt(contract, body) + receipt_digest = digest_document(document) + existing = ( + await session.execute( + select(ReviewReceipt).where(ReviewReceipt.receipt_digest == receipt_digest) + ) + ).scalar_one_or_none() + if existing: + return existing + receipt = ReviewReceipt( + contract_id=contract.id, + owner_id=body.owner_id, + actor=body.actor, + disposition=body.disposition, + contract_digest=body.contract_digest, + receipt_digest=receipt_digest, + artifact_hashes=body.artifact_hashes, + assertion_ids=body.assertion_ids, + checks=body.checks, + note=body.note, + source_repo=body.source.repo, + source_path=body.source.path, + source_revision=body.source.revision, + submitted_at=utc_now(), + document=document, + ) + session.add(receipt) + try: + await session.commit() + except IntegrityError as exc: + await session.rollback() + raise HTTPException( + 409, "receipt source revision already contains different evidence" + ) from exc + await session.refresh(receipt) + return receipt diff --git a/api/routers/sbom.py b/api/routers/sbom.py index ee9bf1e..a5cb550 100644 --- a/api/routers/sbom.py +++ b/api/routers/sbom.py @@ -1,7 +1,8 @@ import uuid +import logging from datetime import datetime, timezone -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, Request from sqlalchemy import and_, func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -18,8 +19,18 @@ from api.schemas.sbom import ( SBOMSnapshotDetail, SBOMSnapshotRead, ) +from api.services.legacy_meter import identity_from_request, record_legacy_usage +from api.services.sbom_nexus import ( + SBOMNexusError, + get_json, + post_json, + reads_from_nexus, + writes_to_nexus, +) +from api.services.repository_aliases import resolve_repository_slug router = APIRouter(prefix="/sbom", tags=["sbom"]) +logger = logging.getLogger(__name__) _COPYLEFT_PATTERNS = {"GPL", "AGPL", "LGPL", "EUPL", "CDDL", "MPL"} @@ -54,10 +65,37 @@ def _latest_snapshot_ids_subquery(): @router.post("/ingest/") async def ingest_sbom( body: SBOMIngest, + request: Request, session: AsyncSession = Depends(get_session), ) -> dict: """Create a new SBOM snapshot for a repo. Previous snapshots are retained.""" - repo = await _get_repo_by_slug(body.repo_slug, session) + resolution = await resolve_repository_slug(session, body.repo_slug) + repo = resolution.repo + if writes_to_nexus(): + nexus_body = body.model_dump(mode="json") + nexus_body["repo_slug"] = resolution.canonical_slug + payload = await _nexus_post("/sbom/ingest/", body=nexus_body) + try: + snapshot_at = datetime.fromisoformat( + payload["snapshot_at"].replace("Z", "+00:00") + ) + result = { + "repo_slug": payload["repo_slug"], + "snapshot_id": payload["snapshot_id"], + "ingested": payload["ingested"], + "snapshot_at": payload["snapshot_at"], + } + except (AttributeError, KeyError, TypeError, ValueError) as exc: + raise HTTPException( + status_code=502, + detail="SBOM Nexus returned an invalid ingest response", + ) from exc + repo.last_sbom_at = snapshot_at + repo.sbom_source = "sbom-nexus" + await session.commit() + await _meter_compat(session, request, "POST", "/sbom/ingest/") + return result + now = datetime.now(tz=timezone.utc) snap = SBOMSnapshot( @@ -90,8 +128,9 @@ async def ingest_sbom( repo.sbom_source = "manual" await session.commit() + await _meter_compat(session, request, "POST", "/sbom/ingest/") return { - "repo_slug": body.repo_slug, + "repo_slug": resolution.canonical_slug, "snapshot_id": str(snap.id), "ingested": len(body.entries), "snapshot_at": now.isoformat(), @@ -100,10 +139,23 @@ async def ingest_sbom( @router.get("/snapshots/", response_model=list[SBOMSnapshotRead]) async def list_snapshots( + request: Request, repo_slug: str | None = Query(None), session: AsyncSession = Depends(get_session), ) -> list[SBOMSnapshotRead]: """List SBOM snapshots, newest first. Optionally filter by repo.""" + await _meter_compat(session, request, "GET", "/sbom/snapshots/") + if reads_from_nexus(): + if repo_slug: + repo_slug = ( + await resolve_repository_slug(session, repo_slug) + ).canonical_slug + payload = await _nexus_get( + "/sbom/snapshots/", + params={"repo_slug": repo_slug} if repo_slug else None, + ) + return await _translate_snapshots(payload, session) + q = select(SBOMSnapshot).order_by(SBOMSnapshot.snapshot_at.desc()) if repo_slug: repo = await _get_repo_by_slug(repo_slug, session) @@ -115,9 +167,21 @@ async def list_snapshots( @router.get("/snapshots/{snapshot_id}", response_model=SBOMSnapshotDetail) async def get_snapshot( snapshot_id: uuid.UUID, + request: Request, session: AsyncSession = Depends(get_session), ) -> SBOMSnapshotDetail: """Get a snapshot with its full entry list.""" + await _meter_compat(session, request, "GET", "/sbom/snapshots/{snapshot_id}") + if reads_from_nexus(): + payload = await _nexus_get(f"/sbom/snapshots/{snapshot_id}") + repo_ids = await _local_repo_ids([payload], session) + repo_id = repo_ids[payload["repo_slug"]] + translated = _translate_snapshot(payload, repo_id) + translated["entries"] = [ + _translate_entry(entry, repo_id) for entry in payload.get("entries", []) + ] + return SBOMSnapshotDetail.model_validate(translated) + snap = await session.get(SBOMSnapshot, snapshot_id) if snap is None: raise HTTPException(status_code=404, detail=f"Snapshot '{snapshot_id}' not found") @@ -140,6 +204,7 @@ async def get_snapshot( @router.get("/") async def list_sbom_entries( + request: Request, repo_slug: str | None = Query(None), ecosystem: Ecosystem | None = Query(None), license_spdx: str | None = Query(None), @@ -148,6 +213,30 @@ async def list_sbom_entries( session: AsyncSession = Depends(get_session), ) -> list[SBOMEntryRead]: """Return entries from the latest snapshot per repo (default) or filter by repo.""" + await _meter_compat(session, request, "GET", "/sbom/") + if reads_from_nexus(): + if repo_slug: + repo_slug = ( + await resolve_repository_slug(session, repo_slug) + ).canonical_slug + params = { + key: value + for key, value in { + "repo_slug": repo_slug, + "ecosystem": ecosystem.value if ecosystem is not None else None, + "license_spdx": license_spdx, + "is_direct": is_direct, + "is_dev": is_dev, + }.items() + if value is not None + } + payload = await _nexus_get("/sbom/", params=params) + repo_ids = await _local_repo_ids(payload, session) + return [ + SBOMEntryRead.model_validate(_translate_entry(entry, repo_ids[entry["repo_slug"]])) + for entry in payload + ] + if repo_slug: repo = await _get_repo_by_slug(repo_slug, session) latest_snap_id_sq = ( @@ -177,9 +266,15 @@ async def list_sbom_entries( @router.get("/report/licences/", response_model=LicenceReport) async def licence_report( + request: Request, session: AsyncSession = Depends(get_session), ) -> LicenceReport: """Group latest-snapshot SBOM entries by SPDX licence identifier, flag copyleft.""" + await _meter_compat(session, request, "GET", "/sbom/report/licences/") + if reads_from_nexus(): + payload = await _nexus_get("/sbom/report/licences/") + return LicenceReport.model_validate(payload) + latest_ids_sq = _latest_snapshot_ids_subquery() rows = await session.execute( select(SBOMEntry, ManagedRepo.slug) @@ -212,10 +307,20 @@ async def licence_report( @router.get("/{repo_slug}", response_model=SBOMRepoView) async def get_repo_sbom( repo_slug: str, + request: Request, session: AsyncSession = Depends(get_session), ) -> SBOMRepoView: """Return the latest snapshot entries for a specific repo.""" - repo = await _get_repo_by_slug(repo_slug, session) + resolution = await resolve_repository_slug(session, repo_slug) + repo = resolution.repo + await _meter_compat(session, request, "GET", "/sbom/{repo_slug}") + if reads_from_nexus(): + payload = await _nexus_get(f"/sbom/{resolution.canonical_slug}") + payload["entries"] = [ + _translate_entry(entry, repo.id) for entry in payload.get("entries", []) + ] + return SBOMRepoView.model_validate(payload) + latest_snap_id_sq = ( select(SBOMSnapshot.id) .where(SBOMSnapshot.repo_id == repo.id) @@ -230,7 +335,7 @@ async def get_repo_sbom( ) entries = list(rows.scalars().all()) return SBOMRepoView( - repo_slug=repo_slug, + repo_slug=resolution.canonical_slug, last_sbom_at=repo.last_sbom_at, entry_count=len(entries), entries=[SBOMEntryRead.model_validate(e) for e in entries], @@ -238,8 +343,86 @@ async def get_repo_sbom( async def _get_repo_by_slug(slug: str, session: AsyncSession) -> ManagedRepo: - result = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == slug)) - repo = result.scalar_one_or_none() - if repo is None: - raise HTTPException(status_code=404, detail=f"Repo '{slug}' not found") - return repo + resolution = await resolve_repository_slug(session, slug) + return resolution.repo + + +async def _nexus_get(path: str, *, params: dict | None = None): + try: + return await get_json(path, params=params) + except SBOMNexusError as exc: + raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc + + +async def _nexus_post(path: str, *, body: dict): + try: + return await post_json(path, body=body) + except SBOMNexusError as exc: + raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc + + +async def _local_repo_ids(items: list[dict], session: AsyncSession) -> dict[str, uuid.UUID]: + slugs = {item.get("repo_slug") for item in items} + if None in slugs: + raise HTTPException(status_code=502, detail="SBOM Nexus response omitted repo_slug") + repo_ids: dict[str, uuid.UUID] = {} + for slug in slugs: + resolution = await resolve_repository_slug(session, slug, required=False) + if resolution is not None: + repo_ids[slug] = resolution.repo.id + missing = sorted(slugs - repo_ids.keys()) + if missing: + raise HTTPException( + status_code=502, + detail=f"SBOM Nexus returned repositories absent from State Hub: {', '.join(missing)}", + ) + return repo_ids + + +async def _translate_snapshots( + items: list[dict], session: AsyncSession +) -> list[SBOMSnapshotRead]: + repo_ids = await _local_repo_ids(items, session) + return [ + SBOMSnapshotRead.model_validate(_translate_snapshot(item, repo_ids[item["repo_slug"]])) + for item in items + ] + + +def _translate_snapshot(item: dict, repo_id: uuid.UUID) -> dict: + translated = {**item, "repo_id": repo_id} + source = translated.get("source") + if ( + translated.get("legacy_id") + and isinstance(source, str) + and source.startswith("state-hub:") + ): + translated["source"] = source.removeprefix("state-hub:") + return translated + + +def _translate_entry(item: dict, repo_id: uuid.UUID) -> dict: + return {**item, "repo_id": repo_id} + + +async def _meter_compat( + session: AsyncSession, + request: Request, + method: str, + route: str, +) -> None: + """Meter the compatibility surface without making route success depend on it.""" + interface_key = f"rest_api:{method} {route}" + try: + await record_legacy_usage( + session, + interface_key=interface_key, + interface_kind="rest_api", + replacement_ref="sbom-nexus:/sbom/", + owner_component="state-hub.sbom-compat", + replacement_verified=reads_from_nexus(), + identity=identity_from_request(request), + ) + except Exception: + await session.rollback() + logger.warning("legacy-meter failed to record %s", interface_key, exc_info=True) diff --git a/api/routers/services.py b/api/routers/services.py index 763da99..982ab1b 100644 --- a/api/routers/services.py +++ b/api/routers/services.py @@ -13,7 +13,6 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from api.database import get_session -from api.models.managed_repo import ManagedRepo from api.models.service_catalog import ( ServiceCatalog, ServiceCloud, @@ -22,6 +21,7 @@ from api.models.service_catalog import ( ServiceThirdParty, ) from api.schemas.service import ServiceCatalogRead, ServiceUpsert +from api.services.repository_aliases import resolve_repository_slug router = APIRouter(prefix="/services", tags=["services"]) @@ -42,6 +42,7 @@ async def list_services( development_type: str | None = None, maturity_level: int | None = None, status: str | None = None, + repo_slug: str | None = None, session: AsyncSession = Depends(get_session), ) -> list[ServiceCatalog]: q = select(ServiceCatalog).options(*_WITH_EXTENSIONS) @@ -53,6 +54,11 @@ async def list_services( q = q.where(ServiceCatalog.maturity_level == maturity_level) if status: q = q.where(ServiceCatalog.status == status) + if repo_slug: + resolution = await resolve_repository_slug(session, repo_slug) + q = q.join(ServiceFirstParty).where( + ServiceFirstParty.repo_id == resolution.repo.id + ) q = q.order_by(ServiceCatalog.name.asc()) result = await session.execute(q) return list(result.scalars().all()) @@ -131,12 +137,10 @@ async def _apply_extensions(svc: ServiceCatalog, body: ServiceUpsert, session: A if body.first_party is not None: data = body.first_party.model_dump(exclude={"repo_slug"}) if body.first_party.repo_slug and not data.get("repo_id"): - repo = (await session.execute( - select(ManagedRepo).where(ManagedRepo.slug == body.first_party.repo_slug) - )).scalar_one_or_none() - if repo is None: - raise HTTPException(status_code=404, detail=f"Repo '{body.first_party.repo_slug}' not found") - data["repo_id"] = repo.id + resolution = await resolve_repository_slug( + session, body.first_party.repo_slug + ) + data["repo_id"] = resolution.repo.id await _upsert_ext(ServiceFirstParty, svc.id, data, session) diff --git a/api/routers/state.py b/api/routers/state.py index 7e5e01d..c78f238 100644 --- a/api/routers/state.py +++ b/api/routers/state.py @@ -1,13 +1,15 @@ import time from datetime import datetime, timedelta, timezone -from fastapi import APIRouter, Depends, Request, Response +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response from fastapi.responses import JSONResponse from sqlalchemy import func, select, text from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import noload, selectinload +from api.config import settings from api.database import get_session +from api.services.schema_state import schema_state from api.flow_defs import assertion_result_to_dict, load_flow from api.models.capability_request import CapabilityRequest from api.models.contribution import Contribution, ContributionStatus, ContributionType @@ -53,6 +55,9 @@ from api.services.summary_cache import ( get_summary_cache, register_summary_cache_invalidation, ) +from api.services.ops_run_projection import get_ops_run_projection +from api.services.sbom_nexus import get_json as get_sbom_nexus_json +from api.services.sbom_nexus import reads_from_nexus def _dual_workplan_refs( @@ -70,6 +75,12 @@ def _dual_workplan_refs( "workstream_slug": slug, } from api.task_status import TERMINAL_TASK_STATUSES, status_value +from api.work_record_flavor import ( + RESIDUAL_FLAVOR, + WORK_RECORD_FLAVORS, + is_residual_flavor, + normalize_flavor, +) from api.workplan_status import ( CLOSED_WORKPLAN_STATUSES, OPEN_WORKPLAN_STATUSES, @@ -95,12 +106,44 @@ def _summary_cache_headers( response.headers["Cache-Control"] = "max-age=15, stale-while-revalidate=120" +def _apply_summary_flavor_view( + summary: StateSummary, + *, + include_residuals: bool, + flavor: str | None, +) -> StateSummary: + """Default views omit residual workplans; totals.residual_open stays honest.""" + wanted = normalize_flavor(flavor) + if wanted is not None and wanted not in WORK_RECORD_FLAVORS: + raise HTTPException( + status_code=422, + detail=( + f"Unknown work-record flavor {wanted!r}; " + f"expected one of {', '.join(WORK_RECORD_FLAVORS)}" + ), + ) + show_residuals = include_residuals or wanted == RESIDUAL_FLAVOR + residual_rows = list(summary.residual_open_workplans) + open_rows = list(summary.open_workplans) + if wanted is not None: + open_rows = [row for row in open_rows if row.flavor == wanted] + residual_rows = [row for row in residual_rows if row.flavor == wanted] + return summary.model_copy( + update={ + "open_workplans": open_rows, + "residual_open_workplans": residual_rows if show_residuals else [], + } + ) + + @router.get("/summary", response_model=StateSummary) async def get_summary( request: Request, response: Response, session: AsyncSession = Depends(get_session), refresh: bool = False, + include_residuals: bool = Query(False), + flavor: str | None = Query(None), ) -> StateSummary: revision = await fetch_summary_revision(session) revision_token = revision.combined_fingerprint() @@ -111,22 +154,38 @@ async def get_summary( if cache_status == "hit-revision" and cached is not None: _summary_cache_headers(response, cache_status="hit-revision", revision=revision_token) - return cached + return _apply_summary_flavor_view( + cached.model_copy(update={"ops_runs": await get_ops_run_projection()}), + include_residuals=include_residuals, + flavor=flavor, + ) if cache_status == "progress-section" and cached is not None: result = await apply_progress_section(session, cached, revision) _summary_cache_headers(response, cache_status="hit-revision", revision=revision_token) - return result + return _apply_summary_flavor_view( + result.model_copy(update={"ops_runs": await get_ops_run_projection()}), + include_residuals=include_residuals, + flavor=flavor, + ) if cache_status == "stale" and cached is not None: cache.schedule_refresh(revision) _summary_cache_headers(response, cache_status="stale", revision=revision_token) - return cached + return _apply_summary_flavor_view( + cached.model_copy(update={"ops_runs": await get_ops_run_projection()}), + include_residuals=include_residuals, + flavor=flavor, + ) result = await build_state_summary(session) cache.store(result, revision) _summary_cache_headers(response, cache_status="miss", revision=revision_token) - return result + return _apply_summary_flavor_view( + result.model_copy(update={"ops_runs": await get_ops_run_projection(refresh=force_refresh)}), + include_residuals=include_residuals, + flavor=flavor, + ) async def build_state_summary(session: AsyncSession) -> StateSummary: @@ -331,6 +390,7 @@ async def build_state_summary(session: AsyncSession) -> StateSummary: ), archived=ws_counts.get("archived", 0), total=sum(ws_counts.values()), + residual_open=0, ), tasks=TaskTotals( wait=task_counts.get(TaskStatus.wait, 0), @@ -363,23 +423,7 @@ async def build_state_summary(session: AsyncSession) -> StateSummary: )} contribution_counts = {**contrib_type_counts, **contrib_status_counts} - # Licence risk: copyleft packages in direct prod deps - _COPYLEFT_PATS = ("GPL", "AGPL", "LGPL", "EUPL", "CDDL", "MPL") - copyleft_risk_rows = await session.execute( - select(func.count()).select_from(SBOMEntry) - .where(SBOMEntry.is_direct.is_(True)) - .where(SBOMEntry.is_dev.is_(False)) - ) - # Filter in Python since ILIKE across multiple patterns is verbose in SQLAlchemy - all_direct_prod_rows = await session.execute( - select(SBOMEntry.license_spdx) - .where(SBOMEntry.is_direct.is_(True)) - .where(SBOMEntry.is_dev.is_(False)) - ) - licence_risk_count = sum( - 1 for (lic,) in all_direct_prod_rows.all() - if lic and any(pat in lic.upper() for pat in _COPYLEFT_PATS) - ) + licence_risk_count, _, _ = await _sbom_metrics(session) # Open capability requests (non-terminal statuses) open_cap_req_count = (await session.execute( @@ -408,6 +452,15 @@ async def build_state_summary(session: AsyncSession) -> StateSummary: ) for w in open_ws ] + relevant_rows = [ + row for row, w in zip(open_workplan_rows, open_ws) + if not is_residual_flavor(w.flavor) + ] + residual_rows = [ + row for row, w in zip(open_workplan_rows, open_ws) + if is_residual_flavor(w.flavor) + ] + totals.workstreams.residual_open = len(residual_rows) result = StateSummary( generated_at=datetime.now(tz=timezone.utc), @@ -429,7 +482,8 @@ async def build_state_summary(session: AsyncSession) -> StateSummary: licence_risk_count=licence_risk_count, open_capability_requests=open_cap_req_count, ranked_suggestions=ranked_suggestions, - open_workplans=open_workplan_rows, + open_workplans=relevant_rows, + residual_open_workplans=residual_rows, ) return result @@ -532,6 +586,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview: w for w in workstreams_all if normalize_workplan_status(w.status) in OPEN_WORKPLAN_STATUSES ] + residual_open = sum(1 for w in open_ws if is_residual_flavor(w.flavor)) open_ws_ids = [w.id for w in open_ws] dep_rows = [] if open_ws_ids: @@ -591,6 +646,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview: ), archived=ws_counts.get("archived", 0), total=sum(ws_counts.values()), + residual_open=residual_open, ), tasks=TaskTotals( wait=task_totals_by_status.get("wait", 0), @@ -645,23 +701,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview: )} contribution_counts = {**contrib_type_counts, **contrib_status_counts} - _COPYLEFT_PATS = ("GPL", "AGPL", "LGPL", "EUPL", "CDDL", "MPL") - all_direct_prod_rows = await session.execute( - select(SBOMEntry.license_spdx) - .where(SBOMEntry.is_direct.is_(True)) - .where(SBOMEntry.is_dev.is_(False)) - ) - licence_risk_count = sum( - 1 for (lic,) in all_direct_prod_rows.all() - if lic and any(pat in lic.upper() for pat in _COPYLEFT_PATS) - ) - - snapshot_count, package_total = (await session.execute( - select( - func.count(SBOMSnapshot.id), - func.coalesce(func.sum(SBOMSnapshot.entry_count), 0), - ) - )).one() + licence_risk_count, snapshot_count, package_total = await _sbom_metrics(session) open_cap_req_count = (await session.execute( select(func.count()).select_from(CapabilityRequest).where( @@ -695,6 +735,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview: id=w.id, title=w.title, status=normalize_workplan_status(w.status), + flavor=w.flavor, domain=repo["domain_slug"] if repo else (topic.domain_slug if topic else "unknown"), repo_label=repo["slug"] if repo else workplan.get("repo_slug", "unassigned"), workplan_filename=workplan.get("filename"), @@ -741,6 +782,39 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview: ) +async def _sbom_metrics(session: AsyncSession) -> tuple[int, int, int]: + """Return compatibility summary metrics from the selected SBOM authority.""" + if reads_from_nexus(): + snapshots = await get_sbom_nexus_json("/sbom/snapshots/") + report = await get_sbom_nexus_json("/sbom/report/licences/") + return ( + int(report.get("copyleft_direct_count") or 0), + len(snapshots), + sum(int(snapshot.get("entry_count") or 0) for snapshot in snapshots), + ) + + copyleft_patterns = ("GPL", "AGPL", "LGPL", "EUPL", "CDDL", "MPL") + rows = await session.execute( + select(SBOMEntry.license_spdx) + .where(SBOMEntry.is_direct.is_(True)) + .where(SBOMEntry.is_dev.is_(False)) + ) + licence_risk_count = sum( + 1 + for (licence,) in rows.all() + if licence and any(pattern in licence.upper() for pattern in copyleft_patterns) + ) + snapshot_count, package_total = ( + await session.execute( + select( + func.count(SBOMSnapshot.id), + func.coalesce(func.sum(SBOMSnapshot.entry_count), 0), + ) + ) + ).one() + return licence_risk_count, int(snapshot_count or 0), int(package_total or 0) + + async def _build_domain_summaries(session: AsyncSession) -> list[DomainSummary]: """Compute per-domain stats for the state summary.""" domains_rows = await session.execute( @@ -789,7 +863,10 @@ async def _build_domain_summaries(session: AsyncSession) -> list[DomainSummary]: @router.get("/deps", response_model=list[WorkstreamWithDeps]) -async def get_deps(session: AsyncSession = Depends(get_session)) -> list[WorkstreamWithDeps]: +async def get_deps( + session: AsyncSession = Depends(get_session), + include_residuals: bool = Query(False), +) -> list[WorkstreamWithDeps]: """Lightweight dep-graph endpoint: open workstreams with their dependency edges only. Returns the same structure as open_workplans in /state/summary but skips @@ -803,6 +880,8 @@ async def get_deps(session: AsyncSession = Depends(get_session)) -> list[Workstr .order_by(Workplan.due_date.asc().nullslast(), Workplan.created_at) ) open_ws = list(open_ws_rows.scalars().all()) + if not include_residuals: + open_ws = [w for w in open_ws if not is_residual_flavor(w.flavor)] open_ws_ids = [w.id for w in open_ws] dep_rows = [] @@ -938,6 +1017,10 @@ async def _derive_next_steps(session: AsyncSession) -> tuple[list[NextStep], lis if task.id in seen_task_ids: continue ws = await session.get(Workplan, decision.workplan_id, options=[noload("*")]) + if ws is not None and is_residual_flavor(ws.flavor): + continue + if is_residual_flavor(task.flavor): + continue domain_slug = await _get_domain_slug_for_workplan(ws, session) steps.append(NextStep( type="resolved_decision", @@ -982,6 +1065,7 @@ async def _derive_next_steps(session: AsyncSession) -> tuple[list[NextStep], lis Workplan.title, Workplan.slug, Workplan.topic_id, + Workplan.flavor, ).where(Workplan.id.in_(dep_ws_ids)) ) ws_info = { @@ -990,8 +1074,9 @@ async def _derive_next_steps(session: AsyncSession) -> tuple[list[NextStep], lis "title": title, "slug": slug, "topic_id": topic_id, + "flavor": flavor, } - for ws_id, status, title, slug, topic_id in ws_rows + for ws_id, status, title, slug, topic_id, flavor in ws_rows } ready_from_ws_ids = [ @@ -1017,12 +1102,16 @@ async def _derive_next_steps(session: AsyncSession) -> tuple[list[NextStep], lis for from_ws_id in ready_from_ws_ids: from_ws = ws_info.get(from_ws_id, {}) + if is_residual_flavor(from_ws.get("flavor")): + continue todo_tasks = todo_by_ws.get(from_ws_id, []) if not todo_tasks: continue task = min(todo_tasks, key=lambda t: (_PRIORITY_RANK.get(t.priority, 99), t.created_at)) if task.id in seen_task_ids: continue + if is_residual_flavor(task.flavor): + continue domain_slug = await _get_domain_slug_for_topic(from_ws.get("topic_id"), session) _blocker_slugs = [] for tid in dep_map[from_ws_id]: @@ -1104,7 +1193,16 @@ async def get_next_steps(session: AsyncSession = Depends(get_session)) -> list[N async def health_check(session: AsyncSession = Depends(get_session)) -> dict: try: await session.execute(text("SELECT 1")) - return {"status": "ok", "db": "connected"} + return { + "status": "ok", + "db": "connected", + # Identity, so a caller can verify it reached the hub it meant to. + "instance_role": settings.state_hub_instance_role, + "instance_label": settings.state_hub_instance_label, + # Surfaced, not merely logged: a schema behind the code is a fault + # the operator must be able to see (STATE-WP-0083-T07). + "schema": await schema_state(session), + } except Exception as exc: return JSONResponse( status_code=503, diff --git a/api/routers/suggestions.py b/api/routers/suggestions.py index b04c044..ed5df8c 100644 --- a/api/routers/suggestions.py +++ b/api/routers/suggestions.py @@ -1,259 +1,45 @@ -import uuid +"""Retired: the suggestion backlog. -from fastapi import APIRouter, Depends, HTTPException, Query, status -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession +Slice E1 of the State Hub retirement (`STATE-WP-0079-T05`). The capability was +superseded by the **intake** work-record entity; mutations were retired +2026-07-21 under `CUST-WP-0061-T06`, and the reads were kept live only so the +historical record stayed reachable. -from api.database import get_session -from api.models.domain import Domain -from api.models.suggestion import ( - OPEN_SUGGESTION_STAGES, - Suggestion, - SuggestionNote, - SuggestionStage, -) -from api.models.task import Task, TaskPriority, TaskStatus -from api.schemas.suggestion import ( - SuggestionBumpRelevance, - SuggestionCreate, - SuggestionDecline, - SuggestionNoteRead, - SuggestionPromote, - SuggestionRead, - SuggestionVet, -) -from api.services.suggestion_relevance import bump_relevance -from api.services.suggestion_wsjf import compute_wsjf, cost_of_delay, suggestion_sort_key -from api.task_status import normalize_task_status +That history is now archived at +`the-custodian/docs/archived-suggestion-backlog.md` — all 10 suggestions, 10 +notes and 5 relevance bumps, every one closed as `declined` during the intake +migration and none promoted. With a durable record outside this repo, the read +surface has no remaining job, so the whole router answers 410. + +The `suggestions`, `suggestion_notes` and `suggestion_relevance_bumps` tables +are deliberately left in place: they are `retire`/`archive` in `SHR-INV-0001` +and are captured by the final dump at `STATE-WP-0079-T06`. Dropping them here +would remove data ahead of the dump for no gain. +""" + +from fastapi import APIRouter, HTTPException router = APIRouter(prefix="/suggestions", tags=["suggestions"]) -_ALLOWED_VET_FROM = {SuggestionStage.suggestion} -_ALLOWED_DECLINE_FROM = {SuggestionStage.suggestion, SuggestionStage.requirement} -_ALLOWED_PROMOTE_FROM = {SuggestionStage.requirement} +_DETAIL = ( + "suggestions are retired (STATE-WP-0079-T05, slice E1). " + "Use POST /intakes/ for new discovery work. The historical backlog is " + "archived at the-custodian/docs/archived-suggestion-backlog.md — see also " + "the-custodian/intake-legacy-suggestions-migration.md and " + "canon/standards/work-record-types_v0.1.md." +) -async def _resolve_domain_id(slug: str, session: AsyncSession) -> uuid.UUID: - row = await session.execute( - select(Domain.id).where(Domain.slug == slug, Domain.status == "active") - ) - domain_id = row.scalar_one_or_none() - if domain_id is None: - valid = [r[0] for r in (await session.execute( - select(Domain.slug).where(Domain.status == "active") - )).all()] - raise HTTPException( - status_code=422, - detail=f"Unknown domain '{slug}'. Valid domains: {sorted(valid)}", - ) - return domain_id +def _retired() -> HTTPException: + return HTTPException(status_code=410, detail=_DETAIL) -def _enrich_read(suggestion: Suggestion) -> SuggestionRead: - data = SuggestionRead.model_validate(suggestion) - data.cost_of_delay = cost_of_delay(suggestion) - data.wsjf = compute_wsjf(suggestion) - return data - - -async def _get_suggestion_or_404( - suggestion_id: uuid.UUID, - session: AsyncSession, -) -> Suggestion: - suggestion = await session.get(Suggestion, suggestion_id) - if suggestion is None: - raise HTTPException(status_code=404, detail="Suggestion not found") - return suggestion - - -def _reject_stage(suggestion: Suggestion, allowed: set[SuggestionStage], action: str) -> None: - if suggestion.stage not in allowed: - raise HTTPException( - status_code=409, - detail=f"Cannot {action} suggestion in stage '{suggestion.stage.value}'", - ) - - -@router.get("/", response_model=list[SuggestionRead]) -async def list_suggestions( - domain: str | None = None, - stage: SuggestionStage | None = None, - include_terminal: bool = Query(False), - rank: str | None = Query(None), - limit: int = Query(100, ge=1, le=500), - session: AsyncSession = Depends(get_session), -) -> list[SuggestionRead]: - q = select(Suggestion) - if domain: - domain_id = await _resolve_domain_id(domain, session) - q = q.where(Suggestion.domain_id == domain_id) - if stage: - q = q.where(Suggestion.stage == stage) - elif not include_terminal: - q = q.where(Suggestion.stage.in_(OPEN_SUGGESTION_STAGES)) - result = await session.execute(q) - suggestions = list(result.scalars().all()) - if rank == "wsjf": - suggestions.sort(key=suggestion_sort_key) - else: - suggestions.sort(key=lambda s: s.created_at) - return [_enrich_read(s) for s in suggestions[:limit]] - - -@router.post("/", response_model=SuggestionRead, status_code=status.HTTP_201_CREATED) -async def create_suggestion( - body: SuggestionCreate, - session: AsyncSession = Depends(get_session), -) -> SuggestionRead: - domain_id = await _resolve_domain_id(body.domain, session) - suggestion = Suggestion( - domain_id=domain_id, - topic_id=body.topic_id, - workplan_id=body.workplan_id, - title=body.title, - description=body.description, - origin=body.origin, - origin_ref=body.origin_ref, - base_value=body.base_value, - job_size=body.job_size, - relevance_weight=body.relevance_weight, - ) - session.add(suggestion) - await session.commit() - await session.refresh(suggestion) - return _enrich_read(suggestion) - - -@router.get("/{suggestion_id}", response_model=SuggestionRead) -async def get_suggestion( - suggestion_id: uuid.UUID, - session: AsyncSession = Depends(get_session), -) -> SuggestionRead: - suggestion = await _get_suggestion_or_404(suggestion_id, session) - return _enrich_read(suggestion) - - -@router.post("/{suggestion_id}/vet", response_model=SuggestionRead) -async def vet_suggestion( - suggestion_id: uuid.UUID, - body: SuggestionVet, - session: AsyncSession = Depends(get_session), -) -> SuggestionRead: - suggestion = await _get_suggestion_or_404(suggestion_id, session) - _reject_stage(suggestion, _ALLOWED_VET_FROM, "vet") - suggestion.stage = SuggestionStage.requirement - if body.base_value is not None: - suggestion.base_value = body.base_value - if body.job_size is not None: - suggestion.job_size = body.job_size - if body.relevance_weight is not None: - suggestion.relevance_weight = body.relevance_weight - if body.workplan_id is not None: - suggestion.workplan_id = body.workplan_id - session.add( - SuggestionNote( - suggestion_id=suggestion.id, - stage=SuggestionStage.requirement.value, - author=body.author, - content=body.note, - ) - ) - await session.commit() - await session.refresh(suggestion) - return _enrich_read(suggestion) - - -@router.post("/{suggestion_id}/decline", response_model=SuggestionRead) -async def decline_suggestion( - suggestion_id: uuid.UUID, - body: SuggestionDecline, - session: AsyncSession = Depends(get_session), -) -> SuggestionRead: - suggestion = await _get_suggestion_or_404(suggestion_id, session) - _reject_stage(suggestion, _ALLOWED_DECLINE_FROM, "decline") - suggestion.stage = SuggestionStage.declined - session.add( - SuggestionNote( - suggestion_id=suggestion.id, - stage=SuggestionStage.declined.value, - author=body.author, - content=body.note, - ) - ) - await session.commit() - await session.refresh(suggestion) - return _enrich_read(suggestion) - - -@router.post("/{suggestion_id}/promote", response_model=SuggestionRead) -async def promote_suggestion_to_task( - suggestion_id: uuid.UUID, - body: SuggestionPromote, - session: AsyncSession = Depends(get_session), -) -> SuggestionRead: - suggestion = await _get_suggestion_or_404(suggestion_id, session) - _reject_stage(suggestion, _ALLOWED_PROMOTE_FROM, "promote") - if suggestion.workplan_id is None: - raise HTTPException( - status_code=409, - detail="Suggestion must have workplan_id before promotion", - ) - - task = Task( - workplan_id=suggestion.workplan_id, - title=body.task_title or suggestion.title, - description=body.task_description or suggestion.description, - status=TaskStatus(normalize_task_status(body.task_status)), - priority=TaskPriority(body.task_priority), - ) - session.add(task) - await session.flush() - - suggestion.stage = SuggestionStage.promoted - suggestion.promoted_task_id = task.id - if body.note: - session.add( - SuggestionNote( - suggestion_id=suggestion.id, - stage=SuggestionStage.promoted.value, - author=body.author, - content=body.note, - ) - ) - await session.commit() - await session.refresh(suggestion) - return _enrich_read(suggestion) - - -@router.post("/{suggestion_id}/bump-relevance", response_model=SuggestionRead) -async def bump_suggestion_relevance( - suggestion_id: uuid.UUID, - body: SuggestionBumpRelevance, - session: AsyncSession = Depends(get_session), -) -> SuggestionRead: - suggestion = await _get_suggestion_or_404(suggestion_id, session) - source_key = body.author or "explicit" - await bump_relevance( - session, - suggestion, - source="explicit", - source_key=source_key, - reason=body.reason, - ) - await session.commit() - await session.refresh(suggestion) - return _enrich_read(suggestion) - - -@router.get("/{suggestion_id}/notes", response_model=list[SuggestionNoteRead]) -async def list_suggestion_notes( - suggestion_id: uuid.UUID, - session: AsyncSession = Depends(get_session), -) -> list[SuggestionNote]: - await _get_suggestion_or_404(suggestion_id, session) - result = await session.execute( - select(SuggestionNote) - .where(SuggestionNote.suggestion_id == suggestion_id) - .order_by(SuggestionNote.created_at) - ) - return list(result.scalars().all()) \ No newline at end of file +@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() diff --git a/api/routers/tasks.py b/api/routers/tasks.py index b13eb06..b76746c 100644 --- a/api/routers/tasks.py +++ b/api/routers/tasks.py @@ -169,6 +169,8 @@ async def bulk_status_sync( updated: list[Task] = [] events: list[ProgressEvent] = [] author = body.author or "custodian" + # Cache repo resolution for dual-run writeback (RMGR-WP-0003) + repo_cache: dict = {} for update in body.updates: task = tasks_by_id[update.task_id] previous_status = status_value(task.status) @@ -182,6 +184,32 @@ async def bulk_status_sync( parent_workstream=ws, previous_task_status=previous_status, ) + if target_status != previous_status: + try: + from api.models.managed_repo import ManagedRepo + from api.services.repo_manager_dual_run import try_writeback_for_task + from api.services.workplan_files import resolve_repo_path + + repo_id = ws.repo_id if ws else None + if repo_id is not None: + if repo_id not in repo_cache: + repo_cache[repo_id] = await session.get(ManagedRepo, repo_id) + repo = repo_cache[repo_id] + if repo is not None: + try_writeback_for_task( + repo_path=resolve_repo_path(repo), + repo_slug=repo.slug, + task_id=str(task.id), + status=target_status, + reason="state-hub bulk-status-sync dual-run", + ) + except Exception: + import logging + + logging.getLogger(__name__).exception( + "repo-manager dual-run bulk writeback failed for task %s", + update.task_id, + ) event = ProgressEvent( task_id=task.id, workplan_id=task.workplan_id, @@ -263,6 +291,33 @@ async def update_task( parent_workstream=ws, previous_task_status=previous_status, ) + # RMGR-WP-0002 dual-run: optional checkout writeback via repo-manager + if new_status != previous_status: + try: + from api.models.managed_repo import ManagedRepo + from api.services.repo_manager_dual_run import try_writeback_for_task + from api.services.workplan_files import resolve_repo_path + + ws_full = ws or await session.get(Workplan, task.workplan_id) + repo = None + if ws_full and ws_full.repo_id: + repo = await session.get(ManagedRepo, ws_full.repo_id) + if repo is not None: + repo_path = resolve_repo_path(repo) + try_writeback_for_task( + repo_path=repo_path, + repo_slug=repo.slug, + task_id=str(task.id), + status=new_status, + reason="state-hub PATCH /tasks dual-run", + ) + except Exception: + # Dual-run must not break native DB update path + import logging + + logging.getLogger(__name__).exception( + "repo-manager dual-run writeback failed for task %s", task_id + ) await session.commit() await session.refresh(task) diff --git a/api/routers/token_events.py b/api/routers/token_events.py index 6dcd853..802e173 100644 --- a/api/routers/token_events.py +++ b/api/routers/token_events.py @@ -13,6 +13,7 @@ from api.models.task import Task from api.models.token_event import TokenEvent from api.models.workplan import Workplan from api.services.legacy_compat import meter_legacy_query_param +from api.services.repository_aliases import resolve_repository_slug from api.schemas.token_event import ( RepoTokenSummary, TokenAggregateRow, @@ -288,6 +289,7 @@ async def get_token_summary( @router.get("/by-repo/", response_model=list[RepoTokenSummary]) async def get_tokens_by_repo( + repo_slug: str | None = None, measurement_kind: str | None = None, source_provider: str | None = None, since: datetime | None = None, @@ -304,6 +306,12 @@ async def get_tokens_by_repo( Only events that resolve to a repo are included. """ + requested_repo_id = None + if repo_slug: + requested_repo_id = ( + await resolve_repository_slug(session, repo_slug) + ).repo.id + # Fetch all events, workstreams, repos in three queries (avoids N+1) events_result = await session.execute( _filter_query( @@ -341,6 +349,8 @@ async def get_tokens_by_repo( rid = resolve_repo_id(e) if not rid or rid not in repo_map: continue + if requested_repo_id is not None and rid != requested_repo_id: + continue if rid not in groups: groups[rid] = { "repo_id": rid, diff --git a/api/routers/work_record_projection.py b/api/routers/work_record_projection.py new file mode 100644 index 0000000..ae16d86 --- /dev/null +++ b/api/routers/work_record_projection.py @@ -0,0 +1,151 @@ +"""Primary-only fast path for one forge-derived repository projection.""" + +from __future__ import annotations + +import asyncio + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.config import settings +from api.database import get_session +from api.models.task import Task +from api.models.workplan import Workplan +from api.models.workplan_dependency import WorkplanDependency +from api.schemas.work_record_projection import ( + RepositoryProjectionReconcile, + RepositoryProjectionSnapshot, +) +from api.services.forge_projection import ( + ForgeDeriveError, + ForgeUnreadableError, + derive_from_forge, + reset_repository_projection, +) +from api.services.repository_aliases import resolve_repository_slug + +router = APIRouter(prefix="/repos", tags=["repository-work-record-projection"]) + + +@router.get( + "/{slug}/work-record-projection/snapshot", + response_model=RepositoryProjectionSnapshot, +) +async def repository_work_record_snapshot( + slug: str, + session: AsyncSession = Depends(get_session), +) -> RepositoryProjectionSnapshot: + """Return all consistency-check inputs for one repository in one request.""" + resolution = await resolve_repository_slug(session, slug) + workplans = list( + ( + await session.execute( + select(Workplan) + .where(Workplan.repo_id == resolution.repo.id) + .order_by(Workplan.slug) + ) + ).scalars() + ) + workplan_ids = [workplan.id for workplan in workplans] + tasks: list[Task] = [] + dependencies: list[WorkplanDependency] = [] + if workplan_ids: + tasks = list( + ( + await session.execute( + select(Task) + .where(Task.workplan_id.in_(workplan_ids)) + .order_by(Task.workplan_id, Task.id) + ) + ).scalars() + ) + dependencies = list( + ( + await session.execute( + select(WorkplanDependency) + .where(WorkplanDependency.from_workplan_id.in_(workplan_ids)) + .order_by( + WorkplanDependency.from_workplan_id, WorkplanDependency.id + ) + ) + ).scalars() + ) + return RepositoryProjectionSnapshot( + schema="state-hub.repository-projection-snapshot.v1", + repo_slug=resolution.canonical_slug, + repo_id=resolution.repo.id, + workplans=workplans, + tasks=tasks, + dependencies=dependencies, + ) + + +@router.post("/{slug}/work-record-projection/reconcile") +async def reconcile_repository_work_records( + slug: str, + body: RepositoryProjectionReconcile, + session: AsyncSession = Depends(get_session), +) -> dict: + """Derive an exact pushed commit centrally and apply it transactionally.""" + if settings.state_hub_instance_role != "primary": + raise HTTPException( + status_code=409, + detail={ + "message": "repository projection writes require the primary State Hub", + "instance_role": settings.state_hub_instance_role, + "instance_label": settings.state_hub_instance_label, + }, + ) + + try: + derived = await asyncio.to_thread(derive_from_forge, slug) + except ForgeUnreadableError as exc: + raise HTTPException( + status_code=424, + detail={ + "message": "repository is unreadable from the forge", + "detail": str(exc)[:300], + }, + ) from exc + except ForgeDeriveError as exc: + raise HTTPException( + status_code=502, + detail={ + "message": "forge projection derivation failed", + "detail": str(exc)[:300], + }, + ) from exc + + if derived.commit.lower() != body.expected_commit: + raise HTTPException( + status_code=409, + detail={ + "message": "forge default branch is not at the expected commit", + "expected_commit": body.expected_commit, + "derived_commit": derived.commit, + }, + ) + + outcome = await reset_repository_projection( + session, + slug, + acknowledge_retirements=body.acknowledge_retirements, + derived=derived, + ) + if outcome.status in {"applied", "noop"} or outcome.released: + await session.commit() + else: + await session.rollback() + + from api.routers.workstreams import _invalidate_workplan_index_cache + + _invalidate_workplan_index_cache() + return { + "schema": "state-hub.repository-projection-reconcile.v1", + "instance_role": settings.state_hub_instance_role, + "instance_label": settings.state_hub_instance_label, + "expected_commit": body.expected_commit, + "derived_commit": derived.commit, + "outcome": outcome.to_dict(), + } diff --git a/api/routers/workstreams.py b/api/routers/workstreams.py index 5ee52c4..bd636cd 100644 --- a/api/routers/workstreams.py +++ b/api/routers/workstreams.py @@ -9,7 +9,7 @@ from typing import Any import yaml from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status -from sqlalchemy import select +from sqlalchemy import or_, select from sqlalchemy.ext.asyncio import AsyncSession from api.database import get_session @@ -24,6 +24,11 @@ from api.schemas.workplan import ( ) from api.services.lifecycle import transition_workplan_status from api.services.legacy_compat import retire_legacy_route +from api.work_record_flavor import ( + RESIDUAL_FLAVOR, + WORK_RECORD_FLAVORS, + normalize_flavor, +) from api.workplan_status import ( is_supported_workplan_status, normalize_workplan_status, @@ -88,6 +93,8 @@ async def _list_workplans( status_filter: str | None, owner: str | None, slug: str | None, + flavor: str | None = None, + include_residuals: bool = True, session: AsyncSession, ) -> list[Workplan]: q = select(Workplan) @@ -106,6 +113,19 @@ async def _list_workplans( q = q.where(Workplan.owner == owner) if slug: q = q.where(Workplan.slug == slug) + wanted = normalize_flavor(flavor) + if wanted is not None: + if wanted not in WORK_RECORD_FLAVORS: + raise HTTPException( + status_code=422, + detail=( + f"Unknown work-record flavor {wanted!r}; " + f"expected one of {', '.join(WORK_RECORD_FLAVORS)}" + ), + ) + q = q.where(Workplan.flavor == wanted) + elif not include_residuals: + q = q.where(or_(Workplan.flavor.is_(None), Workplan.flavor != RESIDUAL_FLAVOR)) q = q.order_by( Workplan.planning_priority.asc().nullslast(), Workplan.planning_order.asc().nullslast(), @@ -365,6 +385,8 @@ async def list_workplans( status: str | None = None, owner: str | None = None, slug: str | None = None, + flavor: str | None = Query(None), + include_residuals: bool = Query(True), session: AsyncSession = Depends(get_session), ) -> list[Workplan]: return await _list_workplans( @@ -374,6 +396,8 @@ async def list_workplans( status_filter=status, owner=owner, slug=slug, + flavor=flavor, + include_residuals=include_residuals, session=session, ) @@ -411,8 +435,11 @@ async def sync_workplan_bindings( """Upsert workstation workplan file bindings for remote API index fallback.""" synced_at = datetime.now(timezone.utc) updated = 0 + requested_ids = {entry.workplan_id for entry in body.bindings} + rows = await session.execute(select(Workplan).where(Workplan.id.in_(requested_ids))) + workplans = {workplan.id: workplan for workplan in rows.scalars().all()} for entry in body.bindings: - wp = await session.get(Workplan, entry.workplan_id) + wp = workplans.get(entry.workplan_id) if wp is None: continue wp.backing_filename = entry.filename @@ -524,4 +551,4 @@ async def archive_workplan( workplan_id: uuid.UUID, session: AsyncSession = Depends(get_session), ) -> Workplan: - return await _archive_workplan(workplan_id=workplan_id, session=session) \ No newline at end of file + return await _archive_workplan(workplan_id=workplan_id, session=session) diff --git a/api/schemas/decision.py b/api/schemas/decision.py index 09e5260..edba07e 100644 --- a/api/schemas/decision.py +++ b/api/schemas/decision.py @@ -9,6 +9,7 @@ from pydantic import AliasChoices, Field class DecisionCreate(LegacyWorkstreamIdBodyMixin, BaseModel): + id: uuid.UUID | None = None topic_id: uuid.UUID | None = None workplan_id: uuid.UUID | None = Field( default=None, @@ -65,4 +66,4 @@ class DecisionRead(OptionalWorkplanIdCompatMixin, BaseModel): escalation_note: str | None = None superseded_by: uuid.UUID | None = None created_at: datetime - updated_at: datetime \ No newline at end of file + updated_at: datetime diff --git a/api/schemas/execution.py b/api/schemas/execution.py index 3e0df39..68f282f 100644 --- a/api/schemas/execution.py +++ b/api/schemas/execution.py @@ -104,3 +104,5 @@ class ExecutionSemantics(BaseModel): concurrency_modes: dict[str, str] state_hub_responsibility: list[str] activity_core_responsibility: list[str] + launch_requests_accepted: bool = False + replacements: dict[str, str] = Field(default_factory=dict) diff --git a/api/schemas/fabric_graph.py b/api/schemas/fabric_graph.py index b1ea79e..6afe417 100644 --- a/api/schemas/fabric_graph.py +++ b/api/schemas/fabric_graph.py @@ -27,7 +27,10 @@ class FabricGraphSource(BaseModel): repo: str | None = None producer: str | None = None registry: str | None = None - commit: str | None = None + # Accepted-snapshot-set revisions include the algorithm and digest, for + # example ``snapshot-set:sha256:<64 hex chars>``. They are intentionally + # longer than a Git SHA but still bounded before persistence. + commit: str | None = Field(default=None, max_length=255) path: str | None = None generation_reason: str | None = None @@ -98,6 +101,18 @@ class FabricGraphAccountingPayload(BaseModel): valid_until: str | None = None +class FabricGraphDeploymentOverlayPayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + deployment_environment: str | None = None + deployment_scenario: str | None = None + routing_authority: str | None = None + access_zone: str | None = None + policy_authority: str | None = None + exposure_class: str | None = None + route_evidence: dict[str, str | int | float | bool | None] = Field(default_factory=dict) + + class FabricGraphEvidencePayload(BaseModel): model_config = ConfigDict(extra="forbid") @@ -154,6 +169,7 @@ class FabricGraphNodePayload(BaseModel): containment: FabricGraphContainmentPayload | None = None ownership: FabricGraphOwnershipPayload | None = None accounting: FabricGraphAccountingPayload | None = None + deployment_overlay: FabricGraphDeploymentOverlayPayload | None = None evidence: FabricGraphEvidencePayload | None = None canon_category: str | None = None canon_anchor: str | None = None @@ -180,6 +196,7 @@ class FabricGraphEdgePayload(BaseModel): boundary: FabricGraphBoundaryPayload | None = None utility: FabricGraphUtilityPayload | None = None accounting: FabricGraphAccountingPayload | None = None + deployment_overlay: FabricGraphDeploymentOverlayPayload | None = None evidence: FabricGraphEvidencePayload | None = None attributes: dict[str, Any] = Field(default_factory=dict) @@ -244,6 +261,17 @@ class FabricGraphIngestResult(BaseModel): edge_count: int +class FabricGraphActivationRequest(BaseModel): + requested_by: str = "operator" + + +class FabricGraphActivationResult(BaseModel): + import_run: FabricGraphImportRead + previous_import_id: uuid.UUID | None = None + activated: bool + idempotent: bool + + class FabricGraphNodeRead(BaseModel): model_config = ConfigDict(from_attributes=True) diff --git a/api/schemas/identifier_migration.py b/api/schemas/identifier_migration.py new file mode 100644 index 0000000..8b06629 --- /dev/null +++ b/api/schemas/identifier_migration.py @@ -0,0 +1,39 @@ +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class SealedProjectionRepairSubmit(BaseModel): + plan: dict[str, Any] + repo_slug: str = Field(pattern=r"^[a-z0-9][a-z0-9-]{0,99}$") + unit: dict[str, Any] + expected_plan_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + source_revision: str = Field(pattern=r"^[0-9a-f]{40}$") + source_fingerprint: str = Field(pattern=r"^[0-9a-f]{64}$") + source_clean: bool + source_synchronized: bool + primary_confirmed: bool + projection_identity: str = Field(min_length=1, max_length=200) + + +class SealedProjectionRepairReceiptRead(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + schema_version: str = Field(alias="schema") + outcome: str + repo_slug: str + repository_id: str + workplan_record_id: str + workplan_old_id: str + task_records: list[tuple[str, str]] + plan_sha256: str + source_revision: str + source_fingerprint: str + projection_identity: str + observed_at: str + + +class IdentifierMigrationApplySubmit(BaseModel): + plan: dict[str, Any] + expected_plan_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + primary_confirmed: bool diff --git a/api/schemas/intake.py b/api/schemas/intake.py new file mode 100644 index 0000000..54ff1ca --- /dev/null +++ b/api/schemas/intake.py @@ -0,0 +1,89 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, model_validator + +from api.models.intake import IntakeLane, IntakeOutcome, IntakeStatus + + +class IntakeCreate(BaseModel): + id: uuid.UUID | None = None + topic_id: uuid.UUID | None = None + workplan_id: uuid.UUID | None = None + repo_id: uuid.UUID | None = None + title: str + description: str | None = None + lane: IntakeLane = IntakeLane.green + origin: str | None = None + origin_ref: str | None = None + source_repo_path: str | None = None + + @model_validator(mode="after") + def scope_required(self) -> "IntakeCreate": + if self.topic_id is None and self.workplan_id is None and self.repo_id is None: + raise ValueError("At least one of topic_id, workplan_id, or repo_id must be set") + return self + + +class IntakeUpdate(BaseModel): + title: str | None = None + description: str | None = None + lane: IntakeLane | None = None + status: IntakeStatus | None = None + origin: str | None = None + origin_ref: str | None = None + routed_note: str | None = None + + +class IntakeRoute(BaseModel): + """Move an intake from open/vetted into routed — the state that makes + it eligible for the promotion transition (CUST-WP-0061-T03).""" + + routed_note: str | None = None + + +class IntakeClose(BaseModel): + outcome: IntakeOutcome + promoted_to: str | None = None + note: str | None = None + + @model_validator(mode="after") + def promoted_requires_target(self) -> "IntakeClose": + if self.outcome == IntakeOutcome.promoted and not self.promoted_to: + raise ValueError("outcome=promoted requires promoted_to") + return self + + +class IntakeNoteCreate(BaseModel): + content: str + author: str | None = None + + +class IntakeNoteRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + author: str | None = None + content: str + created_at: datetime + + +class IntakeRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + topic_id: uuid.UUID | None = None + workplan_id: uuid.UUID | None = None + repo_id: uuid.UUID | None = None + title: str + description: str | None = None + lane: IntakeLane + status: IntakeStatus + outcome: IntakeOutcome | None = None + origin: str | None = None + origin_ref: str | None = None + promoted_to: str | None = None + source_repo_path: str | None = None + routed_note: str | None = None + closed_at: datetime | None = None + created_at: datetime + updated_at: datetime + notes: list[IntakeNoteRead] = [] diff --git a/api/schemas/managed_repo.py b/api/schemas/managed_repo.py index 82d619e..ff1f5cf 100644 --- a/api/schemas/managed_repo.py +++ b/api/schemas/managed_repo.py @@ -80,6 +80,7 @@ class RepoCreate(CoreRepoCreate, ClassificationFields): class RepoUpdate(ClassificationFields): + domain_slug: str | None = None name: str | None = None local_path: str | None = None remote_url: str | None = None @@ -113,6 +114,11 @@ class RepoRead(CoreRepoRead, ClassificationFields): last_state_synced_at: datetime | None = None created_at: datetime updated_at: datetime + requested_slug: str | None = None + canonical_slug: str | None = None + slug_status: Literal["canonical", "alias"] = "canonical" + aliases: list[str] = Field(default_factory=list) + stale_external_references: list[dict[str, Any]] = Field(default_factory=list) class DispatchTask(BaseModel): @@ -157,6 +163,11 @@ class ScopeIssueDetail(BaseModel): class RepoDispatch(BaseModel): repo_slug: str + requested_slug: str + canonical_slug: str + slug_status: Literal["canonical", "alias"] + aliases: list[str] = Field(default_factory=list) + stale_external_references: list[dict[str, Any]] = Field(default_factory=list) active_goal: dict[str, Any] | None active_workplans: list[DispatchWorkplan] human_interventions: list[DispatchTask] @@ -176,4 +187,4 @@ class RepoScopeHealth(BaseModel): local_path: str | None = None path_available: bool scope_needs_review: bool - scope_issue_details: list[ScopeIssueDetail] \ No newline at end of file + scope_issue_details: list[ScopeIssueDetail] diff --git a/api/schemas/ops_run.py b/api/schemas/ops_run.py new file mode 100644 index 0000000..6cad88d --- /dev/null +++ b/api/schemas/ops_run.py @@ -0,0 +1,27 @@ +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, Field + + +class OpsRunProjectionItem(BaseModel): + id: str + definition: str | None = None + target_repo: str | None = None + state: str + lease: dict[str, Any] | None = None + updated_at: datetime | None = None + last_error: str | None = None + + +class OpsRunProjection(BaseModel): + available: bool = False + stale: bool = False + source: str = "activity-core" + generated_at: datetime + open: int = 0 + claimed: int = 0 + failed_24h: int = 0 + stuck_open_or_claimed: int = 0 + items: list[OpsRunProjectionItem] = Field(default_factory=list) + error: str | None = None diff --git a/api/schemas/repository_rename.py b/api/schemas/repository_rename.py new file mode 100644 index 0000000..aed4083 --- /dev/null +++ b/api/schemas/repository_rename.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, Field + + +class EdgeWriteEvidence(BaseModel): + id: str + status: str = "queued" + source_host: str | None = None + path: str | None = None + + +class RepositoryRenamePreflightRequest(BaseModel): + new_slug: str = Field(min_length=1, max_length=100, pattern=r"^[a-z0-9][a-z0-9-]*$") + queued_edge_writes: list[EdgeWriteEvidence] = Field(default_factory=list) + + +class RepositoryRenamePreflightRead(BaseModel): + schema_version: Literal["state-hub.repository-rename-preflight.v1"] + repo_id: uuid.UUID + old_slug: str + new_slug: str + safe_to_apply: bool + blockers: list[dict[str, Any]] + warnings: list[dict[str, Any]] + current: dict[str, Any] + target: dict[str, Any] + baselines: dict[str, Any] + active_work: dict[str, Any] + affected: dict[str, Any] + queued_edge_writes: list[dict[str, Any]] + proposed_mutations: list[dict[str, Any]] + retained_history: list[dict[str, Any]] + report_checksum: str + preflight_token: str | None + preflighted_at: datetime + expires_at: datetime | None + + +class ForgeIdentityVerifyRequest(BaseModel): + provider: Literal["forgejo"] = "forgejo" + forge_instance: str + forge_owner: str + forge_repository_id: int = Field(gt=0) + verified_by: str = Field(min_length=1, max_length=160) + + +class RepositoryRenameOperationCreate(BaseModel): + operation_id: uuid.UUID | None = None + new_slug: str = Field(min_length=1, max_length=100, pattern=r"^[a-z0-9][a-z0-9-]*$") + preflight_token: str + confirmation: str + actor: str = Field(min_length=1, max_length=160) + queued_edge_writes: list[EdgeWriteEvidence] = Field(default_factory=list) + + +class RepositoryRenamePhaseApply(BaseModel): + expected_phase: str + confirmation: str + checks: dict[str, bool] = Field(default_factory=dict) + evidence: dict[str, Any] = Field(default_factory=dict) + + +class RepositoryRenameRollbackRequest(BaseModel): + expected_phase: str + confirmation: str + + +class RepositoryRenameOperationRead(BaseModel): + id: uuid.UUID + repo_id: uuid.UUID + phase: str + old_slug: str + new_slug: str + expected_forge_repository_id: int + expected_source_commit: str + expected_default_branch: str + actor: str + phase_changed_at: datetime + preflighted_at: datetime | None + preflight_expires_at: datetime | None + completed_at: datetime | None + rolled_back_at: datetime | None + evidence: dict[str, Any] + error_code: str | None + error_message: str | None + error_details: dict[str, Any] | None + error_at: datetime | None + no_op: bool = False + + +class RepositoryRenameVerificationRead(BaseModel): + operation_id: uuid.UUID + repo_id: uuid.UUID + phase: str + ok: bool + checks: list[dict[str, Any]] + baseline_checksum: str + current_checksum: str + relationship_checksums: dict[str, dict[str, str]] + + +class RepositoryRenameRollbackPreflightRead(BaseModel): + operation_id: uuid.UUID + repo_id: uuid.UUID + rollback_from_phase: str + safe_to_rollback: bool + blockers: list[dict[str, Any]] + irreversible: list[dict[str, Any]] + operation: RepositoryRenameOperationRead diff --git a/api/schemas/review_contract.py b/api/schemas/review_contract.py new file mode 100644 index 0000000..c5556fd --- /dev/null +++ b/api/schemas/review_contract.py @@ -0,0 +1,96 @@ +import uuid +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + + +class SourceRef(BaseModel): + repo: str = Field(min_length=1, max_length=100) + path: str = Field(min_length=1, max_length=1000) + revision: str = Field(pattern=r"^[0-9a-f]{40,64}$") + + +class ReviewContractProject(BaseModel): + contract: dict[str, Any] + source: SourceRef + decision_id: uuid.UUID | None = None + workplan_id: uuid.UUID | None = None + task_id: uuid.UUID | None = None + required_for_decision: bool = False + + +class ReviewContractRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + contract_key: str + schema_version: str + contract_digest: str + source_repo: str + source_path: str + source_revision: str + document: dict[str, Any] + active: bool + required_for_decision: bool + decision_id: uuid.UUID | None + workplan_id: uuid.UUID | None + task_id: uuid.UUID | None + projected_at: datetime + created_at: datetime + updated_at: datetime + + +class ReviewReceiptSubmit(BaseModel): + owner_id: str = Field(min_length=1, max_length=160) + actor: str = Field(min_length=2, max_length=160) + disposition: Literal["approve", "request_changes"] + contract_digest: str = Field(pattern=r"^[0-9a-f]{64}$") + artifact_hashes: dict[str, str] = Field(default_factory=dict) + assertion_ids: list[str] = Field(default_factory=list) + checks: list[dict[str, Any]] = Field(default_factory=list) + note: str | None = Field(default=None, max_length=2000) + source: SourceRef + + +class ReviewReceiptRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + contract_id: uuid.UUID + owner_id: str + actor: str + disposition: str + contract_digest: str + receipt_digest: str + artifact_hashes: dict[str, str] + assertion_ids: list[str] + checks: list[dict[str, Any]] + note: str | None + source_repo: str + source_path: str + source_revision: str + submitted_at: datetime + created_at: datetime + + +class OwnerReviewState(BaseModel): + owner_id: str + status: Literal["missing", "approved", "request_changes", "stale"] + receipt_id: uuid.UUID | None = None + receipt_digest: str | None = None + submitted_at: datetime | None = None + + +class GateReviewState(BaseModel): + gate_id: str + policy: Literal["all_required"] + owners: list[str] + satisfied: bool + + +class ReviewAggregateRead(BaseModel): + contract_key: str + contract_digest: str + satisfied: bool + authorizes_execution: Literal[False] = False + owners: list[OwnerReviewState] + gates: list[GateReviewState] diff --git a/api/schemas/state.py b/api/schemas/state.py index d665585..3b9e53e 100644 --- a/api/schemas/state.py +++ b/api/schemas/state.py @@ -11,6 +11,7 @@ from api.schemas.task import TaskRead from api.schemas.topic import TopicWithWorkstreams from api.schemas.suggestion import RankedSuggestionDigest from api.schemas.workstream import WorkstreamWithDeps +from api.schemas.ops_run import OpsRunProjection class TopicTotals(BaseModel): @@ -29,6 +30,7 @@ class WorkstreamTotals(BaseModel): finished: int = 0 archived: int = 0 total: int = 0 + residual_open: int = 0 class TaskTotals(BaseModel): @@ -84,18 +86,21 @@ class StateSummary(BaseModel): blocked_tasks: list[TaskRead] = [] recent_progress: list[ProgressEventRead] open_workplans: list[WorkstreamWithDeps] = [] + residual_open_workplans: list[WorkstreamWithDeps] = [] next_steps: list[NextStep] = [] domains: list[DomainSummary] = [] contribution_counts: dict[str, int] = {} licence_risk_count: int = 0 open_capability_requests: int = 0 ranked_suggestions: list[RankedSuggestionDigest] = [] + ops_runs: OpsRunProjection | None = None class DashboardWorkplanRow(BaseModel): id: uuid.UUID title: str status: str + flavor: str | None = None domain: str = "unknown" repo_label: str = "unassigned" workplan_filename: str | None = None diff --git a/api/schemas/task.py b/api/schemas/task.py index ef1c608..31bf85d 100644 --- a/api/schemas/task.py +++ b/api/schemas/task.py @@ -7,6 +7,7 @@ from pydantic import BaseModel, ConfigDict, field_validator, model_validator from api.models.task import TaskPriority, TaskStatus from api.schemas.compat import WorkplanIdCompatMixin, WorkplanIdCreateMixin from api.task_status import normalize_task_status +from api.work_record_flavor import WORK_RECORD_FLAVORS, normalize_flavor class TaskStatusMixin(BaseModel): @@ -18,7 +19,23 @@ class TaskStatusMixin(BaseModel): return normalize_task_status(value) -class TaskCreate(TaskStatusMixin, WorkplanIdCreateMixin): +class TaskFlavorMixin(BaseModel): + @field_validator("flavor", mode="before", check_fields=False) + @classmethod + def _normalize_flavor(cls, value): + flavor = normalize_flavor(value) + if flavor is not None and flavor not in WORK_RECORD_FLAVORS: + raise ValueError( + f"Unknown work-record flavor {flavor!r}; " + f"expected one of {', '.join(WORK_RECORD_FLAVORS)}" + ) + return flavor + + +class TaskCreate(TaskStatusMixin, TaskFlavorMixin, WorkplanIdCreateMixin): + id: uuid.UUID | None = None + record_id: str | None = None + flavor: str | None = None title: str description: str | None = None status: TaskStatus = TaskStatus.todo @@ -37,8 +54,9 @@ class TaskCreate(TaskStatusMixin, WorkplanIdCreateMixin): return self -class TaskUpdate(TaskStatusMixin): +class TaskUpdate(TaskStatusMixin, TaskFlavorMixin): title: str | None = None + flavor: str | None = None description: str | None = None status: TaskStatus | None = None priority: TaskPriority | None = None @@ -99,6 +117,8 @@ class TaskStatusBulkSync(BaseModel): class TaskRead(TaskStatusMixin, WorkplanIdCompatMixin): model_config = ConfigDict(from_attributes=True) id: uuid.UUID + record_id: str | None = None + flavor: str | None = None title: str description: str | None = None status: TaskStatus diff --git a/api/schemas/work_record_projection.py b/api/schemas/work_record_projection.py new file mode 100644 index 0000000..7167cbf --- /dev/null +++ b/api/schemas/work_record_projection.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import re +import uuid +from typing import Literal + +from pydantic import BaseModel, Field, field_validator + +from api.schemas.task import TaskRead +from api.schemas.workplan import WorkplanRead +from api.schemas.workplan_dependency import WorkplanDependencyRead + + +class RepositoryProjectionReconcile(BaseModel): + expected_commit: str + acknowledge_retirements: bool = False + + @field_validator("expected_commit") + @classmethod + def validate_commit(cls, value: str) -> str: + value = value.strip().lower() + if not re.fullmatch(r"[0-9a-f]{40}", value): + raise ValueError("expected_commit must be a full 40-character Git SHA") + return value + + +class RepositoryProjectionSnapshot(BaseModel): + """One bounded read of a repository's complete work-record projection.""" + + schema_version: Literal["state-hub.repository-projection-snapshot.v1"] = Field( + validation_alias="schema", + serialization_alias="schema", + ) + repo_slug: str + repo_id: uuid.UUID + workplans: list[WorkplanRead] + tasks: list[TaskRead] + dependencies: list[WorkplanDependencyRead] diff --git a/api/schemas/workplan.py b/api/schemas/workplan.py index 90be8b8..31bf49c 100644 --- a/api/schemas/workplan.py +++ b/api/schemas/workplan.py @@ -5,6 +5,12 @@ from typing import Literal from pydantic import BaseModel, ConfigDict, field_validator from api.schemas.workplan_dependency import WorkplanDepStub +from api.work_record_flavor import ( + FLAVOR_PROMOTION_REASONS, + WORK_RECORD_FLAVORS, + normalize_flavor, + normalize_promotion_reason, +) from api.workplan_status import normalize_workplan_status WorkplanStatus = Literal[ @@ -28,7 +34,43 @@ class WorkplanStatusMixin(BaseModel): return normalize_workplan_status(value) -class WorkplanCreate(WorkplanStatusMixin): +class WorkplanFlavorMixin(BaseModel): + @field_validator("flavor", mode="before", check_fields=False) + @classmethod + def _normalise_flavor(cls, value): + flavor = normalize_flavor(value) + if flavor is not None and flavor not in WORK_RECORD_FLAVORS: + raise ValueError( + f"Unknown work-record flavor {flavor!r}; " + f"expected one of {', '.join(WORK_RECORD_FLAVORS)}" + ) + return flavor + + @field_validator("flavor_promotion_reason", mode="before", check_fields=False) + @classmethod + def _normalise_promotion_reason(cls, value): + reason = normalize_promotion_reason(value) + if reason is not None and reason not in FLAVOR_PROMOTION_REASONS: + raise ValueError( + f"Unknown flavor_promotion_reason {reason!r}; " + f"expected one of {', '.join(FLAVOR_PROMOTION_REASONS)}" + ) + return reason + + @field_validator("flavor_promoted_from", mode="before", check_fields=False) + @classmethod + def _normalise_promoted_from(cls, value): + flavor = normalize_flavor(value) + if flavor is not None and flavor not in WORK_RECORD_FLAVORS: + raise ValueError( + f"Unknown flavor_promoted_from {flavor!r}; " + f"expected one of {', '.join(WORK_RECORD_FLAVORS)}" + ) + return flavor + + +class WorkplanCreate(WorkplanStatusMixin, WorkplanFlavorMixin): + id: uuid.UUID | None = None repo_id: uuid.UUID topic_id: uuid.UUID | None = None slug: str @@ -39,6 +81,9 @@ class WorkplanCreate(WorkplanStatusMixin): due_date: date | None = None planning_priority: str | None = None planning_order: int | None = None + flavor: str | None = None + flavor_promotion_reason: str | None = None + flavor_promoted_from: str | None = None execution_state: ExecutionState = "manual" launch_mode: LaunchMode = "manual" concurrency_mode: ConcurrencyMode = "sequential" @@ -48,7 +93,7 @@ class WorkplanCreate(WorkplanStatusMixin): repo_goal_id: uuid.UUID | None = None -class WorkplanUpdate(WorkplanStatusMixin): +class WorkplanUpdate(WorkplanStatusMixin, WorkplanFlavorMixin): title: str | None = None description: str | None = None status: WorkplanStatus | None = None @@ -56,6 +101,9 @@ class WorkplanUpdate(WorkplanStatusMixin): due_date: date | None = None planning_priority: str | None = None planning_order: int | None = None + flavor: str | None = None + flavor_promotion_reason: str | None = None + flavor_promoted_from: str | None = None execution_state: ExecutionState | None = None launch_mode: LaunchMode | None = None concurrency_mode: ConcurrencyMode | None = None @@ -94,6 +142,9 @@ class WorkplanRead(WorkplanStatusMixin): due_date: date | None = None planning_priority: str | None = None planning_order: int | None = None + flavor: str | None = None + flavor_promotion_reason: str | None = None + flavor_promoted_from: str | None = None execution_state: ExecutionState = "manual" launch_mode: LaunchMode = "manual" concurrency_mode: ConcurrencyMode = "sequential" @@ -121,4 +172,4 @@ class WorkplanWithDeps(WorkplanWithTaskCounts): """WorkplanWithTaskCounts enriched with dependency graph edges.""" depends_on: list[WorkplanDepStub] = [] blocks: list[WorkplanDepStub] = [] - blocked_reasons: list[dict] = [] \ No newline at end of file + blocked_reasons: list[dict] = [] diff --git a/api/services/execution_queue.py b/api/services/execution_queue.py index 01a5263..afd4dba 100644 --- a/api/services/execution_queue.py +++ b/api/services/execution_queue.py @@ -9,7 +9,7 @@ EXECUTION_STATES = { "manual": "Not queued for autonomous pickup; humans or agents may still work manually.", "queued": "Candidate for ordered pickup when dependencies and concurrency allow it.", "scheduled": "Waiting for an external launch window; State Hub stores the requested time.", - "launching": "A launch request asks for immediate pickup or has been handed off.", + "launching": "Legacy state only; it does not prove that any consumer accepted pickup.", "paused": "Temporarily held outside the pickup stack.", "completed": "Execution intent is closed; lifecycle status remains authoritative.", "cancelled": "Execution intent was cancelled without changing lifecycle status.", @@ -19,7 +19,7 @@ LAUNCH_MODES = { "manual": "Do not request automation; keep intent visible only.", "queued": "Place in the prioritized stack for later pickup.", "scheduled": "Request pickup at or after a selected time.", - "immediate": "Request prompt activity-core or agent pickup.", + "immediate": "Legacy intent only; workplan launch-request pickup is retired.", } CONCURRENCY_MODES = { @@ -30,16 +30,26 @@ CONCURRENCY_MODES = { STATE_HUB_RESPONSIBILITIES = [ "store lifecycle status separately from execution intent", "rank candidate workplans and expose dependency-aware eligibility", - "record launch requests and handoff metadata durably", - "surface manual, queued, scheduled, and immediate intent to operators", + "preserve historical launch-request and execution-intent rows during retirement", + "reject new workplan launch requests because no pickup consumer exists", ] ACTIVITY_CORE_RESPONSIBILITIES = [ "own schedules, wakeups, and recurring automation", - "dispatch coding agents and coordinate parallel execution", - "acknowledge, run, and complete launch requests when available", + "claim and complete ops runs created by ActivityDefinition fires", + "do not consume State Hub workplan launch requests", ] +EXECUTION_REPLACEMENTS = { + "POST /execution/launch-requests": ( + "Queue work in the authoritative repository file; for recurring or operational " + "fires use an ActivityDefinition and activity-core ops_run" + ), + "GET /execution/launch-requests": "GET /ops-runs on activity-core for automation history", + "GET /execution/workplan-stack": "Repo Manager work index or hub-core projection", + "PATCH /execution/workplans/{id}/intent": "Edit the authoritative workplan file", +} + EXECUTION_STATE_RANK = { "launching": 0, "queued": 1, diff --git a/api/services/fabric_graph.py b/api/services/fabric_graph.py index d8a5f56..48f1fef 100644 --- a/api/services/fabric_graph.py +++ b/api/services/fabric_graph.py @@ -221,6 +221,55 @@ async def ingest_fabric_graph_export( return import_run, True, False +async def activate_fabric_graph_import( + session: AsyncSession, + import_id: Any, + *, + requested_by: str, +) -> tuple[FabricGraphImport | None, Any | None, bool]: + """Select a retained valid import as the active read model. + + Imports are immutable. Activation only moves the per-source ``is_latest`` + marker, which makes cutover rollback fast and avoids re-fetching an older + authority payload during an incident. + """ + result = await session.execute( + select(FabricGraphImport).where(FabricGraphImport.id == import_id) + ) + import_run = result.scalar_one_or_none() + if import_run is None: + return None, None, False + if import_run.validation_status != "valid": + raise ValueError("Only a valid Fabric graph import can be activated.") + + previous_result = await session.execute( + select(FabricGraphImport).where( + FabricGraphImport.source_repo_slug == import_run.source_repo_slug, + FabricGraphImport.is_latest.is_(True), + ) + ) + previous = previous_result.scalars().first() + if previous is not None and previous.id == import_run.id: + return import_run, previous.id, False + + await _mark_latest(session, import_run) + import_run.last_seen_at = datetime.now(timezone.utc) + await _record_progress( + session, + "Fabric graph read model activation changed.", + { + "source_repo_slug": import_run.source_repo_slug, + "import_id": str(import_run.id), + "previous_import_id": str(previous.id) if previous else None, + "content_hash": import_run.content_hash, + "requested_by": requested_by, + }, + ) + await session.commit() + await session.refresh(import_run) + return import_run, previous.id if previous else None, True + + def validate_fabric_graph_export(payload: dict[str, Any]) -> FabricGraphExportPayload: try: export = FabricGraphExportPayload.model_validate(payload) @@ -324,7 +373,10 @@ async def _record_invalid_import( import_run = FabricGraphImport( source_repo_slug=source_repo_slug, source_url=source_url, - source_commit=_source_value(payload, "commit"), + # Invalid payloads are retained for diagnostics, but their + # denormalized provenance must not make the rejection path fail. + # The complete input remains available in ``graph_json``. + source_commit=_bounded_source_value(payload, "commit", max_length=255), source_path=_source_value(payload, "path"), api_version=str(payload.get("apiVersion")) if payload.get("apiVersion") else None, export_kind=str(payload.get("kind")) if payload.get("kind") else None, @@ -620,6 +672,13 @@ def _source_value(payload: dict[str, Any], field: str) -> str | None: return str(value) if value else None +def _bounded_source_value( + payload: dict[str, Any], field: str, *, max_length: int +) -> str | None: + value = _source_value(payload, field) + return value[:max_length] if value is not None else None + + def _parse_datetime(value: Any) -> datetime | None: if not isinstance(value, str) or not value: return None diff --git a/api/services/forge_credential.py b/api/services/forge_credential.py new file mode 100644 index 0000000..5714b65 --- /dev/null +++ b/api/services/forge_credential.py @@ -0,0 +1,122 @@ +"""Resolve the forge read credential (STATE-WP-0084-T03, MASON-WP-0003-T05). + +Three sources, tried in order: a mounted file, an environment variable, and +OpenBao via Kubernetes auth. Production uses the third — this cluster has no +agent injector and no secrets-store CSI driver, so the pod authenticates with +its projected ServiceAccount token and reads the KV path itself, which is what +`MASON-WP-0003-T02` built the Kubernetes auth role for. The first two exist so +the code is runnable and testable outside the cluster. + +**Absent is a supported state, never an error.** A hub with no credential still +derives every public repository; only private ones become unreadable, and +`STATE-WP-0084-T01` already reports that as its own named condition rather than +as records that stopped deriving. So every failure here — no configuration, no +network, OpenBao down, permission denied — resolves to `None`. Raising would +convert "cannot read nine repositories" into "the whole pass failed". +""" + +from __future__ import annotations + +import logging +import os +import time +from pathlib import Path + +import httpx + +logger = logging.getLogger(__name__) + +TOKEN_ENV = "FORGE_READ_TOKEN" +TOKEN_FILE_ENV = "FORGE_READ_TOKEN_FILE" +OPENBAO_ADDR_ENV = "OPENBAO_ADDR" +OPENBAO_ROLE_ENV = "OPENBAO_K8S_ROLE" +OPENBAO_JWT_PATH_ENV = "OPENBAO_K8S_TOKEN_PATH" +OPENBAO_AUTH_MOUNT_ENV = "OPENBAO_K8S_AUTH_MOUNT" +SECRET_PATH_ENV = "FORGE_READ_SECRET_PATH" +SECRET_KEY_ENV = "FORGE_READ_SECRET_KEY" + +# Long enough that a fleet reset of 121 repositories does not re-authenticate +# 121 times; short enough that a rotated token is picked up without a redeploy, +# which is what MASON-WP-0003-T02 requires of this lane. +CACHE_TTL_SECONDS = 300.0 + +_cache: tuple[float, str | None] | None = None + + +def reset_cache() -> None: + global _cache + _cache = None + + +def _from_file() -> str | None: + path = os.environ.get(TOKEN_FILE_ENV) + if not path: + return None + try: + return Path(path).read_text(encoding="utf-8").strip() or None + except OSError: + # Deliberately not falling through to the environment: a broken mount + # that silently used a stale value would look like success. + logger.warning("forge credential: token file %s is unreadable", path) + return None + + +def _from_env() -> str | None: + return (os.environ.get(TOKEN_ENV) or "").strip() or None + + +def _from_openbao() -> str | None: + addr = (os.environ.get(OPENBAO_ADDR_ENV) or "").strip().rstrip("/") + secret_path = (os.environ.get(SECRET_PATH_ENV) or "").strip().strip("/") + role = (os.environ.get(OPENBAO_ROLE_ENV) or "").strip() + jwt_path = os.environ.get(OPENBAO_JWT_PATH_ENV) or "/var/run/secrets/openbao/token" + mount = (os.environ.get(OPENBAO_AUTH_MOUNT_ENV) or "kubernetes").strip("/") + key = (os.environ.get(SECRET_KEY_ENV) or "token").strip() + if not (addr and secret_path and role): + return None + try: + jwt = Path(jwt_path).read_text(encoding="utf-8").strip() + except OSError: + logger.warning("forge credential: no ServiceAccount token at %s", jwt_path) + return None + try: + with httpx.Client(timeout=10.0) as client: + login = client.post( + f"{addr}/v1/auth/{mount}/login", json={"role": role, "jwt": jwt} + ) + login.raise_for_status() + client_token = login.json()["auth"]["client_token"] + read = client.get( + f"{addr}/v1/{secret_path}", headers={"X-Vault-Token": client_token} + ) + read.raise_for_status() + data = read.json()["data"] + # KV v2 nests the payload under a second "data"; v1 does not. + if isinstance(data.get("data"), dict): + data = data["data"] + except (httpx.HTTPError, KeyError, ValueError) as exc: + # Never include the response body: a failed KV read can echo content. + logger.warning("forge credential: OpenBao lookup failed (%s)", type(exc).__name__) + return None + value = data.get(key) + if not isinstance(value, str) or not value.strip(): + logger.warning("forge credential: key %r absent at the KV path", key) + return None + return value.strip() + + +def forge_read_token(*, use_cache: bool = True) -> str | None: + """The forge read credential, or `None` if this instance has none.""" + global _cache + now = time.monotonic() + if use_cache and _cache is not None and now - _cache[0] < CACHE_TTL_SECONDS: + return _cache[1] + if os.environ.get(TOKEN_FILE_ENV): + # Configured to use a file means *that* file and nothing else. Falling + # back would let a broken mount quietly resolve to a stale environment + # value that nobody knows is in use. + token = _from_file() + else: + token = _from_env() or _from_openbao() + _cache = (now, token) + return token diff --git a/api/services/forge_projection.py b/api/services/forge_projection.py new file mode 100644 index 0000000..2071da1 --- /dev/null +++ b/api/services/forge_projection.py @@ -0,0 +1,1499 @@ +"""Derive a repository's work-record projection from the forge (STATE-WP-0083-T01). + +`ADR-012` decision 1 makes the forge the projection source. Central does its own +reading: it clones the repository's default branch and derives from that, rather +than accepting a projection computed elsewhere — which `ADR-010` decision 5 +forbids. + +This module is read-only. Deriving must be safe to run at any time, because it is +what makes the reset in `T03` verifiable: you can always ask what the projection +*should* be without changing anything. +""" + +from __future__ import annotations + +import os +import base64 +import re +import subprocess +import tempfile +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import yaml + +# Same derivation as ADR-007 / repo-manager, so a forge-derived projection and a +# preliminary overlay compute identical identities for the same record. +from api.services.task_record_id_backfill import qualify_task_id + +_WORK_RECORD_NAMESPACE = uuid.UUID("a4058507-5c4a-5a00-ab06-fffa4fb46009") +_TASK_BLOCK_RE = re.compile(r"```task\s*\n(.*?)\n```", re.DOTALL) +_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)$", re.MULTILINE) +DEFAULT_FORGE_BASE = "https://forgejo.coulomb.social/coulomb" + + +_IDENTIFIER_SLUG = re.compile( + r"^[a-z0-9-]*-wp-\d{3,}(?:-t\d{2})?$" # PREFIX-WP-0001, -T01 + r"|^[a-z0-9-]*adhoc[a-z0-9-]*?-\d{4}-\d{2}-\d{2}(?:-t\d{2})?$" # daily ad-hoc, qualified either side +) + + +def _slug_is_identifier(slug: str) -> bool: + """Whether a row's slug is a work-record identifier or a title slug. + + This is what separates a re-key from a rename when the path cannot. A row + whose slug is an identifier is *claiming* to be that record, so if the forge + derives a different identifier for the same file, they are two records and + the old one retires. A row whose slug is a title (`three-phoenix-ha-cluster` + for `RCLUSTER-WP-0007`) never claimed one: those are hub-first rows from + before ADR-001, where the backing path is the only link there has ever been, + and path matching is the only thing that can hold them together. + """ + return bool(_IDENTIFIER_SLUG.match((slug or "").strip().lower())) + + +def _identity_is_derived(row: Any) -> bool: + """Whether this row's UUID was derived from its identifier (ADR-007). + + Derived rows are UUIDv5 over the work-record namespace. Rows predating + derived identity carry a random v4 UUID, and for those the identifier is a + label rather than the identity — so the path fallback still applies, which + is what keeps rename detection working for legacy records. + """ + rid = getattr(row, "id", None) + return getattr(rid, "version", None) == 5 + + +def derived_record_uuid(record_id: str) -> str: + return str(uuid.uuid5(_WORK_RECORD_NAMESPACE, f"helixforge\n{record_id}")) + + +class ForgeDeriveError(RuntimeError): + """The repository could not be read from the forge.""" + + +class ForgeUnreadableError(ForgeDeriveError): + """Central is not permitted to read this repository — a policy, not a fault. + + `ADR-012`'s premise (the forge is the projection source) holds only for + repositories central can read, and until `STATE-WP-0084` nothing said so: + a private repository failed the same way a broken one did, so "cannot read" + and "does not exist" were indistinguishable from the outside. + + They must never be confused, because they authorise opposite things. A + repository that does not derive may have had its files removed deliberately; + a repository we cannot read tells us nothing at all about its files. Only + the first can justify retiring a record. + + Forgejo answers an unauthenticated request for a private repository with a + 404, so "not found" is classified as unreadable too. That is deliberate: the + two cases are genuinely indistinguishable at this layer, and the safe + reading of an ambiguous answer is the one that cannot destroy a record. + """ + + +# git says this in several ways depending on version, transport, and whether a +# credential helper is installed; all of them mean the same thing here. +_UNREADABLE_MARKERS = ( + "could not read username", + "could not read password", + "authentication failed", + "terminal prompts disabled", + "invalid username or password", + "403 forbidden", + "the requested url returned error: 403", + "the requested url returned error: 401", + "repository not found", + "remote: not found", + "does not appear to be a git repository", +) + + +def _is_unreadable(message: str) -> bool: + low = message.lower() + if "not found" in low and "fatal: repository" in low: + return True + return any(marker in low for marker in _UNREADABLE_MARKERS) + + +@dataclass +class DerivedTask: + record_id: str + uuid: str + title: str | None + status: str | None + priority: str | None + description: str | None = None + needs_human: bool = False + intervention_note: str | None = None + blocking_reason: str | None = None + + +@dataclass +class DerivedWorkplan: + record_id: str + uuid: str + title: str | None + status: str | None + relative_path: str + archived: bool + owner: str | None = None + description: str | None = None + tasks: list[DerivedTask] = field(default_factory=list) + + +@dataclass +class DerivedProjection: + repo_slug: str + commit: str + workplans: list[DerivedWorkplan] = field(default_factory=list) + # False when the checkout has no `workplans/` directory at all. An empty + # projection then means "we did not find the records", which is not the same + # claim as "this repository has no records" — and only the second one could + # ever justify retiring anything. + records_source_present: bool = True + + @property + def task_count(self) -> int: + return sum(len(w.tasks) for w in self.workplans) + + @property + def retirement_eligible(self) -> bool: + """Whether an absence in this projection is evidence of an absence. + + A projection that could not be read never reaches this: it raises. What + this rules out is the quieter case — a clone that succeeded and returned + nothing, which is what would have retired every record in a repository + had a clone ever come back empty instead of failing (`STATE-WP-0084-T01`; + the near-miss was `vergabe-teilnahme`). + """ + return self.records_source_present + + def to_dict(self) -> dict[str, Any]: + return { + "schema": "state-hub.forge-projection.v1", + "repo_slug": self.repo_slug, + # Provenance is not optional: a projection that cannot name the + # commit it came from cannot be audited (ADR-012 decision 2). + "commit": self.commit, + "records_source_present": self.records_source_present, + "retirement_eligible": self.retirement_eligible, + "workplans": [ + { + "record_id": w.record_id, + "uuid": w.uuid, + "title": w.title, + "status": w.status, + "owner": w.owner, + "description": w.description, + "relative_path": w.relative_path, + "archived": w.archived, + "tasks": [ + { + "record_id": t.record_id, + "uuid": t.uuid, + "title": t.title, + "status": t.status, + "priority": t.priority, + "description": t.description, + "needs_human": t.needs_human, + "intervention_note": t.intervention_note, + "blocking_reason": t.blocking_reason, + } + for t in w.tasks + ], + } + for w in self.workplans + ], + } + + +from api.services.forge_credential import forge_read_token # noqa: F401 + +# Kept as module attributes so callers and tests that reached for them here +# still resolve after the sources moved to `forge_credential`. +FORGE_TOKEN_ENV = "FORGE_READ_TOKEN" +FORGE_TOKEN_FILE_ENV = "FORGE_READ_TOKEN_FILE" + + +def _credential_env(token: str | None) -> dict[str, str]: + """Git config carrying the credential, passed by environment not argv. + + `-c http.extraHeader=...` would place the token in the process command line, + where it is readable by anything that can run `ps` and lands in any log that + records invocations. GIT_CONFIG_* achieves the same configuration without + that exposure. + """ + if not token: + return {} + header = base64.b64encode(f"x-access-token:{token}".encode()).decode() + return { + "GIT_CONFIG_COUNT": "1", + "GIT_CONFIG_KEY_0": "http.extraHeader", + "GIT_CONFIG_VALUE_0": f"Authorization: Basic {header}", + } + + +def _run_git( + *args: str, cwd: str | None = None, timeout: float = 120.0, token: str | None = None +) -> str: + # Without this a clone of a private repository blocks on a username prompt + # instead of failing, and an unattended derivation pass hangs rather than + # reporting. Failing fast is what makes the unreadable case observable. + env = {**os.environ, "GIT_TERMINAL_PROMPT": "0", "GIT_ASKPASS": "", "GCM_INTERACTIVE": "never"} + env.update(_credential_env(token)) + proc = subprocess.run( + ["git", *args], cwd=cwd, capture_output=True, text=True, timeout=timeout, env=env + ) + if proc.returncode != 0: + detail = (proc.stderr or proc.stdout).strip()[:400] + if token: + # Never let a credential reach an exception that is logged, stored + # in a reset outcome, or returned over the API. + detail = detail.replace(token, "***") + raise ForgeDeriveError(detail) + return proc.stdout.strip() + + +def _split_frontmatter(text: str) -> tuple[dict, str]: + if not text.startswith("---"): + return {}, text + end = text.find("\n---", 3) + if end == -1: + return {}, text + raw = text[3:end] + body = text[end + 4 :] + try: + meta = yaml.safe_load(raw) or {} + except yaml.YAMLError: + return {}, text + return (meta if isinstance(meta, dict) else {}), body + + +def _clean_task_description(raw: str) -> str | None: + """Match the file-authoritative consistency parser's section boundaries.""" + lines = raw.splitlines() + while lines and (not lines[0].strip() or lines[0].strip() in {"---", "***", "___"}): + lines.pop(0) + while lines and (not lines[-1].strip() or lines[-1].strip() in {"---", "***", "___"}): + lines.pop() + return "\n".join(lines).strip() or None + + +def _parse_tasks(body: str, workplan_id: str) -> list[DerivedTask]: + headings = [ + (m.start(), len(m.group(1)), m.group(2).strip()) for m in _HEADING_RE.finditer(body) + ] + out: list[DerivedTask] = [] + task_matches = list(_TASK_BLOCK_RE.finditer(body)) + for index, m in enumerate(task_matches): + try: + block = yaml.safe_load(m.group(1).strip()) or {} + except yaml.YAMLError: + continue + if not isinstance(block, dict): + continue + rid = str(block.get("id") or "").strip() + if not rid: + continue + prev = [(pos, level, text) for pos, level, text in headings if pos < m.start()] + heading = prev[-1] if prev else None + title = block.get("title") or (heading[2] if heading else None) + # Nested evidence belongs to this task. Stop at the next peer/ancestor + # heading or task block, as the ordinary consistency path does. + description_end = task_matches[index + 1].start() if index + 1 < len(task_matches) else len(body) + following_heading = next(( + pos for pos, level, _text in headings + if pos > m.end() and (heading is None or level <= heading[1]) + ), None) + if following_heading is not None: + description_end = min(description_end, following_heading) + description = _clean_task_description(body[m.end():description_end]) + if description is None and block.get("description") is not None: + description = str(block["description"]) + out.append( + DerivedTask( + # A bare `T01` is not an identifier: it is unique only within + # its workplan, so `uuid5("T01")` is the same UUID for every + # workplan in the fleet. llm-connect's 91 task blocks derive + # just 49 distinct UUIDs unqualified, and creating its + # workplans fails on a duplicate task primary key. + # + # Qualifying with the owning workplan is the same rule + # `task_record_id_backfill.qualify_task_id` applies to stored + # ids; both must agree or the backfill and the projection + # disagree about what a task is called. + record_id=qualify_task_id(rid, workplan_id) or rid, + # Derived, not read from the file: the forge projection must not + # inherit an identifier the file happens to carry. + uuid=derived_record_uuid(qualify_task_id(rid, workplan_id) or rid), + title=title, + status=(str(block["status"]).strip() if block.get("status") else None), + priority=(str(block["priority"]).strip() if block.get("priority") else None), + description=description, + needs_human=bool(block.get("needs_human", False)), + intervention_note=( + str(block["intervention_note"]) + if block.get("intervention_note") is not None + else None + ), + blocking_reason=( + str(block["blocking_reason"]) + if block.get("blocking_reason") is not None + else None + ), + ) + ) + return out + + +def _workplan_description(body: str) -> str | None: + """Return bounded prose under ``## Goal`` when one is present.""" + match = re.search(r"^##\s+Goal\s*$", body, re.MULTILINE | re.IGNORECASE) + if match is None: + return None + remainder = body[match.end() :] + next_heading = re.search(r"^##\s+", remainder, re.MULTILINE) + if next_heading is not None: + remainder = remainder[: next_heading.start()] + value = remainder.strip() + return value[:4000] or None + + +def derive_from_checkout(repo_root: Path, repo_slug: str, commit: str) -> DerivedProjection: + """Derive a projection from an already-materialised checkout.""" + proj = DerivedProjection(repo_slug=repo_slug, commit=commit) + wp_dir = repo_root / "workplans" + if not wp_dir.is_dir(): + proj.records_source_present = False + return proj + 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 + meta, body = _split_frontmatter(text) + if str(meta.get("type") or "").strip() != "workplan": + continue + rid = str(meta.get("id") or "").strip() + if not rid: + continue + proj.workplans.append( + DerivedWorkplan( + record_id=rid, + uuid=derived_record_uuid(rid), + title=(str(meta["title"]).strip() if meta.get("title") else None), + status=(str(meta["status"]).strip() if meta.get("status") else None), + relative_path=str(path.relative_to(repo_root).as_posix()), + archived=path.parent.name == "archived", + owner=(str(meta["owner"]).strip() if meta.get("owner") else None), + description=( + str(meta["description"]).strip() + if meta.get("description") + else _workplan_description(body) + ), + tasks=_parse_tasks(body, rid), + ) + ) + proj.workplans.sort(key=lambda w: w.record_id) + return proj + + +def derive_from_forge( + repo_slug: str, *, forge_base: str = DEFAULT_FORGE_BASE, ref: str | None = None +) -> DerivedProjection: + """Clone the repository's default branch from the forge and derive from it. + + A fresh shallow clone every time, deliberately: the source is what the forge + holds now, and a reused working copy is how a projection ends up reflecting + someone's local state instead (ADR-012 context). + """ + url = f"{forge_base.rstrip('/')}/{repo_slug}.git" + token = forge_read_token() + with tempfile.TemporaryDirectory(prefix=f"forge-{repo_slug}-") as tmp: + args = ["clone", "--depth", "1", "--quiet"] + if ref: + args += ["--branch", ref] + try: + _run_git(*args, url, tmp, token=token) + except subprocess.TimeoutExpired as exc: + raise ForgeDeriveError(f"clone timed out for {repo_slug}") from exc + except ForgeDeriveError as exc: + # Classify before propagating. A caller that cannot tell "not + # permitted" from "broken" will eventually treat one as the other. + if _is_unreadable(str(exc)): + raise ForgeUnreadableError( + f"{repo_slug} could not be read from the forge: {exc}" + ) from exc + raise + commit = _run_git("rev-parse", "HEAD", cwd=tmp) + return derive_from_checkout(Path(tmp), repo_slug, commit) + + +# --------------------------------------------------------------------------- +# Comparison against what the hub currently holds (STATE-WP-0083-T02) +# --------------------------------------------------------------------------- + + + +_ARCHIVE_PREFIX_RE = re.compile(r"^\d{6}-") + + +def _path_key(path: str) -> str: + """Normalise a workplan path so an archived copy matches its live one.""" + name = path.rsplit("/", 1)[-1] + return _ARCHIVE_PREFIX_RE.sub("", name).strip().lower() + + +@dataclass +class ProjectionDiff: + """What a reset would change, computed without changing anything. + + This is what makes `ADR-012` decision 7's "verifiable" real: the reset can + always be inspected before it runs, and its result compared against the forge + afterwards. + """ + + repo_slug: str + commit: str + missing: list[dict[str, Any]] = field(default_factory=list) # forge has, hub lacks + stale: list[dict[str, Any]] = field(default_factory=list) # hub has, forge lacks + differing: list[dict[str, Any]] = field(default_factory=list) # both, fields differ + # More than one row/file claims one canonical identity. Choosing a winner + # would turn a diagnostic into an implicit identity decision. + ambiguous: list[dict[str, Any]] = field(default_factory=list) + # Set when the source could not support a claim of absence, so `stale` was + # deliberately left empty rather than computed (`STATE-WP-0084-T01`). + stale_withheld: str | None = None + + @property + def clean(self) -> bool: + return not (self.missing or self.stale or self.differing or self.ambiguous) + + @property + def would_remove(self) -> int: + return len(self.stale) + + def to_dict(self) -> dict[str, Any]: + return { + "schema": "state-hub.projection-diff.v1", + "repo_slug": self.repo_slug, + "commit": self.commit, + "clean": self.clean, + "counts": { + "missing": len(self.missing), + "stale": len(self.stale), + "differing": len(self.differing), + "ambiguous": len(self.ambiguous), + }, + "stale_withheld": self.stale_withheld, + "missing": self.missing, + "stale": self.stale, + "differing": self.differing, + "ambiguous": self.ambiguous, + } + + +def _derived_identity_ambiguities(derived: DerivedProjection) -> list[dict[str, Any]]: + """Return duplicate canonical identities declared by one forge projection.""" + workplans: dict[str, list[DerivedWorkplan]] = {} + tasks: dict[str, list[tuple[DerivedWorkplan, DerivedTask]]] = {} + for workplan in derived.workplans: + workplans.setdefault(workplan.record_id.strip().lower(), []).append(workplan) + for task in workplan.tasks: + tasks.setdefault(task.record_id.strip().lower(), []).append((workplan, task)) + + out: list[dict[str, Any]] = [] + for rows in workplans.values(): + if len(rows) > 1: + out.append( + { + "kind": "workplan", + "record_id": rows[0].record_id, + "source": "forge", + "paths": [row.relative_path for row in rows], + } + ) + for rows in tasks.values(): + if len(rows) > 1: + out.append( + { + "kind": "task", + "record_id": rows[0][1].record_id, + "source": "forge", + "workplans": [row[0].record_id for row in rows], + } + ) + return out + + +def diff_against_hub( + derived: DerivedProjection, + hub_workplans: list[dict[str, Any]], + hub_tasks_by_workplan: dict[str, list[dict[str, Any]]], +) -> ProjectionDiff: + """Compare a derived projection with the hub's current records. + + Pure: takes the hub's state as data rather than reading it, so the comparison + is testable without a database and cannot accidentally mutate anything. + """ + d = ProjectionDiff(repo_slug=derived.repo_slug, commit=derived.commit) + d.ambiguous.extend(_derived_identity_ambiguities(derived)) + if not derived.retirement_eligible: + # Compute what is missing and what differs as usual — those only ever + # add or correct. Absence is the one conclusion this source cannot + # support, so it is not drawn at all rather than drawn and then filtered. + d.stale_withheld = ( + "the checkout has no workplans/ directory, so an absent record is " + "unexplained rather than evidence of removal" + ) + + # Match on canonical identity, never on UUID. Most hub records still carry + # pre-ADR-007 random identifiers, so a UUID-keyed comparison reports every + # record as simultaneously missing and stale — and a reset built on that + # would destroy and recreate the entire projection. The canonical record id + # is what is stable across the identifier migration; the backing file is the + # fallback when a hub slug was derived from a filename rather than an id. + def _wp_key(record_id: str | None, slug: str | None, path: str | None) -> str: + if record_id: + return record_id.strip().lower() + if path: + return _path_key(path) + return (slug or "").strip().lower() + + want_wp = {w.record_id.strip().lower(): w for w in derived.workplans} + have_wp: dict[str, dict[str, Any]] = {} + want_paths = {_path_key(w.relative_path): k for k, w in want_wp.items()} + for w in hub_workplans: + slug = str(w.get("slug") or "") + key = slug.strip().lower() + if key not in want_wp: + # Slugs were not always the canonical id; fall back to the file. + bp = w.get("backing_relative_path") + if bp and _path_key(bp) in want_paths: + key = want_paths[_path_key(bp)] + else: + cand = [k for k in want_wp if slug.lower().startswith(k + "-")] + if len(cand) == 1: + key = cand[0] + have_wp[key] = w + + for key, w in want_wp.items(): + if key not in have_wp: + d.missing.append({"kind": "workplan", "record_id": w.record_id, "uuid": w.uuid}) + for key, w in have_wp.items(): + if key not in want_wp: + if d.stale_withheld: + continue + uid = str(w["id"]) + d.stale.append( + { + "kind": "workplan", + "uuid": uid, + "slug": w.get("slug"), + "status": w.get("status"), + # A stale record with no backing file is the case that must + # never be destroyed silently (ADR-012 decision 7). + "has_backing_file": bool( + w.get("backing_filename") or w.get("backing_relative_path") + ), + } + ) + for key, w in want_wp.items(): + cur = have_wp.get(key) + uid = str(cur["id"]) if cur else w.uuid + if not cur: + continue + changed = {} + if (cur.get("status") or None) != (w.status or None): + changed["status"] = {"hub": cur.get("status"), "forge": w.status} + cur_path = cur.get("backing_relative_path") or None + if cur_path != w.relative_path: + changed["backing_relative_path"] = {"hub": cur_path, "forge": w.relative_path} + if changed: + d.differing.append( + {"kind": "workplan", "record_id": w.record_id, "uuid": uid, "changed": changed} + ) + + for w in derived.workplans: + hub_rows = hub_tasks_by_workplan.get(w.uuid, []) + if not hub_rows: + cur = have_wp.get(w.record_id.strip().lower()) + if cur: + hub_rows = hub_tasks_by_workplan.get(str(cur["id"]), []) + # Match on the canonical record id where the hub has one. Rows created + # before STATE-WP-0083-T06 fall back to title, which is why those are + # reported rather than acted on: a renamed heading is indistinguishable + # from a replaced task under title matching. + def _task_key(record_id: str | None, title: str | None) -> str: + if record_id: + return record_id.strip().lower() + return "title:" + (title or "").strip().lower() + + have_groups: dict[str, list[dict[str, Any]]] = {} + for task in hub_rows: + have_groups.setdefault( + _task_key(task.get("record_id"), task.get("title")), [] + ).append(task) + for key, rows in have_groups.items(): + if len(rows) > 1: + d.ambiguous.append( + { + "kind": "task", + "record_id": rows[0].get("record_id"), + "source": "hub", + "workplan": w.record_id, + "uuids": [str(row["id"]) for row in rows], + "match_key": key, + } + ) + # Keep one representative so the rest of the diff remains useful, but + # `ambiguous` makes the result non-clean and forbids treating it as an + # actionable reset plan. + have = {key: rows[0] for key, rows in have_groups.items()} + want = {_task_key(t.record_id, t.title): t for t in w.tasks} + for key, t in want.items(): + if key not in have: + d.missing.append( + {"kind": "task", "record_id": t.record_id, "uuid": t.uuid, + "workplan": w.record_id} + ) + for key, t in have.items(): + if key not in want: + if d.stale_withheld: + continue + uid = str(t["id"]) + d.stale.append( + {"kind": "task", "uuid": uid, "title": t.get("title"), + "status": t.get("status"), "workplan": w.record_id, + "has_backing_file": False} + ) + for key, t in want.items(): + cur = have.get(key) + if cur and (cur.get("status") or None) != (t.status or None): + d.differing.append( + {"kind": "task", "record_id": t.record_id, "uuid": t.uuid, + "workplan": w.record_id, + "changed": {"status": {"hub": cur.get("status"), "forge": t.status}}} + ) + return d + + +# --------------------------------------------------------------------------- +# Applying the reset (STATE-WP-0083-T03) +# --------------------------------------------------------------------------- + + +@dataclass +class ResetOutcome: + repo_slug: str + commit: str + status: str # applied | refused | noop | unreadable + created: list[str] = field(default_factory=list) + updated: list[str] = field(default_factory=list) + retired: list[str] = field(default_factory=list) + refused: list[dict[str, Any]] = field(default_factory=list) + # Identifiers freed from rows retired before retirement released them. + released: list[str] = field(default_factory=list) + created_tasks: list[str] = field(default_factory=list) + updated_tasks: list[str] = field(default_factory=list) + cancelled_tasks: list[str] = field(default_factory=list) + notes: list[str] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + return { + "schema": "state-hub.projection-reset.v1", + "repo_slug": self.repo_slug, + "commit": self.commit, + "status": self.status, + "counts": { + "created": len(self.created), + "updated": len(self.updated), + "retired": len(self.retired), + "refused": len(self.refused), + "released": len(self.released), + "created_tasks": len(self.created_tasks), + "updated_tasks": len(self.updated_tasks), + "cancelled_tasks": len(self.cancelled_tasks), + }, + "created": self.created, + "updated": self.updated, + "retired": self.retired, + "released": self.released, + "created_tasks": self.created_tasks, + "updated_tasks": self.updated_tasks, + "cancelled_tasks": self.cancelled_tasks, + "refused": self.refused, + "notes": self.notes, + } + + +RETIRED_SLUG_MARK = "@retired-" + + +def _tombstone_slug(slug: str, when: datetime) -> str: + """Release the identifier a retired row was holding. + + `slug` is unique across the whole table, so retirement that only sets a + timestamp leaves the identifier locked to a record nothing derives any + more — and the repository that legitimately owns it can never claim it. + That is what kept repo-seed refused after core-hub's inherited REPO-WP rows + were retired. + + The row, its UUID, and its progress events are untouched; only the + human-facing identifier is stamped, so history stays attached to the record + it happened under. Re-retiring an already-stamped row must not stack marks, + or the column overflows after a few passes. + """ + base = (slug or "").split(RETIRED_SLUG_MARK)[0] + stamped = f"{base}{RETIRED_SLUG_MARK}{when:%Y%m%d}" + return stamped[:100] + + +RETIRE_REASON = "no longer derived from the forge" + + +def _coerce_task_status(raw: str | None) -> Any: + from api.models.task import TaskStatus + + if not raw: + return None + try: + return TaskStatus(str(raw).strip().lower()) + except ValueError: + return None + + +def _task_status_value(status: Any) -> str: + return str(getattr(status, "value", status) or "") + + +def _sync_existing_workplan_tasks( + session: Any, + row: Any, + derived_wp: DerivedWorkplan, + hub_tasks: list[Any], + outcome: ResetOutcome, +) -> None: + """Match identified tasks of an existing workplan by record_id. + + Rows with no ``record_id`` predate the backfill and cannot be qualified + from the file alone (CUST-WP-0068-T09). They are not cancelled or + overwritten. + """ + from api.models.task import Task, TaskPriority, TaskStatus + + want = { + t.record_id.strip().lower(): t + for t in derived_wp.tasks + if t.record_id and t.record_id.strip() + } + matched: dict[str, Any] = {} + claimed: set[str] = set() + stale: list[Any] = [] + by_uuid = {str(ht.id): ht for ht in hub_tasks} + for ht in hub_tasks: + rid = (ht.record_id or "").strip().lower() + if rid and rid in want: + matched[rid] = ht + claimed.add(str(ht.id)) + elif rid: + stale.append(ht) + + for key, dt in want.items(): + ht = matched.get(key) + if ht is None and dt.uuid in by_uuid: + # Already on the hub under the derived UUID, but record_id was never + # written (the registrar-minted case). Overwriting would collide. + ht = by_uuid[dt.uuid] + matched[key] = ht + claimed.add(str(ht.id)) + if ht is None: + kwargs: dict[str, Any] = { + "id": uuid.UUID(dt.uuid), + "workplan_id": row.id, + "record_id": dt.record_id, + "title": (dt.title or dt.record_id), + "description": dt.description, + "needs_human": dt.needs_human, + "intervention_note": dt.intervention_note, + "blocking_reason": dt.blocking_reason, + } + st = _coerce_task_status(dt.status) + if st is not None: + kwargs["status"] = st + if dt.priority: + try: + kwargs["priority"] = TaskPriority(dt.priority.strip().lower()) + except ValueError: + pass + session.add(Task(**kwargs)) + outcome.created_tasks.append(dt.record_id) + continue + changed = False + if not (ht.record_id or "").strip(): + ht.record_id = dt.record_id + changed = True + if dt.title and dt.title.strip() and ht.title != dt.title.strip(): + ht.title = dt.title.strip() + changed = True + if getattr(ht, "description", None) != dt.description: + ht.description = dt.description + changed = True + st = _coerce_task_status(dt.status) + if st is not None and ht.status != st: + ht.status = st + changed = True + if getattr(ht, "needs_human", False) != dt.needs_human: + ht.needs_human = dt.needs_human + changed = True + if getattr(ht, "intervention_note", None) != dt.intervention_note: + ht.intervention_note = dt.intervention_note + changed = True + if getattr(ht, "blocking_reason", None) != dt.blocking_reason: + ht.blocking_reason = dt.blocking_reason + changed = True + if dt.priority: + try: + task_priority = TaskPriority(dt.priority.strip().lower()) + except ValueError: + task_priority = None + if task_priority is not None and getattr(ht, "priority", None) != task_priority: + ht.priority = task_priority + changed = True + if changed: + outcome.updated_tasks.append(dt.record_id) + + for ht in stale: + if _task_status_value(ht.status) in {"wait", "todo", "progress"}: + ht.status = TaskStatus.cancel + outcome.cancelled_tasks.append(ht.record_id or str(ht.id)) + + +async def reset_repository_projection( + session: Any, + repo_slug: str, + *, + acknowledge_retirements: bool = False, + forge_base: str = DEFAULT_FORGE_BASE, + derived: DerivedProjection | None = None, +) -> ResetOutcome: + """Reconcile one repository's workplan projection against the forge. + + Creates what the forge has and the hub lacks, updates what differs, and + retires what no longer derives. It does not delete: hub-native records + reference workplans with `ON DELETE RESTRICT`, and destroying a progress + event to tidy a derived projection would lose hub-native truth to fix a + derived-state problem (`ADR-012` decision 7 as amended). + + Retirement is refused by default. A record that stops deriving may mean the + file was removed deliberately — or that someone pointed this at the wrong + branch. The caller must say which. + + Scope: workplans, and their tasks. Tasks of existing workplans are matched + by ``record_id`` (STATE-WP-0083-T06); rows with no ``record_id`` are left + alone. A derived task the hub lacks is created; an identified hub task the + forge no longer derives is cancelled if it is still open. + """ + from datetime import datetime, timezone + + from sqlalchemy import select + + from api.models.managed_repo import ManagedRepo + from api.models.task import Task + from api.models.workplan import Workplan + + if derived is None: + try: + derived = derive_from_forge(repo_slug, forge_base=forge_base) + except ForgeUnreadableError as exc: + # Not an error: a statement about what central is permitted to see. + # Reported as its own status so a caller cannot mistake it for a + # repository whose records stopped deriving (`STATE-WP-0084-T01`). + out = ResetOutcome(repo_slug=repo_slug, commit="", status="unreadable") + out.refused.append( + {"reason": "repository could not be read from the forge", "slug": repo_slug, + "detail": str(exc)[:300]} + ) + out.notes.append( + "Nothing was changed and nothing was retired. This says nothing " + "about whether the repository's records still exist." + ) + return out + outcome = ResetOutcome(repo_slug=repo_slug, commit=derived.commit, status="noop") + + repo = ( + await session.execute(select(ManagedRepo).where(ManagedRepo.slug == repo_slug)) + ).scalar_one_or_none() + if repo is None: + outcome.status = "refused" + outcome.refused.append({"reason": "repository is not registered", "slug": repo_slug}) + return outcome + + source_ambiguities = _derived_identity_ambiguities(derived) + if source_ambiguities: + outcome.status = "refused" + for ambiguity in source_ambiguities: + outcome.refused.append( + { + "reason": "forge projection contains duplicate canonical identity", + **ambiguity, + } + ) + outcome.notes.append( + "Duplicate identities in the authoritative projection require a file-level " + "decision; the reset will not choose a winner." + ) + return outcome + + rows = list( + ( + await session.execute(select(Workplan).where(Workplan.repo_id == repo.id)) + ).scalars() + ) + + want = {w.record_id.strip().lower(): w for w in derived.workplans} + want_paths = {_path_key(w.relative_path): k for k, w in want.items()} + + # The derived UUID is the strongest key there is: if a row's id equals + # uuid5 of a wanted record's identifier, that row *is* that record, whatever + # its slug says. Checking it first is what stops a correct record with a + # legacy title slug — `testdrive-jsui-publication` for MARKITECT-WP-0002 — + # from being read as a re-key and proposed for retirement. + want_by_uuid = {w.uuid: k for k, w in want.items()} + + # Release identifiers held by rows retired before retirement freed them. + # This runs ahead of every refusal path, because it completes a decision + # already taken rather than making a new one — gating it behind an unrelated + # pending decision left four identifiers locked in repositories refusing for + # reasons having nothing to do with them. + # + # A row the forge still derives is exempt: stamping it would change the slug + # out from under the matching below, so the record would fail to match its + # own file and never be un-retired. + for r in rows: + if r.projection_retired_at is None or RETIRED_SLUG_MARK in (r.slug or ""): + continue + if (r.slug or "").strip().lower() in want or str(r.id) in want_by_uuid: + continue + before = r.slug + r.slug = _tombstone_slug(r.slug or str(r.id), r.projection_retired_at) + outcome.released.append(before) + + # How a row came to claim a key, strongest first. Two rows can claim the + # same record — `cust-wp-0010` by its own identifier and + # `workstream-lifecycle-documentation` by path, both pointing at + # CUST-WP-0010's file. Assigning into `matched` unconditionally let the + # later one win and dropped the other silently: never matched, so never + # stale, so never reported by any pass. + UUID_MATCH, SLUG_MATCH, PATH_MATCH, PREFIX_MATCH = 0, 1, 2, 3 + + matched: dict[str, Any] = {} + claim: dict[str, int] = {} + displaced: list[Any] = [] + + def _claim(key: str, row: Any, strength: int) -> None: + held = claim.get(key) + if held is None: + matched[key], claim[key] = row, strength + return + if strength < held: + # The new claim is stronger; the incumbent loses the record and + # becomes a retirement candidate rather than disappearing. + displaced.append(matched[key]) + matched[key], claim[key] = row, strength + else: + displaced.append(row) + + for row in rows: + key = (row.slug or "").strip().lower() + by_uuid = want_by_uuid.get(str(row.id)) + if by_uuid is not None: + _claim(by_uuid, row, UUID_MATCH) + continue + if key in want: + # The identifier itself derives again — including for a retired row, + # which is then deliberately un-retired below. + _claim(key, row, SLUG_MATCH) + continue + if row.projection_retired_at is None and RETIRED_SLUG_MARK in (row.slug or ""): + # A stamped slug with a cleared flag is a resurrected row: the mark + # is evidence a retirement happened and the flag says it did not. + # It must land in `matched` under its own key so it shows up as + # stale — skipping it outright, as a genuinely retired row is + # skipped, is what left these reporting `noop` forever. + matched[key] = row + continue + if row.projection_retired_at is not None: + # Already retired: nothing to do, and nothing to re-decide. + # + # Past this point only the heuristics remain, and a retired row must + # not be matched by those. Retirement is a + # decision, and re-deriving the same file must not silently undo it. + # + # Releasing the identifier makes this necessary: the tombstoned slug + # is no longer an identifier and a legacy row is not UUID-derived, so + # both re-key guards fall through to path matching — which matches + # the file the row was retired *for* and resurrects it, alongside the + # correct record already created from that same file. + # + # A record that genuinely returns matches by UUID or by its own + # identifier above, and is un-retired there. + continue + if key not in want: + if _identity_is_derived(row) or _slug_is_identifier(row.slug or ""): + # ADR-007: a record identified by an identifier *is* that + # identifier. Two cases reach here — a derived (v5) row, and a + # legacy row whose slug is still a work-record identifier. Both + # are re-keys, not renames. + # + # ADR-007: a derived record's identity *is* a function of its + # identifier, so a changed identifier means a different record — + # the old one retires and the new one is created. Matching it to + # the file by path instead would update the row in place while + # its UUID still encodes the old identifier, leaving the file + # and the hub disagreeing about what the record is called. + # + # This is not hypothetical: the ad-hoc requalification + # (CUST-WP-0066) deliberately keeps the filename, so for those + # records a re-key *never* changes the path. Path matching + # cannot tell a re-key from a rename there, and silently chose + # rename for all 30 of them. + # + # The row keeps its own slug as key, so it lands in `stale` and + # becomes a retirement candidate rather than vanishing. + matched[key] = row + continue + bp = row.backing_relative_path + if bp and _path_key(bp) in want_paths: + _claim(want_paths[_path_key(bp)], row, PATH_MATCH) + continue + cand = [k for k in want if key.startswith(k + "-")] + if len(cand) == 1: + _claim(cand[0], row, PREFIX_MATCH) + continue + matched[key] = row + + # An identifier this repository would create may already belong to another + # repository. Two repositories creating the same daily identifier on the + # same day is a documented case (CUST-WP-0066), and the derivation is + # deliberately deterministic, so the collision is real rather than + # incidental. Refuse and say so: a constraint violation is a stack trace, + # a refusal is something the caller can rule on. + creating = [w for k, w in want.items() if k not in matched] + if creating: + foreign = list( + ( + await session.execute( + select(Workplan).where( + Workplan.id.in_([uuid.UUID(w.uuid) for w in creating]), + Workplan.repo_id != repo.id, + ) + ) + ).scalars() + ) + # `slug` carries its own unique constraint across the whole table, so an + # identifier check alone is not enough: two repositories can derive + # different identifiers whose slugs still collide. Missing this is what + # left disaster-control raising IntegrityError after the identifier + # refusal was added. + slug_clash = list( + ( + await session.execute( + select(Workplan).where( + Workplan.slug.in_([w.record_id.lower() for w in creating]), + Workplan.repo_id != repo.id, + ) + ) + ).scalars() + ) + if slug_clash: + held = {(r.slug or "").lower(): r for r in slug_clash} + outcome.status = "refused" + for w in creating: + row = held.get(w.record_id.lower()) + if row is None: + continue + outcome.refused.append( + { + "reason": "slug already belongs to another repository", + "record_id": w.record_id, + "slug": w.record_id.lower(), + "held_by_id": str(row.id), + } + ) + outcome.notes.append( + "Identifier collision is an identity decision, not a projection " + "one; acknowledging retirements does not authorise it." + ) + return outcome + + if foreign: + owned = {str(r.id): r for r in foreign} + outcome.status = "refused" + for w in creating: + held = owned.get(w.uuid) + if held is None: + continue + outcome.refused.append( + { + "reason": "derived identifier already belongs to another repository", + "record_id": w.record_id, + "uuid": w.uuid, + "held_by_slug": held.slug, + } + ) + outcome.notes.append( + "Identifier collision is an identity decision, not a projection " + "one; acknowledging retirements does not authorise it." + ) + return outcome + + stale = [ + r for k, r in matched.items() + if k not in want and r.projection_retired_at is None + ] + [r for r in displaced if r.projection_retired_at is None] + if stale and not derived.retirement_eligible: + # Acknowledgement cannot authorise this. The caller is confirming that + # records which stopped deriving should be retired; here nothing has + # been shown to have stopped deriving, because the source produced no + # records to compare against. Consenting to a conclusion is not the + # same as the evidence for it existing. + outcome.status = "refused" + for r in stale: + outcome.refused.append( + { + "reason": "source produced no records; absence is unexplained", + "slug": r.slug, + "status": r.status, + "backing_relative_path": r.backing_relative_path, + } + ) + outcome.notes.append( + "The checkout has no workplans/ directory. Retirement withheld " + "regardless of acknowledgement (STATE-WP-0084-T01)." + ) + return outcome + if stale and not acknowledge_retirements: + outcome.status = "refused" + for r in stale: + outcome.refused.append( + { + "reason": "would be retired; the forge no longer derives it", + "slug": r.slug, + "status": r.status, + "backing_relative_path": r.backing_relative_path, + } + ) + outcome.notes.append( + "Re-run with acknowledgement to retire these. Nothing was changed." + ) + return outcome + + now = datetime.now(tz=timezone.utc) + + existing_ids = [matched[k].id for k, _w in want.items() if k in matched] + tasks_by_wp: dict[Any, list[Any]] = {} + if existing_ids: + loaded = list( + ( + await session.execute(select(Task).where(Task.workplan_id.in_(existing_ids))) + ).scalars() + ) + for task_row in loaded: + tasks_by_wp.setdefault(task_row.workplan_id, []).append(task_row) + + # Canonical task identity is scoped to the matched current workplan while + # reconciling. Duplicate rows attached to a retired/displaced workplan are + # historical evidence and are deliberately not loaded above. Two rows with + # one identity inside the matched workplan, however, are genuinely + # ambiguous: the reset cannot know which row owns progress or dependencies. + current_task_ambiguities: list[dict[str, Any]] = [] + for key, workplan in want.items(): + row = matched.get(key) + if row is None: + continue + desired_record_ids = { + task.record_id.strip().lower() for task in workplan.tasks + } + groups: dict[str, list[Any]] = {} + for task_row in tasks_by_wp.get(row.id, []): + record_id = (task_row.record_id or "").strip().lower() + if record_id and record_id in desired_record_ids: + groups.setdefault(record_id, []).append(task_row) + for task_rows in groups.values(): + if len(task_rows) > 1: + current_task_ambiguities.append( + { + "reason": "current workplan contains duplicate canonical task identity", + "kind": "task", + "record_id": task_rows[0].record_id, + "workplan": workplan.record_id, + "uuids": [str(task_row.id) for task_row in task_rows], + } + ) + if current_task_ambiguities: + outcome.status = "refused" + outcome.refused.extend(current_task_ambiguities) + outcome.notes.append( + "Retired workplan copies do not block reset, but duplicate identities " + "inside the matched current workplan require manual disposition." + ) + return outcome + + # Check every task the reset would create in one query. A derived UUID held + # by another workplan is a global identity collision. A repeated record_id + # on a different workplan is not sufficient by itself: terminal rows from + # older, wrong projections retain that label as history and tasks have no + # projection-retirement marker. Letting the INSERT discover a UUID collision + # would turn a deterministic refusal into an IntegrityError. + creating_tasks: dict[str, tuple[DerivedWorkplan, DerivedTask]] = {} + for key, workplan in want.items(): + row = matched.get(key) + local_rows = tasks_by_wp.get(row.id, []) if row is not None else [] + local_record_ids = { + (task_row.record_id or "").strip().lower() + for task_row in local_rows + if (task_row.record_id or "").strip() + } + local_uuids = {str(task_row.id) for task_row in local_rows} + for task in workplan.tasks: + if ( + task.record_id.strip().lower() not in local_record_ids + and task.uuid not in local_uuids + ): + creating_tasks[task.uuid] = (workplan, task) + + if creating_tasks: + current_holder_filters = [Workplan.projection_retired_at.is_(None)] + if stale: + # The caller has already acknowledged these retirements (the + # unacknowledged path returned above). Their tasks are preserved as + # history, but must not prevent the replacement projection from + # claiming its deterministic identities in the same transaction. + current_holder_filters.append( + Workplan.id.not_in([workplan.id for workplan in stale]) + ) + task_holders = list( + ( + await session.execute( + select(Task) + .join(Workplan, Workplan.id == Task.workplan_id) + .where( + *current_holder_filters, + Task.id.in_( + [uuid.UUID(task_id) for task_id in creating_tasks] + ), + ) + ) + ).scalars() + ) + if task_holders: + outcome.status = "refused" + for holder in task_holders: + workplan, task = creating_tasks[str(holder.id)] + outcome.refused.append( + { + "reason": "derived task identifier already belongs to another current workplan", + "kind": "task", + "record_id": task.record_id, + "uuid": task.uuid, + "workplan": workplan.record_id, + "held_by_workplan_id": str(holder.workplan_id), + } + ) + outcome.notes.append( + "Task identity collisions require an identity decision; the reset " + "will not overwrite or move the existing row." + ) + return outcome + + for key, w in want.items(): + row = matched.get(key) + if row is None: + row = Workplan( + id=uuid.UUID(w.uuid), + repo_id=repo.id, + topic_id=repo.topic_id, + slug=w.record_id.lower(), + title=w.title or w.record_id, + description=w.description, + status=w.status or "proposed", + owner=w.owner, + backing_filename=w.relative_path.rsplit("/", 1)[-1], + backing_relative_path=w.relative_path, + backing_archived=w.archived, + derived_from_commit=derived.commit, + ) + session.add(row) + await session.flush() + for t in w.tasks: + # Safe only because nothing exists to mis-match against: this + # workplan is new to the hub. + session.add( + Task( + id=uuid.UUID(t.uuid), + workplan_id=row.id, + record_id=t.record_id, + title=t.title or t.record_id, + description=t.description, + status=t.status or "todo", + priority=t.priority or "medium", + needs_human=t.needs_human, + intervention_note=t.intervention_note, + blocking_reason=t.blocking_reason, + ) + ) + outcome.created.append(w.record_id) + continue + + changed = False + # The title is derived like every other field, and not syncing it left + # `cust-wp-0010` reading "Domain and Repository Goals" while its file + # said "Workplan Lifecycle Documentation" — a record correctly + # identified and correctly backed, describing the wrong work. + # + # An empty derived title is not an answer: three activity-core files + # parse to no title at all, and blanking a real one is worse than + # leaving it stale. + if w.title and w.title.strip() and row.title != w.title.strip(): + row.title = w.title.strip() + changed = True + if w.description and getattr(row, "description", None) != w.description: + row.description = w.description + changed = True + if getattr(row, "owner", None) != w.owner: + row.owner = w.owner + changed = True + if w.status and row.status != w.status: + row.status = w.status + changed = True + if row.backing_relative_path != w.relative_path: + row.backing_relative_path = w.relative_path + row.backing_filename = w.relative_path.rsplit("/", 1)[-1] + row.backing_archived = w.archived + changed = True + if row.projection_retired_at is not None: + # It derives again; un-retire rather than leaving a contradiction. + row.projection_retired_at = None + row.projection_retired_reason = None + changed = True + if row.derived_from_commit != derived.commit: + row.derived_from_commit = derived.commit + changed = True + if changed: + outcome.updated.append(w.record_id) + _sync_existing_workplan_tasks( + session, row, w, tasks_by_wp.get(row.id, []), outcome + ) + + for r in stale: + outcome.retired.append(r.slug or str(r.id)) + r.projection_retired_at = now + r.projection_retired_reason = RETIRE_REASON + r.slug = _tombstone_slug(r.slug or str(r.id), now) + + if ( + outcome.created + or outcome.updated + or outcome.retired + or outcome.released + or outcome.created_tasks + or outcome.updated_tasks + or outcome.cancelled_tasks + ): + outcome.status = "applied" + return outcome + + +# --------------------------------------------------------------------------- +# Fleet form (STATE-WP-0083-T04) +# --------------------------------------------------------------------------- + + +@dataclass +class FleetResetOutcome: + results: dict[str, dict[str, Any]] = field(default_factory=dict) + errors: dict[str, str] = field(default_factory=dict) + # Kept apart from `errors` on purpose. Nine repositories sitting in an error + # bucket read as nine broken repositories; they were nine we were not + # allowed to read, which is a different thing to go and fix + # (`STATE-WP-0083-T04`, 2026-08-26). + unreadable: dict[str, str] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + by_status: dict[str, int] = {} + for r in self.results.values(): + by_status[r["status"]] = by_status.get(r["status"], 0) + 1 + return { + "schema": "state-hub.fleet-projection-reset.v1", + "repositories": len(self.results) + len(self.errors) + len(self.unreadable), + "by_status": by_status, + "errored": len(self.errors), + "unreadable_count": len(self.unreadable), + "unreadable": self.unreadable, + "totals": { + k: sum(r["counts"][k] for r in self.results.values()) + for k in ("created", "updated", "retired", "released", "refused") + }, + "results": self.results, + "errors": self.errors, + } + + +async def reset_fleet_projection( + session_factory: Any, + repo_slugs: list[str], + *, + acknowledge_retirements: bool = False, + forge_base: str = DEFAULT_FORGE_BASE, +) -> FleetResetOutcome: + """Reset every repository, one at a time, sharing the per-repository path. + + The fleet form is a loop over the repository form and nothing else + (`ADR-012` decision 7). The rarely-run wide operation must be the frequently + run narrow one, or the wide one is trusted on the strength of never having + been exercised. + + A repository that refuses or errors is recorded and the pass continues. + Aborting on the first refusal would mean one unresolved repository blocks + reconstruction everywhere — which in practice means permanently. + + Each repository gets its own session, so one failure cannot roll back + another's work or leave a poisoned transaction behind. + """ + outcome = FleetResetOutcome() + for slug in repo_slugs: + try: + async with session_factory() as session: + result = await reset_repository_projection( + session, + slug, + acknowledge_retirements=acknowledge_retirements, + forge_base=forge_base, + ) + # A released identifier is a repair that stands on its own, so + # it must survive a refusal in the same repository — otherwise + # moving the release ahead of the refusal returns achieves + # nothing and the rollback quietly undoes it. + if result.status == "applied" or result.released: + await session.commit() + else: + await session.rollback() + if result.status == "unreadable": + detail = next( + (r.get("detail", "") for r in result.refused), "" + ) + outcome.unreadable[slug] = detail or "could not be read from the forge" + else: + outcome.results[slug] = result.to_dict() + except ForgeUnreadableError as exc: + # Reachable when the caller supplied its own derivation path. + outcome.unreadable[slug] = str(exc)[:300] + except Exception as exc: # noqa: BLE001 - one repo must not end the pass + outcome.errors[slug] = f"{type(exc).__name__}: {exc}"[:300] + return outcome diff --git a/api/services/forge_repository.py b/api/services/forge_repository.py new file mode 100644 index 0000000..1565cdb --- /dev/null +++ b/api/services/forge_repository.py @@ -0,0 +1,215 @@ +"""Narrow Forgejo repository boundary used by repository rename operations. + +The lifecycle service depends on this protocol instead of calling Forgejo +directly. Tests replace it with an in-memory gateway, which makes interruption +and resume tests deterministic and guarantees that API tests cannot rename a +live repository. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Protocol +from urllib.parse import quote, urljoin + +import httpx + +from api.config import settings +from api.services.forge_credential import forge_read_token + + +WRITE_TOKEN_ENV = "FORGE_RENAME_TOKEN" +WRITE_TOKEN_FILE_ENV = "FORGE_RENAME_TOKEN_FILE" + + +class ForgeRepositoryError(RuntimeError): + pass + + +class ForgeRepositoryUnreadable(ForgeRepositoryError): + pass + + +class ForgeRepositoryRedirected(ForgeRepositoryUnreadable): + """Requested coordinate redirects; no credentials are forwarded.""" + + def __init__(self, source_url: str, location: str): + super().__init__("Forge repository coordinate redirects") + self.target_url = urljoin(source_url, location) if location else None + + +class ForgeRepositoryConflict(ForgeRepositoryError): + pass + + +@dataclass(frozen=True) +class ForgeRepositorySnapshot: + repository_id: int + owner: str + name: str + full_name: str + default_branch: str + head_commit: str + clone_url: str | None + html_url: str | None + projection_readable: bool + projection_source_present: bool + projection_entry_count: int | None + + def as_dict(self) -> dict: + return { + "repository_id": self.repository_id, + "owner": self.owner, + "name": self.name, + "full_name": self.full_name, + "default_branch": self.default_branch, + "head_commit": self.head_commit, + "clone_url": self.clone_url, + "html_url": self.html_url, + "projection_readable": self.projection_readable, + "projection_source_present": self.projection_source_present, + "projection_entry_count": self.projection_entry_count, + } + + +class ForgeRepositoryGateway(Protocol): + async def inspect( + self, *, instance: str, owner: str, name: str + ) -> ForgeRepositorySnapshot | None: ... + + async def rename( + self, *, instance: str, owner: str, old_name: str, new_name: str + ) -> ForgeRepositorySnapshot: ... + + +def _write_token() -> str | None: + path = os.environ.get(WRITE_TOKEN_FILE_ENV) + if path: + try: + return Path(path).read_text(encoding="utf-8").strip() or None + except OSError: + return None + return (os.environ.get(WRITE_TOKEN_ENV) or "").strip() or None + + +class ForgejoRepositoryGateway: + """Forgejo v1 API adapter with separate read and rename credentials.""" + + @staticmethod + def _headers(token: str | None) -> dict[str, str]: + return {"Authorization": f"token {token}"} if token else {} + + @staticmethod + def _api(instance: str, owner: str, name: str) -> str: + base = instance.rstrip("/") + return f"{base}/api/v1/repos/{quote(owner, safe='')}/{quote(name, safe='')}" + + async def _inspect_with_token( + self, *, instance: str, owner: str, name: str, token: str | None + ) -> ForgeRepositorySnapshot | None: + url = self._api(instance, owner, name) + timeout = settings.repository_rename_forge_timeout_seconds + try: + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.get(url, headers=self._headers(token)) + if response.status_code in {301, 302, 303, 307, 308}: + raise ForgeRepositoryRedirected( + url, response.headers.get("location", "") + ) + if response.status_code == 404: + if token is None: + raise ForgeRepositoryUnreadable( + "Forge returned not-found without an authenticated absence proof" + ) + return None + response.raise_for_status() + repo = response.json() + branch_name = str(repo.get("default_branch") or "") + if not branch_name: + raise ForgeRepositoryUnreadable("Forge repository has no default branch") + branch = await client.get( + f"{url}/branches/{quote(branch_name, safe='')}", + headers=self._headers(token), + ) + branch.raise_for_status() + branch_data = branch.json() + commit = branch_data.get("commit") or {} + head = str(commit.get("id") or commit.get("sha") or "") + if not head: + raise ForgeRepositoryUnreadable("Forge default branch has no readable head") + projection = await client.get( + f"{url}/contents/workplans", + params={"ref": head}, + headers=self._headers(token), + ) + source_present = projection.status_code == 200 + if projection.status_code not in {200, 404}: + projection.raise_for_status() + entries = projection.json() if source_present else None + except ForgeRepositoryError: + raise + except (httpx.HTTPError, ValueError, KeyError) as exc: + raise ForgeRepositoryUnreadable( + f"Forge repository inspection failed ({type(exc).__name__})" + ) from exc + return ForgeRepositorySnapshot( + repository_id=int(repo["id"]), + owner=str((repo.get("owner") or {}).get("login") or owner), + name=str(repo["name"]), + full_name=str(repo.get("full_name") or f"{owner}/{name}"), + default_branch=branch_name, + head_commit=head, + clone_url=repo.get("clone_url"), + html_url=repo.get("html_url"), + projection_readable=True, + projection_source_present=source_present, + projection_entry_count=(len(entries) if isinstance(entries, list) else None), + ) + + async def inspect( + self, *, instance: str, owner: str, name: str + ) -> ForgeRepositorySnapshot | None: + return await self._inspect_with_token( + instance=instance, owner=owner, name=name, token=forge_read_token() + ) + + async def rename( + self, *, instance: str, owner: str, old_name: str, new_name: str + ) -> ForgeRepositorySnapshot: + token = _write_token() + if not token: + raise ForgeRepositoryUnreadable("Forge rename credential is unavailable") + url = self._api(instance, owner, old_name) + try: + async with httpx.AsyncClient( + timeout=settings.repository_rename_forge_timeout_seconds + ) as client: + response = await client.patch( + url, + json={"name": new_name}, + headers=self._headers(token), + ) + if response.status_code in {409, 422}: + raise ForgeRepositoryConflict("Forge rejected the target repository name") + response.raise_for_status() + except ForgeRepositoryError: + raise + except httpx.HTTPError as exc: + raise ForgeRepositoryUnreadable( + f"Forge repository rename failed ({type(exc).__name__})" + ) from exc + renamed = await self._inspect_with_token( + instance=instance, owner=owner, name=new_name, token=token + ) + if renamed is None: + raise ForgeRepositoryUnreadable("Renamed Forge repository is not readable") + return renamed + + +_gateway = ForgejoRepositoryGateway() + + +def get_forge_repository_gateway() -> ForgeRepositoryGateway: + return _gateway diff --git a/api/services/legacy_meter.py b/api/services/legacy_meter.py index 5528443..de8f1bd 100644 --- a/api/services/legacy_meter.py +++ b/api/services/legacy_meter.py @@ -305,7 +305,13 @@ def _summarize_interface( ] window = _counters(window_buckets) last_seen = max((bucket.last_seen_at for bucket in buckets), default=None) - retirement_candidate, reason = _retirement_state(interface, window.calls) + retirement_candidate, reason = _retirement_state( + interface, + window.calls, + all_time_calls=all_time.calls, + last_seen_at=last_seen, + window_end=window_end, + ) return LegacyInterfaceSummary( interface=LegacyInterfaceRead.model_validate(interface), all_time=all_time, @@ -340,7 +346,34 @@ def _bucket_counts(buckets: list[LegacyInterfaceUsageBucket], bucket_kind: str) return counts -def _retirement_state(interface: LegacyInterface, window_calls: int) -> tuple[bool, str]: +# Minimum quiet period before an interface may be retired, scaled to how much +# traffic it ever carried. A review window only proves the interface was silent +# for that window; for a high-volume interface that is weak evidence, since a +# monthly or quarterly caller can easily fall outside it. Thresholds are +# (all-time calls, minimum days since last_seen_at). +RETIREMENT_QUIET_LADDER: tuple[tuple[int, int], ...] = ( + (10_000, 60), + (100, 30), + (1, 7), +) + + +def _required_quiet_days(all_time_calls: int) -> int: + """Days of silence required before retirement, by all-time call volume.""" + for threshold, days in RETIREMENT_QUIET_LADDER: + if all_time_calls >= threshold: + return days + return 0 + + +def _retirement_state( + interface: LegacyInterface, + window_calls: int, + *, + all_time_calls: int = 0, + last_seen_at: datetime | None = None, + window_end: datetime | None = None, +) -> tuple[bool, str]: if interface.status == "retired": return False, "already retired" if interface.manual_hold: @@ -351,6 +384,19 @@ def _retirement_state(interface: LegacyInterface, window_calls: int) -> tuple[bo return False, "replacement not verified" if window_calls > 0: return False, f"{window_calls} call(s) in review window" + + required_days = _required_quiet_days(all_time_calls) + if required_days and last_seen_at is not None and window_end is not None: + quiet_days = (window_end - _ensure_datetime(last_seen_at)).days + if quiet_days < required_days: + return False, ( + f"quiet {quiet_days}d of {required_days}d required " + f"for {all_time_calls} all-time call(s)" + ) + return True, ( + f"no usage in review window; quiet {quiet_days}d " + f"(>= {required_days}d required for {all_time_calls} all-time call(s))" + ) return True, "no measured usage in review window" diff --git a/api/services/ops_run_projection.py b/api/services/ops_run_projection.py new file mode 100644 index 0000000..5632aa6 --- /dev/null +++ b/api/services/ops_run_projection.py @@ -0,0 +1,130 @@ +"""Read-only activity-core ops_run projection (STATE-WP-0078).""" + +from __future__ import annotations + +import asyncio +import time +from datetime import datetime, timedelta, timezone +from typing import Any + +import httpx + +from api.config import settings +from api.schemas.ops_run import OpsRunProjection, OpsRunProjectionItem + + +_cache: OpsRunProjection | None = None +_cache_at: float = 0.0 +_lock = asyncio.Lock() + + +def reset_ops_run_projection_cache() -> None: + global _cache, _cache_at + _cache = None + _cache_at = 0.0 + + +def _parse_datetime(value: Any) -> datetime | None: + if not value or not isinstance(value, str): + return None + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) + + +async def _fetch_ops_runs() -> dict[str, Any]: + if not settings.activity_core_url: + raise RuntimeError("ACTIVITY_CORE_URL is not configured") + headers = {} + if settings.activity_core_worker_token: + headers["X-Worker-Token"] = settings.activity_core_worker_token + async with httpx.AsyncClient( + base_url=settings.activity_core_url.rstrip("/"), + timeout=10.0, + follow_redirects=True, + trust_env=False, + headers=headers, + ) as client: + response = await client.get("/ops-runs", params={"limit": 200}) + response.raise_for_status() + return response.json() + + +def _project(payload: dict[str, Any], now: datetime) -> OpsRunProjection: + raw_items = payload.get("items") or [] + counts = payload.get("counts") or {} + failed_cutoff = now - timedelta(hours=24) + stuck_cutoff = now - timedelta(hours=max(0.1, settings.ops_run_sla_hours)) + failed_24h = 0 + stuck = 0 + items: list[OpsRunProjectionItem] = [] + + for raw in raw_items: + state = str(raw.get("state") or "unknown") + updated_at = _parse_datetime(raw.get("updated_at")) + created_at = _parse_datetime(raw.get("created_at")) + if state == "failed" and updated_at and updated_at >= failed_cutoff: + failed_24h += 1 + if state in {"open", "claimed"} and (created_at or updated_at): + if (created_at or updated_at) < stuck_cutoff: + stuck += 1 + + result = raw.get("result") if isinstance(raw.get("result"), dict) else {} + last_error = result.get("error") or raw.get("last_error") + if state in {"open", "claimed", "failed"}: + items.append(OpsRunProjectionItem( + id=str(raw.get("id")), + definition=str(raw.get("activity_definition_id")) if raw.get("activity_definition_id") else None, + target_repo=raw.get("target_repo"), + state=state, + lease={ + "owner": raw.get("claim_owner"), + "until": raw.get("lease_until"), + "attempt": raw.get("attempt", 0), + } if raw.get("claim_owner") or raw.get("lease_until") else None, + updated_at=updated_at, + last_error=str(last_error) if last_error else None, + )) + + items.sort(key=lambda item: item.updated_at or datetime.min.replace(tzinfo=timezone.utc), reverse=True) + return OpsRunProjection( + available=True, + generated_at=now, + open=int(counts.get("open", sum(item.state == "open" for item in items))), + claimed=int(counts.get("claimed", sum(item.state == "claimed" for item in items))), + failed_24h=failed_24h, + stuck_open_or_claimed=stuck, + items=items[:50], + ) + + +async def get_ops_run_projection(*, refresh: bool = False) -> OpsRunProjection: + global _cache, _cache_at + now_mono = time.monotonic() + ttl = max(1.0, settings.ops_run_projection_ttl_seconds) + if not refresh and _cache is not None and now_mono - _cache_at < ttl: + return _cache + + async with _lock: + now_mono = time.monotonic() + if not refresh and _cache is not None and now_mono - _cache_at < ttl: + return _cache + now = datetime.now(timezone.utc) + try: + projection = _project(await _fetch_ops_runs(), now) + except Exception as exc: + if _cache is not None and _cache.available: + projection = _cache.model_copy(update={ + "stale": True, + "error": f"activity-core refresh failed: {exc}", + }) + else: + projection = OpsRunProjection( + generated_at=now, + error=f"activity-core unavailable: {exc}", + ) + _cache = projection + _cache_at = now_mono + return projection diff --git a/api/services/repo_manager_dual_run.py b/api/services/repo_manager_dual_run.py new file mode 100644 index 0000000..4cc98f2 --- /dev/null +++ b/api/services/repo_manager_dual_run.py @@ -0,0 +1,544 @@ +"""State Hub dual-run adapter → Repo Manager (RMGR-WP-0002/0003 Stage B). + +Config file (same as repo-manager): ~/.repo-manager/dual-run.yaml +or RM_DUAL_RUN_CONFIG. Env overrides when set: + + RM_WRITEBACK, RM_RECONCILE, RM_WRITEBACK_PUSH, RM_PILOT_REPOS, RM_METER_PATH + +Rollback: disable in config / unset env → native State Hub path. +""" + +from __future__ import annotations + +import json +import logging +import os +import subprocess +import sys +import uuid +from functools import lru_cache +from pathlib import Path +from typing import Any + +import yaml + +logger = logging.getLogger(__name__) + +_TRUE = frozenset({"1", "true", "yes", "on"}) +_FALSE = frozenset({"0", "false", "no", "off"}) + + +def config_path() -> Path: + raw = os.environ.get("RM_DUAL_RUN_CONFIG", "~/.repo-manager/dual-run.yaml") + return Path(raw).expanduser() + + +@lru_cache(maxsize=1) +def _load_file_config() -> dict[str, Any]: + path = config_path() + if not path.is_file(): + return {} + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except (OSError, yaml.YAMLError): + return {} + return data if isinstance(data, dict) else {} + + +def reload_config() -> None: + _load_file_config.cache_clear() + + +def _env_bool(name: str) -> bool | None: + raw = os.environ.get(name) + if raw is None or raw.strip() == "": + return None + v = raw.strip().lower() + if v in _TRUE: + return True + if v in _FALSE: + return False + return None + + +def _cfg_bool(key: str, env_name: str, default: bool = False) -> bool: + ev = _env_bool(env_name) + if ev is not None: + return ev + cfg = _load_file_config() + if key in cfg: + val = cfg[key] + if isinstance(val, bool): + return val + if isinstance(val, str): + return val.strip().lower() in _TRUE + return default + + +def writeback_enabled() -> bool: + return _cfg_bool("writeback", "RM_WRITEBACK", False) + + +def reconcile_enabled() -> bool: + return _cfg_bool("reconcile", "RM_RECONCILE", False) + + +def writeback_push_enabled() -> bool: + return _cfg_bool("writeback_push", "RM_WRITEBACK_PUSH", False) + + +def pilot_slugs() -> set[str] | None: + raw = os.environ.get("RM_PILOT_REPOS") + if raw is not None: + raw = raw.strip() + if not raw: + return set() + return {s.strip() for s in raw.split(",") if s.strip()} + cfg = _load_file_config() + if "pilot_repos" not in cfg: + return None + val = cfg["pilot_repos"] + if val is None: + return None + if isinstance(val, str): + if not val.strip(): + return set() + return {s.strip() for s in val.split(",") if s.strip()} + if isinstance(val, list): + return {str(s).strip() for s in val if str(s).strip()} + return None + + +def slug_allowed(slug: str | None) -> bool: + if not slug: + return False + pilots = pilot_slugs() + if pilots is None: + return True + return slug in pilots + + +def writeback_for_repo(slug: str | None) -> bool: + return writeback_enabled() and slug_allowed(slug) + + +def reconcile_for_repo(slug: str | None) -> bool: + return reconcile_enabled() and slug_allowed(slug) + + +def meter_path() -> Path: + raw = os.environ.get("RM_METER_PATH") + if raw: + return Path(raw).expanduser() + cfg = _load_file_config() + if cfg.get("meter_path"): + return Path(str(cfg["meter_path"])).expanduser() + return Path("~/.repo-manager/mutation-meter.jsonl").expanduser() + + +def record_mutation( + *, + source: str, + kind: str, + repo_slug: str | None, + detail: dict[str, Any] | None = None, +) -> None: + try: + from datetime import UTC, datetime + + path = meter_path() + path.parent.mkdir(parents=True, exist_ok=True) + row = { + "ts": datetime.now(UTC).isoformat().replace("+00:00", "Z"), + "source": source, + "kind": kind, + "repo_slug": repo_slug, + "detail": detail or {}, + } + with path.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(row, default=str) + "\n") + except OSError: + pass + + +def run_rmgr(args: list[str], *, timeout: int = 120) -> tuple[int, str, str]: + env = os.environ.copy() + custom = os.environ.get("RMGR_BIN", "").strip() + if custom: + cmd = custom.split() + args + else: + src = os.environ.get("REPO_MANAGER_SRC", "").strip() + sibling = Path(src) if src else (Path.home() / "repo-manager" / "src") + if not sibling.is_dir() and (Path.home() / "repo-manager" / "src").is_dir(): + sibling = Path.home() / "repo-manager" / "src" + if sibling.is_dir(): + env["PYTHONPATH"] = str(sibling) + ( + os.pathsep + env["PYTHONPATH"] if env.get("PYTHONPATH") else "" + ) + cmd = [ + sys.executable, + "-c", + "from repo_manager.cli import main; import sys; raise SystemExit(main(sys.argv[1:]))", + *args, + ] + else: + cmd = ["rmgr", *args] + + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout, + check=False, + env=env, + ) + return proc.returncode, proc.stdout, proc.stderr + + +def rm_update_task_status( + *, + repo_path: str | Path, + task_id: str, + status: str, + repo_slug: str | None = None, + reason: str = "state-hub dual-run", + push: bool | None = None, + correlation_id: str | None = None, +) -> dict[str, Any]: + correlation_id = correlation_id or str(uuid.uuid4()) + if push is None: + push = writeback_push_enabled() + args = [ + "update-task-status", + "--path", + str(repo_path), + "--task-id", + str(task_id), + "--status", + status, + "--reason", + reason, + "--correlation-id", + correlation_id, + "--idempotency-key", + f"sh-dual-{task_id}-{status}-{correlation_id}", + ] + if repo_slug: + args.extend(["--slug", repo_slug]) + if push: + args.append("--push") + + code, out, err = run_rmgr(args) + try: + result = json.loads(out.strip() or "{}") + except json.JSONDecodeError: + result = { + "status": "failed", + "error": { + "code": "internal", + "message": f"rmgr non-json exit={code} stderr={err!r} stdout={out[:500]!r}", + }, + "correlation_id": correlation_id, + } + if code != 0 and result.get("status") not in ("applied", "rejected"): + result.setdefault("status", "failed") + result.setdefault( + "error", + {"code": "internal", "message": f"rmgr exit={code} stderr={err!r}"}, + ) + return result + + +def rm_update_workplan( + *, + repo_path: str | Path, + workplan_id: str, + operation: str = "update", + title: str | None = None, + goal: str | None = None, + status: str | None = None, + owner: str | None = None, + domain: str | None = None, + topic_slug: str | None = None, + repo_slug: str | None = None, + reason: str = "state-hub dual-run", + push: bool | None = None, + correlation_id: str | None = None, + confirm_archive: bool = False, +) -> dict[str, Any]: + """Delegate a file-backed workplan mutation to Repo Manager. + + ``operation=archive`` is the recoverable counterpart of State Hub's + DELETE workplan route; it never erases repository history. + """ + correlation_id = correlation_id or str(uuid.uuid4()) + if operation not in {"create", "update", "archive"}: + return { + "status": "rejected", + "correlation_id": correlation_id, + "error": {"code": "validation_error", "message": f"invalid operation {operation!r}"}, + } + if push is None: + push = writeback_push_enabled() + cli_operation = "delete" if operation == "archive" else operation + args = [ + "workplan", + cli_operation, + "--path", + str(repo_path), + "--workplan-id", + str(workplan_id), + "--reason", + reason, + "--correlation-id", + correlation_id, + "--idempotency-key", + f"sh-dual-workplan-{operation}-{workplan_id}-{correlation_id}", + ] + for flag, value in ( + ("--title", title), + ("--goal", goal), + ("--status", status), + ("--owner", owner), + ("--domain", domain), + ("--topic-slug", topic_slug), + ("--slug", repo_slug), + ): + if value is not None: + args.extend([flag, value]) + if operation == "archive" and confirm_archive: + args.append("--confirm") + if push: + args.append("--push") + + code, out, err = run_rmgr(args) + try: + result = json.loads(out.strip() or "{}") + except json.JSONDecodeError: + result = { + "status": "failed", + "error": { + "code": "internal", + "message": f"rmgr non-json exit={code} stderr={err!r} stdout={out[:500]!r}", + }, + "correlation_id": correlation_id, + } + if code != 0 and result.get("status") not in ("applied", "rejected"): + result.setdefault("status", "failed") + result.setdefault( + "error", + {"code": "internal", "message": f"rmgr exit={code} stderr={err!r}"}, + ) + return result + + +def rm_update_register_entry( + *, + repo_path: str | Path, + kind: str, + entry_id: str, + operation: str = "put", + title: str | None = None, + status: str | None = None, + data: dict[str, Any] | None = None, + note: str | None = None, + author: str | None = None, + repo_slug: str | None = None, + reason: str = "state-hub retirement adapter", + push: bool | None = None, + correlation_id: str | None = None, +) -> dict[str, Any]: + """Delegate register create/update/defer/note to Repo Manager.""" + correlation_id = correlation_id or str(uuid.uuid4()) + if operation not in {"put", "defer", "note"}: + return { + "status": "rejected", + "correlation_id": correlation_id, + "error": {"code": "validation_error", "message": f"invalid operation {operation!r}"}, + } + if push is None: + push = writeback_push_enabled() + args = [ + "register", + operation, + "--path", + str(repo_path), + "--kind", + kind, + "--entry-id", + entry_id, + "--reason", + reason, + "--correlation-id", + correlation_id, + "--idempotency-key", + f"sh-dual-register-{operation}-{kind}-{entry_id}-{correlation_id}", + ] + for flag, value in ( + ("--title", title), + ("--status", status), + ("--note", note), + ("--author", author), + ("--slug", repo_slug), + ): + if value is not None: + args.extend([flag, value]) + if data is not None: + args.extend(["--data-json", json.dumps(data, separators=(",", ":"))]) + if push: + args.append("--push") + + code, out, err = run_rmgr(args) + try: + result = json.loads(out.strip() or "{}") + except json.JSONDecodeError: + result = { + "status": "failed", + "error": { + "code": "internal", + "message": f"rmgr non-json exit={code} stderr={err!r} stdout={out[:500]!r}", + }, + "correlation_id": correlation_id, + } + if code != 0 and result.get("status") not in ("applied", "rejected"): + result.setdefault("status", "failed") + result.setdefault( + "error", + {"code": "internal", "message": f"rmgr exit={code} stderr={err!r}"}, + ) + return result + + +def rm_scan_sbom( + *, + repo_path: str | Path, + repo_slug: str | None = None, +) -> dict[str, Any]: + """Derive a versioned SBOM snapshot from repository-owned sources.""" + args = ["sbom", "scan", "--path", str(repo_path)] + if repo_slug: + args.extend(["--slug", repo_slug]) + + code, out, err = run_rmgr(args) + try: + result = json.loads(out.strip() or "{}") + except json.JSONDecodeError: + result = { + "ok": False, + "error": f"rmgr non-json exit={code} stderr={err!r} stdout={out[:500]!r}", + } + if code != 0: + result.setdefault("ok", False) + result.setdefault("error", f"rmgr exit={code} stderr={err!r}") + result["exit_code"] = code + return result + + +def rm_scaffold( + *, + repo_path: str | Path, + flavor: str, + wp_prefix: str | None = None, + slug: str | None = None, + domain: str = "infotech", + force: bool = False, + commit: bool = False, +) -> dict[str, Any]: + """Delegate repository scaffolding to ``rmgr scaffold`` (STATE-WP-0080-T02).""" + args = [ + "scaffold", + "--path", + str(repo_path), + "--flavor", + flavor, + "--domain", + domain, + ] + if slug: + args.extend(["--slug", slug]) + if wp_prefix: + args.extend(["--wp-prefix", wp_prefix]) + if force: + args.append("--force") + if not commit: + args.append("--no-commit") + code, out, err = run_rmgr(args) + try: + result = json.loads(out.strip() or "{}") + except json.JSONDecodeError: + result = { + "status": "failed", + "error": { + "code": "internal", + "message": f"rmgr non-json exit={code} stderr={err!r} stdout={out[:500]!r}", + }, + } + if code != 0 and result.get("status") not in ("applied", "rejected"): + result.setdefault("status", "failed") + result.setdefault( + "error", + {"code": "internal", "message": f"rmgr exit={code} stderr={err!r}"}, + ) + result["exit_code"] = code + return result + + +def rm_reconcile(*, repo_path: str | Path, repo_slug: str | None = None) -> dict[str, Any]: + args = ["reconcile", "--path", str(repo_path)] + if repo_slug: + args.extend(["--slug", repo_slug]) + code, out, err = run_rmgr(args) + try: + result = json.loads(out.strip() or "{}") + except json.JSONDecodeError: + result = {"ok": False, "error": err or out, "exit_code": code} + result["exit_code"] = code + return result + + +def try_writeback_for_task( + *, + repo_path: str | Path | None, + repo_slug: str | None, + task_id: str, + status: str, + reason: str = "state-hub dual-run PATCH /tasks", +) -> dict[str, Any] | None: + if not writeback_for_repo(repo_slug): + return None + if not repo_path: + logger.warning("RM dual-run writeback skipped: no repo_path for %s", repo_slug) + return None + path = Path(repo_path) + if not path.is_dir(): + logger.warning("RM dual-run writeback skipped: missing path %s", path) + return None + + result = rm_update_task_status( + repo_path=path, + task_id=task_id, + status=status, + repo_slug=repo_slug, + reason=reason, + push=writeback_push_enabled(), + ) + if result.get("status") == "applied": + record_mutation( + source="repo-manager", + kind="task_status_writeback_via_sh_facade", + repo_slug=repo_slug, + detail={ + "task_id": task_id, + "status": status, + "git_sha": (result.get("evidence") or {}).get("git_sha"), + "push_ok": (result.get("evidence") or {}).get("push_ok"), + }, + ) + else: + logger.warning( + "RM dual-run writeback failed for %s task=%s: %s", + repo_slug, + task_id, + result.get("error") or result, + ) + return result diff --git a/api/services/repository_aliases.py b/api/services/repository_aliases.py new file mode 100644 index 0000000..a0e6e2d --- /dev/null +++ b/api/services/repository_aliases.py @@ -0,0 +1,229 @@ +"""Canonical repository identity resolution across current and prior slugs. + +The slug registry is the lookup boundary. Historical records deliberately keep +the slug they recorded; callers use ``slug_values`` when they need an identity- +wide read and ``canonical_slug`` when they create a new reference. +""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass + +from fastapi import HTTPException +from sqlalchemy import func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import noload + +from api.models.fabric_graph import FabricGraphEdge, FabricGraphImport, FabricGraphNode +from api.models.managed_repo import ManagedRepo +from api.models.repository_rename import RepositorySlug + + +@dataclass(frozen=True) +class RepositorySlugResolution: + repo: ManagedRepo + requested_slug: str + canonical_slug: str + slug_status: str + aliases: tuple[str, ...] + source_operation_id: uuid.UUID | None = None + + @property + def slug_values(self) -> tuple[str, ...]: + return (self.canonical_slug, *self.aliases) + + +async def resolve_repository_slug( + session: AsyncSession, + slug: str, + *, + required: bool = True, +) -> RepositorySlugResolution | None: + """Resolve a current or protected prior slug to one repository UUID.""" + + record = ( + await session.execute( + select(RepositorySlug).where(RepositorySlug.slug == slug) + ) + ).scalar_one_or_none() + + # Compatibility for databases upgraded before the identity backfill has run. + if record is None: + repo = ( + await session.execute(select(ManagedRepo).where(ManagedRepo.slug == slug)) + ).scalar_one_or_none() + if repo is None: + if required: + raise HTTPException(status_code=404, detail=f"Repo '{slug}' not found") + return None + return RepositorySlugResolution( + repo=repo, + requested_slug=slug, + canonical_slug=repo.slug, + slug_status="canonical", + aliases=(), + ) + + repo = await session.get(ManagedRepo, record.repo_id) + if repo is None: + raise HTTPException( + status_code=409, + detail=f"Repository slug registry entry '{slug}' has no repository", + ) + records = list( + ( + await session.execute( + select(RepositorySlug) + .where(RepositorySlug.repo_id == repo.id) + .order_by(RepositorySlug.kind.desc(), RepositorySlug.slug) + ) + ).scalars() + ) + canonicals = [item for item in records if item.kind == "canonical"] + if len(canonicals) != 1 or canonicals[0].slug != repo.slug: + raise HTTPException( + status_code=409, + detail=f"Repository '{repo.id}' has inconsistent canonical slug state", + ) + return RepositorySlugResolution( + repo=repo, + requested_slug=slug, + canonical_slug=repo.slug, + slug_status=record.kind, + aliases=tuple(item.slug for item in records if item.kind == "alias"), + source_operation_id=record.source_operation_id, + ) + + +async def repository_resolutions_for_repositories( + session: AsyncSession, + repositories: list[ManagedRepo], +) -> dict[uuid.UUID, RepositorySlugResolution]: + """Resolve a repository collection with one slug-registry query. + + Collection callers already hold canonical ``ManagedRepo`` rows. Resolving + every row through :func:`resolve_repository_slug` would issue two identity + queries per repository. Load the complete alias sets once instead while + retaining the pre-registry compatibility behavior. + """ + + if not repositories: + return {} + + repository_ids = [repository.id for repository in repositories] + records = list( + ( + await session.execute( + select(RepositorySlug) + .options( + noload(RepositorySlug.repo), + noload(RepositorySlug.source_operation), + ) + .where(RepositorySlug.repo_id.in_(repository_ids)) + .order_by(RepositorySlug.repo_id, RepositorySlug.kind.desc(), RepositorySlug.slug) + ) + ).scalars() + ) + records_by_repository: dict[uuid.UUID, list[RepositorySlug]] = { + repository_id: [] for repository_id in repository_ids + } + for record in records: + records_by_repository[record.repo_id].append(record) + + resolutions: dict[uuid.UUID, RepositorySlugResolution] = {} + for repository in repositories: + repository_records = records_by_repository[repository.id] + requested_record = next( + (record for record in repository_records if record.slug == repository.slug), + None, + ) + if requested_record is None: + # Compatibility for databases upgraded before the identity backfill. + resolutions[repository.id] = RepositorySlugResolution( + repo=repository, + requested_slug=repository.slug, + canonical_slug=repository.slug, + slug_status="canonical", + aliases=(), + ) + continue + + canonicals = [record for record in repository_records if record.kind == "canonical"] + if len(canonicals) != 1 or canonicals[0].slug != repository.slug: + raise HTTPException( + status_code=409, + detail=f"Repository '{repository.id}' has inconsistent canonical slug state", + ) + resolutions[repository.id] = RepositorySlugResolution( + repo=repository, + requested_slug=repository.slug, + canonical_slug=repository.slug, + slug_status=requested_record.kind, + aliases=tuple( + record.slug for record in repository_records if record.kind == "alias" + ), + source_operation_id=requested_record.source_operation_id, + ) + + return resolutions + + +async def canonicalize_repository_slug(session: AsyncSession, value: str) -> str: + """Canonicalize a value only when it is a registered repository identity.""" + + resolution = await resolve_repository_slug(session, value, required=False) + return resolution.canonical_slug if resolution is not None else value + + +async def repository_resolution_for_id( + session: AsyncSession, repo: ManagedRepo +) -> RepositorySlugResolution: + return await resolve_repository_slug(session, repo.slug) # type: ignore[return-value] + + +async def stale_external_references( + session: AsyncSession, + resolution: RepositorySlugResolution, +) -> list[dict[str, object]]: + """Name external projections that still contain an historical slug. + + These are handoffs to the projection owner, never implicit rewrite targets. + """ + + if not resolution.aliases: + return [] + aliases = list(resolution.aliases) + checks = ( + (FabricGraphImport, "source_repo_slug"), + (FabricGraphNode, "source_repo_slug"), + (FabricGraphNode, "repo_slug"), + (FabricGraphEdge, "source_repo_slug"), + ) + stale: list[dict[str, object]] = [] + for model, field_name in checks: + field = getattr(model, field_name) + rows = ( + await session.execute( + select(field, func.count()).where(field.in_(aliases)).group_by(field) + ) + ).all() + for value, count in rows: + stale.append( + { + "owner": "railiance-fabric", + "surface": model.__tablename__, + "field": field_name, + "value": value, + "count": count, + "status": "stale", + "handoff": "owner update or re-ingest required", + } + ) + return stale + + +def affected_slug_predicate(field: object, slugs: tuple[str, ...]): + """Match a JSONB slug-list against any name in one repository lineage.""" + + return or_(*(field.contains([slug]) for slug in slugs)) diff --git a/api/services/repository_identity.py b/api/services/repository_identity.py new file mode 100644 index 0000000..3eb2e09 --- /dev/null +++ b/api/services/repository_identity.py @@ -0,0 +1,23 @@ +from sqlalchemy.ext.asyncio import AsyncSession + +from api.models.managed_repo import ManagedRepo +from api.models.repository_rename import RepositoryForgeIdentity, RepositorySlug + + +def stage_initial_repository_identity( + session: AsyncSession, repo: ManagedRepo +) -> None: + """Attach non-guessed identity state to a newly flushed repository row.""" + if repo.id is None: + raise ValueError("repository must be flushed before identity records are staged") + session.add_all( + [ + RepositoryForgeIdentity(repo_id=repo.id), + RepositorySlug( + repo_id=repo.id, + slug=repo.slug, + kind="canonical", + protected=True, + ), + ] + ) diff --git a/api/services/repository_rename.py b/api/services/repository_rename.py new file mode 100644 index 0000000..7065353 --- /dev/null +++ b/api/services/repository_rename.py @@ -0,0 +1,1784 @@ +"""Fail-closed, UUID-addressed repository rename lifecycle (STATE-WP-0085-T03).""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import uuid +from copy import deepcopy +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Any +from urllib.parse import quote + +from sqlalchemy import or_, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from api.config import settings +from api.models.agent_message import AgentMessage +from api.models.capability_catalog import CapabilityCatalog +from api.models.decision import Decision +from api.models.fabric_graph import FabricGraphNode +from api.models.interface_change import InterfaceChange +from api.models.managed_repo import ManagedRepo +from api.models.progress_event import ProgressEvent +from api.models.repository_rename import ( + RepositoryForgeIdentity, + RepositoryRenameOperation, + RepositorySlug, +) +from api.models.sbom_entry import SBOMEntry +from api.models.sbom_snapshot import SBOMSnapshot +from api.models.service_catalog import ServiceFirstParty +from api.models.task import Task +from api.models.token_event import TokenEvent +from api.models.workplan import Workplan +from api.schemas.repository_rename import ( + ForgeIdentityVerifyRequest, + RepositoryRenameOperationCreate, + RepositoryRenamePhaseApply, + RepositoryRenamePreflightRequest, +) +from api.services.forge_repository import ( + ForgeRepositoryConflict, + ForgeRepositoryGateway, + ForgeRepositoryRedirected, + ForgeRepositorySnapshot, + ForgeRepositoryUnreadable, +) + + +ACTIVE_OPERATION_PHASES = { + "draft", + "preflighted", + "forge-renamed", + "statehub-rebound", + "source-synced", + "consumers-verified", + "rollback-preflight", +} +FORWARD_PHASES = [ + "preflighted", + "forge-renamed", + "statehub-rebound", + "source-synced", + "consumers-verified", + "completed", +] + + +class RenameLifecycleError(RuntimeError): + status_code = 409 + code = "repository_rename_conflict" + + def __init__(self, message: str, *, details: dict[str, Any] | None = None): + super().__init__(message) + self.details = details or {} + + +class RenameNotFound(RenameLifecycleError): + status_code = 404 + code = "repository_rename_not_found" + + +class RenamePreconditionFailed(RenameLifecycleError): + status_code = 412 + code = "repository_rename_precondition_failed" + + +class RenameServiceUnavailable(RenameLifecycleError): + status_code = 503 + code = "repository_rename_service_unavailable" + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _json_default(value: Any) -> str: + if isinstance(value, datetime): + return value.astimezone(timezone.utc).isoformat() + if isinstance(value, uuid.UUID): + return str(value) + if hasattr(value, "value"): + return str(value.value) + return str(value) + + +def canonical_json(value: Any) -> bytes: + return json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + default=_json_default, + ).encode("utf-8") + + +def checksum(value: Any) -> str: + return hashlib.sha256(canonical_json(value)).hexdigest() + + +def _b64encode(value: bytes) -> str: + return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii") + + +def _b64decode(value: str) -> bytes: + return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + + +def _sign_preflight(payload: dict[str, Any]) -> str: + secret = settings.repository_rename_preflight_secret + if not secret: + raise RenameServiceUnavailable( + "Repository rename preflight signing is not configured" + ) + encoded = _b64encode(canonical_json(payload)) + signature = hmac.new(secret.encode(), encoded.encode(), hashlib.sha256).digest() + return f"{encoded}.{_b64encode(signature)}" + + +def _verify_preflight_token(token: str) -> dict[str, Any]: + secret = settings.repository_rename_preflight_secret + if not secret: + raise RenameServiceUnavailable( + "Repository rename preflight signing is not configured" + ) + try: + encoded, supplied = token.split(".", 1) + expected = hmac.new(secret.encode(), encoded.encode(), hashlib.sha256).digest() + if not hmac.compare_digest(expected, _b64decode(supplied)): + raise ValueError("signature") + payload = json.loads(_b64decode(encoded)) + expires_at = datetime.fromisoformat(payload["expires_at"]) + except (ValueError, KeyError, json.JSONDecodeError) as exc: + raise RenamePreconditionFailed("Invalid repository rename preflight token") from exc + if expires_at <= utcnow(): + raise RenamePreconditionFailed("Repository rename preflight token has expired") + return payload + + +def confirmation_for(repo_id: uuid.UUID, old_slug: str, new_slug: str) -> str: + return f"rename:{repo_id}:{old_slug}:{new_slug}" + + +def rollback_confirmation_for(operation_id: uuid.UUID) -> str: + return f"rollback:{operation_id}" + + +async def _id_rows(session: AsyncSession, query) -> list[str]: + result = await session.execute(query) + return sorted(str(value) for value in result.scalars().all()) + + +def _record_summary(ids: list[str]) -> dict[str, Any]: + return {"count": len(ids), "ids": ids, "checksum": checksum(ids)} + + +async def _relationship_rows( + session: AsyncSession, query, fields: tuple[str, ...] +) -> list[dict[str, Any]]: + def normalize(value: Any) -> Any: + if value is None or isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, datetime): + return value.astimezone(timezone.utc).isoformat() + if isinstance(value, uuid.UUID): + return str(value) + if hasattr(value, "value"): + return value.value + return str(value) + + result = await session.execute(query) + rows = [ + {field: normalize(getattr(row, field)) for field in fields} + for row in result.all() + ] + return sorted(rows, key=lambda row: canonical_json(row)) + + +def _relationship_summary( + rows: list[dict[str, Any]], *, totals: dict[str, int] | None = None +) -> dict[str, Any]: + ids = sorted(row["id"] for row in rows) + result = { + "count": len(rows), + "ids": ids, + "checksum": checksum(ids), + "relationships": rows, + "relationship_checksum": checksum(rows), + } + if totals is not None: + result["totals"] = totals + result["totals_checksum"] = checksum(totals) + return result + + +async def collect_continuity_baseline( + session: AsyncSession, repo_id: uuid.UUID +) -> dict[str, Any]: + workplans = await _relationship_rows( + session, + select( + Workplan.id.label("id"), + Workplan.repo_id.label("repo_id"), + Workplan.slug.label("slug"), + ).where(Workplan.repo_id == repo_id), + ("id", "repo_id", "slug"), + ) + workplan_uuids = [uuid.UUID(row["id"]) for row in workplans] + tasks = await _relationship_rows( + session, + select( + Task.id.label("id"), + Task.workplan_id.label("workplan_id"), + Task.record_id.label("record_id"), + ).where( + Task.workplan_id.in_(workplan_uuids) if workplan_uuids else False + ), + ("id", "workplan_id", "record_id"), + ) + task_uuids = [uuid.UUID(row["id"]) for row in tasks] + progress = await _relationship_rows( + session, + select( + ProgressEvent.id.label("id"), + ProgressEvent.workplan_id.label("workplan_id"), + ProgressEvent.task_id.label("task_id"), + ProgressEvent.decision_id.label("decision_id"), + ).where( + or_( + ProgressEvent.workplan_id.in_(workplan_uuids) + if workplan_uuids + else False, + ProgressEvent.task_id.in_(task_uuids) if task_uuids else False, + ) + ), + ("id", "workplan_id", "task_id", "decision_id"), + ) + decisions = await _relationship_rows( + session, + select( + Decision.id.label("id"), + Decision.workplan_id.label("workplan_id"), + ).where( + Decision.workplan_id.in_(workplan_uuids) if workplan_uuids else False + ), + ("id", "workplan_id"), + ) + token_events = await _relationship_rows( + session, + select( + TokenEvent.id.label("id"), + TokenEvent.repo_id.label("repo_id"), + TokenEvent.workplan_id.label("workplan_id"), + TokenEvent.task_id.label("task_id"), + TokenEvent.tokens_in.label("tokens_in"), + TokenEvent.tokens_out.label("tokens_out"), + TokenEvent.cached_input_tokens.label("cached_input_tokens"), + TokenEvent.reasoning_output_tokens.label("reasoning_output_tokens"), + TokenEvent.raw_total_tokens.label("raw_total_tokens"), + ).where( + or_( + TokenEvent.repo_id == repo_id, + TokenEvent.workplan_id.in_(workplan_uuids) + if workplan_uuids + else False, + TokenEvent.task_id.in_(task_uuids) if task_uuids else False, + ) + ), + ( + "id", + "repo_id", + "workplan_id", + "task_id", + "tokens_in", + "tokens_out", + "cached_input_tokens", + "reasoning_output_tokens", + "raw_total_tokens", + ), + ) + token_totals = { + field: sum(int(row[field]) for row in token_events if row[field] is not None) + for field in ( + "tokens_in", + "tokens_out", + "cached_input_tokens", + "reasoning_output_tokens", + "raw_total_tokens", + ) + } + sbom_snapshots = await _relationship_rows( + session, + select( + SBOMSnapshot.id.label("id"), + SBOMSnapshot.repo_id.label("repo_id"), + ).where(SBOMSnapshot.repo_id == repo_id), + ("id", "repo_id"), + ) + sbom_entries = await _relationship_rows( + session, + select( + SBOMEntry.id.label("id"), + SBOMEntry.repo_id.label("repo_id"), + SBOMEntry.snapshot_id.label("snapshot_id"), + ).where(SBOMEntry.repo_id == repo_id), + ("id", "repo_id", "snapshot_id"), + ) + services = await _relationship_rows( + session, + select( + ServiceFirstParty.service_id.label("id"), + ServiceFirstParty.repo_id.label("repo_id"), + ).where(ServiceFirstParty.repo_id == repo_id), + ("id", "repo_id"), + ) + capabilities = await _relationship_rows( + session, + select( + CapabilityCatalog.id.label("id"), + CapabilityCatalog.repo_id.label("repo_id"), + ).where(CapabilityCatalog.repo_id == repo_id), + ("id", "repo_id"), + ) + interface_changes = await _relationship_rows( + session, + select( + InterfaceChange.id.label("id"), + InterfaceChange.repo_id.label("repo_id"), + ).where(InterfaceChange.repo_id == repo_id), + ("id", "repo_id"), + ) + bindings = await _relationship_rows( + session, + select( + Workplan.id.label("id"), + Workplan.repo_id.label("repo_id"), + Workplan.backing_relative_path.label("backing_relative_path"), + Workplan.backing_filename.label("backing_filename"), + ).where( + Workplan.repo_id == repo_id, + Workplan.backing_relative_path.is_not(None), + ), + ("id", "repo_id", "backing_relative_path", "backing_filename"), + ) + slug_rows = await _relationship_rows( + session, + select( + RepositorySlug.id.label("id"), + RepositorySlug.repo_id.label("repo_id"), + RepositorySlug.slug.label("slug"), + RepositorySlug.kind.label("kind"), + ).where(RepositorySlug.repo_id == repo_id), + ("id", "repo_id", "slug", "kind"), + ) + known_slugs = [row["slug"] for row in slug_rows] + messages = await _relationship_rows( + session, + select( + AgentMessage.id.label("id"), + AgentMessage.from_agent.label("from_agent"), + AgentMessage.to_agent.label("to_agent"), + AgentMessage.thread_id.label("thread_id"), + ).where( + or_( + AgentMessage.from_agent.in_(known_slugs), + AgentMessage.to_agent.in_(known_slugs), + ) + if known_slugs + else False + ), + ("id", "from_agent", "to_agent", "thread_id"), + ) + active_work = await _active_work(session, repo_id) + active_dispatch = [ + {"id": row["id"], "repo_id": str(repo_id), "kind": "workplan"} + for row in active_work["workplans"] + ] + [ + { + "id": row["id"], + "workplan_id": row["workplan_id"], + "kind": "task", + } + for row in active_work["tasks"] + ] + active_dispatch = sorted(active_dispatch, key=lambda row: canonical_json(row)) + records = { + "repository": _relationship_summary( + [{"id": str(repo_id), "repo_id": str(repo_id)}] + ), + "workplans": _relationship_summary(workplans), + "tasks": _relationship_summary(tasks), + "progress_events": _relationship_summary(progress), + "decisions": _relationship_summary(decisions), + "token_events": _relationship_summary(token_events, totals=token_totals), + "sbom_snapshots": _relationship_summary(sbom_snapshots), + "sbom_entries": _relationship_summary(sbom_entries), + "services": _relationship_summary(services), + "capabilities": _relationship_summary(capabilities), + "interface_changes": _relationship_summary(interface_changes), + "workplan_bindings": _relationship_summary(bindings), + "active_dispatch": _relationship_summary(active_dispatch), + "aliases": _relationship_summary(slug_rows), + "messages": _relationship_summary(messages), + } + records["continuity_checksum"] = checksum(records) + return records + + +async def _active_work(session: AsyncSession, repo_id: uuid.UUID) -> dict[str, Any]: + workplans_result = await session.execute( + select(Workplan.id, Workplan.slug, Workplan.status) + .where( + Workplan.repo_id == repo_id, + Workplan.status.not_in(("finished", "archived")), + ) + .order_by(Workplan.slug) + ) + workplans = [ + {"id": str(row.id), "slug": row.slug, "status": row.status} + for row in workplans_result.all() + ] + workplan_ids = [uuid.UUID(row["id"]) for row in workplans] + tasks_result = await session.execute( + select(Task.id, Task.workplan_id, Task.record_id, Task.status) + .where( + Task.workplan_id.in_(workplan_ids) if workplan_ids else False, + Task.status.not_in(("done", "cancel")), + ) + .order_by(Task.id) + ) + tasks = [ + { + "id": str(row.id), + "workplan_id": str(row.workplan_id), + "record_id": row.record_id, + "status": getattr(row.status, "value", str(row.status)), + } + for row in tasks_result.all() + ] + return {"workplans": workplans, "tasks": tasks} + + +async def _affected_records( + session: AsyncSession, repo_id: uuid.UUID, old_slug: str +) -> dict[str, Any]: + messages = await _id_rows( + session, + select(AgentMessage.id).where( + or_(AgentMessage.from_agent == old_slug, AgentMessage.to_agent == old_slug) + ), + ) + interface_mentions = await _id_rows( + session, + select(InterfaceChange.id).where( + InterfaceChange.affected_repo_slugs.contains([old_slug]) + ), + ) + fabric_nodes = await _id_rows( + session, + select(FabricGraphNode.id).where( + or_( + FabricGraphNode.repo_slug == old_slug, + FabricGraphNode.source_repo_slug == old_slug, + ) + ), + ) + return { + "messages": _record_summary(messages), + "interface_change_mentions": _record_summary(interface_mentions), + "fabric_nodes": _record_summary(fabric_nodes), + "external_handoffs": [ + item + for item, ids in ( + ("interface-change-consumers", interface_mentions), + ("fabric-graph-projections", fabric_nodes), + ) + if ids + ], + } + + +def _new_remote_url(repo: ManagedRepo, identity: RepositoryForgeIdentity, new_slug: str) -> str: + base = str(identity.forge_instance).rstrip("/") + suffix = ".git" if (repo.remote_url or "").endswith(".git") else "" + return f"{base}/{identity.forge_owner}/{new_slug}{suffix}" + + +async def _load_repo_identity( + session: AsyncSession, repo_id: uuid.UUID +) -> tuple[ManagedRepo, RepositoryForgeIdentity | None]: + repo = await session.get(ManagedRepo, repo_id) + if repo is None: + raise RenameNotFound(f"Repository {repo_id} was not found") + result = await session.execute( + select(RepositoryForgeIdentity).where( + RepositoryForgeIdentity.repo_id == repo_id + ) + ) + return repo, result.scalar_one_or_none() + + +async def verify_forge_identity( + session: AsyncSession, + gateway: ForgeRepositoryGateway, + repo_id: uuid.UUID, + body: ForgeIdentityVerifyRequest, +) -> RepositoryForgeIdentity: + repo, identity = await _load_repo_identity(session, repo_id) + if identity is None: + raise RenamePreconditionFailed("Repository has no Forge identity row") + try: + snapshot = await gateway.inspect( + instance=body.forge_instance, + owner=body.forge_owner, + name=repo.slug, + ) + except ForgeRepositoryUnreadable as exc: + raise RenamePreconditionFailed(str(exc)) from exc + if snapshot is None: + raise RenamePreconditionFailed("Forge repository is absent or unreadable") + if snapshot.name != repo.slug: + raise RenamePreconditionFailed( + "Forge identity verification followed a repository redirect", + details={"expected_name": repo.slug, "actual_name": snapshot.name}, + ) + if snapshot.repository_id != body.forge_repository_id: + raise RenamePreconditionFailed( + "Forge repository ID does not match the asserted immutable ID", + details={ + "expected_forge_repository_id": body.forge_repository_id, + "actual_forge_repository_id": snapshot.repository_id, + }, + ) + if identity.verification_state == "verified": + actual = ( + identity.provider, + identity.forge_instance, + identity.forge_owner, + identity.forge_repository_id, + ) + supplied = ( + body.provider, + body.forge_instance.rstrip("/"), + body.forge_owner, + body.forge_repository_id, + ) + if actual != supplied: + raise RenamePreconditionFailed("Verified Forge identity is immutable") + return identity + identity.provider = body.provider + identity.forge_instance = body.forge_instance.rstrip("/") + identity.forge_owner = body.forge_owner + identity.forge_repository_id = body.forge_repository_id + identity.verification_state = "verified" + identity.verified_at = utcnow() + identity.verified_by = body.verified_by + identity.verification_evidence = { + "forge_snapshot": snapshot.as_dict(), + "verified_repo_slug": repo.slug, + } + await session.commit() + await session.refresh(identity) + return identity + + +async def build_preflight( + session: AsyncSession, + gateway: ForgeRepositoryGateway, + repo_id: uuid.UUID, + body: RepositoryRenamePreflightRequest, + *, + issue_token: bool = True, +) -> dict[str, Any]: + repo, identity = await _load_repo_identity(session, repo_id) + now = utcnow() + blockers: list[dict[str, Any]] = [] + warnings: list[dict[str, Any]] = [] + old_snapshot: ForgeRepositorySnapshot | None = None + target_snapshot: ForgeRepositorySnapshot | None = None + + if issue_token and not settings.repository_rename_preflight_secret: + blockers.append( + { + "code": "preflight_signing_unavailable", + "message": "Repository rename preflight signing is not configured", + } + ) + + if repo.slug == body.new_slug: + blockers.append({"code": "same_slug", "message": "Target slug equals canonical slug"}) + claimed = await session.execute( + select(RepositorySlug).where(RepositorySlug.slug == body.new_slug) + ) + claimed_row = claimed.scalar_one_or_none() + old_claim = await session.execute( + select(RepositorySlug).where(RepositorySlug.slug == repo.slug) + ) + old_claim_row = old_claim.scalar_one_or_none() + if ( + old_claim_row is None + or old_claim_row.repo_id != repo.id + or old_claim_row.kind != "canonical" + ): + blockers.append( + { + "code": "statehub_canonical_inconsistent", + "message": "Managed repository slug is not its protected canonical route", + } + ) + if claimed_row is not None: + blockers.append( + { + "code": "statehub_slug_claimed", + "message": "Target slug is already protected in State Hub", + "repo_id": str(claimed_row.repo_id), + "kind": claimed_row.kind, + } + ) + active_result = await session.execute( + select(RepositoryRenameOperation).where( + RepositoryRenameOperation.repo_id == repo_id, + RepositoryRenameOperation.phase.in_(ACTIVE_OPERATION_PHASES), + ) + ) + active_operations = list(active_result.scalars().all()) + if active_operations: + blockers.append( + { + "code": "active_rename_operation", + "message": "Repository already has an active rename operation", + "operation_ids": [str(item.id) for item in active_operations], + } + ) + target_operation_result = await session.execute( + select(RepositoryRenameOperation.id, RepositoryRenameOperation.repo_id).where( + RepositoryRenameOperation.new_slug == body.new_slug, + RepositoryRenameOperation.phase.in_(ACTIVE_OPERATION_PHASES), + RepositoryRenameOperation.repo_id != repo_id, + ) + ) + target_operations = list(target_operation_result.all()) + if target_operations: + blockers.append( + { + "code": "target_held_by_active_operation", + "message": "Target slug is reserved by another active rename operation", + "operations": [ + {"operation_id": str(row.id), "repo_id": str(row.repo_id)} + for row in target_operations + ], + } + ) + if body.queued_edge_writes: + blockers.append( + { + "code": "queued_edge_writes", + "message": "Caller reported edge writes that must be replayed or resolved first", + "count": len(body.queued_edge_writes), + } + ) + if identity is None or identity.verification_state != "verified": + blockers.append( + { + "code": "forge_identity_unverified", + "message": "Repository Forge identity is not verified", + } + ) + else: + try: + old_snapshot = await gateway.inspect( + instance=str(identity.forge_instance), + owner=str(identity.forge_owner), + name=repo.slug, + ) + target_snapshot = await gateway.inspect( + instance=str(identity.forge_instance), + owner=str(identity.forge_owner), + name=body.new_slug, + ) + except ForgeRepositoryUnreadable as exc: + blockers.append( + {"code": "forge_unreadable", "message": str(exc)} + ) + if old_snapshot is None: + blockers.append( + {"code": "forge_source_absent", "message": "Canonical Forge repository is absent or unreadable"} + ) + else: + if old_snapshot.name != repo.slug: + blockers.append( + { + "code": "forge_redirected", + "message": "Forge source lookup resolved to another coordinate", + "expected": repo.slug, + "actual": old_snapshot.name, + } + ) + if old_snapshot.repository_id != identity.forge_repository_id: + blockers.append( + { + "code": "wrong_forge_repository_id", + "message": "Forge repository ID differs from verified identity", + "expected": identity.forge_repository_id, + "actual": old_snapshot.repository_id, + } + ) + if not old_snapshot.projection_readable or not old_snapshot.projection_source_present: + blockers.append( + { + "code": "projection_unreadable", + "message": "Forge work-record projection source is not readable", + } + ) + if target_snapshot is not None: + blockers.append( + { + "code": "forge_target_claimed", + "message": "Target Forge repository name is already claimed", + "forge_repository_id": target_snapshot.repository_id, + } + ) + + baselines = await collect_continuity_baseline(session, repo_id) + active_work = await _active_work(session, repo_id) + affected = await _affected_records(session, repo_id, repo.slug) + affected.update( + { + "workplan_bindings": baselines["workplan_bindings"], + "workplans": baselines["workplans"], + "tasks": baselines["tasks"], + "progress_events": baselines["progress_events"], + "decisions": baselines["decisions"], + "token_events": baselines["token_events"], + "interface_changes": baselines["interface_changes"], + "sbom_snapshots": baselines["sbom_snapshots"], + "sbom_entries": baselines["sbom_entries"], + "services": baselines["services"], + "capability_entries": baselines["capabilities"], + } + ) + if active_work["workplans"]: + warnings.append( + { + "code": "active_work_present", + "message": "Active work must be quiesced or explicitly coordinated during cutover", + "workplan_count": len(active_work["workplans"]), + "task_count": len(active_work["tasks"]), + } + ) + + identity_dict = ( + { + "id": str(identity.id), + "verification_state": identity.verification_state, + "provider": identity.provider, + "forge_instance": identity.forge_instance, + "forge_owner": identity.forge_owner, + "forge_repository_id": identity.forge_repository_id, + } + if identity is not None + else None + ) + new_remote = _new_remote_url(repo, identity, body.new_slug) if identity else None + proposed = [ + {"phase": "forge-renamed", "system": "forgejo", "field": "name", "from": repo.slug, "to": body.new_slug}, + {"phase": "statehub-rebound", "system": "state-hub", "field": "managed_repos.slug", "from": repo.slug, "to": body.new_slug}, + {"phase": "statehub-rebound", "system": "state-hub", "field": "managed_repos.remote_url", "from": repo.remote_url, "to": new_remote}, + {"phase": "statehub-rebound", "system": "state-hub", "field": "repository_slugs", "from": f"{repo.slug}:canonical", "to": f"{repo.slug}:alias,{body.new_slug}:canonical"}, + ] + retained = [ + {"field": "managed_repos.id", "value": str(repo.id)}, + {"field": "repository_forge_identities.forge_repository_id", "value": identity.forge_repository_id if identity else None}, + {"field": "repository_slugs.alias", "value": repo.slug}, + {"field": "managed_repos.local_path", "value": repo.local_path}, + {"field": "managed_repos.host_paths", "value": repo.host_paths}, + {"field": "telemetry_and_work_record_ids", "value": baselines["continuity_checksum"]}, + ] + stable_report = { + "schema_version": "state-hub.repository-rename-preflight.v1", + "repo_id": str(repo.id), + "old_slug": repo.slug, + "new_slug": body.new_slug, + "safe_to_apply": not blockers, + "blockers": blockers, + "warnings": warnings, + "current": { + "statehub": { + "repo_id": str(repo.id), + "slug": repo.slug, + "name": repo.name, + "remote_url": repo.remote_url, + "local_path": repo.local_path, + "host_paths": repo.host_paths, + "canonical_available": bool( + old_claim_row + and old_claim_row.repo_id == repo.id + and old_claim_row.kind == "canonical" + ), + "slug_registry": ( + { + "id": str(old_claim_row.id), + "kind": old_claim_row.kind, + "protected": old_claim_row.protected, + } + if old_claim_row + else None + ), + }, + "forge_identity": identity_dict, + "forge_available": old_snapshot is not None, + "forge": old_snapshot.as_dict() if old_snapshot else None, + }, + "target": { + "slug": body.new_slug, + "statehub_available": claimed_row is None, + "forge_available": target_snapshot is None, + "forge": target_snapshot.as_dict() if target_snapshot else None, + }, + "baselines": baselines, + "active_work": active_work, + "affected": affected, + "queued_edge_writes": [item.model_dump(mode="json") for item in body.queued_edge_writes], + "proposed_mutations": proposed, + "retained_history": retained, + } + report_checksum = checksum(stable_report) + expires_at = now + timedelta(seconds=settings.repository_rename_preflight_ttl_seconds) + token = None + if issue_token and not blockers: + token = _sign_preflight( + { + "version": 1, + "repo_id": str(repo.id), + "old_slug": repo.slug, + "new_slug": body.new_slug, + "forge_repository_id": identity.forge_repository_id if identity else None, + "source_commit": old_snapshot.head_commit if old_snapshot else None, + "report_checksum": report_checksum, + "issued_at": now.isoformat(), + "expires_at": expires_at.isoformat(), + } + ) + return { + **stable_report, + "report_checksum": report_checksum, + "preflight_token": token, + "preflighted_at": now, + "expires_at": expires_at if token else None, + } + + +async def create_operation( + session: AsyncSession, + gateway: ForgeRepositoryGateway, + repo_id: uuid.UUID, + body: RepositoryRenameOperationCreate, +) -> tuple[RepositoryRenameOperation, bool]: + if body.operation_id is not None: + existing = await session.get(RepositoryRenameOperation, body.operation_id) + if existing is not None: + if ( + existing.repo_id != repo_id + or existing.new_slug != body.new_slug + or existing.actor != body.actor + or body.confirmation + != confirmation_for(repo_id, existing.old_slug, existing.new_slug) + ): + raise RenamePreconditionFailed( + "Repository rename operation ID is already bound to another request" + ) + return existing, True + + token = _verify_preflight_token(body.preflight_token) + if token.get("repo_id") != str(repo_id) or token.get("new_slug") != body.new_slug: + raise RenamePreconditionFailed("Preflight token does not address this rename") + preflight = await build_preflight( + session, + gateway, + repo_id, + RepositoryRenamePreflightRequest( + new_slug=body.new_slug, queued_edge_writes=body.queued_edge_writes + ), + issue_token=False, + ) + if not preflight["safe_to_apply"]: + raise RenamePreconditionFailed( + "Repository rename preflight is no longer safe", + details={"blockers": preflight["blockers"]}, + ) + if preflight["report_checksum"] != token.get("report_checksum"): + raise RenamePreconditionFailed( + "Repository rename preflight evidence is stale", + details={ + "expected_report_checksum": token.get("report_checksum"), + "actual_report_checksum": preflight["report_checksum"], + }, + ) + old_slug = preflight["old_slug"] + if body.confirmation != confirmation_for(repo_id, old_slug, body.new_slug): + raise RenamePreconditionFailed("Explicit repository rename confirmation is incorrect") + repo, identity = await _load_repo_identity(session, repo_id) + if identity is None or identity.verification_state != "verified": + raise RenamePreconditionFailed("Repository Forge identity is not verified") + forge = preflight["current"]["forge"] + now = utcnow() + operation = RepositoryRenameOperation( + id=body.operation_id or uuid.uuid4(), + repo_id=repo.id, + forge_identity_id=identity.id, + forge_identity_state="verified", + expected_provider=str(identity.provider), + expected_forge_instance=str(identity.forge_instance), + expected_forge_owner=str(identity.forge_owner), + expected_forge_repository_id=int(identity.forge_repository_id), + expected_source_commit=forge["head_commit"], + expected_default_branch=forge["default_branch"], + old_slug=repo.slug, + new_slug=body.new_slug, + old_coordinates={ + "slug": repo.slug, + "name": repo.name, + "remote_url": repo.remote_url, + "local_path": repo.local_path, + "host_paths": repo.host_paths, + }, + new_coordinates={ + "slug": body.new_slug, + "name": body.new_slug, + "remote_url": _new_remote_url(repo, identity, body.new_slug), + "local_path": repo.local_path, + "host_paths": repo.host_paths, + }, + phase="preflighted", + actor=body.actor, + phase_changed_at=now, + preflighted_at=datetime.fromisoformat(token["issued_at"]), + preflight_expires_at=datetime.fromisoformat(token["expires_at"]), + evidence={ + "preflight": { + "report_checksum": preflight["report_checksum"], + "baselines": preflight["baselines"], + "affected": preflight["affected"], + "warnings": preflight["warnings"], + }, + "phases": { + "preflighted": {"at": now.isoformat(), "resumed": False} + }, + "telemetry": { + "started_at": now.isoformat(), + "phase_attempts": {"preflighted": 1}, + "phase_durations_ms": {"preflighted": 0}, + "retries": 0, + "failures": 0, + "rollback_attempts": 0, + "verification_outcome": "pending", + }, + }, + ) + session.add(operation) + try: + await session.commit() + except IntegrityError as exc: + await session.rollback() + if body.operation_id is not None: + existing = await session.get(RepositoryRenameOperation, body.operation_id) + if ( + existing is not None + and existing.repo_id == repo_id + and existing.new_slug == body.new_slug + and existing.actor == body.actor + and body.confirmation + == confirmation_for(repo_id, existing.old_slug, existing.new_slug) + ): + return existing, True + raise RenamePreconditionFailed( + "A conflicting repository rename operation was created" + ) from exc + await session.refresh(operation) + return operation, False + + +async def load_operation_by_id( + session: AsyncSession, + operation_id: uuid.UUID, +) -> RepositoryRenameOperation: + operation = await session.get(RepositoryRenameOperation, operation_id) + if operation is None: + raise RenameNotFound( + f"Repository rename operation {operation_id} was not found" + ) + return operation + + +async def load_operation( + session: AsyncSession, + repo_id: uuid.UUID, + operation_id: uuid.UUID, + *, + for_update: bool = False, +) -> RepositoryRenameOperation: + query = select(RepositoryRenameOperation).where( + RepositoryRenameOperation.id == operation_id, + RepositoryRenameOperation.repo_id == repo_id, + ) + if for_update: + query = query.with_for_update() + result = await session.execute(query) + operation = result.scalar_one_or_none() + if operation is None: + raise RenameNotFound(f"Repository rename operation {operation_id} was not found") + return operation + + +async def list_operations( + session: AsyncSession, repo_id: uuid.UUID, *, active_only: bool = False +) -> list[RepositoryRenameOperation]: + repo = await session.get(ManagedRepo, repo_id) + if repo is None: + raise RenameNotFound(f"Repository {repo_id} was not found") + query = ( + select(RepositoryRenameOperation) + .where(RepositoryRenameOperation.repo_id == repo_id) + .order_by(RepositoryRenameOperation.created_at.desc()) + ) + if active_only: + query = query.where(RepositoryRenameOperation.phase.in_(ACTIVE_OPERATION_PHASES)) + result = await session.execute(query) + return list(result.scalars().all()) + + +def _assert_snapshot( + operation: RepositoryRenameOperation, + snapshot: ForgeRepositorySnapshot, + *, + expected_name: str | None = None, +) -> None: + if snapshot.repository_id != operation.expected_forge_repository_id: + raise RenamePreconditionFailed( + "Forge repository ID changed", + details={ + "expected": operation.expected_forge_repository_id, + "actual": snapshot.repository_id, + }, + ) + if snapshot.default_branch != operation.expected_default_branch: + raise RenamePreconditionFailed("Forge default branch changed") + if snapshot.head_commit != operation.expected_source_commit: + raise RenamePreconditionFailed( + "Forge default-branch head moved", + details={ + "expected": operation.expected_source_commit, + "actual": snapshot.head_commit, + }, + ) + if not snapshot.projection_readable or not snapshot.projection_source_present: + raise RenamePreconditionFailed("Forge work-record projection is unreadable") + if expected_name is not None and snapshot.name != expected_name: + raise RenamePreconditionFailed( + "Forge response resolved to a different repository coordinate", + details={"expected_name": expected_name, "actual_name": snapshot.name}, + ) + + +async def _forge_at( + gateway: ForgeRepositoryGateway, + operation: RepositoryRenameOperation, + name: str, +) -> ForgeRepositorySnapshot | None: + try: + return await gateway.inspect( + instance=operation.expected_forge_instance, + owner=operation.expected_forge_owner, + name=name, + ) + except ForgeRepositoryUnreadable as exc: + raise RenamePreconditionFailed(str(exc)) from exc + + +async def _apply_forge_rename( + gateway: ForgeRepositoryGateway, operation: RepositoryRenameOperation +) -> dict[str, Any]: + new = await _forge_at(gateway, operation, operation.new_slug) + if new is not None: + # A redirect is compatibility evidence only. Establish immutable identity + # at the requested new coordinate before accepting the old-name alias. + _assert_snapshot(operation, new, expected_name=operation.new_slug) + try: + old = await gateway.inspect( + instance=operation.expected_forge_instance, + owner=operation.expected_forge_owner, + name=operation.old_slug, + ) + except ForgeRepositoryRedirected as exc: + canonical_api = ( + operation.expected_forge_instance.rstrip("/") + + "/api/v1/repos/" + + quote(operation.expected_forge_owner, safe="") + + "/" + + quote(operation.new_slug, safe="") + ) + if exc.target_url != canonical_api: + raise RenamePreconditionFailed( + "Old Forge name redirects outside the verified new coordinate" + ) from exc + old = None + except ForgeRepositoryUnreadable as exc: + raise RenamePreconditionFailed(str(exc)) from exc + if old is not None: + raise RenamePreconditionFailed("Both old and new Forge names are claimed") + return {"resumed": True, "forge": new.as_dict()} + old = await _forge_at(gateway, operation, operation.old_slug) + if old is None: + raise RenamePreconditionFailed("Forge repository is absent at both expected names") + _assert_snapshot(operation, old, expected_name=operation.old_slug) + try: + renamed = await gateway.rename( + instance=operation.expected_forge_instance, + owner=operation.expected_forge_owner, + old_name=operation.old_slug, + new_name=operation.new_slug, + ) + except (ForgeRepositoryUnreadable, ForgeRepositoryConflict) as exc: + raise RenamePreconditionFailed(str(exc)) from exc + _assert_snapshot(operation, renamed, expected_name=operation.new_slug) + return {"resumed": False, "forge": renamed.as_dict()} + + +async def _apply_statehub_rebind( + session: AsyncSession, operation: RepositoryRenameOperation +) -> dict[str, Any]: + repo = await session.get(ManagedRepo, operation.repo_id, with_for_update=True) + if repo is None: + raise RenameNotFound("Managed repository disappeared") + if repo.id != operation.repo_id: + raise RenamePreconditionFailed("Managed repository UUID changed") + old_result = await session.execute( + select(RepositorySlug) + .where(RepositorySlug.slug == operation.old_slug) + .with_for_update() + ) + new_result = await session.execute( + select(RepositorySlug) + .where(RepositorySlug.slug == operation.new_slug) + .with_for_update() + ) + old_slug = old_result.scalar_one_or_none() + new_slug = new_result.scalar_one_or_none() + if repo.slug == operation.new_slug: + if ( + old_slug is None + or old_slug.repo_id != repo.id + or old_slug.kind != "alias" + or new_slug is None + or new_slug.repo_id != repo.id + or new_slug.kind != "canonical" + ): + raise RenamePreconditionFailed("State Hub rename state is internally inconsistent") + return {"resumed": True, "repo_id": str(repo.id)} + if repo.slug != operation.old_slug: + raise RenamePreconditionFailed("Managed repository slug changed outside this operation") + if old_slug is None or old_slug.repo_id != repo.id or old_slug.kind != "canonical": + raise RenamePreconditionFailed("Old canonical slug record is missing") + if new_slug is not None: + raise RenamePreconditionFailed("Target State Hub slug became claimed") + old_slug.kind = "alias" + old_slug.protected = True + old_slug.source_operation_id = operation.id + await session.flush() + session.add( + RepositorySlug( + repo_id=repo.id, + slug=operation.new_slug, + kind="canonical", + protected=True, + source_operation_id=operation.id, + ) + ) + repo.slug = operation.new_slug + repo.name = operation.new_coordinates["name"] + repo.remote_url = operation.new_coordinates["remote_url"] + await session.flush() + return {"resumed": False, "repo_id": str(repo.id)} + + +async def verify_operation( + session: AsyncSession, + gateway: ForgeRepositoryGateway, + operation: RepositoryRenameOperation, +) -> dict[str, Any]: + checks: list[dict[str, Any]] = [] + + def add(name: str, ok: bool, expected: Any, actual: Any) -> None: + checks.append({"name": name, "ok": ok, "expected": expected, "actual": actual}) + + repo = await session.get(ManagedRepo, operation.repo_id) + add("managed_repository_exists", repo is not None, str(operation.repo_id), str(repo.id) if repo else None) + add("managed_repository_uuid", bool(repo and repo.id == operation.repo_id), str(operation.repo_id), str(repo.id) if repo else None) + expected_name = operation.old_slug if operation.phase == "preflighted" else operation.new_slug + if operation.phase == "rolled-back": + expected_name = operation.old_slug + try: + forge = await _forge_at(gateway, operation, expected_name) + except RenameLifecycleError as exc: + forge = None + add("forge_readable", False, True, str(exc)) + else: + add("forge_readable", forge is not None, True, forge is not None) + if forge is not None: + add("forge_coordinate", forge.name == expected_name, expected_name, forge.name) + add("forge_repository_id", forge.repository_id == operation.expected_forge_repository_id, operation.expected_forge_repository_id, forge.repository_id) + add("source_commit", forge.head_commit == operation.expected_source_commit, operation.expected_source_commit, forge.head_commit) + add("default_branch", forge.default_branch == operation.expected_default_branch, operation.expected_default_branch, forge.default_branch) + add("projection_readable", forge.projection_readable and forge.projection_source_present, True, forge.projection_readable and forge.projection_source_present) + expected_canonical = operation.old_slug if operation.phase in {"preflighted", "forge-renamed", "rolled-back"} else operation.new_slug + canonical_result = await session.execute( + select(RepositorySlug).where( + RepositorySlug.repo_id == operation.repo_id, + RepositorySlug.kind == "canonical", + ) + ) + canonical = canonical_result.scalar_one_or_none() + add("canonical_route", bool(canonical and canonical.slug == expected_canonical), expected_canonical, canonical.slug if canonical else None) + current = await collect_continuity_baseline(session, operation.repo_id) + baseline = (operation.evidence.get("preflight") or {}).get("baselines") or {} + expected_checksum = baseline.get("continuity_checksum", "") + missing: dict[str, list[str]] = {} + changed_relationships: dict[str, list[str]] = {} + preserved_relationship_checksums: dict[str, str] = {} + counts: dict[str, dict[str, int]] = {} + for record_type, baseline_record in baseline.items(): + if record_type == "continuity_checksum" or not isinstance(baseline_record, dict): + continue + current_record = current.get(record_type) or {"ids": [], "count": 0} + absent = sorted(set(baseline_record.get("ids") or []) - set(current_record.get("ids") or [])) + if absent: + missing[record_type] = absent + if record_type != "aliases": + baseline_rows = { + row["id"]: row + for row in baseline_record.get("relationships") or [] + } + current_rows = { + row["id"]: row + for row in current_record.get("relationships") or [] + } + changed = sorted( + record_id + for record_id, baseline_row in baseline_rows.items() + if current_rows.get(record_id) != baseline_row + ) + if changed: + changed_relationships[record_type] = changed + preserved_rows = [ + current_rows[record_id] + for record_id in baseline_rows + if record_id in current_rows + ] + preserved_relationship_checksums[record_type] = checksum( + sorted(preserved_rows, key=lambda row: canonical_json(row)) + ) + counts[record_type] = { + "baseline": int(baseline_record.get("count") or 0), + "current": int(current_record.get("count") or 0), + } + # New telemetry may legitimately arrive while a phased operation is being + # executed. Continuity means every baseline identity still exists; it does + # not freeze the repository's append-only history at the preflight count. + add( + "relationship_continuity", + not missing and not changed_relationships, + {"missing": {}, "changed": {}}, + {"missing": missing, "changed": changed_relationships}, + ) + add( + "record_counts_non_decreasing", + all(item["current"] >= item["baseline"] for item in counts.values()), + {name: item["baseline"] for name, item in counts.items()}, + {name: item["current"] for name, item in counts.items()}, + ) + baseline_token_totals = (baseline.get("token_events") or {}).get("totals") or {} + current_token_totals = (current.get("token_events") or {}).get("totals") or {} + add( + "token_totals_non_decreasing", + all( + int(current_token_totals.get(name) or 0) >= int(value or 0) + for name, value in baseline_token_totals.items() + ), + baseline_token_totals, + current_token_totals, + ) + slug_result = await session.execute( + select(RepositorySlug).where( + RepositorySlug.repo_id == operation.repo_id, + RepositorySlug.slug.in_((operation.old_slug, operation.new_slug)), + ) + ) + slug_routes = {row.slug: row.kind for row in slug_result.scalars().all()} + if operation.phase in {"preflighted", "forge-renamed"}: + expected_routes = {operation.old_slug: "canonical"} + elif operation.phase == "rolled-back": + expected_routes = { + operation.old_slug: "canonical", + operation.new_slug: "alias", + } + else: + expected_routes = { + operation.old_slug: "alias", + operation.new_slug: "canonical", + } + add( + "slug_routes", + all(slug_routes.get(slug) == kind for slug, kind in expected_routes.items()), + expected_routes, + slug_routes, + ) + return { + "operation_id": operation.id, + "repo_id": operation.repo_id, + "phase": operation.phase, + "ok": all(item["ok"] for item in checks), + "checks": checks, + "baseline_checksum": expected_checksum, + "current_checksum": current["continuity_checksum"], + "relationship_checksums": { + "baseline": { + name: value.get("relationship_checksum") + for name, value in baseline.items() + if isinstance(value, dict) and name != "aliases" + }, + "preserved": preserved_relationship_checksums, + }, + } + + +def _telemetry(operation: RepositoryRenameOperation) -> tuple[dict[str, Any], dict[str, Any]]: + journal = deepcopy(operation.evidence or {}) + telemetry = dict(journal.get("telemetry") or {}) + telemetry.setdefault("started_at", operation.preflighted_at.isoformat()) + telemetry.setdefault("phase_attempts", {}) + telemetry.setdefault("phase_durations_ms", {}) + telemetry.setdefault("retries", 0) + telemetry.setdefault("failures", 0) + telemetry.setdefault("rollback_attempts", 0) + telemetry.setdefault("verification_outcome", "pending") + return journal, telemetry + + +def _record_retry(operation: RepositoryRenameOperation, phase: str) -> None: + journal, telemetry = _telemetry(operation) + attempts = dict(telemetry["phase_attempts"]) + attempts[phase] = int(attempts.get(phase) or 0) + 1 + telemetry["phase_attempts"] = attempts + telemetry["retries"] = int(telemetry["retries"]) + 1 + telemetry["last_retry"] = {"phase": phase, "at": utcnow().isoformat()} + journal["telemetry"] = telemetry + operation.evidence = journal + + +def _record_failure( + operation: RepositoryRenameOperation, phase: str, exc: RenameLifecycleError +) -> None: + journal, telemetry = _telemetry(operation) + failures_by_phase = dict(telemetry.get("failures_by_phase") or {}) + failures_by_phase[phase] = int(failures_by_phase.get(phase) or 0) + 1 + telemetry["failures_by_phase"] = failures_by_phase + telemetry["failures"] = int(telemetry["failures"]) + 1 + telemetry["last_failure"] = { + "phase": phase, + "code": exc.code, + "at": utcnow().isoformat(), + } + if phase.startswith("rollback"): + telemetry["rollback_outcome"] = "failed" + journal["telemetry"] = telemetry + operation.evidence = journal + + +def _record_phase(operation: RepositoryRenameOperation, phase: str, evidence: dict[str, Any]) -> None: + now = utcnow() + journal, telemetry = _telemetry(operation) + phases = dict(journal.get("phases") or {}) + # Verification payloads use native UUID/datetime values for API response + # typing. The operation journal is JSONB, so normalize at the boundary + # instead of leaking storage concerns through every verifier. + safe_evidence = json.loads(canonical_json(evidence)) + phases[phase] = {"at": now.isoformat(), **safe_evidence} + journal["phases"] = phases + attempts = dict(telemetry["phase_attempts"]) + attempts[phase] = int(attempts.get(phase) or 0) + 1 + telemetry["phase_attempts"] = attempts + durations = dict(telemetry["phase_durations_ms"]) + durations[phase] = max( + 0, int((now - operation.phase_changed_at).total_seconds() * 1000) + ) + telemetry["phase_durations_ms"] = durations + telemetry["last_phase"] = phase + if phase == "rollback-preflight": + telemetry["rollback_attempts"] = int(telemetry["rollback_attempts"]) + 1 + telemetry["rollback_outcome"] = "approved" + elif phase == "rolled-back": + telemetry["rollback_outcome"] = "completed" + verification = evidence.get("verification") + if isinstance(verification, dict): + telemetry["verification_outcome"] = ( + "passed" if verification.get("ok") else "failed" + ) + journal["telemetry"] = telemetry + operation.evidence = journal + operation.phase = phase + operation.phase_changed_at = now + operation.error_code = None + operation.error_message = None + operation.error_details = None + operation.error_at = None + if phase == "completed": + operation.completed_at = now + if phase == "rolled-back": + operation.rolled_back_at = now + + +def _phase_index(phase: str) -> int: + try: + return FORWARD_PHASES.index(phase) + except ValueError: + return -1 + + +async def apply_phase( + session: AsyncSession, + gateway: ForgeRepositoryGateway, + repo_id: uuid.UUID, + operation_id: uuid.UUID, + requested_phase: str, + body: RepositoryRenamePhaseApply, +) -> tuple[RepositoryRenameOperation, bool]: + if requested_phase not in FORWARD_PHASES[1:]: + raise RenamePreconditionFailed(f"Unsupported forward phase '{requested_phase}'") + operation = await load_operation(session, repo_id, operation_id, for_update=True) + if body.confirmation != confirmation_for(repo_id, operation.old_slug, operation.new_slug): + raise RenamePreconditionFailed("Explicit repository rename confirmation is incorrect") + current_index = _phase_index(operation.phase) + requested_index = _phase_index(requested_phase) + if current_index >= requested_index >= 0: + _record_retry(operation, requested_phase) + await session.commit() + await session.refresh(operation) + return operation, True + if operation.phase != body.expected_phase: + raise RenamePreconditionFailed( + "Repository rename phase compare-and-set failed", + details={"expected": body.expected_phase, "actual": operation.phase}, + ) + if requested_index != current_index + 1: + raise RenamePreconditionFailed( + "Repository rename phases cannot be skipped", + details={"current": operation.phase, "requested": requested_phase}, + ) + try: + if requested_phase == "forge-renamed": + evidence = await _apply_forge_rename(gateway, operation) + elif requested_phase == "statehub-rebound": + forge = await _forge_at(gateway, operation, operation.new_slug) + if forge is None: + raise RenamePreconditionFailed("Forge rename is not observable") + _assert_snapshot(operation, forge, expected_name=operation.new_slug) + evidence = await _apply_statehub_rebind(session, operation) + elif requested_phase == "source-synced": + if not body.evidence or body.evidence.get("fresh_clone") is not True: + raise RenamePreconditionFailed( + "Source synchronization requires fresh-clone operator evidence" + ) + clone_repository_id = body.evidence.get("forge_repository_id") + if clone_repository_id != operation.expected_forge_repository_id: + raise RenamePreconditionFailed( + "Fresh clone points at the wrong Forge repository ID", + details={ + "expected": operation.expected_forge_repository_id, + "actual": clone_repository_id, + }, + ) + clone_head = body.evidence.get("head_commit") + if clone_head != operation.expected_source_commit: + raise RenamePreconditionFailed( + "Fresh clone head does not match the rename baseline", + details={ + "expected": operation.expected_source_commit, + "actual": clone_head, + }, + ) + forge = await _forge_at(gateway, operation, operation.new_slug) + if forge is None: + raise RenamePreconditionFailed("Renamed Forge repository is absent") + _assert_snapshot(operation, forge, expected_name=operation.new_slug) + evidence = {"forge": forge.as_dict(), "operator_evidence": body.evidence} + elif requested_phase == "consumers-verified": + if not body.checks or not all(body.checks.values()): + raise RenamePreconditionFailed( + "Consumer verification requires a non-empty set of passing checks", + details={"checks": body.checks}, + ) + verification = await verify_operation(session, gateway, operation) + if not verification["ok"]: + raise RenamePreconditionFailed( + "Continuity verification failed before consumer acceptance", + details=verification, + ) + evidence = {"checks": body.checks, "verification": verification, "operator_evidence": body.evidence} + else: # completed + verification = await verify_operation(session, gateway, operation) + if not verification["ok"]: + raise RenamePreconditionFailed( + "Repository rename verification failed", details=verification + ) + evidence = {"verification": verification} + _record_phase(operation, requested_phase, evidence) + await session.commit() + await session.refresh(operation) + return operation, False + except RenameLifecycleError as exc: + # Roll back every State Hub write attempted by the phase. A Forge + # rename may already have committed externally; the unchanged journal + # phase is precisely what lets the next request discover and resume it. + await session.rollback() + failed = await load_operation(session, repo_id, operation_id, for_update=True) + _record_failure(failed, requested_phase, exc) + failed.error_code = exc.code + failed.error_message = str(exc) + failed.error_details = json.loads(canonical_json(exc.details)) + failed.error_at = utcnow() + await session.commit() + raise + except IntegrityError as exc: + await session.rollback() + failure = RenamePreconditionFailed( + "Repository rename phase lost a database compare-and-set race" + ) + failed = await load_operation(session, repo_id, operation_id, for_update=True) + _record_failure(failed, requested_phase, failure) + failed.error_code = failure.code + failed.error_message = str(failure) + failed.error_details = json.loads(canonical_json(failure.details)) + failed.error_at = utcnow() + await session.commit() + raise failure from exc + + +async def rollback_preflight( + session: AsyncSession, + gateway: ForgeRepositoryGateway, + repo_id: uuid.UUID, + operation_id: uuid.UUID, + *, + expected_phase: str, + confirmation: str, +) -> tuple[RepositoryRenameOperation, dict[str, Any]]: + operation = await load_operation(session, repo_id, operation_id, for_update=True) + if operation.phase == "rolled-back": + return operation, {"rollback_from_phase": "rolled-back", "safe_to_rollback": True, "blockers": [], "irreversible": []} + if confirmation != rollback_confirmation_for(operation.id): + raise RenamePreconditionFailed("Explicit rollback confirmation is incorrect") + if operation.phase != expected_phase: + raise RenamePreconditionFailed( + "Rollback preflight phase compare-and-set failed", + details={"expected": expected_phase, "actual": operation.phase}, + ) + if operation.phase == "rollback-preflight": + rollback_data = (operation.evidence or {}).get("rollback_preflight") or {} + return operation, rollback_data + competing_result = await session.execute( + select(RepositoryRenameOperation.id).where( + RepositoryRenameOperation.repo_id == repo_id, + RepositoryRenameOperation.id != operation.id, + RepositoryRenameOperation.phase.in_(ACTIVE_OPERATION_PHASES), + ) + ) + competing = [str(value) for value in competing_result.scalars().all()] + old = await _forge_at(gateway, operation, operation.old_slug) + new = await _forge_at(gateway, operation, operation.new_slug) + blockers: list[dict[str, Any]] = [] + if competing: + blockers.append( + { + "code": "newer_active_rename_operation", + "operation_ids": competing, + } + ) + rollback_from_phase = operation.phase + if operation.phase == "preflighted": + if old is not None and new is None: + try: + _assert_snapshot(operation, old, expected_name=operation.old_slug) + except RenameLifecycleError as exc: + blockers.append({"code": exc.code, "message": str(exc), **exc.details}) + elif old is None and new is not None: + # Forge committed but the phase journal did not. This is the + # principal interruption case the operation ID must recover from. + rollback_from_phase = "forge-renamed-unrecorded" + try: + _assert_snapshot(operation, new, expected_name=operation.new_slug) + except RenameLifecycleError as exc: + blockers.append({"code": exc.code, "message": str(exc), **exc.details}) + elif old is None: + blockers.append({"code": "forge_repository_absent_at_both_names"}) + else: + blockers.append({"code": "both_forge_slugs_claimed"}) + else: + if old is not None: + blockers.append({"code": "old_forge_slug_claimed", "forge_repository_id": old.repository_id}) + if new is None: + blockers.append({"code": "renamed_forge_repository_absent"}) + else: + try: + _assert_snapshot(operation, new, expected_name=operation.new_slug) + except RenameLifecycleError as exc: + blockers.append({"code": exc.code, "message": str(exc), **exc.details}) + irreversible = [ + {"code": "external_caches", "message": "External caches and redirects may retain the new coordinate."}, + {"code": "consumer_changes", "message": "Consumer commits made after cutover are not rewritten automatically."}, + {"code": "telemetry_history", "message": "The attempted operation and phase evidence remain durable."}, + ] + data = { + "rollback_from_phase": rollback_from_phase, + "safe_to_rollback": not blockers, + "blockers": blockers, + "irreversible": irreversible, + } + if blockers: + failure = RenamePreconditionFailed( + "Repository rename rollback is unsafe", details=data + ) + _record_failure(operation, "rollback-preflight", failure) + operation.error_code = failure.code + operation.error_message = str(failure) + operation.error_details = json.loads(canonical_json(failure.details)) + operation.error_at = utcnow() + await session.commit() + raise failure + journal = deepcopy(operation.evidence or {}) + journal["rollback_preflight"] = data + operation.evidence = journal + _record_phase(operation, "rollback-preflight", data) + await session.commit() + await session.refresh(operation) + return operation, data + + +async def _rollback_statehub( + session: AsyncSession, operation: RepositoryRenameOperation +) -> dict[str, Any]: + repo = await session.get(ManagedRepo, operation.repo_id, with_for_update=True) + if repo is None: + raise RenameNotFound("Managed repository disappeared") + if repo.slug == operation.old_slug: + return {"resumed": True, "repo_id": str(repo.id)} + if repo.slug != operation.new_slug: + raise RenamePreconditionFailed("Managed repository slug changed outside this operation") + old_result = await session.execute( + select(RepositorySlug).where(RepositorySlug.slug == operation.old_slug).with_for_update() + ) + new_result = await session.execute( + select(RepositorySlug).where(RepositorySlug.slug == operation.new_slug).with_for_update() + ) + old_slug = old_result.scalar_one_or_none() + new_slug = new_result.scalar_one_or_none() + if not old_slug or old_slug.repo_id != repo.id or old_slug.kind != "alias": + raise RenamePreconditionFailed("Protected old alias is unavailable for rollback") + if not new_slug or new_slug.repo_id != repo.id or new_slug.kind != "canonical": + raise RenamePreconditionFailed("New canonical slug is unavailable for rollback") + new_slug.kind = "alias" + new_slug.protected = True + new_slug.source_operation_id = operation.id + await session.flush() + old_slug.kind = "canonical" + old_slug.protected = True + old_slug.source_operation_id = operation.id + repo.slug = operation.old_slug + repo.name = operation.old_coordinates["name"] + repo.remote_url = operation.old_coordinates["remote_url"] + await session.flush() + return {"resumed": False, "repo_id": str(repo.id)} + + +async def apply_rollback( + session: AsyncSession, + gateway: ForgeRepositoryGateway, + repo_id: uuid.UUID, + operation_id: uuid.UUID, + *, + expected_phase: str, + confirmation: str, +) -> tuple[RepositoryRenameOperation, bool]: + operation = await load_operation(session, repo_id, operation_id, for_update=True) + if operation.phase == "rolled-back": + _record_retry(operation, "rolled-back") + await session.commit() + await session.refresh(operation) + return operation, True + if operation.phase != expected_phase or operation.phase != "rollback-preflight": + raise RenamePreconditionFailed( + "Rollback phase compare-and-set failed", + details={"expected": expected_phase, "actual": operation.phase}, + ) + if confirmation != rollback_confirmation_for(operation.id): + raise RenamePreconditionFailed("Explicit rollback confirmation is incorrect") + rollback_from = ((operation.evidence or {}).get("rollback_preflight") or {}).get("rollback_from_phase") + if not rollback_from: + raise RenamePreconditionFailed("Rollback preflight evidence is missing") + try: + old = await _forge_at(gateway, operation, operation.old_slug) + new = await _forge_at(gateway, operation, operation.new_slug) + forge_resumed = False + if rollback_from != "preflighted": + if old is not None: + _assert_snapshot(operation, old, expected_name=operation.old_slug) + if new is not None: + raise RenamePreconditionFailed( + "Both Forge coordinates are claimed during rollback" + ) + forge_resumed = True + else: + if new is None: + raise RenamePreconditionFailed( + "Forge repository is absent during rollback" + ) + _assert_snapshot(operation, new, expected_name=operation.new_slug) + try: + restored = await gateway.rename( + instance=operation.expected_forge_instance, + owner=operation.expected_forge_owner, + old_name=operation.new_slug, + new_name=operation.old_slug, + ) + except (ForgeRepositoryUnreadable, ForgeRepositoryConflict) as exc: + raise RenamePreconditionFailed(str(exc)) from exc + _assert_snapshot( + operation, restored, expected_name=operation.old_slug + ) + statehub = await _rollback_statehub(session, operation) + _record_phase( + operation, + "rolled-back", + { + "rollback_from_phase": rollback_from, + "forge_resumed": forge_resumed, + "statehub": statehub, + }, + ) + await session.commit() + await session.refresh(operation) + return operation, False + except RenameLifecycleError as exc: + await session.rollback() + failed = await load_operation(session, repo_id, operation_id, for_update=True) + _record_failure(failed, "rolled-back", exc) + failed.error_code = exc.code + failed.error_message = str(exc) + failed.error_details = json.loads(canonical_json(exc.details)) + failed.error_at = utcnow() + await session.commit() + raise diff --git a/api/services/review_contracts.py b/api/services/review_contracts.py new file mode 100644 index 0000000..0db02be --- /dev/null +++ b/api/services/review_contracts.py @@ -0,0 +1,336 @@ +from __future__ import annotations + +import hashlib +import json +from datetime import datetime, timezone +from typing import Any + +from fastapi import HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.models.review_contract import ReviewContract, ReviewReceipt +from api.schemas.review_contract import ( + GateReviewState, + OwnerReviewState, + ReviewAggregateRead, +) + + +def canonical_json(value: Any) -> bytes: + return json.dumps( + value, sort_keys=True, separators=(",", ":"), ensure_ascii=True + ).encode() + + +def digest_document(value: Any) -> str: + return hashlib.sha256(canonical_json(value)).hexdigest() + + +def _assertion_id(text: str) -> str: + return "assertion:" + hashlib.sha256(text.encode()).hexdigest()[:20] + + +def normalize_contract(raw: dict[str, Any]) -> tuple[dict[str, Any], str]: + """Validate v1 or adapt the WP-0024 prototype without executing its checks.""" + digest = digest_document(raw) + if raw.get("interface") == "railiance.owner-review" and raw.get("version") == 1: + owners_raw = raw.get("owners") + task_owners = raw.get("task_owners") + hashes = raw.get("artifact_sha256") + if ( + not isinstance(owners_raw, dict) + or not owners_raw + or not isinstance(task_owners, dict) + or not task_owners + ): + raise HTTPException( + 422, "legacy contract requires non-empty owners and task_owners" + ) + if not isinstance(hashes, dict): + raise HTTPException(422, "legacy contract requires artifact_sha256") + owners = [] + for owner_id, scope in owners_raw.items(): + artifacts = scope.get("artifacts") or [] + assertions = scope.get("assertions") or [] + checks = scope.get("checks") or [] + if ( + not artifacts + or not assertions + or not checks + or any(path not in hashes for path in artifacts) + ): + raise HTTPException(422, f"{owner_id}: incomplete legacy owner scope") + owners.append( + { + "id": owner_id, + "artifact_ids": artifacts, + "assertions": [ + {"id": _assertion_id(text), "statement": text} + for text in assertions + ], + "check_ids": checks, + } + ) + owner_ids = {item["id"] for item in owners} + gates = [] + for gate_id, required in task_owners.items(): + if ( + not required + or len(required) != len(set(required)) + or not set(required) <= owner_ids + ): + raise HTTPException(422, f"{gate_id}: invalid legacy owner set") + gates.append({"id": gate_id, "policy": "all_required", "owners": required}) + normalized = { + "schema_version": "review-contract/v1", + "contract_key": raw.get("workplan_id"), + "subject": {"kind": "workplan", "id": raw.get("workplan_id")}, + "owners": owners, + "gates": gates, + "artifacts": { + path: {"algorithm": "sha256", "digest": value} + for path, value in hashes.items() + }, + "allowed_dispositions": [ + item.replace("-", "_") for item in (raw.get("decisions") or []) + ], + "legacy": { + "interface": raw["interface"], + "version": 1, + "source_digest": digest, + }, + } + return validate_v1(normalized), digest + if raw.get("schema_version") != "review-contract/v1": + raise HTTPException(422, "schema_version must be review-contract/v1") + return validate_v1(raw), digest + + +def validate_v1(doc: dict[str, Any]) -> dict[str, Any]: + contract_key, subject = doc.get("contract_key"), doc.get("subject") + owners, gates, artifacts = doc.get("owners"), doc.get("gates"), doc.get("artifacts") + dispositions = doc.get("allowed_dispositions") + if not isinstance(contract_key, str) or not contract_key: + raise HTTPException(422, "contract_key is required") + if ( + not isinstance(subject, dict) + or not subject.get("kind") + or not subject.get("id") + ): + raise HTTPException(422, "typed subject kind and id are required") + if ( + not isinstance(owners, list) + or not owners + or not isinstance(gates, list) + or not gates + ): + raise HTTPException(422, "non-empty owners and gates are required") + if not isinstance(artifacts, dict) or not artifacts: + raise HTTPException(422, "revision-pinned artifacts are required") + if ( + set(dispositions or []) != {"approve", "request_changes"} + or len(dispositions) != 2 + ): + raise HTTPException( + 422, "allowed_dispositions must contain approve and request_changes only" + ) + owner_ids: set[str] = set() + for owner in owners: + if not isinstance(owner, dict): + raise HTTPException(422, "owner entries must be objects") + owner_id = owner.get("id") + artifact_ids = owner.get("artifact_ids") or [] + assertions = owner.get("assertions") or [] + checks = owner.get("check_ids") or [] + assertion_ids = [ + item.get("id") for item in assertions if isinstance(item, dict) + ] + if ( + not owner_id + or owner_id in owner_ids + or not artifact_ids + or not checks + or not assertion_ids + ): + raise HTTPException( + 422, + "each owner needs a unique id and non-empty artifact/assertion/check scope", + ) + if len(assertion_ids) != len(set(assertion_ids)) or any( + path not in artifacts for path in artifact_ids + ): + raise HTTPException(422, f"{owner_id}: invalid assertion or artifact scope") + owner_ids.add(owner_id) + for artifact_id, artifact in artifacts.items(): + if ( + not isinstance(artifact, dict) + or artifact.get("algorithm") != "sha256" + or not _is_sha256(artifact.get("digest")) + ): + raise HTTPException( + 422, f"{artifact_id}: only sha256 artifacts are supported" + ) + gate_ids: set[str] = set() + for gate in gates: + if not isinstance(gate, dict): + raise HTTPException(422, "gate entries must be objects") + required = gate.get("owners") or [] + if ( + gate.get("policy") != "all_required" + or not gate.get("id") + or gate["id"] in gate_ids + ): + raise HTTPException( + 422, "v1 gates require unique ids and all_required policy" + ) + if ( + not required + or len(required) != len(set(required)) + or not set(required) <= owner_ids + ): + raise HTTPException(422, f"{gate.get('id')}: gate owner set is invalid") + gate_ids.add(gate["id"]) + return doc + + +def _is_sha256(value: Any) -> bool: + return ( + isinstance(value, str) + and len(value) == 64 + and all(c in "0123456789abcdef" for c in value) + ) + + +def owner_scope(contract: ReviewContract, owner_id: str) -> dict[str, Any]: + for owner in contract.document["owners"]: + if owner["id"] == owner_id: + return owner + raise HTTPException(422, f"unknown owner: {owner_id}") + + +def validate_receipt(contract: ReviewContract, body: Any) -> dict[str, Any]: + if body.contract_digest != contract.contract_digest: + raise HTTPException( + 409, "contract digest mismatch; re-review the active contract" + ) + scope = owner_scope(contract, body.owner_id) + if body.disposition == "request_changes": + if not body.note or not body.note.strip(): + raise HTTPException(422, "request_changes requires a note") + else: + expected_artifacts = { + key: contract.document["artifacts"][key]["digest"] + for key in scope["artifact_ids"] + } + if body.artifact_hashes != expected_artifacts: + raise HTTPException( + 422, "approval artifact hashes do not exactly match the owner scope" + ) + expected_assertions = {item["id"] for item in scope["assertions"]} + if set(body.assertion_ids) != expected_assertions or len( + body.assertion_ids + ) != len(expected_assertions): + raise HTTPException( + 422, "approval assertions do not exactly match the owner scope" + ) + expected_checks = set(scope["check_ids"]) + actual_checks = { + item.get("id") for item in body.checks if isinstance(item, dict) + } + if actual_checks != expected_checks or len(body.checks) != len(expected_checks): + raise HTTPException( + 422, "approval checks do not exactly match the owner scope" + ) + if any( + item.get("passed") is not True or item.get("read_only") is not True + for item in body.checks + ): + raise HTTPException( + 422, "every approval check must be passed and explicitly read_only" + ) + return { + "owner_id": body.owner_id, + "actor": body.actor, + "disposition": body.disposition, + "contract_digest": body.contract_digest, + "artifact_hashes": body.artifact_hashes, + "assertion_ids": body.assertion_ids, + "checks": body.checks, + "note": body.note, + "source": body.source.model_dump(), + } + + +async def active_contract(session: AsyncSession, contract_key: str) -> ReviewContract: + result = await session.execute( + select(ReviewContract).where( + ReviewContract.contract_key == contract_key, ReviewContract.active.is_(True) + ) + ) + contract = result.scalar_one_or_none() + if contract is None: + raise HTTPException(404, f"active review contract not found: {contract_key}") + return contract + + +async def aggregate( + session: AsyncSession, contract: ReviewContract +) -> ReviewAggregateRead: + rows = await session.execute( + select(ReviewReceipt) + .where(ReviewReceipt.contract_id == contract.id) + .order_by(ReviewReceipt.submitted_at.desc(), ReviewReceipt.id.desc()) + ) + latest: dict[str, ReviewReceipt] = {} + for receipt in rows.scalars(): + latest.setdefault(receipt.owner_id, receipt) + prior = await session.execute( + select(ReviewReceipt.owner_id) + .join(ReviewContract) + .where( + ReviewContract.contract_key == contract.contract_key, + ReviewContract.id != contract.id, + ) + .distinct() + ) + stale_owners = set(prior.scalars()) + owner_states, statuses = [], {} + for owner in contract.document["owners"]: + owner_id, receipt = owner["id"], latest.get(owner["id"]) + state = ( + receipt.disposition.replace("approve", "approved") + if receipt + else ("stale" if owner_id in stale_owners else "missing") + ) + statuses[owner_id] = state + owner_states.append( + OwnerReviewState( + owner_id=owner_id, + status=state, + receipt_id=receipt.id if receipt else None, + receipt_digest=receipt.receipt_digest if receipt else None, + submitted_at=receipt.submitted_at if receipt else None, + ) + ) + gates = [ + GateReviewState( + gate_id=gate["id"], + policy="all_required", + owners=gate["owners"], + satisfied=all(statuses[owner] == "approved" for owner in gate["owners"]), + ) + for gate in contract.document["gates"] + ] + return ReviewAggregateRead( + contract_key=contract.contract_key, + contract_digest=contract.contract_digest, + satisfied=all(gate.satisfied for gate in gates), + authorizes_execution=False, + owners=owner_states, + gates=gates, + ) + + +def utc_now() -> datetime: + return datetime.now(timezone.utc) diff --git a/api/services/sbom_nexus.py b/api/services/sbom_nexus.py new file mode 100644 index 0000000..229a43e --- /dev/null +++ b/api/services/sbom_nexus.py @@ -0,0 +1,70 @@ +"""Read-only client for the reversible SBOM Nexus compatibility facade.""" +from __future__ import annotations + +from typing import Any + +import httpx + +from api.config import settings + + +class SBOMNexusError(RuntimeError): + """An SBOM Nexus request failed without a usable compatibility response.""" + + def __init__(self, status_code: int, detail: str): + super().__init__(detail) + self.status_code = status_code + self.detail = detail + + +def reads_from_nexus() -> bool: + return settings.sbom_nexus_read_mode == "nexus" + + +def writes_to_nexus() -> bool: + return settings.sbom_nexus_write_mode == "nexus" + + +async def request_json( + method: str, + path: str, + *, + params: dict[str, Any] | None = None, + body: dict[str, Any] | None = None, +) -> Any: + """Call Nexus; never fall back silently to legacy storage.""" + if not settings.sbom_nexus_url: + raise SBOMNexusError(503, "SBOM Nexus read mode is enabled without SBOM_NEXUS_URL") + + try: + async with httpx.AsyncClient( + base_url=settings.sbom_nexus_url.rstrip("/"), + timeout=settings.sbom_nexus_timeout_seconds, + ) as client: + response = await client.request(method, path, params=params, json=body) + except httpx.RequestError as exc: + raise SBOMNexusError(502, f"SBOM Nexus is unavailable: {exc.__class__.__name__}") from exc + + if response.status_code >= 400: + detail = f"SBOM Nexus returned HTTP {response.status_code}" + try: + payload = response.json() + if isinstance(payload, dict) and isinstance(payload.get("detail"), str): + detail = payload["detail"] + except ValueError: + pass + status_code = 404 if response.status_code == 404 else 502 + raise SBOMNexusError(status_code, detail) + + try: + return response.json() + except ValueError as exc: + raise SBOMNexusError(502, "SBOM Nexus returned invalid JSON") from exc + + +async def get_json(path: str, *, params: dict[str, Any] | None = None) -> Any: + return await request_json("GET", path, params=params) + + +async def post_json(path: str, *, body: dict[str, Any]) -> Any: + return await request_json("POST", path, body=body) diff --git a/api/services/schema_state.py b/api/services/schema_state.py new file mode 100644 index 0000000..24ba58a --- /dev/null +++ b/api/services/schema_state.py @@ -0,0 +1,63 @@ +"""Report whether the database schema matches the code (STATE-WP-0083-T07). + +Central ran two revisions behind the code it was serving, and `review_contracts` +did not exist there although its migration shipped inside the running image. +Nothing surfaced that: the API starts happily against a schema it was not built +for, and only fails when a request happens to touch the missing table. + +A hub that cannot say which schema it is running has the same problem as a +projection that cannot name its source commit — it is asserting correctness it +cannot demonstrate. +""" + +from __future__ import annotations + +from functools import lru_cache +from pathlib import Path +from typing import Any + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +_MIGRATIONS = Path(__file__).resolve().parents[2] / "migrations" + + +@lru_cache(maxsize=1) +def code_head_revision() -> str | None: + """The head revision the shipped migration scripts define. + + Read from the migration files rather than the database: this is what the + code expects, and it must be knowable without a working connection. + """ + try: + from alembic.config import Config + from alembic.script import ScriptDirectory + + cfg = Config() + cfg.set_main_option("script_location", str(_MIGRATIONS)) + heads = ScriptDirectory.from_config(cfg).get_heads() + return heads[0] if len(heads) == 1 else ",".join(sorted(heads)) or None + except Exception: + return None + + +async def db_revision(session: AsyncSession) -> str | None: + try: + result = await session.execute(text("select version_num from alembic_version")) + return result.scalar() + except Exception: + return None + + +async def schema_state(session: AsyncSession) -> dict[str, Any]: + applied = await db_revision(session) + expected = code_head_revision() + # "unknown" is deliberately not "ok": an instance that cannot determine its + # own schema state must not report agreement it has not established. + if applied is None or expected is None: + status = "unknown" + elif applied == expected: + status = "ok" + else: + status = "behind" + return {"status": status, "applied": applied, "expected": expected} diff --git a/api/services/summary_cache.py b/api/services/summary_cache.py index 4061f55..55c6e29 100644 --- a/api/services/summary_cache.py +++ b/api/services/summary_cache.py @@ -27,6 +27,8 @@ from api.models.workplan import Workplan from api.models.workplan_dependency import WorkplanDependency from api.schemas.progress_event import ProgressEventRead from api.schemas.state import StateSummary +from api.services.sbom_nexus import get_json as get_sbom_nexus_json +from api.services.sbom_nexus import reads_from_nexus logger = logging.getLogger(__name__) @@ -88,9 +90,17 @@ async def fetch_summary_revision(session: AsyncSession) -> SummaryRevision: if value is not None: core_parts.append(value) - sbom_at = ( - await session.execute(select(func.max(SBOMSnapshot.snapshot_at))) - ).scalar_one_or_none() + if reads_from_nexus(): + snapshots = await get_sbom_nexus_json("/sbom/snapshots/") + snapshot_times = [ + datetime.fromisoformat(item["snapshot_at"].replace("Z", "+00:00")) + for item in snapshots + ] + sbom_at = max(snapshot_times, default=None) + else: + sbom_at = ( + await session.execute(select(func.max(SBOMSnapshot.snapshot_at))) + ).scalar_one_or_none() progress_at = ( await session.execute(select(func.max(ProgressEvent.created_at))) @@ -285,4 +295,4 @@ async def apply_progress_section( cache._entry.progress_revision = revision.progress_fingerprint() else: cache.store(merged, revision) - return merged \ No newline at end of file + return merged diff --git a/api/services/task_record_id_backfill.py b/api/services/task_record_id_backfill.py new file mode 100644 index 0000000..c2ff3c4 --- /dev/null +++ b/api/services/task_record_id_backfill.py @@ -0,0 +1,214 @@ +"""Backfill canonical record ids onto existing task rows (STATE-WP-0083-T06). + +Only the repository files hold the mapping. A file task declares both its +canonical id and the projection UUID it was registered under: + + ```task + id: CUST-WP-0067-T01 + state_hub_task_id: "f3608db4-..." + ``` + +so the pairing can be read directly rather than guessed from titles. Anything a +file does not claim is left alone: a task row whose canonical id cannot be +established keeps `record_id` null, and the reset continues to refuse to act on +it. An unknown identity must stay unknown rather than be inferred. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +_TASK_BLOCK_RE = re.compile(r"```task\s*\n(.*?)\n```", re.DOTALL) +_ID_RE = re.compile(r"^id:\s*(\S+)", re.MULTILINE) +_UUID_RE = re.compile(r'state_hub_task_id:\s*"?([0-9a-f-]{36})"?') +_FRONTMATTER_ID_RE = re.compile(r"^id:\s*(\S+)", re.MULTILINE) +# A task id written as a bare "T01" is unique only inside its own workplan. +# Storing it as a canonical identifier makes every workplan's first task share +# one identity — 51 such ids were assigned to 148 rows before this was caught. +_SHORT_TASK_ID_RE = re.compile(r"^T\d+$", re.IGNORECASE) + + +def qualify_task_id(task_id: str, workplan_id: str | None) -> str | None: + """Return a canonical task id, or None when identity cannot be established.""" + task_id = task_id.strip() + if not _SHORT_TASK_ID_RE.fullmatch(task_id): + return task_id + if not workplan_id: + # Unqualifiable: leaving it unidentified is correct, since an identity + # that is not unique is worse than none. + return None + return f"{workplan_id.strip()}-{task_id.upper()}" + + +@dataclass +class BackfillReport: + scanned_files: int = 0 + pairs_found: int = 0 + updated: int = 0 + already_set: int = 0 + conflicts: list[dict[str, str]] = field(default_factory=list) + unmatched_uuids: int = 0 + + def to_dict(self) -> dict[str, Any]: + return { + "schema": "state-hub.task-record-id-backfill.v1", + "scanned_files": self.scanned_files, + "pairs_found": self.pairs_found, + "updated": self.updated, + "already_set": self.already_set, + "unmatched_uuids": self.unmatched_uuids, + "conflicts": self.conflicts, + } + + +def collect_pairs(roots: list[Path]) -> tuple[dict[str, str], BackfillReport]: + """Map projection UUID -> canonical record id, from workplan files.""" + report = BackfillReport() + pairs: dict[str, str] = {} + for root in roots: + wp_dir = root / "workplans" + if not wp_dir.is_dir(): + continue + for path in sorted(wp_dir.rglob("*.md")): + if path.name.startswith("."): + continue + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + report.scanned_files += 1 + head = text.split("---", 2)[1] if text.startswith("---") and text.count("---") >= 2 else "" + wp_match = _FRONTMATTER_ID_RE.search(head) + workplan_id = wp_match.group(1).strip() if wp_match else None + for block in _TASK_BLOCK_RE.finditer(text): + body = block.group(1) + rid = _ID_RE.search(body) + uid = _UUID_RE.search(body) + if not rid or not uid: + continue + record_id = qualify_task_id(rid.group(1), workplan_id) + if record_id is None: + continue + task_uuid = uid.group(1) + prior = pairs.get(task_uuid) + if prior and prior != record_id: + # One UUID claimed by two canonical ids: a duplicate + # registration. Recording it and skipping is the only safe + # option — picking one would fabricate an identity. + report.conflicts.append( + {"uuid": task_uuid, "first": prior, "second": record_id} + ) + continue + pairs[task_uuid] = record_id + report.pairs_found = len(pairs) + return pairs, report + + +async def backfill_task_record_ids( + session: Any, roots: list[Path], *, dry_run: bool = True +) -> BackfillReport: + from sqlalchemy import select + + from api.models.task import Task + + pairs, report = collect_pairs(roots) + if not pairs: + return report + + rows = list((await session.execute(select(Task))).scalars()) + by_id = {str(r.id): r for r in rows} + for task_uuid, record_id in pairs.items(): + row = by_id.get(task_uuid) + if row is None: + report.unmatched_uuids += 1 + continue + if row.record_id == record_id: + report.already_set += 1 + continue + if row.record_id and row.record_id != record_id: + report.conflicts.append( + {"uuid": task_uuid, "first": row.record_id, "second": record_id} + ) + continue + report.updated += 1 + if not dry_run: + row.record_id = record_id + return report + + +async def backfill_from_forge( + session: Any, + repo_slugs: list[str], + *, + forge_base: str | None = None, + dry_run: bool = True, +) -> BackfillReport: + """Backfill from repositories cloned out of the forge. + + The local-path variant above needs a workstation checkout, which central + does not have and should not depend on: `ADR-012` decision 1 makes the forge + the projection source, and a backfill sourced from someone's laptop would + reintroduce exactly the coupling that ADR removes. + + Central can clone the forge directly, so it reads the pairing from the same + place it derives everything else. + """ + import tempfile + + from api.services.forge_projection import ( + DEFAULT_FORGE_BASE, + ForgeDeriveError, + _run_git, + ) + + base = forge_base or DEFAULT_FORGE_BASE + report = BackfillReport() + pairs: dict[str, str] = {} + + for slug in repo_slugs: + url = f"{base.rstrip('/')}/{slug}.git" + with tempfile.TemporaryDirectory(prefix=f"backfill-{slug}-") as tmp: + try: + _run_git("clone", "--depth", "1", "--quiet", url, tmp) + except (ForgeDeriveError, Exception): + # A repository that cannot be read contributes nothing. It must + # not silently reduce what the rest can identify. + continue + repo_pairs, repo_report = collect_pairs([Path(tmp)]) + report.scanned_files += repo_report.scanned_files + report.conflicts.extend(repo_report.conflicts) + for uid, rid in repo_pairs.items(): + prior = pairs.get(uid) + if prior and prior != rid: + report.conflicts.append({"uuid": uid, "first": prior, "second": rid}) + continue + pairs[uid] = rid + + report.pairs_found = len(pairs) + if not pairs: + return report + + from sqlalchemy import select + + from api.models.task import Task + + rows = list((await session.execute(select(Task))).scalars()) + by_id = {str(r.id): r for r in rows} + for uid, rid in pairs.items(): + row = by_id.get(uid) + if row is None: + report.unmatched_uuids += 1 + continue + if row.record_id == rid: + report.already_set += 1 + continue + if row.record_id and row.record_id != rid: + report.conflicts.append({"uuid": uid, "first": row.record_id, "second": rid}) + continue + report.updated += 1 + if not dry_run: + row.record_id = rid + return report diff --git a/api/services/work_record_identifier_migration.py b/api/services/work_record_identifier_migration.py new file mode 100644 index 0000000..f0d2a4d --- /dev/null +++ b/api/services/work_record_identifier_migration.py @@ -0,0 +1,799 @@ +"""Transactional State Hub projection migration for deterministic work-record UUIDs.""" + +from __future__ import annotations + +import hashlib +import json +import re +import uuid +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import func, select, text +from sqlalchemy.ext.asyncio import AsyncSession + +from api.models.managed_repo import ManagedRepo +from api.models.task import Task, TaskPriority, TaskStatus +from api.models.work_record_identifier_alias import WorkRecordIdentifierAlias +from api.services.repository_aliases import resolve_repository_slug +from api.models.workplan import Workplan + +PLAN_SCHEMA = "repo-manager.identifier-migration-plan.v1" +FLEET_NAMESPACE = "helixforge" +DERIVATION_NAMESPACE_UUID = uuid.UUID("a4058507-5c4a-5a00-ab06-fffa4fb46009") +_RECORD_ID_RE = re.compile(r"^[A-Z][A-Z0-9-]*-WP-[0-9]{4}(?:-T[0-9]{2,})?$") + + +class IdentifierMigrationError(ValueError): + """The signed plan or current projection is unsafe to migrate.""" + + +@dataclass(frozen=True) +class IdentifierMigrationResult: + repo_slug: str + plan_sha256: str + direction: str + replacements: int + migrated: int + already_derived: int + assignments_deferred: int + + +@dataclass(frozen=True) +class SealedProjectionRepairReceipt: + """Non-secret evidence for one bounded pre-derivation projection repair.""" + + schema: str + outcome: str + repo_slug: str + repository_id: str + workplan_record_id: str + workplan_old_id: str + task_records: tuple[tuple[str, str], ...] + plan_sha256: str + source_revision: str + source_fingerprint: str + projection_identity: str + observed_at: str + + +def verify_plan(plan: dict[str, Any]) -> str: + """Validate the immutable plan envelope and deterministic UUID mappings.""" + if plan.get("schema") != PLAN_SCHEMA: + raise IdentifierMigrationError("unsupported identifier migration plan schema") + if plan.get("namespace") != FLEET_NAMESPACE: + raise IdentifierMigrationError( + f"plan namespace must be {FLEET_NAMESPACE!r}" + ) + if plan.get("apply_policy") != "all-or-nothing per repository": + raise IdentifierMigrationError("plan does not declare repository-atomic apply") + if plan.get("ready_to_apply") is not True: + raise IdentifierMigrationError("plan is not ready_to_apply") + + expected = plan.get("plan_sha256") + if not isinstance(expected, str) or not re.fullmatch(r"[0-9a-f]{64}", expected): + raise IdentifierMigrationError("plan has no valid SHA-256 seal") + unsealed = {key: value for key, value in plan.items() if key != "plan_sha256"} + canonical = json.dumps(unsealed, sort_keys=True, separators=(",", ":")).encode() + actual = hashlib.sha256(canonical).hexdigest() + if actual != expected: + raise IdentifierMigrationError("plan SHA-256 mismatch") + return expected + + +def _repository_mappings( + plan: dict[str, Any], repo_slug: str +) -> tuple[list[dict[str, Any]], int]: + matches = [item for item in plan.get("repositories", []) if item.get("repo") == repo_slug] + if len(matches) != 1: + raise IdentifierMigrationError( + f"repository {repo_slug!r} must occur exactly once in plan" + ) + repository = matches[0] + if repository.get("eligible") is not True or repository.get("atomic_unit") is not True: + raise IdentifierMigrationError(f"repository {repo_slug!r} is not eligible and atomic") + + replacements: list[dict[str, Any]] = [] + assignments = 0 + seen_old: set[uuid.UUID] = set() + seen_new: set[uuid.UUID] = set() + for raw in repository.get("mappings", []): + action = raw.get("action") + if action == "assign": + assignments += 1 + continue + if action == "unchanged": + continue + if action != "replace": + raise IdentifierMigrationError(f"unsupported mapping action {action!r}") + if raw.get("repo") != repo_slug: + raise IdentifierMigrationError("mapping repository does not match atomic unit") + kind = raw.get("kind") + record_id = raw.get("record_id") + if kind not in {"workplan", "task"}: + raise IdentifierMigrationError(f"unsupported record kind {kind!r}") + if not isinstance(record_id, str) or not _RECORD_ID_RE.fullmatch(record_id): + raise IdentifierMigrationError(f"noncanonical record id {record_id!r}") + try: + old_id = uuid.UUID(str(raw.get("current_uuid"))) + new_id = uuid.UUID(str(raw.get("derived_uuid"))) + except (TypeError, ValueError) as exc: + raise IdentifierMigrationError(f"invalid UUID mapping for {record_id}") from exc + expected_new = uuid.uuid5( + DERIVATION_NAMESPACE_UUID, + f"{FLEET_NAMESPACE}\n{record_id}", + ) + if new_id != expected_new: + raise IdentifierMigrationError(f"derived UUID mismatch for {record_id}") + if old_id == new_id or old_id in seen_old or new_id in seen_new: + raise IdentifierMigrationError(f"non-unique replacement for {record_id}") + seen_old.add(old_id) + seen_new.add(new_id) + replacements.append( + { + "kind": kind, + "record_id": record_id, + "old_id": old_id, + "new_id": new_id, + } + ) + return replacements, assignments + + +def _repository_entry(plan: dict[str, Any], repo_slug: str) -> dict[str, Any]: + matches = [item for item in plan.get("repositories", []) if item.get("repo") == repo_slug] + if len(matches) != 1: + raise IdentifierMigrationError( + f"repository {repo_slug!r} must occur exactly once in plan" + ) + return matches[0] + + +def _repair_unit_mappings( + plan: dict[str, Any], repo_slug: str, unit: dict[str, Any] +) -> tuple[dict[str, Any], list[dict[str, Any]]]: + replacements, _assignments = _repository_mappings(plan, repo_slug) + workplan = unit.get("workplan") + tasks = unit.get("tasks") + if not isinstance(workplan, dict) or not isinstance(tasks, list): + raise IdentifierMigrationError("repair unit requires one workplan and a task list") + + workplan_record_id = workplan.get("record_id") + workplan_matches = [ + mapping + for mapping in replacements + if mapping["kind"] == "workplan" and mapping["record_id"] == workplan_record_id + ] + if len(workplan_matches) != 1: + raise IdentifierMigrationError( + "repair workplan must occur exactly once as a replacement in the sealed plan" + ) + workplan_mapping = workplan_matches[0] + raw_workplan_mapping = next( + raw + for raw in _repository_entry(plan, repo_slug).get("mappings", []) + if raw.get("kind") == "workplan" and raw.get("record_id") == workplan_record_id + ) + authoritative_path = raw_workplan_mapping.get("path") + if not isinstance(authoritative_path, str) or not authoritative_path: + raise IdentifierMigrationError("sealed workplan mapping has no authoritative path") + + task_record_ids = [task.get("record_id") for task in tasks if isinstance(task, dict)] + if len(task_record_ids) != len(tasks) or len(set(task_record_ids)) != len(tasks): + raise IdentifierMigrationError("repair task record ids must be explicit and unique") + task_mappings = [ + mapping + for mapping in replacements + if mapping["kind"] == "task" and mapping["record_id"] in task_record_ids + ] + mapped_task_ids = {mapping["record_id"] for mapping in task_mappings} + if mapped_task_ids != set(task_record_ids): + raise IdentifierMigrationError( + "repair tasks do not exactly match replacement mappings in the sealed plan" + ) + + raw_unit_task_ids = { + raw.get("record_id") + for raw in _repository_entry(plan, repo_slug).get("mappings", []) + if raw.get("kind") == "task" + and raw.get("action") == "replace" + and raw.get("path") == authoritative_path + } + if raw_unit_task_ids != set(task_record_ids): + raise IdentifierMigrationError( + "repair must include every replaced task in the sealed workplan unit" + ) + return workplan_mapping, task_mappings + + +def _uuid_field(value: Any, label: str) -> uuid.UUID: + try: + return uuid.UUID(str(value)) + except (TypeError, ValueError) as exc: + raise IdentifierMigrationError(f"invalid UUID for {label}") from exc + + +def _repair_payloads( + unit: dict[str, Any], + workplan_mapping: dict[str, Any], + task_mappings: list[dict[str, Any]], +) -> tuple[dict[str, Any], list[dict[str, Any]]]: + raw_workplan = unit["workplan"] + if _uuid_field(raw_workplan.get("id"), "workplan") != workplan_mapping["old_id"]: + raise IdentifierMigrationError("repair workplan UUID does not match sealed old UUID") + slug = raw_workplan.get("slug") + title = raw_workplan.get("title") + status = raw_workplan.get("status") + if not isinstance(slug, str) or not slug or not isinstance(title, str) or not title: + raise IdentifierMigrationError("repair workplan requires explicit slug and title") + if status not in { + "proposed", "ready", "active", "blocked", "backlog", "finished", "archived" + }: + raise IdentifierMigrationError(f"unsupported repair workplan status {status!r}") + workplan_payload = { + "id": workplan_mapping["old_id"], + "topic_id": _uuid_field(raw_workplan.get("topic_id"), "workplan topic"), + "slug": slug, + "title": title, + "description": raw_workplan.get("description"), + "status": status, + "owner": raw_workplan.get("owner"), + "planning_priority": raw_workplan.get("planning_priority"), + "planning_order": raw_workplan.get("planning_order"), + } + + mappings_by_record = {mapping["record_id"]: mapping for mapping in task_mappings} + task_payloads: list[dict[str, Any]] = [] + for raw_task in unit["tasks"]: + mapping = mappings_by_record[raw_task["record_id"]] + if _uuid_field(raw_task.get("id"), raw_task["record_id"]) != mapping["old_id"]: + raise IdentifierMigrationError( + f"repair task UUID does not match sealed old UUID for {raw_task['record_id']}" + ) + title = raw_task.get("title") + if not isinstance(title, str) or not title: + raise IdentifierMigrationError( + f"repair task {raw_task['record_id']} requires an explicit title" + ) + try: + status_value = TaskStatus(raw_task.get("status", "todo")) + priority_value = TaskPriority(raw_task.get("priority", "medium")) + except ValueError as exc: + raise IdentifierMigrationError( + f"invalid task state for {raw_task['record_id']}" + ) from exc + parent_record_id = raw_task.get("parent_record_id") + if parent_record_id is not None and parent_record_id not in mappings_by_record: + raise IdentifierMigrationError( + f"parent task for {raw_task['record_id']} is outside the sealed repair unit" + ) + task_payloads.append( + { + "record_id": raw_task["record_id"], + "id": mapping["old_id"], + "title": title, + "description": raw_task.get("description"), + "status": status_value, + "priority": priority_value, + "assignee": raw_task.get("assignee"), + "blocking_reason": raw_task.get("blocking_reason"), + "needs_human": bool(raw_task.get("needs_human", False)), + "intervention_note": raw_task.get("intervention_note"), + "parent_task_id": ( + mappings_by_record[parent_record_id]["old_id"] + if parent_record_id is not None + else None + ), + } + ) + if task_payloads[-1]["needs_human"] and not task_payloads[-1]["intervention_note"]: + raise IdentifierMigrationError( + f"repair task {raw_task['record_id']} needs an intervention note" + ) + if ( + task_payloads[-1]["status"] == TaskStatus.wait + and task_payloads[-1]["needs_human"] + and not task_payloads[-1]["blocking_reason"] + ): + raise IdentifierMigrationError( + f"repair task {raw_task['record_id']} needs a blocking reason" + ) + + pending = list(task_payloads) + ordered: list[dict[str, Any]] = [] + emitted: set[uuid.UUID] = set() + while pending: + ready = [ + task + for task in pending + if task["parent_task_id"] is None or task["parent_task_id"] in emitted + ] + if not ready: + raise IdentifierMigrationError("repair task parentage contains a cycle") + for task in ready: + pending.remove(task) + ordered.append(task) + emitted.add(task["id"]) + return workplan_payload, ordered + + +def _same_workplan(row: Workplan, expected: dict[str, Any], repo_id: uuid.UUID) -> bool: + fields = ( + "id", "topic_id", "slug", "title", "description", "status", "owner", + "planning_priority", "planning_order", + ) + return row.repo_id == repo_id and all( + getattr(row, field) == expected[field] for field in fields + ) + + +def _same_task(row: Task, expected: dict[str, Any], workplan_id: uuid.UUID) -> bool: + fields = ( + "id", "title", "description", "status", "priority", "assignee", + "blocking_reason", "needs_human", "intervention_note", "parent_task_id", + ) + return row.workplan_id == workplan_id and all( + getattr(row, field) == expected[field] for field in fields + ) + + +_TASK_DUPLICATE_FIELDS = ( + "workplan_id", + "title", + "description", + "status", + "priority", + "assignee", + "due_date", + "blocking_reason", + "needs_human", + "intervention_note", + "parent_task_id", +) + + +def _same_duplicate_task( + legacy: Task, + derived: Task, + record_id: str, +) -> bool: + """Accept only a deterministic duplicate of an otherwise identical legacy row.""" + return ( + legacy.record_id in {None, record_id} + and derived.record_id == record_id + and all( + getattr(legacy, field) == getattr(derived, field) + for field in _TASK_DUPLICATE_FIELDS + ) + ) + + +async def _task_reference_count(session: AsyncSession, task_id: uuid.UUID) -> int: + """Count every inbound task reference before duplicate coalescence. + + Coalescence deliberately refuses referenced legacy rows. Updating references + could collide with relationships already attached to the deterministic row; + a zero-reference proof keeps the repair lossless and reversible. + """ + return int( + await session.scalar( + text( + "SELECT " + "(SELECT count(*) FROM token_events WHERE task_id = :task_id) + " + "(SELECT count(*) FROM tasks WHERE parent_task_id = :task_id) + " + "(SELECT count(*) FROM review_contracts WHERE task_id = :task_id) + " + "(SELECT count(*) FROM progress_events WHERE task_id = :task_id) + " + "(SELECT count(*) FROM capability_requests WHERE blocking_task_id = :task_id) + " + "(SELECT count(*) FROM workplan_dependencies WHERE to_task_id = :task_id) + " + "(SELECT count(*) FROM suggestions WHERE promoted_task_id = :task_id)" + ), + {"task_id": task_id}, + ) + or 0 + ) + + +async def repair_absent_prederivation_projection( + session: AsyncSession, + plan: dict[str, Any], + repo_slug: str, + unit: dict[str, Any], + *, + expected_plan_sha256: str, + source_revision: str, + source_fingerprint: str, + source_clean: bool, + source_synchronized: bool, + primary_confirmed: bool, + projection_identity: str, + observed_at: datetime | None = None, +) -> SealedProjectionRepairReceipt: + """Restore one exact random-ID workplan unit into an absent projection. + + This is intentionally separate from ordinary consistency repair. The caller + must independently prove Git cleanliness/synchronization and provide the + authoritative unit parsed from the pinned source revision. + """ + plan_sha256 = verify_plan(plan) + if plan_sha256 != expected_plan_sha256: + raise IdentifierMigrationError("explicit plan SHA-256 does not match sealed plan") + if not primary_confirmed: + raise IdentifierMigrationError("projection repair requires explicit primary confirmation") + if not source_clean or not source_synchronized: + raise IdentifierMigrationError("projection repair requires a clean synchronized source") + if not re.fullmatch(r"[0-9a-f]{40}", source_revision): + raise IdentifierMigrationError("source revision must be a full Git SHA") + if not re.fullmatch(r"[0-9a-f]{64}", source_fingerprint): + raise IdentifierMigrationError("source fingerprint must be SHA-256") + if not projection_identity.strip(): + raise IdentifierMigrationError("projection identity is required") + + repository_entry = _repository_entry(plan, repo_slug) + if source_revision != repository_entry.get("planned_head_sha"): + raise IdentifierMigrationError("source revision drifted from sealed plan") + if source_fingerprint != repository_entry.get("source_fingerprint"): + raise IdentifierMigrationError("source fingerprint drifted from sealed plan") + workplan_mapping, task_mappings = _repair_unit_mappings(plan, repo_slug, unit) + workplan_payload, task_payloads = _repair_payloads( + unit, workplan_mapping, task_mappings + ) + if session.in_transaction(): + raise IdentifierMigrationError("projection repair requires a fresh database session") + + outcome = "repaired" + async with session.begin(): + resolution = await resolve_repository_slug(session, repo_slug, required=False) + if resolution is None: + raise IdentifierMigrationError(f"repository projection is absent: {repo_slug}") + repo = await session.scalar( + select(ManagedRepo) + .where(ManagedRepo.id == resolution.repo.id) + .with_for_update() + ) + if repo.topic_id != workplan_payload["topic_id"]: + raise IdentifierMigrationError("repair topic does not match repository projection") + + old_ids = [workplan_mapping["old_id"], *(m["old_id"] for m in task_mappings)] + new_workplan = await session.get(Workplan, workplan_mapping["new_id"]) + new_tasks = [await session.get(Task, mapping["new_id"]) for mapping in task_mappings] + if new_workplan is not None or any(task is not None for task in new_tasks): + raise IdentifierMigrationError("derived target presence blocks projection repair") + alias_count = await session.scalar( + select(func.count()).select_from(WorkRecordIdentifierAlias).where( + WorkRecordIdentifierAlias.old_id.in_(old_ids) + ) + ) + if alias_count: + raise IdentifierMigrationError("durable alias presence blocks projection repair") + + old_workplan = await session.get(Workplan, workplan_mapping["old_id"]) + old_tasks = [await session.get(Task, mapping["old_id"]) for mapping in task_mappings] + present_count = int(old_workplan is not None) + sum(task is not None for task in old_tasks) + expected_count = 1 + len(task_mappings) + if present_count not in {0, expected_count}: + raise IdentifierMigrationError("partial old projection presence blocks repair") + + if present_count == expected_count: + if not _same_workplan(old_workplan, workplan_payload, repo.id): + raise IdentifierMigrationError( + "existing old workplan differs from sealed repair unit" + ) + expected_tasks = {task["id"]: task for task in task_payloads} + if any( + not _same_task(task, expected_tasks[task.id], workplan_mapping["old_id"]) + for task in old_tasks + ): + raise IdentifierMigrationError("existing old tasks differ from sealed repair unit") + outcome = "verified_noop" + else: + slug_conflict = await session.scalar( + select(Workplan.id).where(Workplan.slug == workplan_payload["slug"]) + ) + if slug_conflict is not None: + raise IdentifierMigrationError("workplan slug is already projected") + session.add( + Workplan(repo_id=repo.id, **workplan_payload) + ) + await session.flush() + for task_payload in task_payloads: + values = {key: value for key, value in task_payload.items() if key != "record_id"} + session.add(Task(workplan_id=workplan_mapping["old_id"], **values)) + await session.flush() + + timestamp = observed_at or datetime.now(UTC) + if timestamp.tzinfo is None: + raise IdentifierMigrationError("receipt observation time must be timezone-aware") + return SealedProjectionRepairReceipt( + schema="state-hub.sealed-prederivation-projection-repair-receipt.v1", + outcome=outcome, + repo_slug=repo_slug, + repository_id=str(repo.id), + workplan_record_id=workplan_mapping["record_id"], + workplan_old_id=str(workplan_mapping["old_id"]), + task_records=tuple( + (mapping["record_id"], str(mapping["old_id"])) for mapping in task_mappings + ), + plan_sha256=plan_sha256, + source_revision=source_revision, + source_fingerprint=source_fingerprint, + projection_identity=projection_identity, + observed_at=timestamp.astimezone(UTC).isoformat().replace("+00:00", "Z"), + ) + + +async def _assert_projection_preconditions( + session: AsyncSession, + repository_id: uuid.UUID, + repo_slug: str, + replacements: list[dict[str, Any]], + *, + reverse: bool, +) -> dict[str, str]: + states: dict[str, str] = {} + for mapping in replacements: + source_id = mapping["new_id"] if reverse else mapping["old_id"] + target_id = mapping["old_id"] if reverse else mapping["new_id"] + if mapping["kind"] == "workplan": + query = text( + "SELECT workplans.id, workplans.repo_id, workplans.slug " + "FROM workplans WHERE workplans.id = :record_id FOR UPDATE" + ) + else: + query = text( + "SELECT tasks.id, workplans.repo_id, tasks.record_id " + "FROM tasks " + "JOIN workplans ON workplans.id = tasks.workplan_id " + "WHERE tasks.id = :record_id FOR UPDATE" + ) + source = (await session.execute(query, {"record_id": source_id})).one_or_none() + target = (await session.execute(query, {"record_id": target_id})).one_or_none() + for label, row in (("source", source), ("target", target)): + if row is not None and row.repo_id != repository_id: + raise IdentifierMigrationError( + f"{label} {mapping['kind']} {row.id} belongs to another repository" + ) + + if reverse: + if source is None: + raise IdentifierMigrationError( + f"source {mapping['kind']} {source_id} is absent from repository {repo_slug}" + ) + if target is not None: + raise IdentifierMigrationError( + f"target {mapping['kind']} {target_id} already exists" + ) + states[mapping["record_id"]] = "derived_source" + continue + + if source is not None and target is not None: + if mapping["kind"] != "task": + raise IdentifierMigrationError( + f"both legacy and derived {mapping['kind']} rows exist for {mapping['record_id']}" + ) + legacy_task = await session.scalar( + select(Task).where(Task.id == source_id).with_for_update() + ) + derived_task = await session.scalar( + select(Task).where(Task.id == target_id).with_for_update() + ) + if ( + legacy_task is None + or derived_task is None + or not _same_duplicate_task( + legacy_task, derived_task, mapping["record_id"] + ) + ): + raise IdentifierMigrationError( + f"legacy and derived task rows differ for {mapping['record_id']}" + ) + references = await _task_reference_count(session, source_id) + if references: + raise IdentifierMigrationError( + f"legacy duplicate task has {references} inbound references for " + f"{mapping['record_id']}" + ) + states[mapping["record_id"]] = "duplicate_target" + continue + if source is None and target is None: + raise IdentifierMigrationError( + f"neither legacy nor derived {mapping['kind']} row exists for {mapping['record_id']}" + ) + if source is not None: + states[mapping["record_id"]] = "legacy_source" + continue + + # A pre-existing derived target is safe only when it already represents + # the canonical record from this repository. This is the state produced + # when forge reconciliation reached a partial projection before the + # sealed file migration did. + identity = target.slug if mapping["kind"] == "workplan" else target.record_id + if not isinstance(identity, str) or identity.strip().lower() != mapping["record_id"].lower(): + raise IdentifierMigrationError( + f"derived target identity mismatch for {mapping['record_id']}" + ) + states[mapping["record_id"]] = "derived_target" + return states + + +async def apply_repository_identifier_migration( + session: AsyncSession, + plan: dict[str, Any], + repo_slug: str, +) -> IdentifierMigrationResult: + """Replace one repository's projected UUIDs in one database transaction.""" + plan_sha256 = verify_plan(plan) + replacements, assignments = _repository_mappings(plan, repo_slug) + if session.in_transaction(): + raise IdentifierMigrationError("migration requires a fresh database session") + + async with session.begin(): + resolution = await resolve_repository_slug(session, repo_slug, required=False) + if resolution is None: + raise IdentifierMigrationError(f"repository projection is absent: {repo_slug}") + projection_states = await _assert_projection_preconditions( + session, resolution.repo.id, repo_slug, replacements, reverse=False + ) + aliases = { + alias.old_id: alias + for alias in ( + await session.execute( + select(WorkRecordIdentifierAlias).where( + WorkRecordIdentifierAlias.old_id.in_( + [mapping["old_id"] for mapping in replacements] + ) + ) + ) + ).scalars() + } + for mapping in replacements: + alias = aliases.get(mapping["old_id"]) + if alias is None: + alias = WorkRecordIdentifierAlias( + old_id=mapping["old_id"], + new_id=mapping["new_id"], + record_kind=mapping["kind"], + record_id=mapping["record_id"], + repo_slug=repo_slug, + namespace=FLEET_NAMESPACE, + plan_sha256=plan_sha256, + ) + session.add(alias) + aliases[mapping["old_id"]] = alias + elif ( + alias.new_id != mapping["new_id"] + or alias.record_kind != mapping["kind"] + or alias.record_id != mapping["record_id"] + or alias.repo_slug != repo_slug + or alias.namespace != FLEET_NAMESPACE + or alias.plan_sha256 != plan_sha256 + or alias.migration_status not in {"reversed", "applied"} + ): + raise IdentifierMigrationError( + f"conflicting durable alias for {mapping['old_id']}" + ) + await session.flush() + + for mapping in ( + item + for item in replacements + if projection_states[item["record_id"]] == "duplicate_target" + ): + result = await session.execute( + text("DELETE FROM tasks WHERE id = :old_id"), + {"old_id": mapping["old_id"]}, + ) + if result.rowcount != 1: + raise IdentifierMigrationError( + f"failed to coalesce duplicate {mapping['record_id']}" + ) + + for kind in ("workplan", "task"): + table = "workplans" if kind == "workplan" else "tasks" + for mapping in ( + item + for item in replacements + if item["kind"] == kind + and projection_states[item["record_id"]] == "legacy_source" + ): + result = await session.execute( + text(f"UPDATE {table} SET id = :new_id WHERE id = :old_id"), + {"old_id": mapping["old_id"], "new_id": mapping["new_id"]}, + ) + if result.rowcount != 1: + raise IdentifierMigrationError( + f"failed to replace {mapping['record_id']}" + ) + + now = datetime.now(UTC) + for alias in aliases.values(): + alias.migration_status = "applied" + alias.applied_at = now + alias.reversed_at = None + + return IdentifierMigrationResult( + repo_slug=repo_slug, + plan_sha256=plan_sha256, + direction="forward", + replacements=len(replacements), + migrated=sum( + state in {"legacy_source", "duplicate_target"} + for state in projection_states.values() + ), + already_derived=sum( + state == "derived_target" for state in projection_states.values() + ), + assignments_deferred=assignments, + ) + + +async def reverse_repository_identifier_migration( + session: AsyncSession, + plan: dict[str, Any], + repo_slug: str, +) -> IdentifierMigrationResult: + """Restore one repository's prior projected UUIDs and retain aliases.""" + plan_sha256 = verify_plan(plan) + replacements, assignments = _repository_mappings(plan, repo_slug) + if session.in_transaction(): + raise IdentifierMigrationError("migration requires a fresh database session") + + async with session.begin(): + resolution = await resolve_repository_slug(session, repo_slug, required=False) + if resolution is None: + raise IdentifierMigrationError(f"repository projection is absent: {repo_slug}") + aliases = list( + ( + await session.execute( + select(WorkRecordIdentifierAlias).where( + WorkRecordIdentifierAlias.plan_sha256 == plan_sha256, + WorkRecordIdentifierAlias.repo_slug == repo_slug, + ) + ) + ).scalars() + ) + aliases_by_old = {alias.old_id: alias for alias in aliases} + if len(aliases_by_old) != len(replacements): + raise IdentifierMigrationError("durable alias set is incomplete") + for mapping in replacements: + alias = aliases_by_old.get(mapping["old_id"]) + if ( + alias is None + or alias.new_id != mapping["new_id"] + or alias.record_kind != mapping["kind"] + or alias.record_id != mapping["record_id"] + or alias.migration_status != "applied" + ): + raise IdentifierMigrationError( + f"durable alias is not applied for {mapping['record_id']}" + ) + await _assert_projection_preconditions( + session, resolution.repo.id, repo_slug, replacements, reverse=True + ) + + for kind in ("task", "workplan"): + table = "tasks" if kind == "task" else "workplans" + for mapping in (item for item in replacements if item["kind"] == kind): + result = await session.execute( + text(f"UPDATE {table} SET id = :old_id WHERE id = :new_id"), + {"old_id": mapping["old_id"], "new_id": mapping["new_id"]}, + ) + if result.rowcount != 1: + raise IdentifierMigrationError( + f"failed to reverse {mapping['record_id']}" + ) + + now = datetime.now(UTC) + for alias in aliases: + alias.migration_status = "reversed" + alias.reversed_at = now + + return IdentifierMigrationResult( + repo_slug=repo_slug, + plan_sha256=plan_sha256, + direction="reverse", + replacements=len(replacements), + migrated=len(replacements), + already_derived=0, + assignments_deferred=assignments, + ) diff --git a/api/services/write_idempotency.py b/api/services/write_idempotency.py index fb0c646..7d8c543 100644 --- a/api/services/write_idempotency.py +++ b/api/services/write_idempotency.py @@ -41,15 +41,16 @@ WRITE_ROUTE_RULES: tuple[WriteRouteRule, ...] = ( WriteRouteRule("POST", r"/decisions", "append", "record decision"), WriteRouteRule("PATCH", r"/tasks/[^/]+", "replace", "update task"), WriteRouteRule("POST", r"/tasks/bulk-status-sync", "replace", "bulk task status sync"), + WriteRouteRule( + "POST", + r"/repos/[^/]+/work-record-projection/reconcile", + "replace", + "reconcile one forge-derived repository projection", + ), WriteRouteRule("PATCH", r"/decisions/[^/]+", "replace", "update decision"), WriteRouteRule("POST", r"/decisions/[^/]+/resolve", "replace", "resolve decision"), WriteRouteRule("PATCH", r"/workplans/[^/]+", "replace", "update workplan"), WriteRouteRule("PATCH", r"/workstreams/[^/]+", "replace", "update legacy workstream alias"), - WriteRouteRule("POST", r"/suggestions", "append", "create suggestion"), - WriteRouteRule("POST", r"/suggestions/[^/]+/vet", "replace", "vet suggestion"), - WriteRouteRule("POST", r"/suggestions/[^/]+/decline", "replace", "decline suggestion"), - WriteRouteRule("POST", r"/suggestions/[^/]+/promote", "replace", "promote suggestion to task"), - WriteRouteRule("POST", r"/suggestions/[^/]+/bump-relevance", "append", "bump suggestion relevance"), ) diff --git a/api/work_record_flavor.py b/api/work_record_flavor.py new file mode 100644 index 0000000..5fc9fe3 --- /dev/null +++ b/api/work_record_flavor.py @@ -0,0 +1,48 @@ +"""Work-record flavor (STATE-WP-0092). + +Flavor is a closed bucket on workplans and tasks, orthogonal to kind and +status. Unset flavor is not residual. +""" + +from __future__ import annotations + +from typing import Any + +WORK_RECORD_FLAVORS: tuple[str, ...] = ( + "planning", + "implementation", + "refactoring", + "extension", + "residual", +) +RESIDUAL_FLAVOR = "residual" +FLAVOR_PROMOTION_REASONS: tuple[str, ...] = ("demand", "risk") + +_EMPTY = {"", "~", "null", "none", "nil"} + + +def normalize_flavor(value: Any) -> str | None: + if value is None: + return None + text = str(value).strip().lower() + if text in _EMPTY: + return None + return text + + +def is_known_flavor(value: Any) -> bool: + flavor = normalize_flavor(value) + return flavor is None or flavor in WORK_RECORD_FLAVORS + + +def is_residual_flavor(value: Any) -> bool: + return normalize_flavor(value) == RESIDUAL_FLAVOR + + +def normalize_promotion_reason(value: Any) -> str | None: + if value is None: + return None + text = str(value).strip().lower() + if text in _EMPTY: + return None + return text diff --git a/custodian_cli.py b/custodian_cli.py index f0638b9..f777e44 100644 --- a/custodian_cli.py +++ b/custodian_cli.py @@ -18,9 +18,11 @@ import re import subprocess import sys import urllib.error +import urllib.parse import urllib.request from pathlib import Path +from repository_rename_cli import configure_repo_commands from statehub_register import run_register as run_statehub_register STATE_HUB_DIR = Path(__file__).resolve().parent @@ -402,6 +404,8 @@ def cmd_fix_consistency(args: argparse.Namespace) -> None: cmd.append("--remote") if args.no_writeback: cmd.append("--no-writeback") + if getattr(args, "bootstrap_empty_projection", False): + cmd.append("--bootstrap-empty-projection") if args.archive_closed: cmd.append("--archive-closed") if args.archive_workplan: @@ -422,6 +426,53 @@ def cmd_fix_consistency(args: argparse.Namespace) -> None: sys.exit(exit_code) +def cmd_quality_debt(args: argparse.Namespace) -> None: + """List DoX quality debt (STATE-WP-0077) — ready without DoR-Ok, etc.""" + script = STATE_HUB_DIR / "scripts" / "quality_debt.py" + if not script.exists(): + print(f"ERROR: quality_debt.py not found at {script}") + sys.exit(1) + cmd = [sys.executable, str(script)] + if args.repo_path: + cmd.extend(["--repo-path", str(Path(args.repo_path).expanduser().resolve())]) + else: + cmd.append("--here") + if args.api_base: + cmd.extend(["--api-base", args.api_base]) + if args.no_hub: + cmd.append("--no-hub") + if args.as_json: + cmd.append("--json") + if args.strict: + cmd.append("--strict") + result = subprocess.run(cmd) + sys.exit(result.returncode) + + +def cmd_promote_intake(args: argparse.Namespace) -> None: + """Promote a routed intake into a workplan, task, decision, or engagement.""" + script = STATE_HUB_DIR / "scripts" / "promote_intake.py" + if not script.exists(): + print(f"ERROR: promote_intake.py not found at {script}") + sys.exit(1) + + cmd = [ + sys.executable, str(script), args.intake_id, + "--to", args.to, + "--repo-path", str(Path(args.repo_path).expanduser().resolve()), + "--repo-slug", args.repo_slug, + "--domain", args.domain, + "--api-base", args.api_base, + ] + if args.target_file: + cmd.extend(["--target-file", args.target_file]) + if args.workplan_file: + cmd.extend(["--workplan-file", args.workplan_file]) + + result = subprocess.run(cmd) + sys.exit(result.returncode) + + def cmd_create_workstream(args: argparse.Namespace) -> None: """Create a workstream under a domain's topic.""" _api_get("/state/health") @@ -508,12 +559,22 @@ def cmd_status(_args: argparse.Namespace) -> None: """Quick status: API health + summary totals.""" health = _api_get("/state/health") print(f"API: {health.get('status', '?')} DB: {health.get('db', '?')}") + # Which instance answered, not merely that one did. + role = health.get("instance_role") or "undeclared" + label = health.get("instance_label") + print(f"Instance: {role}{f' ({label})' if label else ''}") summary = _api_get("/state/summary") t = summary["totals"] - print(f"Topics: {t['topics']['active']} active") - print(f"Workstreams: {t['workstreams']['active']} active, {t['workstreams']['blocked']} blocked") - print(f"Tasks: {t['tasks']['in_progress']} in-progress, {t['tasks']['todo']} todo, {t['tasks']['blocked']} blocked") - print(f"Decisions: {t['decisions']['open']} open, {t['decisions']['escalated']} escalated") + topics = t.get("topics", {}) + workplans = t.get("workplans") or t.get("workstreams", {}) + tasks = t.get("tasks", {}) + decisions = t.get("decisions", {}) + print(f"API base: {API_BASE}") + print(f"Topics: {topics.get('active', 0)} active") + print(f"Workplans: {workplans.get('active', 0)} active, {workplans.get('blocked', 0)} blocked") + # Task statuses are wait|todo|progress|done|cancel (see workplan-convention.md). + print(f"Tasks: {tasks.get('progress', 0)} in-progress, {tasks.get('todo', 0)} todo, {tasks.get('wait', 0)} waiting") + print(f"Decisions: {decisions.get('open', 0)} open, {decisions.get('escalated', 0)} escalated") blocking = summary.get("blocking_decisions", []) if blocking: print(f"\nBlocking decisions ({len(blocking)}):") @@ -522,6 +583,73 @@ def cmd_status(_args: argparse.Namespace) -> None: print(f" [{deadline}] {d['title']}") +def _load_json_file(path: str) -> dict: + target = Path(path) + try: + value = json.loads(target.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + print(f"ERROR: invalid JSON file {target}: {exc}") + sys.exit(2) + if not isinstance(value, dict): + print(f"ERROR: {target} must contain a JSON object") + sys.exit(2) + return value + + +def _git_source(path: str, repo_slug: str | None = None) -> tuple[Path, dict]: + target = Path(path).resolve() + try: + root = Path(subprocess.check_output( + ["git", "-C", str(target.parent), "rev-parse", "--show-toplevel"], + text=True, + ).strip()) + revision = subprocess.check_output( + ["git", "-C", str(root), "rev-parse", "HEAD"], text=True + ).strip() + relative = target.relative_to(root).as_posix() + except (subprocess.CalledProcessError, ValueError) as exc: + print(f"ERROR: {target} must be inside a Git repository: {exc}") + sys.exit(2) + return target, {"repo": repo_slug or root.name, "path": relative, "revision": revision} + + +def cmd_review_project(args: argparse.Namespace) -> None: + """Project a file-authoritative contract wrapper through the direct API.""" + target, source = _git_source(args.file, args.source_repo) + contract = _load_json_file(str(target)) + payload = contract if "contract" in contract and "source" in contract else { + "contract": contract, + "source": source, + "decision_id": args.decision_id, + "workplan_id": args.workplan_id, + "task_id": args.task_id, + "required_for_decision": args.required_for_decision, + } + print(json.dumps( + _api_post("/review-contracts/projections", payload), + indent=2, + )) + + +def cmd_review_submit(args: argparse.Namespace) -> None: + """Submit a file-authoritative receipt against an active contract.""" + key = urllib.parse.quote(args.contract_key, safe="") + target, source = _git_source(args.file, args.source_repo) + receipt = _load_json_file(str(target)) + if "source" not in receipt: + receipt["source"] = source + print(json.dumps( + _api_post(f"/review-contracts/{key}/receipts", receipt), + indent=2, + )) + + +def cmd_review_status(args: argparse.Namespace) -> None: + """Print the derived owner and gate matrix; never an execution authorization.""" + key = urllib.parse.quote(args.contract_key, safe="") + print(json.dumps(_api_get(f"/review-contracts/{key}/aggregate"), indent=2)) + + def _outbox_store(args): from api.edge.outbox import OutboxStore, default_outbox_path @@ -601,6 +729,8 @@ def main() -> None: ) sub = parser.add_subparsers(dest="command", required=True) + configure_repo_commands(sub) + # register statehub_reg = sub.add_parser( "register", @@ -608,6 +738,11 @@ def main() -> None: ) statehub_reg.add_argument("--path", default=os.getcwd(), help="Repo directory (defaults to cwd)") statehub_reg.add_argument("--domain", default=None, help="State Hub domain slug") + statehub_reg.add_argument( + "--topic", + default=None, + help="Existing active topic slug within the selected domain", + ) statehub_reg.add_argument("--repo-slug", default=None, help="Repo slug (auto-detected if omitted)") statehub_reg.add_argument("--wp-prefix", default=None, help="Workplan prefix, e.g. STATE-WP") statehub_reg.add_argument("--description", default=None, help="One-sentence repo description") @@ -673,6 +808,11 @@ def main() -> None: fix.add_argument("--remote", action="store_true", help="Pull before fixing; requires --repo or --all") fix.add_argument("--max-seconds", type=int, default=None, help="Wall-clock budget for --remote --all") fix.add_argument("--no-writeback", action="store_true", help="Disable DB-to-file status writeback") + fix.add_argument( + "--bootstrap-empty-projection", + action="store_true", + help="Rebuild file UUIDs only after proving the repo projection is empty", + ) fix.add_argument("--archive-closed", action="store_true", help="Archive closed root workplans after fixing") fix.add_argument("--archive-workplan", default=None, help="Archive only the matching workplan id or filename") fix.add_argument("--archive-date", default=None, help="YYMMDD archive prefix for --archive-closed") @@ -684,6 +824,32 @@ def main() -> None: help="Preserve checker exit code 2 for warnings-only runs", ) + # quality-debt (STATE-WP-0077) + qdebt = sub.add_parser( + "quality-debt", + help="List DoX quality debt: ready without DoR-Ok, finished without DoD-Ok, intakes without DoC-Ok", + ) + qdebt.add_argument("--repo-path", default=None, help="Repo root (default: cwd)") + qdebt.add_argument("--api-base", default=API_BASE, help="State Hub API base URL") + qdebt.add_argument("--no-hub", action="store_true", help="Skip hub intake scan") + qdebt.add_argument("--json", action="store_true", dest="as_json") + qdebt.add_argument("--strict", action="store_true", help="Exit 1 if any debt found") + qdebt.set_defaults(func=cmd_quality_debt) + + # promote-intake + promote = sub.add_parser( + "promote-intake", + help="Promote a routed intake into a workplan, task, decision, or engagement (CUST-WP-0061-T03)", + ) + promote.add_argument("intake_id", help="UUID of the routed intake") + promote.add_argument("--to", required=True, choices=["workplan", "task", "decision", "engagement"]) + promote.add_argument("--repo-path", default=os.getcwd(), help="Local checkout of the target repo") + promote.add_argument("--repo-slug", required=True, help="Registered repo slug") + promote.add_argument("--domain", required=True, help="Market domain slug, e.g. infotech") + promote.add_argument("--target-file", default=None, help="Repo-relative path (required for decision/engagement)") + promote.add_argument("--workplan-file", default=None, help="Repo-relative workplan file path (required for task)") + promote.add_argument("--api-base", default=API_BASE, help="State Hub API base URL") + # create-workstream cws = sub.add_parser("create-workstream", help="Create a workstream under a domain topic") cws.add_argument("--domain", required=True, help="Domain slug to create the workstream under") @@ -736,6 +902,26 @@ def main() -> None: # status sub.add_parser("status", help="Show State Hub health and summary totals") + # review — file-backed multi-owner review projection + review = sub.add_parser("review", help="Project and inspect multi-owner review evidence") + review_sub = review.add_subparsers(dest="review_command", required=True) + review_project = review_sub.add_parser("project", help="Project an authoritative contract wrapper") + review_project.add_argument("file") + review_project.add_argument("--source-repo", default=None) + review_project.add_argument("--decision-id", default=None) + review_project.add_argument("--workplan-id", default=None) + review_project.add_argument("--task-id", default=None) + review_project.add_argument("--required-for-decision", action="store_true") + review_project.set_defaults(func=cmd_review_project) + review_submit = review_sub.add_parser("submit", help="Submit an authoritative receipt file") + review_submit.add_argument("contract_key") + review_submit.add_argument("file") + review_submit.add_argument("--source-repo", default=None) + review_submit.set_defaults(func=cmd_review_submit) + review_status = review_sub.add_parser("status", help="Print owner and gate status") + review_status.add_argument("contract_key") + review_status.set_defaults(func=cmd_review_status) + # dev up — files-first local hub (CUST-WP-0054-T07) dev = sub.add_parser("dev", help="Local dev-hub commands") dev_sub = dev.add_subparsers(dest="dev_command", required=True) @@ -767,6 +953,8 @@ def main() -> None: cmd_ingest_sbom(args) elif args.command == "fix-consistency": cmd_fix_consistency(args) + elif args.command == "promote-intake": + cmd_promote_intake(args) elif args.command == "create-workstream": cmd_create_workstream(args) elif args.command == "create-task": diff --git a/dashboard/observablehq.config.js b/dashboard/observablehq.config.js index e30abbe..b749881 100644 --- a/dashboard/observablehq.config.js +++ b/dashboard/observablehq.config.js @@ -10,7 +10,9 @@ const _configDir = dirname(fileURLToPath(import.meta.url)); const _modalScript = readFileSync( join(_configDir, "src/components/improvement-modal.js"), "utf-8" ) - .replace(/^export function /gm, "function ") // strip ES module export + // Strip ES module exports so the file can run as a plain