feat: harden work-record and SBOM client contracts
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
This commit is contained in:
parent
2577379e36
commit
84952c5212
16 changed files with 605 additions and 30 deletions
|
|
@ -18,7 +18,7 @@
|
||||||
| workplan | RMGR-WP-0008 | finished | — | 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 |
|
| workplan | RMGR-WP-0009 | finished | — | workplans/RMGR-WP-0009-coding-assistant-commit-provenance.md |
|
||||||
| workplan | RMGR-WP-0010 | finished | — | workplans/RMGR-WP-0010-authoritative-workload-references.md |
|
| workplan | RMGR-WP-0010 | finished | — | workplans/RMGR-WP-0010-authoritative-workload-references.md |
|
||||||
| workplan | RMGR-WP-0011 | ready | — | workplans/RMGR-WP-0011-sbom-nexus-production-client.md |
|
| workplan | RMGR-WP-0011 | active | — | workplans/RMGR-WP-0011-sbom-nexus-production-client.md |
|
||||||
| task | RMGR-WP-0001-T01 | done | — | workplans/RMGR-WP-0001-foundation.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 |
|
| task | RMGR-WP-0001-T02 | done | — | workplans/RMGR-WP-0001-foundation.md |
|
||||||
| task | RMGR-WP-0001-T03 | done | — | workplans/RMGR-WP-0001-foundation.md |
|
| task | RMGR-WP-0001-T03 | done | — | workplans/RMGR-WP-0001-foundation.md |
|
||||||
|
|
@ -88,9 +88,9 @@
|
||||||
| task | RMGR-WP-0010-T05 | done | — | workplans/RMGR-WP-0010-authoritative-workload-references.md |
|
| task | RMGR-WP-0010-T05 | done | — | workplans/RMGR-WP-0010-authoritative-workload-references.md |
|
||||||
| task | RMGR-WP-0010-T06 | done | — | workplans/RMGR-WP-0010-authoritative-workload-references.md |
|
| task | RMGR-WP-0010-T06 | done | — | workplans/RMGR-WP-0010-authoritative-workload-references.md |
|
||||||
| task | RMGR-WP-0010-T07 | done | — | workplans/RMGR-WP-0010-authoritative-workload-references.md |
|
| task | RMGR-WP-0010-T07 | done | — | workplans/RMGR-WP-0010-authoritative-workload-references.md |
|
||||||
| task | RMGR-WP-0011-T01 | todo | — | workplans/RMGR-WP-0011-sbom-nexus-production-client.md |
|
| task | RMGR-WP-0011-T01 | done | — | workplans/RMGR-WP-0011-sbom-nexus-production-client.md |
|
||||||
| task | RMGR-WP-0011-T02 | todo | — | workplans/RMGR-WP-0011-sbom-nexus-production-client.md |
|
| task | RMGR-WP-0011-T02 | todo | — | workplans/RMGR-WP-0011-sbom-nexus-production-client.md |
|
||||||
| task | RMGR-WP-0011-T03 | todo | — | workplans/RMGR-WP-0011-sbom-nexus-production-client.md |
|
| task | RMGR-WP-0011-T03 | done | — | workplans/RMGR-WP-0011-sbom-nexus-production-client.md |
|
||||||
| task | RMGR-WP-0011-T04 | todo | — | workplans/RMGR-WP-0011-sbom-nexus-production-client.md |
|
| task | RMGR-WP-0011-T04 | todo | — | workplans/RMGR-WP-0011-sbom-nexus-production-client.md |
|
||||||
| intake | RMGR-IN-0001 | open | — | intakes/intakes.md |
|
| intake | RMGR-IN-0001 | open | — | intakes/intakes.md |
|
||||||
| intake | RMGR-IN-0002 | open | — | intakes/intakes.md |
|
| intake | RMGR-IN-0002 | open | — | intakes/intakes.md |
|
||||||
|
|
|
||||||
44
config/work-record-types.yaml
Normal file
44
config/work-record-types.yaml
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
# Implementation snapshot of the Custodian work-record kind registry.
|
||||||
|
# Canonical source:
|
||||||
|
# the-custodian/canon/standards/work-record-types.yaml (version 0.1)
|
||||||
|
version: "0.1"
|
||||||
|
status: active
|
||||||
|
kinds:
|
||||||
|
- kind: workplan
|
||||||
|
id_patterns:
|
||||||
|
- "^[A-Z]+-WP-[0-9]{4}$"
|
||||||
|
legacy_patterns:
|
||||||
|
- pattern: "^MASON-0001$"
|
||||||
|
source: ops-mason bootstrap workplan created before WP infix convention
|
||||||
|
grandfathered: true
|
||||||
|
- kind: task
|
||||||
|
id_patterns:
|
||||||
|
- "^[A-Z]+-WP-[0-9]{4}-T[0-9]{2,3}$"
|
||||||
|
- "^ADHOC-[0-9]{4}-[0-9]{2}-[0-9]{2}-T[0-9]{2}$"
|
||||||
|
legacy_patterns:
|
||||||
|
- pattern: "^[A-Z]+-WP-[0-9]{4}-T[0-9]$"
|
||||||
|
source: early single-digit task suffixes (pre task-block convention)
|
||||||
|
grandfathered: true
|
||||||
|
- pattern: "^[A-Z]+-WP-[0-9]{4}-LEGACY-T[0-9]{2}$"
|
||||||
|
source: archived-workplan legacy task renumbering
|
||||||
|
grandfathered: true
|
||||||
|
- pattern: "^MASON-0001-T[0-9]{2,3}$"
|
||||||
|
source: ops-mason bootstrap tasks created before WP infix convention
|
||||||
|
grandfathered: true
|
||||||
|
- kind: intake
|
||||||
|
id_patterns:
|
||||||
|
- "^[A-Z]+-IN-[0-9]{4}$"
|
||||||
|
legacy_patterns:
|
||||||
|
- pattern: "^AWQ-[0-9]{3}$"
|
||||||
|
source: binky-control AutopilotWorkQueue
|
||||||
|
grandfathered: true
|
||||||
|
- kind: decision
|
||||||
|
id_patterns:
|
||||||
|
- "^[A-Z]+-DEC-[0-9]{4}-[0-9]{3}$"
|
||||||
|
legacy_patterns:
|
||||||
|
- pattern: "^DEC-[0-9]{4}-[0-9]{3}$"
|
||||||
|
source: binky-control DecisionQueue
|
||||||
|
grandfathered: true
|
||||||
|
identity_reconciliation:
|
||||||
|
repeated_same_id_same_uuid: duplicate_source_occurrence
|
||||||
|
repeated_same_id_different_uuid: identity_collision
|
||||||
|
|
@ -74,6 +74,10 @@ SBOM Nexus is the sole scanner and snapshot product owner. Repo Manager retains
|
||||||
these aliases only so existing repository workflows do not break; set
|
these aliases only so existing repository workflows do not break; set
|
||||||
`SBOM_NEXUS_CLI` when `sbom-nexus` is not on `PATH`. Their JSON includes
|
`SBOM_NEXUS_CLI` when `sbom-nexus` is not on `PATH`. Their JSON includes
|
||||||
`product_owner: sbom-nexus` and the Nexus `sbom-nexus.snapshot.v1` schema.
|
`product_owner: sbom-nexus` and the Nexus `sbom-nexus.snapshot.v1` schema.
|
||||||
|
They also carry `repo_manager_context.mode: local-preview` with explicit false
|
||||||
|
values for authority, persistence, attempt/success advancement, and snapshot
|
||||||
|
history creation. See `docs/sbom-nexus-client-contract_v1.md` for the pinned
|
||||||
|
preview and production-service boundary.
|
||||||
|
|
||||||
Detection covers `uv.lock`, `requirements.txt`, `package-lock.json`,
|
Detection covers `uv.lock`, `requirements.txt`, `package-lock.json`,
|
||||||
`yarn.lock`, `Cargo.lock`, `go.sum`, `.terraform.lock.hcl`, Ansible requirements
|
`yarn.lock`, `Cargo.lock`, `go.sum`, `.terraform.lock.hcl`, Ansible requirements
|
||||||
|
|
|
||||||
72
docs/sbom-nexus-client-contract_v1.md
Normal file
72
docs/sbom-nexus-client-contract_v1.md
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
# SBOM Nexus client contract v1
|
||||||
|
|
||||||
|
Repo Manager consumes SBOM Nexus; it does not implement a second scanner,
|
||||||
|
snapshot store, freshness model, licence classifier, or catch-up scheduler.
|
||||||
|
This contract pins the boundary used by `RMGR-WP-0011`.
|
||||||
|
|
||||||
|
## Authority modes
|
||||||
|
|
||||||
|
| Mode | Surface | Authoritative | Persisted | Advances Nexus state |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| Local preview | `rmgr sbom scan|licence-report` → `sbom-nexus scan` | no | no | no |
|
||||||
|
| Service operation | configured SBOM Nexus HTTP client | yes | Nexus decides | yes, according to the route outcome |
|
||||||
|
|
||||||
|
Every compatibility-command result carries:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"repo_manager_context": {
|
||||||
|
"mode": "local-preview",
|
||||||
|
"authoritative": false,
|
||||||
|
"persisted": false,
|
||||||
|
"advances_last_attempt_at": false,
|
||||||
|
"advances_last_success_at": false,
|
||||||
|
"creates_snapshot_history": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Writing preview JSON to a file creates optional local evidence only. It does
|
||||||
|
not turn the result into an authoritative snapshot or ingest receipt.
|
||||||
|
|
||||||
|
## Pinned snapshot envelope
|
||||||
|
|
||||||
|
Repo Manager accepts `schema: sbom-nexus.snapshot.v1`. Additive fields are
|
||||||
|
compatible. An unknown schema or omission of any of these fields is a contract
|
||||||
|
error: `schema`, `ok`, `repo_slug`, `source_revision`, `generated_at`,
|
||||||
|
`entry_count`, `entries`, `sources`, `licence_report`, and `errors`.
|
||||||
|
|
||||||
|
Repo Manager may project the existing licence-report compatibility shape from
|
||||||
|
that envelope. It must not recalculate licence groups or copyleft signals.
|
||||||
|
|
||||||
|
## Authoritative service routes
|
||||||
|
|
||||||
|
The production client may consume these Nexus-owned routes without depending
|
||||||
|
on Nexus tables or migrations:
|
||||||
|
|
||||||
|
| Purpose | Route |
|
||||||
|
| --- | --- |
|
||||||
|
| Upsert repository projection | `PUT /repositories/{repo_slug}` |
|
||||||
|
| Read repository projections | `GET /repositories/` |
|
||||||
|
| Persist scan outcome | `POST /sbom/{repo_slug}/ingest` |
|
||||||
|
| Persist terminal skip outcome | `POST /sbom/{repo_slug}/skip` |
|
||||||
|
| Read latest repository SBOM | `GET /sbom/{repo_slug}` |
|
||||||
|
| Read immutable snapshot detail | `GET /sbom/snapshots/{snapshot_id}` |
|
||||||
|
| Read licence report | `GET /sbom/report/licences/` |
|
||||||
|
|
||||||
|
Calls use a bounded timeout and surface deterministic transport, HTTP, and
|
||||||
|
contract errors. Credentials enter only through the platform runtime path and
|
||||||
|
must never appear in files, command output, or logs.
|
||||||
|
|
||||||
|
## Repository source identity and provenance
|
||||||
|
|
||||||
|
Repo Manager remains authoritative for repository slug, active state,
|
||||||
|
checkout/source identity, and Git revision. An authoritative scan request must
|
||||||
|
refer to a controlled source artifact or checkout pinned to that revision. It
|
||||||
|
must not infer identity from a path string or repository name, and it must not
|
||||||
|
mount a workstation filesystem into the cluster.
|
||||||
|
|
||||||
|
`CUST-WP-0064` owns the coordinated source-input topology. Until that topology
|
||||||
|
is live, local checkout scans remain previews even when their Git revision is
|
||||||
|
known. SBOM Nexus alone records the resulting authoritative snapshot and
|
||||||
|
attempt/success history.
|
||||||
|
|
@ -347,12 +347,18 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
help="Deprecated compatibility commands delegated to SBOM Nexus",
|
help="Deprecated compatibility commands delegated to SBOM Nexus",
|
||||||
)
|
)
|
||||||
sbom_sub = p_sbom.add_subparsers(dest="sbom_command")
|
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 = sbom_sub.add_parser(
|
||||||
|
"scan",
|
||||||
|
help="Run a local non-authoritative preview through the SBOM Nexus CLI",
|
||||||
|
)
|
||||||
p_sbom_scan.add_argument("--path", default=".")
|
p_sbom_scan.add_argument("--path", default=".")
|
||||||
p_sbom_scan.add_argument("--slug", default=None)
|
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("--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_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 = sbom_sub.add_parser(
|
||||||
|
"licence-report",
|
||||||
|
help="Preview licences locally without persisting or advancing Nexus state",
|
||||||
|
)
|
||||||
p_sbom_report.add_argument("--path", default=".")
|
p_sbom_report.add_argument("--path", default=".")
|
||||||
p_sbom_report.add_argument("--slug", default=None)
|
p_sbom_report.add_argument("--slug", default=None)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import httpx
|
||||||
from repo_manager.gitops import GitError, commit_paths, push_ff
|
from repo_manager.gitops import GitError, commit_paths, push_ff
|
||||||
from repo_manager.parse.record import iter_record_files, parse_record_file
|
from repo_manager.parse.record import iter_record_files, parse_record_file
|
||||||
from repo_manager.parse.workplan import parse_workplan_file
|
from repo_manager.parse.workplan import parse_workplan_file
|
||||||
|
from repo_manager.record_identity import scan_record_identities
|
||||||
|
|
||||||
LOCK_PATH = Path("/tmp/repo-manager-identifier-registrar.lock")
|
LOCK_PATH = Path("/tmp/repo-manager-identifier-registrar.lock")
|
||||||
|
|
||||||
|
|
@ -379,14 +380,27 @@ def registrar_reconcile(
|
||||||
"""Register missing workplan/task UUIDs through one scoped child process."""
|
"""Register missing workplan/task UUIDs through one scoped child process."""
|
||||||
cid = str(uuid.uuid4())
|
cid = str(uuid.uuid4())
|
||||||
repo = path.expanduser().resolve()
|
repo = path.expanduser().resolve()
|
||||||
|
identity = scan_record_identities(repo)
|
||||||
before = _missing_identifiers(repo)
|
before = _missing_identifiers(repo)
|
||||||
evidence: dict[str, Any] = {
|
evidence: dict[str, Any] = {
|
||||||
"repo_path": str(repo),
|
"repo_path": str(repo),
|
||||||
"repo_slug": repo.name,
|
"repo_slug": repo.name,
|
||||||
"api_base": api_base.rstrip("/"),
|
"api_base": api_base.rstrip("/"),
|
||||||
"missing_before": before,
|
"missing_before": before,
|
||||||
|
"record_identity": identity,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if identity["identity_collisions"]:
|
||||||
|
return RegistrarResult(
|
||||||
|
"rejected",
|
||||||
|
evidence,
|
||||||
|
{
|
||||||
|
"code": "record_identity_collision",
|
||||||
|
"message": "same canonical work-record id has conflicting or incomplete UUID assignments",
|
||||||
|
},
|
||||||
|
cid,
|
||||||
|
)
|
||||||
|
|
||||||
repair_projection_id = None
|
repair_projection_id = None
|
||||||
if repair_workplan and bootstrap_empty_projection:
|
if repair_workplan and bootstrap_empty_projection:
|
||||||
return RegistrarResult(
|
return RegistrarResult(
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,11 @@ from repo_manager.index_store import RepoIndex, WorkRecordEntry, _now
|
||||||
from repo_manager.parse.record import iter_record_files, parse_record_file
|
from repo_manager.parse.record import iter_record_files, parse_record_file
|
||||||
from repo_manager.parse.register import iter_register_files, parse_register_file
|
from repo_manager.parse.register import iter_register_files, parse_register_file
|
||||||
from repo_manager.parse.workplan import iter_workplan_files, parse_workplan_file
|
from repo_manager.parse.workplan import iter_workplan_files, parse_workplan_file
|
||||||
|
from repo_manager.record_identity import scan_record_identities
|
||||||
|
|
||||||
|
|
||||||
|
class RecordIdentityCollisionError(ValueError):
|
||||||
|
"""Raised when one canonical record id points at conflicting UUIDs."""
|
||||||
|
|
||||||
|
|
||||||
def _slug_from_path(repo_root: Path) -> str:
|
def _slug_from_path(repo_root: Path) -> str:
|
||||||
|
|
@ -72,7 +77,7 @@ def observe_repository(repo_root: Path, *, slug: str | None = None) -> tuple[dic
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
for task in wp.tasks:
|
for task_number, task in enumerate(wp.tasks, start=1):
|
||||||
records.append(
|
records.append(
|
||||||
WorkRecordEntry(
|
WorkRecordEntry(
|
||||||
kind="task",
|
kind="task",
|
||||||
|
|
@ -83,15 +88,23 @@ def observe_repository(repo_root: Path, *, slug: str | None = None) -> tuple[dic
|
||||||
uuid=task.state_hub_task_id,
|
uuid=task.state_hub_task_id,
|
||||||
parent_id=wp.id,
|
parent_id=wp.id,
|
||||||
extra={
|
extra={
|
||||||
key: task.raw[key]
|
"source_occurrence": f"{wp.path}#task-block-{task_number}",
|
||||||
for key in ("depends_on", "needs_human", "intervention_note", "blocking_reason")
|
**{
|
||||||
if key in task.raw
|
key: task.raw[key]
|
||||||
|
for key in (
|
||||||
|
"depends_on",
|
||||||
|
"needs_human",
|
||||||
|
"intervention_note",
|
||||||
|
"blocking_reason",
|
||||||
|
)
|
||||||
|
if key in task.raw
|
||||||
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
for path in iter_record_files(repo_root):
|
for path in iter_record_files(repo_root):
|
||||||
for record in parse_record_file(path, repo_root=repo_root):
|
for record_number, record in enumerate(parse_record_file(path, repo_root=repo_root), start=1):
|
||||||
records.append(
|
records.append(
|
||||||
WorkRecordEntry(
|
WorkRecordEntry(
|
||||||
kind=record.kind,
|
kind=record.kind,
|
||||||
|
|
@ -100,7 +113,10 @@ def observe_repository(repo_root: Path, *, slug: str | None = None) -> tuple[dic
|
||||||
title=record.title,
|
title=record.title,
|
||||||
source_path=record.source_path,
|
source_path=record.source_path,
|
||||||
uuid=record.uuid,
|
uuid=record.uuid,
|
||||||
extra={"record": record.raw},
|
extra={
|
||||||
|
"record": record.raw,
|
||||||
|
"source_occurrence": f"{record.source_path}#record-block-{record_number}",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -117,6 +133,41 @@ def observe_repository(repo_root: Path, *, slug: str | None = None) -> tuple[dic
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
identity = scan_record_identities(repo_root)
|
||||||
|
if identity["identity_collisions"]:
|
||||||
|
raise RecordIdentityCollisionError(
|
||||||
|
f"work-record identity collision: {identity['identity_collisions']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
diagnostic_events: list[dict] = []
|
||||||
|
for duplicate in identity["duplicate_source_occurrences"]:
|
||||||
|
matches = [
|
||||||
|
record
|
||||||
|
for record in records
|
||||||
|
if record.kind == duplicate["kind"] and record.id == duplicate["id"]
|
||||||
|
]
|
||||||
|
if len(matches) < 2:
|
||||||
|
continue
|
||||||
|
keeper = matches[0]
|
||||||
|
keeper.extra.pop("source_occurrence", None)
|
||||||
|
keeper.extra["source_occurrences"] = duplicate["sources"]
|
||||||
|
records = [record for record in records if record is keeper or record not in matches]
|
||||||
|
diagnostic_events.append(
|
||||||
|
{
|
||||||
|
"type": "repo.work_record.duplicate_source_occurrence",
|
||||||
|
"severity": "warning",
|
||||||
|
"record_kind": duplicate["kind"],
|
||||||
|
"record_id": duplicate["id"],
|
||||||
|
"uuid": duplicate["uuid"],
|
||||||
|
"source_occurrences": duplicate["sources"],
|
||||||
|
"cleanup_required": True,
|
||||||
|
"canon": identity["canon"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for record in records:
|
||||||
|
record.extra.pop("source_occurrence", None)
|
||||||
|
|
||||||
sha = head_sha(repo_root) if is_git_repo(repo_root) else None
|
sha = head_sha(repo_root) if is_git_repo(repo_root) else None
|
||||||
fingerprint, source_files = source_fingerprint(repo_root)
|
fingerprint, source_files = source_fingerprint(repo_root)
|
||||||
index = RepoIndex(
|
index = RepoIndex(
|
||||||
|
|
@ -127,6 +178,7 @@ def observe_repository(repo_root: Path, *, slug: str | None = None) -> tuple[dic
|
||||||
source_fingerprint=fingerprint,
|
source_fingerprint=fingerprint,
|
||||||
source_files=source_files,
|
source_files=source_files,
|
||||||
work_records=records,
|
work_records=records,
|
||||||
|
events=diagnostic_events,
|
||||||
)
|
)
|
||||||
|
|
||||||
snapshot = {
|
snapshot = {
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,9 @@ def iter_record_files(repo_root: Path) -> list[Path]:
|
||||||
root = repo_root / relative
|
root = repo_root / relative
|
||||||
if root.is_dir():
|
if root.is_dir():
|
||||||
files.update(path for path in root.rglob("*.md") if not path.name.startswith("."))
|
files.update(path for path in root.rglob("*.md") if not path.name.startswith("."))
|
||||||
for name in ("INTAKES.md", "DECISIONS.md"):
|
# Fleet repositories use both title-case conventions. Keep these explicit:
|
||||||
|
# arbitrary top-level Markdown remains outside the governed record surface.
|
||||||
|
for name in ("INTAKES.md", "DECISIONS.md", "intakes.md", "decisions.md"):
|
||||||
path = repo_root / name
|
path = repo_root / name
|
||||||
if path.is_file():
|
if path.is_file():
|
||||||
files.add(path)
|
files.add(path)
|
||||||
|
|
|
||||||
91
src/repo_manager/record_identity.py
Normal file
91
src/repo_manager/record_identity.py
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
"""Work-record identity validation and duplicate-source reconciliation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from repo_manager.parse.record import iter_record_files, parse_record_file
|
||||||
|
from repo_manager.parse.workplan import iter_workplan_files, parse_workplan_file
|
||||||
|
|
||||||
|
DEFAULT_KIND_REGISTRY = Path(__file__).resolve().parents[2] / "config" / "work-record-types.yaml"
|
||||||
|
CANON_WORK_RECORD_TYPES = "the-custodian/canon/standards/work-record-types.yaml"
|
||||||
|
|
||||||
|
|
||||||
|
def load_kind_registry(path: Path | None = None) -> dict[str, Any]:
|
||||||
|
data = yaml.safe_load((path or DEFAULT_KIND_REGISTRY).read_text(encoding="utf-8")) or {}
|
||||||
|
if not isinstance(data, dict) or not isinstance(data.get("kinds"), list):
|
||||||
|
raise TypeError("invalid work-record kind registry")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def classify_record_id(
|
||||||
|
kind: str, identifier: str, *, registry_path: Path | None = None
|
||||||
|
) -> str | None:
|
||||||
|
"""Return canonical/grandfathered when the canon registry accepts an id."""
|
||||||
|
registry = load_kind_registry(registry_path)
|
||||||
|
entry = next((item for item in registry["kinds"] if item.get("kind") == kind), None)
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
return None
|
||||||
|
if any(re.fullmatch(pattern, identifier) for pattern in entry.get("id_patterns") or []):
|
||||||
|
return "canonical"
|
||||||
|
for legacy in entry.get("legacy_patterns") or []:
|
||||||
|
if legacy.get("grandfathered") and re.fullmatch(
|
||||||
|
str(legacy.get("pattern") or ""), identifier
|
||||||
|
):
|
||||||
|
return "grandfathered"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def scan_record_identities(repo_root: Path, *, registry_path: Path | None = None) -> dict[str, Any]:
|
||||||
|
"""Describe invalid ids, benign duplicate sources, and hard collisions."""
|
||||||
|
occurrences: dict[tuple[str, str], list[dict[str, Any]]] = defaultdict(list)
|
||||||
|
invalid: list[dict[str, str]] = []
|
||||||
|
|
||||||
|
def add(kind: str, identifier: str | None, record_uuid: str | None, source: str) -> None:
|
||||||
|
if not identifier:
|
||||||
|
return
|
||||||
|
classification = classify_record_id(kind, identifier, registry_path=registry_path)
|
||||||
|
item = {"kind": kind, "id": identifier, "uuid": record_uuid, "source": source}
|
||||||
|
occurrences[(kind, identifier)].append(item)
|
||||||
|
if classification is None:
|
||||||
|
invalid.append({"kind": kind, "id": identifier, "source": source})
|
||||||
|
|
||||||
|
for path in iter_workplan_files(repo_root):
|
||||||
|
workplan = parse_workplan_file(path, repo_root=repo_root)
|
||||||
|
add("workplan", workplan.id, workplan.state_hub_workstream_id, workplan.path)
|
||||||
|
for number, task in enumerate(workplan.tasks, start=1):
|
||||||
|
add("task", task.id, task.state_hub_task_id, f"{workplan.path}#task-block-{number}")
|
||||||
|
for path in iter_record_files(repo_root):
|
||||||
|
for number, record in enumerate(parse_record_file(path, repo_root=repo_root), start=1):
|
||||||
|
add(record.kind, record.id, record.uuid, f"{record.source_path}#record-block-{number}")
|
||||||
|
|
||||||
|
duplicates: list[dict[str, Any]] = []
|
||||||
|
collisions: list[dict[str, Any]] = []
|
||||||
|
for (kind, identifier), items in sorted(occurrences.items()):
|
||||||
|
if len(items) < 2:
|
||||||
|
continue
|
||||||
|
uuids = {item["uuid"] for item in items}
|
||||||
|
result = {
|
||||||
|
"kind": kind,
|
||||||
|
"id": identifier,
|
||||||
|
"uuid": items[0]["uuid"] if len(uuids) == 1 else None,
|
||||||
|
"sources": [item["source"] for item in items],
|
||||||
|
}
|
||||||
|
if len(uuids) == 1 and None not in uuids:
|
||||||
|
duplicates.append(result)
|
||||||
|
else:
|
||||||
|
result["uuids"] = sorted(str(value) for value in uuids)
|
||||||
|
collisions.append(result)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": not invalid and not collisions,
|
||||||
|
"invalid_identifiers": invalid,
|
||||||
|
"duplicate_source_occurrences": duplicates,
|
||||||
|
"identity_collisions": collisions,
|
||||||
|
"canon": CANON_WORK_RECORD_TYPES,
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
"""Deprecated Repo Manager CLI adapter for the SBOM Nexus product owner."""
|
"""Deprecated Repo Manager CLI adapter for the SBOM Nexus product owner."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
@ -8,9 +9,55 @@ import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
SNAPSHOT_SCHEMA = "sbom-nexus.snapshot.v1"
|
||||||
|
SNAPSHOT_REQUIRED_FIELDS = frozenset(
|
||||||
|
{
|
||||||
|
"schema",
|
||||||
|
"ok",
|
||||||
|
"repo_slug",
|
||||||
|
"source_revision",
|
||||||
|
"generated_at",
|
||||||
|
"entry_count",
|
||||||
|
"entries",
|
||||||
|
"sources",
|
||||||
|
"licence_report",
|
||||||
|
"errors",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
PREVIEW_CONTEXT = {
|
||||||
|
"mode": "local-preview",
|
||||||
|
"authoritative": False,
|
||||||
|
"persisted": False,
|
||||||
|
"advances_last_attempt_at": False,
|
||||||
|
"advances_last_success_at": False,
|
||||||
|
"creates_snapshot_history": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SBOMContractError(ValueError):
|
||||||
|
"""Raised when a Nexus response is outside the pinned consumer contract."""
|
||||||
|
|
||||||
|
|
||||||
|
def validate_snapshot_contract(payload: dict[str, Any]) -> None:
|
||||||
|
"""Accept additive fields but reject unknown schemas or missing required fields."""
|
||||||
|
if payload.get("schema") != SNAPSHOT_SCHEMA:
|
||||||
|
raise SBOMContractError(
|
||||||
|
f"unsupported SBOM Nexus schema {payload.get('schema')!r}; expected {SNAPSHOT_SCHEMA!r}"
|
||||||
|
)
|
||||||
|
missing = sorted(SNAPSHOT_REQUIRED_FIELDS - payload.keys())
|
||||||
|
if missing:
|
||||||
|
raise SBOMContractError(f"SBOM Nexus snapshot is missing required fields: {missing}")
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_preview(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload["repo_manager_context"] = dict(PREVIEW_CONTEXT)
|
||||||
|
payload["delegated_by"] = "repo-manager"
|
||||||
|
payload["product_owner"] = "sbom-nexus"
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def scan_repository_via_nexus(repo_root: Path, *, slug: str | None = None) -> dict[str, Any]:
|
def scan_repository_via_nexus(repo_root: Path, *, slug: str | None = None) -> dict[str, Any]:
|
||||||
"""Delegate a local source scan to SBOM Nexus without owning scanner logic."""
|
"""Delegate a non-authoritative local preview to the Nexus-owned CLI."""
|
||||||
executable = os.getenv("SBOM_NEXUS_CLI") or shutil.which("sbom-nexus")
|
executable = os.getenv("SBOM_NEXUS_CLI") or shutil.which("sbom-nexus")
|
||||||
if not executable:
|
if not executable:
|
||||||
return _error(
|
return _error(
|
||||||
|
|
@ -41,8 +88,12 @@ def scan_repository_via_nexus(repo_root: Path, *, slug: str | None = None) -> di
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
return _error("SBOM Nexus CLI returned a non-object JSON response")
|
return _error("SBOM Nexus CLI returned a non-object JSON response")
|
||||||
|
|
||||||
payload["delegated_by"] = "repo-manager"
|
try:
|
||||||
payload["product_owner"] = "sbom-nexus"
|
validate_snapshot_contract(payload)
|
||||||
|
except SBOMContractError as exc:
|
||||||
|
return _error(str(exc), reason="sbom-nexus-contract")
|
||||||
|
|
||||||
|
_mark_preview(payload)
|
||||||
if completed.returncode and payload.get("ok", True):
|
if completed.returncode and payload.get("ok", True):
|
||||||
payload["ok"] = False
|
payload["ok"] = False
|
||||||
payload.setdefault("errors", []).append(
|
payload.setdefault("errors", []).append(
|
||||||
|
|
@ -62,25 +113,27 @@ def licence_report_from_snapshot(snapshot: dict[str, Any]) -> dict[str, Any]:
|
||||||
"source_revision": snapshot.get("source_revision"),
|
"source_revision": snapshot.get("source_revision"),
|
||||||
"generated_at": snapshot.get("generated_at"),
|
"generated_at": snapshot.get("generated_at"),
|
||||||
"entry_count": int(snapshot.get("entry_count") or 0),
|
"entry_count": int(snapshot.get("entry_count") or 0),
|
||||||
"licence_report": snapshot.get("licence_report") or {
|
"licence_report": snapshot.get("licence_report")
|
||||||
|
or {
|
||||||
"groups": [],
|
"groups": [],
|
||||||
"copyleft_direct_prod": [],
|
"copyleft_direct_prod": [],
|
||||||
"copyleft_direct_count": 0,
|
"copyleft_direct_count": 0,
|
||||||
},
|
},
|
||||||
"errors": snapshot.get("errors") or [],
|
"errors": snapshot.get("errors") or [],
|
||||||
|
"repo_manager_context": dict(PREVIEW_CONTEXT),
|
||||||
"delegated_by": "repo-manager",
|
"delegated_by": "repo-manager",
|
||||||
"product_owner": "sbom-nexus",
|
"product_owner": "sbom-nexus",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _error(detail: str) -> dict[str, Any]:
|
def _error(detail: str, *, reason: str = "sbom-nexus-delegation") -> dict[str, Any]:
|
||||||
return {
|
return _mark_preview(
|
||||||
"schema": "sbom-nexus.snapshot.v1",
|
{
|
||||||
"ok": False,
|
"schema": SNAPSHOT_SCHEMA,
|
||||||
"entry_count": 0,
|
"ok": False,
|
||||||
"entries": [],
|
"entry_count": 0,
|
||||||
"sources": [],
|
"entries": [],
|
||||||
"errors": [{"reason": "sbom-nexus-delegation", "detail": detail}],
|
"sources": [],
|
||||||
"delegated_by": "repo-manager",
|
"errors": [{"reason": reason, "detail": detail}],
|
||||||
"product_owner": "sbom-nexus",
|
}
|
||||||
}
|
)
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ from typing import Any, Literal
|
||||||
|
|
||||||
from repo_manager.observe import load_classification
|
from repo_manager.observe import load_classification
|
||||||
from repo_manager.parse.workplan import parse_frontmatter
|
from repo_manager.parse.workplan import parse_frontmatter
|
||||||
|
from repo_manager.record_identity import scan_record_identities
|
||||||
|
|
||||||
CANON_FLAVOR = "the-custodian/canon/standards/project-repository-flavor_v0.1.md"
|
CANON_FLAVOR = "the-custodian/canon/standards/project-repository-flavor_v0.1.md"
|
||||||
CANON_CLASSIFICATION = "the-custodian/canon/standards/repo-classification-standard_v1.0.md"
|
CANON_CLASSIFICATION = "the-custodian/canon/standards/repo-classification-standard_v1.0.md"
|
||||||
|
|
@ -274,4 +275,41 @@ def check_repository(repo_root: Path, *, slug: str | None = None) -> Conformance
|
||||||
canon="ADR-007",
|
canon="ADR-007",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
identity = scan_record_identities(repo_root)
|
||||||
|
for item in identity["invalid_identifiers"]:
|
||||||
|
report.findings.append(
|
||||||
|
Finding(
|
||||||
|
code="work-record-id-invalid",
|
||||||
|
severity="contradictory",
|
||||||
|
path=item["source"],
|
||||||
|
message=f"{item['kind']} id {item['id']!r} is not accepted by the canon kind registry",
|
||||||
|
canon=identity["canon"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for item in identity["duplicate_source_occurrences"]:
|
||||||
|
report.findings.append(
|
||||||
|
Finding(
|
||||||
|
code="work-record-duplicate-source-occurrence",
|
||||||
|
severity="warning",
|
||||||
|
path=item["sources"][0],
|
||||||
|
message=(
|
||||||
|
f"{item['kind']} {item['id']!r} repeats with UUID {item['uuid']}; "
|
||||||
|
f"index once and clean up sources {item['sources']}"
|
||||||
|
),
|
||||||
|
canon=identity["canon"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for item in identity["identity_collisions"]:
|
||||||
|
report.findings.append(
|
||||||
|
Finding(
|
||||||
|
code="work-record-identity-collision",
|
||||||
|
severity="contradictory",
|
||||||
|
path=item["sources"][0],
|
||||||
|
message=(
|
||||||
|
f"{item['kind']} {item['id']!r} has conflicting UUID assignments "
|
||||||
|
f"at {item['sources']}"
|
||||||
|
),
|
||||||
|
canon=identity["canon"],
|
||||||
|
)
|
||||||
|
)
|
||||||
return report
|
return report
|
||||||
|
|
|
||||||
87
tests/test_record_identity.py
Normal file
87
tests/test_record_identity.py
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from repo_manager.observe import RecordIdentityCollisionError, observe_repository
|
||||||
|
from repo_manager.record_identity import classify_record_id, scan_record_identities
|
||||||
|
from repo_manager.standards import check_repository
|
||||||
|
|
||||||
|
|
||||||
|
def _write_mason_workplan(repo: Path, second_uuid: str) -> None:
|
||||||
|
path = repo / "workplans" / "MASON-0001-statehub-bootstrap.md"
|
||||||
|
path.parent.mkdir(parents=True)
|
||||||
|
path.write_text(
|
||||||
|
f"""---
|
||||||
|
id: MASON-0001
|
||||||
|
type: workplan
|
||||||
|
title: Bootstrap
|
||||||
|
status: finished
|
||||||
|
state_hub_workstream_id: "11111111-1111-4111-8111-111111111111"
|
||||||
|
---
|
||||||
|
|
||||||
|
## First occurrence
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: MASON-0001-T01
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "ede1aa62-fc42-494a-9984-3190a52481af"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Second occurrence
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: MASON-0001-T01
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "{second_uuid}"
|
||||||
|
```
|
||||||
|
""",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_grandfathered_mason_identity_is_loaded_from_canon_registry() -> None:
|
||||||
|
assert classify_record_id("workplan", "MASON-0001") == "grandfathered"
|
||||||
|
assert classify_record_id("task", "MASON-0001-T01") == "grandfathered"
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_id_and_uuid_indexes_once_with_cleanup_diagnostic(tmp_path: Path) -> None:
|
||||||
|
_write_mason_workplan(tmp_path, "ede1aa62-fc42-494a-9984-3190a52481af")
|
||||||
|
|
||||||
|
report = scan_record_identities(tmp_path)
|
||||||
|
assert report["identity_collisions"] == []
|
||||||
|
assert report["duplicate_source_occurrences"][0]["sources"] == [
|
||||||
|
"workplans/MASON-0001-statehub-bootstrap.md#task-block-1",
|
||||||
|
"workplans/MASON-0001-statehub-bootstrap.md#task-block-2",
|
||||||
|
]
|
||||||
|
|
||||||
|
snapshot, index = observe_repository(tmp_path, slug="ops-mason")
|
||||||
|
tasks = [record for record in index.work_records if record.kind == "task"]
|
||||||
|
assert snapshot["index"]["task_count"] == 1
|
||||||
|
assert len(tasks) == 1
|
||||||
|
assert (
|
||||||
|
tasks[0].extra["source_occurrences"] == report["duplicate_source_occurrences"][0]["sources"]
|
||||||
|
)
|
||||||
|
assert index.events[0]["type"] == "repo.work_record.duplicate_source_occurrence"
|
||||||
|
|
||||||
|
conformance = check_repository(tmp_path, slug="ops-mason")
|
||||||
|
codes = [finding.code for finding in conformance.findings]
|
||||||
|
assert "work-record-duplicate-source-occurrence" in codes
|
||||||
|
assert not any(
|
||||||
|
finding.code == "work-record-id-invalid" and finding.path.startswith("workplans/")
|
||||||
|
for finding in conformance.findings
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_id_with_different_uuids_fails_closed(tmp_path: Path) -> None:
|
||||||
|
_write_mason_workplan(tmp_path, "22222222-2222-4222-8222-222222222222")
|
||||||
|
|
||||||
|
report = scan_record_identities(tmp_path)
|
||||||
|
assert report["duplicate_source_occurrences"] == []
|
||||||
|
assert report["identity_collisions"][0]["id"] == "MASON-0001-T01"
|
||||||
|
with pytest.raises(RecordIdentityCollisionError):
|
||||||
|
observe_repository(tmp_path, slug="ops-mason")
|
||||||
|
|
||||||
|
conformance = check_repository(tmp_path, slug="ops-mason")
|
||||||
|
assert any(finding.code == "work-record-identity-collision" for finding in conformance.findings)
|
||||||
|
|
@ -120,6 +120,39 @@ status: open
|
||||||
assert missing["decisions"] == ["DEMO-DEC-0001"]
|
assert missing["decisions"] == ["DEMO-DEC-0001"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_scan_includes_lowercase_top_level_record_files(tmp_path: Path) -> None:
|
||||||
|
repo = _fixture(tmp_path)
|
||||||
|
(repo / "intakes.md").write_text(
|
||||||
|
"""# Intakes
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
id: DEMO-IN-0002
|
||||||
|
kind: intake
|
||||||
|
title: Standalone intake
|
||||||
|
status: open
|
||||||
|
```
|
||||||
|
""",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(repo / "decisions.md").write_text(
|
||||||
|
"""# Decisions
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
id: DEMO-DEC-0002
|
||||||
|
kind: decision
|
||||||
|
title: Standalone decision
|
||||||
|
status: accepted
|
||||||
|
```
|
||||||
|
""",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
missing = rr._missing_identifiers(repo)
|
||||||
|
|
||||||
|
assert missing["intakes"] == ["DEMO-IN-0002"]
|
||||||
|
assert missing["decisions"] == ["DEMO-DEC-0002"]
|
||||||
|
|
||||||
|
|
||||||
def test_scopes_registrar_env_and_commits_assigned_ids(tmp_path: Path, monkeypatch) -> None:
|
def test_scopes_registrar_env_and_commits_assigned_ids(tmp_path: Path, monkeypatch) -> None:
|
||||||
repo = _fixture(tmp_path)
|
repo = _fixture(tmp_path)
|
||||||
monkeypatch.setattr(rr, "_check_primary", lambda _api: ({"status": "ok", "db": "connected"}, None))
|
monkeypatch.setattr(rr, "_check_primary", lambda _api: ({"status": "ok", "db": "connected"}, None))
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,14 @@ import json
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from repo_manager.cli import main
|
from repo_manager.cli import main
|
||||||
from repo_manager.sbom_client import (
|
from repo_manager.sbom_client import (
|
||||||
|
SBOMContractError,
|
||||||
licence_report_from_snapshot,
|
licence_report_from_snapshot,
|
||||||
scan_repository_via_nexus,
|
scan_repository_via_nexus,
|
||||||
|
validate_snapshot_contract,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -74,6 +78,14 @@ def test_scan_delegates_to_sbom_nexus_without_shell(monkeypatch, tmp_path: Path)
|
||||||
assert result["schema"] == "sbom-nexus.snapshot.v1"
|
assert result["schema"] == "sbom-nexus.snapshot.v1"
|
||||||
assert result["product_owner"] == "sbom-nexus"
|
assert result["product_owner"] == "sbom-nexus"
|
||||||
assert result["delegated_by"] == "repo-manager"
|
assert result["delegated_by"] == "repo-manager"
|
||||||
|
assert result["repo_manager_context"] == {
|
||||||
|
"mode": "local-preview",
|
||||||
|
"authoritative": False,
|
||||||
|
"persisted": False,
|
||||||
|
"advances_last_attempt_at": False,
|
||||||
|
"advances_last_success_at": False,
|
||||||
|
"creates_snapshot_history": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_missing_nexus_cli_returns_actionable_error(monkeypatch, tmp_path: Path) -> None:
|
def test_missing_nexus_cli_returns_actionable_error(monkeypatch, tmp_path: Path) -> None:
|
||||||
|
|
@ -98,10 +110,41 @@ def test_licence_report_alias_preserves_shape() -> None:
|
||||||
"entry_count",
|
"entry_count",
|
||||||
"licence_report",
|
"licence_report",
|
||||||
"errors",
|
"errors",
|
||||||
|
"repo_manager_context",
|
||||||
"delegated_by",
|
"delegated_by",
|
||||||
"product_owner",
|
"product_owner",
|
||||||
}
|
}
|
||||||
assert result["licence_report"]["copyleft_direct_count"] == 0
|
assert result["licence_report"]["copyleft_direct_count"] == 0
|
||||||
|
assert result["repo_manager_context"]["authoritative"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_contract_allows_additive_fields_and_rejects_unknown_schema() -> None:
|
||||||
|
validate_snapshot_contract({**_snapshot(), "future_addition": {"accepted": True}})
|
||||||
|
|
||||||
|
with pytest.raises(SBOMContractError, match="unsupported SBOM Nexus schema"):
|
||||||
|
validate_snapshot_contract({**_snapshot(), "schema": "sbom-nexus.snapshot.v2"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_turns_unknown_schema_into_deterministic_contract_error(
|
||||||
|
monkeypatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setenv("SBOM_NEXUS_CLI", "/opt/sbom-nexus/bin/sbom-nexus")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
subprocess,
|
||||||
|
"run",
|
||||||
|
lambda command, **kwargs: subprocess.CompletedProcess(
|
||||||
|
command,
|
||||||
|
0,
|
||||||
|
json.dumps({**_snapshot(), "schema": "future.snapshot.v9"}),
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = scan_repository_via_nexus(tmp_path)
|
||||||
|
|
||||||
|
assert result["ok"] is False
|
||||||
|
assert result["errors"][0]["reason"] == "sbom-nexus-contract"
|
||||||
|
assert result["repo_manager_context"]["persisted"] is False
|
||||||
|
|
||||||
|
|
||||||
def test_cli_scan_preserves_output_file_behavior(monkeypatch, tmp_path: Path, capsys) -> None:
|
def test_cli_scan_preserves_output_file_behavior(monkeypatch, tmp_path: Path, capsys) -> None:
|
||||||
|
|
|
||||||
|
|
@ -288,6 +288,25 @@ two older workplans still carry production-absent random UUIDs and remain T04
|
||||||
work; T02 therefore stays `wait`. Evidence:
|
work; T02 therefore stays `wait`. Evidence:
|
||||||
`docs/evidence/RMGR-WP-0005-rail-kubernetes-registrar-2026-08-22.md`.
|
`docs/evidence/RMGR-WP-0005-rail-kubernetes-registrar-2026-08-22.md`.
|
||||||
|
|
||||||
|
**Standalone-record and duplicate-source hardening (2026-08-22):** owner
|
||||||
|
feedback exposed that the governed top-level `intakes.md`/`decisions.md`
|
||||||
|
filenames were absent from Repo Manager's explicit record-file allowlist.
|
||||||
|
Consequently an intake-only reconciliation could return `noop`; it happened to
|
||||||
|
work only when another missing record caused State Hub's broader pass to run.
|
||||||
|
The scanner now recognizes both lowercase and uppercase governed filenames,
|
||||||
|
and a direct scan of the Custodian source returns exactly `CUST-IN-0014` as
|
||||||
|
missing.
|
||||||
|
|
||||||
|
The registrar and conformance paths now also implement the Custodian kind
|
||||||
|
registry's identity-reconciliation ruling. Repeated canonical id plus the same
|
||||||
|
non-null UUID is one indexed record with all source occurrences retained and a
|
||||||
|
governed cleanup warning; conflicting or incomplete UUID assignments fail
|
||||||
|
closed. The grandfathered `MASON-0001` and `MASON-0001-TNN` forms are loaded
|
||||||
|
from the machine-readable canon mapping instead of being rewritten. Live proof
|
||||||
|
against `ops-mason` indexes `MASON-0001-T01` once, preserves both task-block
|
||||||
|
locations, and emits the cleanup diagnostic. T02 remains `wait` because its
|
||||||
|
original pre-derivation reconciliation inventory is not yet exhausted.
|
||||||
|
|
||||||
## Derive identifiers deterministically
|
## Derive identifiers deterministically
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ type: workplan
|
||||||
title: "SBOM Nexus production client and explicit preview semantics"
|
title: "SBOM Nexus production client and explicit preview semantics"
|
||||||
domain: infotech
|
domain: infotech
|
||||||
repo: repo-manager
|
repo: repo-manager
|
||||||
status: ready
|
status: active
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: infotech
|
topic_slug: infotech
|
||||||
quality_dor: DoR-Ok
|
quality_dor: DoR-Ok
|
||||||
|
|
@ -13,6 +13,7 @@ updated: "2026-08-22"
|
||||||
parent_workplan: SBOM-WP-0002
|
parent_workplan: SBOM-WP-0002
|
||||||
related:
|
related:
|
||||||
- CUST-WP-0062
|
- CUST-WP-0062
|
||||||
|
- CUST-WP-0064
|
||||||
- RMGR-WP-0008
|
- RMGR-WP-0008
|
||||||
state_hub_workstream_id: "a6cd9248-e591-51fc-82b0-a0f3799c3939"
|
state_hub_workstream_id: "a6cd9248-e591-51fc-82b0-a0f3799c3939"
|
||||||
---
|
---
|
||||||
|
|
@ -36,7 +37,7 @@ for callers that require authoritative state.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RMGR-WP-0011-T01
|
id: RMGR-WP-0011-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "b3b0f4d9-de14-5429-bc4d-a14d014491b0"
|
state_hub_task_id: "b3b0f4d9-de14-5429-bc4d-a14d014491b0"
|
||||||
```
|
```
|
||||||
|
|
@ -46,6 +47,15 @@ authoritative service routes. Treat additive fields as compatible, reject an
|
||||||
unknown schema, and do not couple Repo Manager to Nexus database tables or
|
unknown schema, and do not couple Repo Manager to Nexus database tables or
|
||||||
migration internals.
|
migration internals.
|
||||||
|
|
||||||
|
**Result (2026-08-22):** `docs/sbom-nexus-client-contract_v1.md` pins the
|
||||||
|
accepted `sbom-nexus.snapshot.v1` envelope and the Nexus-owned repository,
|
||||||
|
ingest/skip, latest-snapshot, immutable-history, and licence-report routes.
|
||||||
|
Additive response fields remain compatible; unknown schemas and missing
|
||||||
|
required fields produce deterministic contract errors. The source handoff now
|
||||||
|
joins `CUST-WP-0064`: Repo Manager supplies repository identity and revision,
|
||||||
|
while the authoritative scan consumes a controlled revision-pinned source. A
|
||||||
|
workstation filesystem must never be mounted into the cluster.
|
||||||
|
|
||||||
## Add the authoritative Nexus service client
|
## Add the authoritative Nexus service client
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
@ -64,7 +74,7 @@ introduced through the platform path, must never enter files, output, or logs.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RMGR-WP-0011-T03
|
id: RMGR-WP-0011-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "5cd717de-d696-5676-9abe-f1a701e48e7e"
|
state_hub_task_id: "5cd717de-d696-5676-9abe-f1a701e48e7e"
|
||||||
```
|
```
|
||||||
|
|
@ -75,6 +85,13 @@ non-authoritative, and not persisted. The compatibility aliases must not imply
|
||||||
that a preview advanced `last_attempt_at`, `last_success_at`, or snapshot
|
that a preview advanced `last_attempt_at`, `last_success_at`, or snapshot
|
||||||
history.
|
history.
|
||||||
|
|
||||||
|
**Result (2026-08-22):** both compatibility aliases now identify every success
|
||||||
|
and error as `mode: local-preview`, `authoritative: false`, `persisted: false`,
|
||||||
|
and explicitly state that no last-attempt, last-success, or snapshot-history
|
||||||
|
state advances. Saving preview JSON remains optional local evidence and cannot
|
||||||
|
be interpreted as an ingest receipt. CLI help, operator documentation, and
|
||||||
|
contract tests carry the same semantics.
|
||||||
|
|
||||||
## Prove cutover and remove ambiguity
|
## Prove cutover and remove ambiguity
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue