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:
parent
6d134425df
commit
d103955217
28 changed files with 970 additions and 48 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
65
docs/adr-002-canonical-utc-time.md
Normal file
65
docs/adr-002-canonical-utc-time.md
Normal 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.
|
||||
52
docs/adr-003-projection-ui-ownership.md
Normal file
52
docs/adr-003-projection-ui-ownership.md
Normal 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 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.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
40
docs/evidence/RMGR-WP-0008-sbom-cutover-2026-08-21.md
Normal file
40
docs/evidence/RMGR-WP-0008-sbom-cutover-2026-08-21.md
Normal 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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -43,3 +43,6 @@ asyncio_mode = "auto"
|
|||
[tool.ruff]
|
||||
line-length = 100
|
||||
src = ["src", "tests"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
extend-select = ["DTZ", "UP"]
|
||||
|
|
|
|||
74
src/repo_manager/authority.py
Normal file
74
src/repo_manager/authority.py
Normal 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,
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
326
src/repo_manager/sbom.py
Normal 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,
|
||||
}
|
||||
|
|
@ -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
26
src/repo_manager/time.py
Normal 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
49
tests/test_authority.py
Normal 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"]
|
||||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
|
|
|||
|
|
@ -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
111
tests/test_sbom.py
Normal 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
17
tests/test_time.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue