feat: finish register receiving and authority routing

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
This commit is contained in:
tegwick 2026-08-21 23:15:48 +02:00
parent 6d134425df
commit d103955217
28 changed files with 970 additions and 48 deletions

View file

@ -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 |

View file

@ -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:<domain_slug>/<repo_slug>"
hub-native-key: "hub:<owner>"
retired-key: "archive:<record_type>"
unknown-record-policy: reject
claimed-owner-mismatch-policy: reject
classes:
file-derived:
authority: repository

View file

@ -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.

View file

@ -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 A1A3 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.

View file

@ -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

View file

@ -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 <repo> --slug
<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

View file

@ -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:<domain>/<repo>` 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.

View file

@ -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`.

View file

@ -43,3 +43,6 @@ asyncio_mode = "auto"
[tool.ruff]
line-length = 100
src = ["src", "tests"]
[tool.ruff.lint]
extend-select = ["DTZ", "UP"]

View file

@ -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,
}

View file

@ -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

View file

@ -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)

View file

@ -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:

View file

@ -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:

View file

@ -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"

View file

@ -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:

View file

@ -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,

View file

@ -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

326
src/repo_manager/sbom.py Normal file
View file

@ -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_.-]+)(?:[>=<!~^]+([^\s;]+))?", line)
if match:
entries.append(_entry(match.group(1), match.group(2), "python", is_direct=True))
return entries
def parse_package_lock(path: Path) -> 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,
}

View file

@ -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

26
src/repo_manager/time.py Normal file
View file

@ -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()

49
tests/test_authority.py Normal file
View file

@ -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"]

View file

@ -2,7 +2,6 @@
from __future__ import annotations
import json
import subprocess
from pathlib import Path

View file

@ -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

111
tests/test_sbom.py Normal file
View file

@ -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"

17
tests/test_time.py Normal file
View file

@ -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

View file

@ -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

View file

@ -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/<kind>.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.