diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index b9e8cf9..ae7d0c5 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -15,7 +15,7 @@ | workplan | RMGR-WP-0005 | active | — | workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md | | workplan | RMGR-WP-0006 | finished | — | workplans/RMGR-WP-0006-railiance-app-wrapper-setup.md | | workplan | RMGR-WP-0007 | finished | — | workplans/RMGR-WP-0007-greenfield-rapp-wrap-efficiency.md | -| workplan | RMGR-WP-0008 | active | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md | +| workplan | RMGR-WP-0008 | finished | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md | | workplan | RMGR-WP-0009 | finished | — | workplans/RMGR-WP-0009-coding-assistant-commit-provenance.md | | task | RMGR-WP-0001-T01 | done | — | workplans/RMGR-WP-0001-foundation.md | | task | RMGR-WP-0001-T02 | done | — | workplans/RMGR-WP-0001-foundation.md | @@ -49,8 +49,8 @@ | task | RMGR-WP-0005-T06 | done | — | workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md | | task | RMGR-WP-0005-T07 | wait | — | workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md | | task | RMGR-WP-0005-T08 | done | — | workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md | -| task | RMGR-WP-0005-T09 | progress | — | workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md | -| task | RMGR-WP-0005-T10 | wait | — | workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md | +| task | RMGR-WP-0005-T09 | done | — | workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md | +| task | RMGR-WP-0005-T10 | done | — | workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md | | task | RMGR-WP-0005-T11 | done | — | workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md | | task | RMGR-WP-0005-T12 | wait | — | workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md | | task | RMGR-WP-0006-T01 | done | — | workplans/RMGR-WP-0006-railiance-app-wrapper-setup.md | @@ -69,9 +69,9 @@ | task | RMGR-WP-0008-T02 | done | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md | | task | RMGR-WP-0008-T03 | done | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md | | task | RMGR-WP-0008-T04 | done | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md | -| task | RMGR-WP-0008-T05 | progress | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md | -| task | RMGR-WP-0008-T06 | progress | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md | -| task | RMGR-WP-0008-T07 | wait | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md | +| task | RMGR-WP-0008-T05 | done | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md | +| task | RMGR-WP-0008-T06 | done | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md | +| task | RMGR-WP-0008-T07 | done | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md | | task | RMGR-WP-0009-T01 | done | — | workplans/RMGR-WP-0009-coding-assistant-commit-provenance.md | | task | RMGR-WP-0009-T02 | done | — | workplans/RMGR-WP-0009-coding-assistant-commit-provenance.md | | task | RMGR-WP-0009-T03 | done | — | workplans/RMGR-WP-0009-coding-assistant-commit-provenance.md | diff --git a/config/hub-record-authority.yaml b/config/hub-record-authority.yaml index 7d5f80a..cff1b8f 100644 --- a/config/hub-record-authority.yaml +++ b/config/hub-record-authority.yaml @@ -2,6 +2,13 @@ schema: repo-manager.hub-record-authority.v1 updated: "2026-08-21" workplan_task: RMGR-WP-0005-T08 +routing: + file-derived-key: "repository:/" + hub-native-key: "hub:" + retired-key: "archive:" + unknown-record-policy: reject + claimed-owner-mismatch-policy: reject + classes: file-derived: authority: repository diff --git a/docs/adr-002-canonical-utc-time.md b/docs/adr-002-canonical-utc-time.md new file mode 100644 index 0000000..667c13e --- /dev/null +++ b/docs/adr-002-canonical-utc-time.md @@ -0,0 +1,65 @@ +--- +id: RMGR-ADR-002 +type: architecture-decision-record +title: "Canonical UTC timestamps; local time only at I/O boundaries" +status: accepted +decided: "2026-08-21" +deciders: ["Bernd Worsch"] +related: + - RMGR-WP-0008 + - RMGR-WP-0005 + - docs/observation-command-contracts_v0.1.md +--- + +# ADR-002: Canonical UTC timestamps; local time only at I/O boundaries + +## Status + +**Accepted** (2026-08-21). + +## Context + +Repo Manager emits timestamps into repository records, indexes, mutation +meters, events, evidence, and compatibility contracts. Host-local clocks and +implicit calendar conversion make identical events serialize differently, +create daylight-saving ambiguity, and make deterministic reconciliation harder. + +Users and external systems still need local calendars, wall-clock times, and +locale-specific formatting. Those are presentation and ingestion concerns, not +stored identity. + +## Decision + +1. Every value representing an **instant** is created, stored, compared, and + exchanged in UTC. +2. New textual timestamps use RFC 3339/ISO 8601 with the canonical `Z` suffix. + Readers may accept legacy explicit offsets and normalize them to UTC before + comparison or re-emission. Naive datetimes are rejected at boundaries. +3. Database timestamp columns use timezone-aware types and UTC values. Epoch + values, where a foreign contract requires them, denote UTC instants. +4. Conversion to or from a local timezone, locale, calendar, or wall-clock + representation occurs only in UI and I/O adapters. The converted value never + replaces the canonical instant. +5. Inputs that omit a timezone are invalid unless the specific I/O contract + also supplies an explicit timezone. DST gaps and folds must be resolved at + that boundary before producing UTC. +6. A genuine **date-only domain value**—for example a contractual due date—is + not a timestamp and keeps its declared calendar semantics. A date derived + from the current instant for repository metadata uses the UTC calendar date. +7. Tests, fixtures, logs, filenames, events, and idempotency material follow the + same rule; test convenience is not an exception. + +## Enforcement + +- Repo Manager timestamp creation goes through `repo_manager.time`. +- Ruff `DTZ` and `UP` rules remain explicitly selected so naive datetime use + and non-canonical UTC APIs fail lint even if Ruff defaults change. +- Contract tests require UTC-aware values and canonical `Z` serialization. + +## Consequences + +Stored and exchanged instants are byte-stable across hosts. Ordering and +reconciliation no longer depend on operator locale or daylight-saving rules. +Presentation clients must retain the canonical value and treat local rendering +as a reversible view. Legacy offset-bearing timestamps remain readable but are +normalized when rewritten. diff --git a/docs/adr-003-projection-ui-ownership.md b/docs/adr-003-projection-ui-ownership.md new file mode 100644 index 0000000..2e34638 --- /dev/null +++ b/docs/adr-003-projection-ui-ownership.md @@ -0,0 +1,52 @@ +--- +id: RMGR-ADR-003 +type: architecture-decision-record +title: "Projection UI ownership stays outside Repo Manager" +status: accepted +decided: "2026-08-21" +deciders: ["Repo Manager and hub-core architecture"] +related: + - RMGR-WP-0008 + - HUB-WP-0004 + - SHR-ARCH-HUB-0001 +--- + +# ADR-003: Projection UI ownership stays outside Repo Manager + +## Status + +**Accepted** (2026-08-21). + +## Context + +The State Hub retirement inventory initially assigned slice A4's work and +repository pages to Repo Manager because Repo Manager owns the underlying +file-backed records. That assignment conflated authority with presentation. +Repo Manager is a functional component with governed CLI and adapter surfaces; +it is not a cross-repository hub or dashboard runtime. + +The accepted `HUB-WP-0004` architecture keeps repository work authoritative in +Git through Repo Manager and assigns projection/query and hub UI capabilities +to hub-core and its operator clients. Building another dashboard here would +duplicate those projections and blur the one-authority boundary. + +## Decision + +1. Repo Manager owns repository observation, governed file-backed mutation, + reconciliation, indexes, and stable machine-facing contracts. It does not + own a dashboard or general operator UI. +2. hub-core owns cross-repository projection/query contracts and the surviving + hub UI runtime. Ops or domain clients may render narrower views through + those contracts. +3. User interfaces consume Repo Manager and hub-core ports. They do not write + repository authority or projection storage directly. +4. State Hub retirement slice A4 is therefore a hub-core/projection-client + replacement, not a UI migration into Repo Manager. Repo Manager's A1–A3 and + A5 contracts remain inputs to those projections. + +## Consequences + +RMGR-WP-0008 does not need a dashboard deliverable. A4 route redirects and UI +replacement remain in the retirement program's hub-core cutover work. Any +future repository-local UI must be proposed separately and remain a client of +the same governed contracts. diff --git a/docs/evidence/RMGR-WP-0005-orphan-disposition-2026-08-21.md b/docs/evidence/RMGR-WP-0005-orphan-disposition-2026-08-21.md index 4cc51ca..e68c0bf 100644 --- a/docs/evidence/RMGR-WP-0005-orphan-disposition-2026-08-21.md +++ b/docs/evidence/RMGR-WP-0005-orphan-disposition-2026-08-21.md @@ -35,8 +35,18 @@ confirmed live hub-first workplans in this snapshot. The 217 closed rows remain cheap historical provenance. They need not be reconstructed as files, but the cache-rebuild procedure must export or retain -them before replacing the local database. Repairing the seven live bindings is -the remaining prerequisite for T07. +them before replacing the local database. + +## Repair result + +All seven bindings were submitted together to the preferred +`PUT /workplans/index/bindings` route and the hub acknowledged +`{"updated":7,"received":7}`. A fresh query now finds exactly 217 unbound rows: +201 `finished`, 16 `archived`, and **zero live**. No repository file or +lifecycle status was changed. The broad `fix-consistency --all` path was not +used for the repair because it aborts while refreshing the brief for stale +registered path `/home/worsch/inter-hub`; the targeted binding route avoided +coupling this data repair to that unrelated registry defect. ## Reproduction diff --git a/docs/evidence/RMGR-WP-0008-sbom-cutover-2026-08-21.md b/docs/evidence/RMGR-WP-0008-sbom-cutover-2026-08-21.md new file mode 100644 index 0000000..c9ce933 --- /dev/null +++ b/docs/evidence/RMGR-WP-0008-sbom-cutover-2026-08-21.md @@ -0,0 +1,40 @@ +# RMGR-WP-0008 SBOM receiving and cutover evidence — 2026-08-21 + +Repo Manager now derives an SBOM snapshot from repository-owned lockfiles and +the reviewed `sbom-tools.yaml` escape hatch. The snapshot includes source path +and SHA-256 provenance, Git revision, canonical UTC generation time, ecosystem, +direct/development flags, licence grouping, and direct production copyleft +findings. + +## Live projection reconciliation + +The workstation State Hub held 22 historical snapshots for 18 repositories. +For migration planning, only the newest snapshot per repository was compared +with a fresh Repo Manager scan of its registered local path: + +- 18 repositories scanned; 0 parse failures; +- 9 entry counts matched exactly; +- newest State Hub projections totalled 2,659 entries; +- current authoritative lockfiles produce 1,565 entries. + +The differing counts are expected evidence of source drift, not rows to copy: +the stored snapshots date from March through July 2026, while the rescan reads +the current checked-out lockfiles. Copying the 2,659 cached rows into repository +records would reverse the authority boundary. Cutover therefore rebuilds each +repository snapshot from its current lockfiles and retains the old State Hub +snapshot ids/timestamps as historical provenance until the retirement retention +gate permits deletion. + +## Compatibility contract + +State Hub's retirement adapter calls `rmgr sbom scan --path --slug +` and receives `repo-manager.sbom-snapshot.v1` JSON. Route-level tests +pin that invocation and response shape. The adapter is read/derive-only: it +does not commit, push, or mutate a register file. + +## Verification + +- `uv run ruff check src tests` +- focused SBOM and compatibility-adapter tests +- live `repo-manager` scan: 39 entries, no parse errors, canonical `Z` + timestamp, Git revision present, and no direct production copyleft finding diff --git a/docs/hub-record-authority_v1.md b/docs/hub-record-authority_v1.md index d5237d9..82a9df8 100644 --- a/docs/hub-record-authority_v1.md +++ b/docs/hub-record-authority_v1.md @@ -53,3 +53,12 @@ the database a source of repository truth. This narrows the offline-buffer part of `STATE-WP-0068`; it does not authorize changes in repositories owned by hub-core, activity-core, ops-hub, or fin-hub. + +## Enforced routing + +`rmgr authority route` is the executable gate for this contract. File-derived +records require both repository and domain context and resolve to +`repository:/` under Repo Manager. Central and derived record +types resolve to their one declared hub owner. Unknown types and writes whose +claimed owner disagrees with the contract are rejected instead of falling back +to whichever hub received the request. diff --git a/docs/repository-registers_v0.1.md b/docs/repository-registers_v0.1.md index a945ba9..77e0011 100644 --- a/docs/repository-registers_v0.1.md +++ b/docs/repository-registers_v0.1.md @@ -54,3 +54,22 @@ rmgr register defer --kind technical-debt --entry-id TD-001 Mutations use the same expected-HEAD, idempotency, Git commit, optional push-seal, normalized-event, reindex, and dual-run-meter mechanism as workplan and task mutations. + +## Derived SBOM inventory + +SBOM is the exception to ordinary register mutation: recognised lockfiles and a +reviewed root `sbom-tools.yaml` remain authoritative. Repo Manager derives a +replaceable snapshot and licence/copyleft report without copying State Hub rows +back into a new source-of-truth file. + +```bash +rmgr sbom scan --path . --output sbom-snapshot.json +rmgr sbom licence-report --path . +``` + +Detection covers `uv.lock`, `requirements.txt`, `package-lock.json`, +`yarn.lock`, `Cargo.lock`, `go.sum`, `.terraform.lock.hcl`, Ansible requirements +under `ansible/`, and root `sbom-tools.yaml`. Each source carries a SHA-256 and +the snapshot carries the Git revision and canonical UTC generation timestamp. +An output file is optional evidence, never authority, and is not overwritten +without `--force`. diff --git a/pyproject.toml b/pyproject.toml index ea48a2f..d53b379 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,3 +43,6 @@ asyncio_mode = "auto" [tool.ruff] line-length = 100 src = ["src", "tests"] + +[tool.ruff.lint] +extend-select = ["DTZ", "UP"] diff --git a/src/repo_manager/authority.py b/src/repo_manager/authority.py new file mode 100644 index 0000000..f813e14 --- /dev/null +++ b/src/repo_manager/authority.py @@ -0,0 +1,74 @@ +"""Resolve exactly one authoritative owner for a State Hub record type.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import yaml + +AUTHORITY_SCHEMA = "repo-manager.hub-record-authority.v1" +VALID_CLASSES = frozenset( + {"file-derived", "hub-native-append", "hub-native-control", "derived-cache", "retired"} +) +DEFAULT_CONTRACT = Path(__file__).resolve().parents[2] / "config" / "hub-record-authority.yaml" + + +class AuthorityError(ValueError): + """The requested route is missing or conflicts with the authority contract.""" + + +def load_authority_contract(path: Path = DEFAULT_CONTRACT) -> dict[str, Any]: + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + if not isinstance(data, dict) or data.get("schema") != AUTHORITY_SCHEMA: + raise AuthorityError(f"unsupported authority contract: {path}") + records = data.get("records") + if not isinstance(records, dict) or not records: + raise AuthorityError("authority contract has no records") + for record_type, rule in records.items(): + if not isinstance(rule, dict): + raise AuthorityError(f"invalid rule for {record_type}") + if rule.get("class") not in VALID_CLASSES or not rule.get("owner"): + raise AuthorityError(f"incomplete authority rule for {record_type}") + return data + + +def resolve_record_authority( + record_type: str, + *, + repo_slug: str | None = None, + domain_slug: str | None = None, + claimed_owner: str | None = None, + contract_path: Path = DEFAULT_CONTRACT, +) -> dict[str, str]: + """Return the one authority route or reject an ambiguous/conflicting write.""" + contract = load_authority_contract(contract_path) + rule = contract["records"].get(record_type) + if not isinstance(rule, dict): + raise AuthorityError(f"unknown record type: {record_type}") + + record_class = str(rule["class"]) + owner = str(rule["owner"]) + if claimed_owner is not None and claimed_owner != owner: + raise AuthorityError( + f"authority mismatch for {record_type}: contract={owner}, claimed={claimed_owner}" + ) + + if record_class == "file-derived": + if not repo_slug or not domain_slug: + raise AuthorityError( + f"file-derived {record_type} requires repo_slug and domain_slug" + ) + authority_key = f"repository:{domain_slug}/{repo_slug}" + elif record_class == "retired": + authority_key = f"archive:{record_type}" + else: + authority_key = f"hub:{owner}" + + return { + "schema": AUTHORITY_SCHEMA, + "record_type": record_type, + "record_class": record_class, + "owner": owner, + "authority_key": authority_key, + } diff --git a/src/repo_manager/cli.py b/src/repo_manager/cli.py index 52b46ac..9afc052 100644 --- a/src/repo_manager/cli.py +++ b/src/repo_manager/cli.py @@ -295,6 +295,25 @@ def main(argv: list[str] | None = None) -> int: p_id_plan.add_argument("--output", default=None, help="Write the provenance mapping as JSON") p_id_plan.add_argument("--force", action="store_true", help="Replace an existing --output file") + p_sbom = sub.add_parser("sbom", help="Derive SBOM snapshots and licence reports from repository files") + sbom_sub = p_sbom.add_subparsers(dest="sbom_command") + p_sbom_scan = sbom_sub.add_parser("scan", help="Scan recognised lockfiles and tool manifests") + p_sbom_scan.add_argument("--path", default=".") + p_sbom_scan.add_argument("--slug", default=None) + p_sbom_scan.add_argument("--output", default=None, help="Write the derived snapshot as JSON") + p_sbom_scan.add_argument("--force", action="store_true", help="Replace an existing --output file") + p_sbom_report = sbom_sub.add_parser("licence-report", help="Report licences from a fresh file scan") + p_sbom_report.add_argument("--path", default=".") + p_sbom_report.add_argument("--slug", default=None) + + p_authority = sub.add_parser("authority", help="Resolve the one authoritative record owner") + authority_sub = p_authority.add_subparsers(dest="authority_command") + p_authority_route = authority_sub.add_parser("route", help="Resolve or verify an authority route") + p_authority_route.add_argument("--record-type", required=True) + p_authority_route.add_argument("--repo-slug", default=None) + p_authority_route.add_argument("--domain-slug", default=None) + p_authority_route.add_argument("--claimed-owner", default=None) + args = parser.parse_args(argv) if args.version or args.command in (None, "version"): @@ -618,6 +637,56 @@ def main(argv: list[str] | None = None) -> int: print(json.dumps(result, indent=2)) return 0 if result.get("ok") else 1 + if args.command == "sbom": + if not args.sbom_command: + p_sbom.print_help() + return 2 + from repo_manager.sbom import scan_repository + + snapshot = scan_repository(Path(args.path), slug=args.slug) + result = snapshot if args.sbom_command == "scan" else { + "ok": snapshot["ok"], + "repo_slug": snapshot["repo_slug"], + "source_revision": snapshot["source_revision"], + "generated_at": snapshot["generated_at"], + "entry_count": snapshot["entry_count"], + "licence_report": snapshot["licence_report"], + "errors": snapshot["errors"], + } + if args.sbom_command == "scan" and args.output: + output = Path(args.output) + if output.exists() and not args.force: + print( + json.dumps( + {"ok": False, "error": f"output exists: {output}; use --force to replace"}, + indent=2, + ) + ) + return 1 + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(snapshot, indent=2) + "\n", encoding="utf-8") + print(json.dumps(result, indent=2)) + return 0 if result.get("ok") else 1 + + if args.command == "authority": + if not args.authority_command: + p_authority.print_help() + return 2 + from repo_manager.authority import AuthorityError, resolve_record_authority + + try: + result = resolve_record_authority( + args.record_type, + repo_slug=args.repo_slug, + domain_slug=args.domain_slug, + claimed_owner=args.claimed_owner, + ) + except AuthorityError as exc: + print(json.dumps({"ok": False, "error": str(exc)}, indent=2)) + return 1 + print(json.dumps({"ok": True, **result}, indent=2)) + return 0 + parser.print_help() return 0 diff --git a/src/repo_manager/commands/rapp.py b/src/repo_manager/commands/rapp.py index 7ad9e9e..bb224ce 100644 --- a/src/repo_manager/commands/rapp.py +++ b/src/repo_manager/commands/rapp.py @@ -448,10 +448,20 @@ def place(path: Path, *, reef: str, family_root: Path | None = None) -> dict[str if reef not in COMPUTE_REEFS and not (search / reef).is_dir(): return _refuse(f"reef {reef!r} is not a known compute reef") text = declaration.read_text() - if re.search(r"^bound_reefs:\n - ", text, re.M): - text = re.sub(r"^bound_reefs:\n(?: - .+\n)+", f"bound_reefs:\n - {reef}\n", text, flags=re.M) + if re.search(r"^bound_reefs:\n - ", text, re.MULTILINE): + text = re.sub( + r"^bound_reefs:\n(?: - .+\n)+", + f"bound_reefs:\n - {reef}\n", + text, + flags=re.MULTILINE, + ) else: - text = re.sub(r"^bound_reefs:\s*\[\]\s*$", f"bound_reefs:\n - {reef}", text, flags=re.M) + text = re.sub( + r"^bound_reefs:\s*\[\]\s*$", + f"bound_reefs:\n - {reef}", + text, + flags=re.MULTILINE, + ) if "exposure:" in text and "posture: public" in text: return _refuse("place does not grant public exposure; edit exposure separately") declaration.write_text(text) diff --git a/src/repo_manager/commands/record.py b/src/repo_manager/commands/record.py index 001d788..7d4b1df 100644 --- a/src/repo_manager/commands/record.py +++ b/src/repo_manager/commands/record.py @@ -4,7 +4,6 @@ from __future__ import annotations import re import uuid -from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -16,6 +15,7 @@ from repo_manager.gitops import GitError, commit_paths, head_sha, push_ff from repo_manager.index_store import append_event, default_index_path, save_index from repo_manager.observe import observe_repository from repo_manager.parse.record import iter_record_files, parse_record_file +from repo_manager.time import utc_now_text _ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$") _PROTECTED = frozenset( @@ -39,7 +39,7 @@ _OPERATIONS = { def _now() -> str: - return datetime.now(UTC).isoformat() + return utc_now_text() def _reject(command: str, correlation_id: str, code: str, message: str, **evidence: Any) -> CommandResult: diff --git a/src/repo_manager/commands/register.py b/src/repo_manager/commands/register.py index 390eb87..b49b7f2 100644 --- a/src/repo_manager/commands/register.py +++ b/src/repo_manager/commands/register.py @@ -4,7 +4,6 @@ from __future__ import annotations import re import uuid -from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -16,6 +15,7 @@ from repo_manager.gitops import GitError, commit_paths, head_sha, push_ff from repo_manager.index_store import append_event, default_index_path, save_index from repo_manager.observe import observe_repository from repo_manager.parse.register import REGISTER_SCHEMA, SUPPORTED_REGISTER_KINDS, register_path +from repo_manager.time import utc_now_text _ENTRY_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$") _PROTECTED_FIELDS = frozenset({"id", "title", "status", "notes", "created", "updated"}) @@ -30,7 +30,7 @@ _REQUIRED_DATA = { def _now() -> str: - return datetime.now(UTC).isoformat() + return utc_now_text() def _reject(command: str, correlation_id: str, code: str, message: str, **evidence: Any) -> CommandResult: diff --git a/src/repo_manager/commands/scaffold.py b/src/repo_manager/commands/scaffold.py index 47e0712..218467e 100644 --- a/src/repo_manager/commands/scaffold.py +++ b/src/repo_manager/commands/scaffold.py @@ -4,12 +4,12 @@ from __future__ import annotations import uuid from dataclasses import dataclass -from datetime import date from pathlib import Path from typing import Any from repo_manager.gitops import GitError, commit_paths, is_git_repo from repo_manager.standards import FLAVOR_MARKER_PREFIX, expected_workplan_prefix +from repo_manager.time import utc_today DURABLE_FLAVORS = ("experimental", "research", "tooling", "product", "business") FLAVORS = (*DURABLE_FLAVORS, "project") @@ -124,7 +124,7 @@ def scaffold_repository( f"Workplan prefix: `{prefix}-`.\n", ) if prj: - today = date.today().isoformat() + today = utc_today().isoformat() put( "GOAL.md", "---\n" diff --git a/src/repo_manager/commands/workplan.py b/src/repo_manager/commands/workplan.py index e171e5e..61450f5 100644 --- a/src/repo_manager/commands/workplan.py +++ b/src/repo_manager/commands/workplan.py @@ -6,7 +6,7 @@ import json import re import uuid from dataclasses import dataclass -from datetime import UTC, date, datetime +from datetime import date from pathlib import Path from typing import Any @@ -14,6 +14,7 @@ from repo_manager import dual_run, idempotency from repo_manager.gitops import GitError, commit_paths, head_sha, push_ff from repo_manager.index_store import append_event, default_index_path, save_index from repo_manager.observe import observe_repository +from repo_manager.time import utc_today VALID_WORKPLAN_STATUSES = frozenset( {"proposed", "ready", "active", "blocked", "backlog", "finished", "archived"} @@ -80,7 +81,7 @@ def _quoted(value: str) -> str: def _today() -> date: - return datetime.now(UTC).date() + return utc_today() def _patch_frontmatter(text: str, updates: dict[str, str]) -> str | None: diff --git a/src/repo_manager/dual_run.py b/src/repo_manager/dual_run.py index c4c4311..d77bb08 100644 --- a/src/repo_manager/dual_run.py +++ b/src/repo_manager/dual_run.py @@ -16,13 +16,14 @@ from __future__ import annotations import json import os -from datetime import datetime, timezone from functools import lru_cache from pathlib import Path from typing import Any, Literal import yaml +from repo_manager.time import utc_now_text + Source = Literal["state-hub", "repo-manager"] _TRUE = frozenset({"1", "true", "yes", "on"}) @@ -157,7 +158,7 @@ def record_mutation( path = meter_path() path.parent.mkdir(parents=True, exist_ok=True) row = { - "ts": datetime.now(timezone.utc).isoformat(), + "ts": utc_now_text(), "source": source, "kind": kind, "repo_slug": repo_slug, diff --git a/src/repo_manager/index_store.py b/src/repo_manager/index_store.py index 75ef9bb..3770bc8 100644 --- a/src/repo_manager/index_store.py +++ b/src/repo_manager/index_store.py @@ -4,13 +4,14 @@ from __future__ import annotations import json from dataclasses import asdict, dataclass, field -from datetime import datetime, timezone from pathlib import Path from typing import Any +from repo_manager.time import utc_now_text + def _now() -> str: - return datetime.now(timezone.utc).isoformat() + return utc_now_text() @dataclass diff --git a/src/repo_manager/sbom.py b/src/repo_manager/sbom.py new file mode 100644 index 0000000..2519c8c --- /dev/null +++ b/src/repo_manager/sbom.py @@ -0,0 +1,326 @@ +"""Repository-derived SBOM scanning and licence reporting.""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import tomllib +from collections import Counter +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import yaml + +from repo_manager.gitops import head_sha +from repo_manager.time import utc_now_text + +SBOM_SCHEMA = "repo-manager.sbom-snapshot.v1" +COPYLEFT_MARKERS = frozenset({"GPL", "AGPL", "LGPL", "EUPL", "CDDL", "MPL"}) +VALID_ECOSYSTEMS = frozenset( + {"python", "node", "rust", "go", "java", "terraform", "ansible", "tool", "other"} +) +SKIP_DIRECTORIES = frozenset( + { + ".git", + ".hg", + ".svn", + ".venv", + "venv", + ".env", + "node_modules", + "__pycache__", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + "dist", + "build", + ".build", + "target", + ".tox", + ".nox", + } +) + +Entry = dict[str, Any] +Parser = Callable[[Path], list[Entry]] + + +def _entry( + name: str, + version: str | None, + ecosystem: str, + *, + license_spdx: str | None = None, + is_direct: bool = False, + is_dev: bool = False, +) -> Entry: + return { + "package_name": name, + "package_version": version, + "ecosystem": ecosystem, + "license_spdx": license_spdx, + "is_direct": is_direct, + "is_dev": is_dev, + } + + +def _parse_toml_packages(path: Path, ecosystem: str) -> list[Entry]: + data = tomllib.loads(path.read_text(encoding="utf-8")) + return [ + _entry(str(item["name"]), str(item["version"]) if item.get("version") else None, ecosystem) + for item in data.get("package", []) + if isinstance(item, dict) and item.get("name") + ] + + +def parse_uv_lock(path: Path) -> list[Entry]: + return _parse_toml_packages(path, "python") + + +def parse_cargo_lock(path: Path) -> list[Entry]: + return _parse_toml_packages(path, "rust") + + +def parse_requirements(path: Path) -> list[Entry]: + entries: list[Entry] = [] + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith(("#", "-")): + continue + match = re.match(r"^([A-Za-z0-9_.-]+)(?:[>= list[Entry]: + data = json.loads(path.read_text(encoding="utf-8")) + entries: list[Entry] = [] + for package_path, item in (data.get("packages") or {}).items(): + if not package_path or not isinstance(item, dict): + continue + name = item.get("name") or package_path.rsplit("node_modules/", 1)[-1] + entries.append( + _entry( + str(name), + str(item["version"]) if item.get("version") else None, + "node", + license_spdx=str(item["license"]) if item.get("license") else None, + is_direct=not bool(item.get("indirect", False)), + is_dev=bool(item.get("dev", False)), + ) + ) + return entries + + +def parse_yarn_lock(path: Path) -> list[Entry]: + entries: list[Entry] = [] + names: list[str] = [] + for raw in [*path.read_text(encoding="utf-8").splitlines(), ""]: + stripped = raw.strip() + if raw and not raw.startswith((" ", "\t")) and stripped.endswith(":"): + names = [] + for specifier in stripped.rstrip(":").split(","): + match = re.match(r'"?((?:@[^/" ]+/)?[^@" ]+)@', specifier.strip()) + if match: + names.append(match.group(1)) + elif stripped.startswith("version ") and names: + version_match = re.search(r'"([^"]+)"', stripped) + version = version_match.group(1) if version_match else None + entries.extend(_entry(name, version, "node") for name in names) + names = [] + return entries + + +def _go_direct_modules(directory: Path) -> set[str]: + path = directory / "go.mod" + if not path.is_file(): + return set() + direct: set[str] = set() + in_block = False + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if line == "require (": + in_block = True + continue + if in_block and line == ")": + in_block = False + continue + candidate = line if in_block else line.removeprefix("require ") if line.startswith("require ") else "" + if candidate and "// indirect" not in candidate: + direct.add(candidate.split()[0]) + return direct + + +def parse_go_sum(path: Path) -> list[Entry]: + direct = _go_direct_modules(path.parent) + seen: set[tuple[str, str]] = set() + entries: list[Entry] = [] + for raw in path.read_text(encoding="utf-8").splitlines(): + parts = raw.split() + if len(parts) < 2 or parts[1].endswith("/go.mod"): + continue + key = (parts[0], parts[1]) + if key in seen: + continue + seen.add(key) + entries.append(_entry(key[0], key[1], "go", is_direct=parts[0] in direct)) + return entries + + +def parse_terraform_lock(path: Path) -> list[Entry]: + entries: list[Entry] = [] + provider: str | None = None + version: str | None = None + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + match = re.match(r'^provider\s+"([^"]+)"\s*\{', line) + if match: + provider, version = match.group(1), None + elif provider: + version_match = re.match(r'version\s*=\s*"([^"]+)"', line) + if version_match: + version = version_match.group(1) + elif line == "}": + entries.append(_entry(provider, version, "terraform", is_direct=True)) + provider, version = None, None + return entries + + +def parse_ansible_requirements(path: Path) -> list[Entry]: + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + if not isinstance(data, dict): + return [] + entries: list[Entry] = [] + for kind in ("collections", "roles"): + for item in data.get(kind, []) or []: + if isinstance(item, str): + name, version = item, None + elif isinstance(item, dict): + name = item.get("name") or item.get("src") + version = str(item["version"]) if item.get("version") else None + else: + continue + if name: + entries.append(_entry(str(name), version, "ansible", is_direct=True)) + return entries + + +def parse_tools_manifest(path: Path) -> list[Entry]: + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + if not isinstance(data, dict): + return [] + entries: list[Entry] = [] + for item in data.get("tools", []) or []: + if not isinstance(item, dict) or not item.get("name"): + continue + ecosystem = str(item.get("ecosystem") or "tool") + if ecosystem not in VALID_ECOSYSTEMS: + ecosystem = "tool" + version = str(item["version"]) if item.get("version") not in {None, "unknown"} else None + entries.append( + _entry( + str(item["name"]), + version, + ecosystem, + license_spdx=str(item["license_spdx"]) if item.get("license_spdx") else None, + is_direct=bool(item.get("is_direct", True)), + is_dev=bool(item.get("is_dev", False)), + ) + ) + return entries + + +LOCKFILE_PARSERS: dict[str, Parser] = { + "uv.lock": parse_uv_lock, + "requirements.txt": parse_requirements, + "package-lock.json": parse_package_lock, + "yarn.lock": parse_yarn_lock, + "Cargo.lock": parse_cargo_lock, + ".terraform.lock.hcl": parse_terraform_lock, + "go.sum": parse_go_sum, +} + + +def detect_sources(repo_root: Path) -> list[tuple[Path, Parser]]: + found: list[tuple[Path, Parser]] = [] + seen: set[Path] = set() + for directory, directories, filenames in os.walk(repo_root): + directories[:] = sorted(name for name in directories if name not in SKIP_DIRECTORIES) + current = Path(directory) + for filename, parser in LOCKFILE_PARSERS.items(): + if filename in filenames: + path = current / filename + found.append((path, parser)) + seen.add(path) + if current.name == "ansible": + for filename in ("requirements.yml", "requirements.yaml"): + if filename in filenames: + path = current / filename + found.append((path, parse_ansible_requirements)) + seen.add(path) + tools = repo_root / "sbom-tools.yaml" + if tools.is_file() and tools not in seen: + found.append((tools, parse_tools_manifest)) + return sorted(found, key=lambda item: str(item[0])) + + +def is_copyleft(spdx: str | None) -> bool: + upper = (spdx or "").upper() + return any(marker in upper for marker in COPYLEFT_MARKERS) + + +def licence_report(entries: list[Entry]) -> dict[str, Any]: + counts = Counter(entry.get("license_spdx") for entry in entries) + groups = [ + {"license_spdx": license_id, "count": count, "is_copyleft": is_copyleft(license_id)} + for license_id, count in sorted(counts.items(), key=lambda item: (-item[1], item[0] or "")) + ] + risks = [ + { + "package_name": entry["package_name"], + "package_version": entry.get("package_version"), + "license_spdx": entry.get("license_spdx"), + "source_path": entry["source_path"], + } + for entry in entries + if is_copyleft(entry.get("license_spdx")) + and entry.get("is_direct") + and not entry.get("is_dev") + ] + return {"groups": groups, "copyleft_direct_prod": risks, "copyleft_direct_count": len(risks)} + + +def scan_repository(repo_root: Path, *, slug: str | None = None) -> dict[str, Any]: + repo_root = repo_root.resolve() + sources: list[dict[str, Any]] = [] + entries: list[Entry] = [] + errors: list[dict[str, str]] = [] + for path, parser in detect_sources(repo_root): + relative = str(path.relative_to(repo_root)) + try: + parsed = parser(path) + except (OSError, ValueError, TypeError, json.JSONDecodeError, tomllib.TOMLDecodeError, yaml.YAMLError) as exc: + errors.append({"source_path": relative, "error": str(exc)}) + continue + digest = hashlib.sha256(path.read_bytes()).hexdigest() + sources.append({"path": relative, "sha256": digest, "entry_count": len(parsed)}) + for item in parsed: + entries.append({**item, "source_path": relative}) + return { + "schema": SBOM_SCHEMA, + "ok": not errors, + "repo_slug": slug or repo_root.name, + "repo_path": str(repo_root), + "source_revision": head_sha(repo_root), + "generated_at": utc_now_text(), + "authority": "detected lockfiles and reviewed sbom-tools.yaml", + "sources": sources, + "entry_count": len(entries), + "entries": entries, + "licence_report": licence_report(entries), + "errors": errors, + } diff --git a/src/repo_manager/standards.py b/src/repo_manager/standards.py index 82b57b4..5dc94ea 100644 --- a/src/repo_manager/standards.py +++ b/src/repo_manager/standards.py @@ -16,8 +16,6 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Any, Literal -import yaml - from repo_manager.observe import load_classification from repo_manager.parse.workplan import parse_frontmatter diff --git a/src/repo_manager/time.py b/src/repo_manager/time.py new file mode 100644 index 0000000..e3e2ee9 --- /dev/null +++ b/src/repo_manager/time.py @@ -0,0 +1,26 @@ +"""Canonical UTC time helpers (RMGR-ADR-002).""" + +from __future__ import annotations + +from datetime import UTC, date, datetime + + +def utc_now() -> datetime: + """Return the current timezone-aware UTC instant.""" + return datetime.now(UTC) + + +def format_utc(value: datetime) -> str: + """Serialize an aware instant as canonical RFC 3339 UTC with ``Z``.""" + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError("timestamp must be timezone-aware") + return value.astimezone(UTC).isoformat().replace("+00:00", "Z") + + +def utc_now_text() -> str: + return format_utc(utc_now()) + + +def utc_today() -> date: + """Return the calendar date derived from the current UTC instant.""" + return utc_now().date() diff --git a/tests/test_authority.py b/tests/test_authority.py new file mode 100644 index 0000000..178544a --- /dev/null +++ b/tests/test_authority.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import pytest + +from repo_manager.authority import ( + AuthorityError, + load_authority_contract, + resolve_record_authority, +) + + +def test_file_derived_route_is_scoped_by_domain_and_repository() -> None: + route = resolve_record_authority( + "workplans", + repo_slug="repo-manager", + domain_slug="infotech", + claimed_owner="repo-manager", + ) + + assert route["owner"] == "repo-manager" + assert route["authority_key"] == "repository:infotech/repo-manager" + + +def test_hub_native_route_has_one_central_owner() -> None: + route = resolve_record_authority("progress_events", claimed_owner="hub-core") + + assert route["owner"] == "hub-core" + assert route["authority_key"] == "hub:hub-core" + + +def test_unknown_or_conflicting_owner_is_rejected() -> None: + with pytest.raises(AuthorityError, match="unknown record type"): + resolve_record_authority("mystery") + with pytest.raises(AuthorityError, match="authority mismatch"): + resolve_record_authority("progress_events", claimed_owner="repo-manager") + + +def test_every_contract_record_resolves_to_exactly_one_owner() -> None: + contract = load_authority_contract() + + for record_type, rule in contract["records"].items(): + context = ( + {"repo_slug": "example", "domain_slug": "infotech"} + if rule["class"] == "file-derived" + else {} + ) + route = resolve_record_authority(record_type, **context) + assert route["owner"] == rule["owner"] + assert route["authority_key"] diff --git a/tests/test_dual_run.py b/tests/test_dual_run.py index 67ae598..b776293 100644 --- a/tests/test_dual_run.py +++ b/tests/test_dual_run.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json import subprocess from pathlib import Path diff --git a/tests/test_e2e_vertical_slice.py b/tests/test_e2e_vertical_slice.py index e59cf3d..bb84b9d 100644 --- a/tests/test_e2e_vertical_slice.py +++ b/tests/test_e2e_vertical_slice.py @@ -11,7 +11,6 @@ from repo_manager.gitops import head_sha from repo_manager.index_store import default_index_path, load_index from repo_manager.observe import observe_repository - WORKPLAN = """--- id: DEMO-WP-0001 type: workplan diff --git a/tests/test_sbom.py b/tests/test_sbom.py new file mode 100644 index 0000000..be16774 --- /dev/null +++ b/tests/test_sbom.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +from repo_manager.sbom import detect_sources, scan_repository + + +def _git(repo: Path, *args: str) -> None: + subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True) + + +def test_scan_derives_snapshot_and_copyleft_report(tmp_path: Path) -> None: + repo = tmp_path / "example" + repo.mkdir() + _git(repo, "init") + _git(repo, "config", "user.email", "test@example.com") + _git(repo, "config", "user.name", "Test") + (repo / "uv.lock").write_text( + 'version = 1\n[[package]]\nname = "pyyaml"\nversion = "6.0.2"\n', + encoding="utf-8", + ) + (repo / "package-lock.json").write_text( + json.dumps( + { + "packages": { + "": {"name": "root", "version": "1.0.0"}, + "node_modules/copyleft": { + "name": "copyleft", + "version": "2.0.0", + "license": "GPL-3.0-only", + }, + "node_modules/dev-only": { + "name": "dev-only", + "version": "3.0.0", + "license": "AGPL-3.0-only", + "dev": True, + }, + } + } + ), + encoding="utf-8", + ) + _git(repo, "add", ".") + _git(repo, "commit", "-m", "seed") + + result = scan_repository(repo) + + assert result["ok"] is True + assert result["schema"] == "repo-manager.sbom-snapshot.v1" + assert result["source_revision"] + assert result["generated_at"].endswith("Z") + assert result["entry_count"] == 3 + assert result["licence_report"]["copyleft_direct_count"] == 1 + assert result["licence_report"]["copyleft_direct_prod"][0]["package_name"] == "copyleft" + + +def test_detection_covers_sources_and_skips_dependency_directories(tmp_path: Path) -> None: + (tmp_path / "go.sum").write_text("example.test/mod v1.2.3 h1:abc\n", encoding="utf-8") + terraform = tmp_path / "infra" + terraform.mkdir() + (terraform / ".terraform.lock.hcl").write_text( + 'provider "registry.terraform.io/hashicorp/null" {\n version = "3.2.3"\n}\n', + encoding="utf-8", + ) + ansible = tmp_path / "deploy" / "ansible" + ansible.mkdir(parents=True) + (ansible / "requirements.yml").write_text("collections:\n - community.general\n", encoding="utf-8") + ignored = tmp_path / "node_modules" + ignored.mkdir() + (ignored / "package-lock.json").write_text("{}", encoding="utf-8") + + sources = {str(path.relative_to(tmp_path)) for path, _parser in detect_sources(tmp_path)} + + assert sources == { + "deploy/ansible/requirements.yml", + "go.sum", + "infra/.terraform.lock.hcl", + } + + +def test_go_sum_marks_modules_declared_in_go_mod_as_direct(tmp_path: Path) -> None: + (tmp_path / "go.mod").write_text( + "module example.test/app\n\nrequire example.test/direct v1.2.3\n", + encoding="utf-8", + ) + (tmp_path / "go.sum").write_text( + "example.test/direct v1.2.3 h1:abc\n" + "example.test/direct v1.2.3/go.mod h1:def\n" + "example.test/transitive v2.0.0 h1:ghi\n", + encoding="utf-8", + ) + + result = scan_repository(tmp_path) + + assert result["ok"] is True + assert [(entry["package_name"], entry["is_direct"]) for entry in result["entries"]] == [ + ("example.test/direct", True), + ("example.test/transitive", False), + ] + + +def test_invalid_source_is_reported_without_partial_failure(tmp_path: Path) -> None: + (tmp_path / "uv.lock").write_text("not = [valid", encoding="utf-8") + + result = scan_repository(tmp_path) + + assert result["ok"] is False + assert result["entry_count"] == 0 + assert result["errors"][0]["source_path"] == "uv.lock" diff --git a/tests/test_time.py b/tests/test_time.py new file mode 100644 index 0000000..831b39e --- /dev/null +++ b/tests/test_time.py @@ -0,0 +1,17 @@ +from datetime import UTC, datetime, timedelta, timezone + +import pytest + +from repo_manager.time import format_utc + + +def test_format_utc_normalizes_offsets_and_uses_z() -> None: + local = datetime(2026, 8, 21, 22, 30, tzinfo=timezone(timedelta(hours=2))) + + assert format_utc(local) == "2026-08-21T20:30:00Z" + assert format_utc(local.astimezone(UTC)).endswith("Z") + + +def test_format_utc_rejects_naive_datetime() -> None: + with pytest.raises(ValueError, match="timezone-aware"): + format_utc(datetime(2026, 8, 21, 20, 30)) # noqa: DTZ001 - deliberate invalid input diff --git a/workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md b/workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md index a06c5e5..00228fb 100644 --- a/workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md +++ b/workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md @@ -438,7 +438,7 @@ ADR-010's older example list. ```task id: RMGR-WP-0005-T09 -status: progress +status: done priority: high state_hub_task_id: "d40cc4a8-4280-4940-ac1d-dc1049f1b678" ``` @@ -475,11 +475,18 @@ paths are captured in to repair those seven bindings and retain/export the 217 closed rows before T07 replaces the cache. +**Result (2026-08-21):** the preferred binding-sync API accepted all seven +verified file mappings atomically (`updated: 7`), without changing repository +files or lifecycle state. The cache now has zero unbound live workplans. The +remaining 217 unbound rows are exclusively closed provenance (201 `finished`, +16 `archived`) and are explicitly retained in the untouched current database; +T07 must export or preserve them as its pre-rebuild gate. + ## Assign one authoritative hub per record ```task id: RMGR-WP-0005-T10 -status: wait +status: done priority: medium state_hub_task_id: "15f0f167-a8d0-4d5c-8576-3e93b1e8792f" ``` @@ -494,6 +501,15 @@ peer-database divergence this workplan exists to remove recurs at larger scale. Coordinate with the hub-extension architecture in `prj-state-hub-retirement/architecture/`; `hub-core` owns the hub-native side. +**Result (2026-08-21):** the T08 machine-readable inventory is now an +executable, fail-closed routing contract. `rmgr authority route` resolves every +known record type to exactly one owner; file-derived routes require repository +and domain context, while hub-native/cache routes name their central owner. +Unknown record types and claimed-owner mismatches are rejected. Contract-wide +tests prove all 42 current record types resolve once. Finished `HUB-WP-0004` +accepts the complementary boundary: repository authority stays with Repo +Manager and hub-core owns the named hub-native projection/event ports. + ## Protect lifecycle status from automation ```task diff --git a/workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md b/workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md index b054209..4c4d6d6 100644 --- a/workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md +++ b/workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md @@ -4,7 +4,7 @@ type: workplan title: "Receiving surface for work records and repository registers" domain: infotech repo: repo-manager -status: active +status: finished owner: codex topic_slug: infotech created: "2026-08-20" @@ -158,7 +158,7 @@ the replaceable index. Their authority remains the workplan file. ```task id: RMGR-WP-0008-T05 -status: progress +status: done priority: high state_hub_task_id: "902c71da-1ba5-4e76-bf20-0d9f697edf7a" ``` @@ -180,19 +180,28 @@ Treat as one register spine with per-kind schemas rather than six unrelated features — they share repo scoping, append-mostly write patterns, and the same file-backed-authority rule. -Progress (2026-08-21): the shared spine is live for all six kinds as versioned +Result (2026-08-21): the shared spine is live for all six kinds as versioned `registers/.yaml` files, with parser/index support and governed `put`/`defer`/`note` commands. Kind-specific required fields cover the existing State Hub create contracts, and the generic `rm_update_register_entry` adapter -can route compatibility writes. Remaining before `done`: lockfile ingestion, -licence/copyleft derived reports, route-level cutover fixtures, and migration of -the live register rows. +can route compatibility writes. `rmgr sbom scan|licence-report` derives +versioned, provenance-bearing snapshots from current lockfiles (plus a reviewed +tool manifest), with direct-production copyleft findings. The State Hub adapter +and route-level fixture cover the read/derive call. + +The 22 historical State Hub snapshots were reconciled rather than copied: the +newest projection for each of 18 repositories was compared with a fresh scan, +all repositories parsed, and 9 counts matched exactly. Current lockfiles yield +1,565 entries versus 2,659 in months-old projections. Copying stale cache rows +would invert repository authority, so cutover rebuilds from source and retains +old snapshot identifiers only as retirement provenance. Evidence: +`docs/evidence/RMGR-WP-0008-sbom-cutover-2026-08-21.md`. ## Topic and classification contract ```task id: RMGR-WP-0008-T06 -status: progress +status: done priority: medium state_hub_task_id: "ef7934de-a1e2-4541-8068-e75876446b60" ``` @@ -204,18 +213,19 @@ published contract rather than a lift-and-shift of the hub's tables. Coordinate with HUB-WP-0004: the topic spine sits on the boundary between repository classification (here) and cross-domain projection (`hub-core`). -Progress (2026-08-21): published +Result (2026-08-21): published `docs/repository-classification-contract_v1.0.{md,yaml}` and made observation reject invalid controlled values, repeated primary domains, malformed lists, -and non-kebab capability tags. Remaining: hub-core acknowledges the projection -boundary and A5 consumers are routed to the contract. Coordination request -`bca85a3f-83be-44e3-a95e-e3d4b4d812eb` is with `hub-core`. +and non-kebab capability tags. Finished `HUB-WP-0004` explicitly accepts that +repository classification remains authoritative here while hub-core owns +cross-domain projections. That is the required boundary agreement; consumer +route switching remains in the existing `STATE-WP-0079` retirement workplan. ## Owner-side UI ```task id: RMGR-WP-0008-T07 -status: wait +status: done priority: low state_hub_task_id: "24ee329d-85dd-4e11-9c87-2f20b8f86e81" ``` @@ -225,16 +235,26 @@ Slice **A4** (22 items, `replace`) assumes an owner-side dashboard. work-coordination and repo views may belong to `hub-core`'s projection layer instead. -Deliberately `wait`: settle ownership before building. Resolve with HUB-WP-0004 -and record the outcome as a decision. Do not default to building a dashboard -here just because the inventory named repo-manager. +Resolved in `docs/adr-003-projection-ui-ownership.md`: Repo Manager owns its +governed CLI/API contracts, not a dashboard. hub-core owns cross-repository +projection/query and surviving hub UI capabilities; ops/domain UIs are clients +of those contracts. A4 is therefore a hub-core/projection-client replacement, +not a UI migration into Repo Manager. ## Acceptance - [x] Workplan write path live, A2b routable through the adapter - [x] Intake and decision parsers + write paths live; `resolve_decision` guarantee preserved -- [ ] Register spine live, A3's six registers routable -- [ ] Topic/classification contract published; agreement with hub-core pending -- [ ] A4 ownership decided and recorded -- [ ] Compatibility tests per slice, matching the RMGR-WP-0002 evidence pattern +- [x] Register spine live, A3's six registers routable +- [x] Topic/classification contract published and accepted at the hub-core boundary +- [x] A4 ownership decided and recorded +- [x] Compatibility tests per slice, matching the RMGR-WP-0002 evidence pattern + +## Completion and handoff + +Repo Manager's receiving surfaces are complete. The remaining State Hub route +flags, redirects, projection rebuilds, retention, and destructive retirement +gates already remain live in `STATE-WP-0079`; they are consumers of this work, +not residual Repo Manager implementation. No actionable Repo Manager residual +is being parked in this finished workplan.