generated from coulomb/repo-seed
http service with health, extension listing, profile validation, run planning, async run jobs, job inspection, and report retrieval
This commit is contained in:
@@ -25,6 +25,7 @@ PYTHONPATH=src python3 -m guide_board run \
|
||||
PYTHONPATH=src python3 -m guide_board runs list
|
||||
PYTHONPATH=src python3 -m guide_board runs trend
|
||||
PYTHONPATH=src python3 -m guide_board runs gate
|
||||
PYTHONPATH=src python3 -m guide_board serve --host 127.0.0.1 --port 8080
|
||||
PYTHONPATH=src python3 -m unittest discover -s tests
|
||||
```
|
||||
|
||||
@@ -38,7 +39,9 @@ PYTHONPATH=src python3 -m guide_board --extension-dir ../open-cmis-tck plan \
|
||||
```
|
||||
|
||||
The same CLI contracts are packaged by the container baseline. See
|
||||
[docs/CONTAINER.md](docs/CONTAINER.md).
|
||||
[docs/CONTAINER.md](docs/CONTAINER.md). The dependency-light local API wraps
|
||||
those contracts for service and container operation; see
|
||||
[docs/LOCAL-SERVICE-API.md](docs/LOCAL-SERVICE-API.md).
|
||||
|
||||
The `sample-noop` extension exercises the guide-board contracts without invoking
|
||||
an external harness. `open-cmis-tck` is the first real seed extension.
|
||||
@@ -47,7 +50,9 @@ See:
|
||||
|
||||
- [INTENT.md](INTENT.md)
|
||||
- [docs/ARCHITECTURE-BLUEPRINT.md](docs/ARCHITECTURE-BLUEPRINT.md)
|
||||
- [docs/COMPLIANCE-EVIDENCE-PACKS.md](docs/COMPLIANCE-EVIDENCE-PACKS.md)
|
||||
- [docs/CONTAINER.md](docs/CONTAINER.md)
|
||||
- [docs/EXTENSION-SDK.md](docs/EXTENSION-SDK.md)
|
||||
- [docs/LOCAL-SERVICE-API.md](docs/LOCAL-SERVICE-API.md)
|
||||
- [extensions/CANDIDATES.md](extensions/CANDIDATES.md)
|
||||
- [workplans/GUIDE-BOARD-WP-0001-bootstrapping.md](workplans/GUIDE-BOARD-WP-0001-bootstrapping.md)
|
||||
|
||||
@@ -301,6 +301,15 @@ cluster, product, API, data archive, host, organization, process, or policy set.
|
||||
Assessment profiles select frameworks, controls, check groups, expectations,
|
||||
waivers, output policies, and retention policies.
|
||||
|
||||
### Local Service Facade
|
||||
|
||||
Wraps the CLI/core contracts in a dependency-light local HTTP API. The service
|
||||
can list extensions, validate profiles, build plans, start assessment jobs,
|
||||
inspect job status, and fetch generated reports.
|
||||
|
||||
The first implementation stores job status in memory and leaves durable evidence
|
||||
in the normal run directory. It does not introduce separate execution semantics.
|
||||
|
||||
### Assessment Planner
|
||||
|
||||
Resolves an assessment profile into an executable run plan:
|
||||
@@ -446,6 +455,11 @@ executable harness exists.
|
||||
|
||||
Examples: GDPR, SOC 2, HIPAA, NF Z 42-013, NF 461, ISO 14641, ISO 15489.
|
||||
|
||||
Procedural packs use evidence request sets to describe artifact collection,
|
||||
review roles, acceptance criteria, confidentiality, renewal expectations, and
|
||||
waiver paths without reproducing restricted standard text. See
|
||||
`docs/COMPLIANCE-EVIDENCE-PACKS.md`.
|
||||
|
||||
### Hybrid Extension
|
||||
|
||||
Combines automated checks, manual evidence, external auditor review, and imported
|
||||
|
||||
141
docs/COMPLIANCE-EVIDENCE-PACKS.md
Normal file
141
docs/COMPLIANCE-EVIDENCE-PACKS.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# Compliance Evidence Pack Strategy
|
||||
|
||||
Status: draft
|
||||
Created: 2026-05-07
|
||||
|
||||
## Purpose
|
||||
|
||||
Compliance evidence packs cover frameworks where guide-board cannot rely on an
|
||||
official executable harness. They help prepare and perform assessments by
|
||||
organizing evidence requests, expected artifacts, reviewer workflow, waivers,
|
||||
and run reports. They do not replace auditors, accredited certification bodies,
|
||||
legal counsel, or official standard text.
|
||||
|
||||
Examples include GDPR, SOC 2, HIPAA, NF Z 42-013, NF 461, ISO 14641, ISO 15489,
|
||||
and similar procedural or control-oriented frameworks.
|
||||
|
||||
## Core Boundary
|
||||
|
||||
Guide-board should keep four layers separate:
|
||||
|
||||
- Official source metadata: authority, framework version, source URL, access
|
||||
constraints, and citation references.
|
||||
- Internal interpretation: locally authored control themes, evidence request
|
||||
wording, review roles, and acceptance criteria.
|
||||
- Target evidence: artifacts, attestations, screenshots, logs, policy files,
|
||||
architecture documents, operational records, and interview notes.
|
||||
- Assessment result: normalized evidence, findings, waivers, trend summaries,
|
||||
and quality gates.
|
||||
|
||||
The core may store references to official clauses, controls, or articles, but it
|
||||
must not redistribute proprietary standard text. Extension authors are
|
||||
responsible for confirming source licensing and citation posture.
|
||||
|
||||
## Extension Shape
|
||||
|
||||
Compliance packs are ordinary extensions with `extension_type` set to
|
||||
`procedural_evidence` or `hybrid`.
|
||||
|
||||
Recommended extension layout:
|
||||
|
||||
```text
|
||||
<extension-id>/
|
||||
INTENT.md
|
||||
extension.json
|
||||
evidence-requests/
|
||||
<request-set-id>.json
|
||||
mappings/
|
||||
<mapping-set-id>.json
|
||||
profiles/
|
||||
reports/
|
||||
workplans/
|
||||
```
|
||||
|
||||
The manifest should declare:
|
||||
|
||||
- framework IDs in `supported_frameworks`,
|
||||
- authority IDs in `authorities`,
|
||||
- manual or procedural `check_groups`,
|
||||
- mapping sets that map request requirement refs to controls or assessment
|
||||
themes,
|
||||
- a certification boundary that states the pack is preparation support only.
|
||||
|
||||
## Evidence Request Sets
|
||||
|
||||
An evidence request set is a reviewable catalog of questions and artifacts. It
|
||||
validates against:
|
||||
|
||||
```text
|
||||
docs/schemas/evidence-request-set.schema.json
|
||||
```
|
||||
|
||||
Each request should include:
|
||||
|
||||
- stable request ID,
|
||||
- requirement refs or internal control refs,
|
||||
- request type, such as document, interview, configuration sample, operational
|
||||
record, attestation, system export, or mixed evidence,
|
||||
- requested artifact classes,
|
||||
- reviewer roles,
|
||||
- acceptance criteria,
|
||||
- confidentiality level,
|
||||
- renewal or freshness expectations.
|
||||
|
||||
Requests should be phrased as collection guidance, not as legal conclusions.
|
||||
|
||||
## Waivers And Expected Gaps
|
||||
|
||||
Evidence packs use the same expectation and waiver model as executable
|
||||
extensions.
|
||||
|
||||
Use expectations for:
|
||||
|
||||
- not-applicable scope boundaries,
|
||||
- unsupported-by-design target posture,
|
||||
- evidence that is intentionally deferred until a later assessment phase.
|
||||
|
||||
Use waivers for:
|
||||
|
||||
- approved exceptions,
|
||||
- compensating-control situations,
|
||||
- temporary missing evidence,
|
||||
- auditor-reviewed deviations.
|
||||
|
||||
Every waiver should include owner, reason, approval status, and expiry.
|
||||
|
||||
## Framework Notes
|
||||
|
||||
GDPR packs should emphasize processing inventory, lawful basis records, data
|
||||
subject rights, subprocessors, transfer posture, breach response, and privacy
|
||||
governance without providing legal advice.
|
||||
|
||||
SOC 2 packs should organize evidence by trust-service criteria references,
|
||||
control ownership, system boundaries, change management, security operations,
|
||||
vendor management, and incident response.
|
||||
|
||||
HIPAA packs should separate administrative, physical, and technical safeguard
|
||||
evidence from policy interpretation and should clearly mark protected-health
|
||||
information handling constraints.
|
||||
|
||||
NF Z 42-013, NF 461, ISO 14641, and ISO 15489 packs should focus on records
|
||||
management, archival integrity, traceability, retention, reversibility,
|
||||
auditability, and governance evidence. Proprietary standard text should be
|
||||
referenced only by stable IDs or user-provided licensed material.
|
||||
|
||||
## Output Model
|
||||
|
||||
Compliance packs should produce the same guide-board outputs as harness
|
||||
extensions:
|
||||
|
||||
- normalized evidence,
|
||||
- findings,
|
||||
- mapping records,
|
||||
- assessment packages,
|
||||
- retention summaries,
|
||||
- trend summaries,
|
||||
- gate summaries.
|
||||
|
||||
Manual requests can initially result in `manual`, `blocked`, `not_applicable`,
|
||||
or `expected_gap` evidence. Later pack-specific runners may import spreadsheets,
|
||||
document inventories, ticket exports, cloud configuration snapshots, or auditor
|
||||
review files and normalize them into the same evidence model.
|
||||
@@ -102,14 +102,17 @@ preflight runner, command wrapper, and mappings. It does not include the final
|
||||
Apache Chemistry TCK dependency graph. A future CMIS image should add Java/Maven
|
||||
and document how the OpenCMIS TCK artifacts are resolved or mounted.
|
||||
|
||||
## Service Path
|
||||
## Service Mode
|
||||
|
||||
A service image should call the same CLI contracts used here:
|
||||
The current core image can run the local service API through the same entrypoint:
|
||||
|
||||
- validate profiles,
|
||||
- build run plans,
|
||||
- execute runs,
|
||||
- read run metadata, evidence, reports, retention summaries, trends, and gates.
|
||||
```sh
|
||||
podman run --rm -p 8080:8080 \
|
||||
-v "$PWD/runs:/runs" \
|
||||
guide-board-core:local \
|
||||
--root /opt/guide-board serve --host 0.0.0.0 --port 8080
|
||||
```
|
||||
|
||||
The service layer may add job tracking and HTTP transport, but it should not
|
||||
create separate execution semantics.
|
||||
The service layer adds in-memory job tracking and HTTP transport. Execution
|
||||
semantics remain the CLI/core semantics documented in
|
||||
`docs/LOCAL-SERVICE-API.md`.
|
||||
|
||||
@@ -21,6 +21,7 @@ extensions/<extension-id>/
|
||||
src/
|
||||
docs/
|
||||
schemas/
|
||||
evidence-requests/
|
||||
checks/
|
||||
mappings/
|
||||
profiles/
|
||||
@@ -157,6 +158,32 @@ to extension-owned mappings and writes normalized mapping records to:
|
||||
runs/<run-id>/normalized/mappings.json
|
||||
```
|
||||
|
||||
## Evidence Request Sets
|
||||
|
||||
Procedural and hybrid compliance extensions may include evidence request sets
|
||||
under:
|
||||
|
||||
```text
|
||||
evidence-requests/<request-set-id>.json
|
||||
```
|
||||
|
||||
These files validate against:
|
||||
|
||||
```text
|
||||
docs/schemas/evidence-request-set.schema.json
|
||||
```
|
||||
|
||||
Evidence request sets are for collection guidance and review workflow. They
|
||||
should reference official requirements by stable IDs or user-held licensed
|
||||
material, but they must not redistribute proprietary standard text. A starter
|
||||
template lives at:
|
||||
|
||||
```text
|
||||
extensions/_template/evidence-request-set.json
|
||||
```
|
||||
|
||||
See `docs/COMPLIANCE-EVIDENCE-PACKS.md` for the compliance-pack strategy.
|
||||
|
||||
## Expectations And Waivers
|
||||
|
||||
Assessment profiles may reference expectation and waiver sets:
|
||||
|
||||
114
docs/LOCAL-SERVICE-API.md
Normal file
114
docs/LOCAL-SERVICE-API.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# Guide Board Local Service API
|
||||
|
||||
Status: draft
|
||||
Created: 2026-05-07
|
||||
|
||||
## Purpose
|
||||
|
||||
The local service API is a thin HTTP facade over the same discovery, validation,
|
||||
planning, execution, and report contracts used by the CLI. It is intended for
|
||||
local development, containerized operation, and future UI integration.
|
||||
|
||||
It does not change certification boundaries: guide-board prepares structured
|
||||
evidence and assessment packages, but it does not issue certifications or audit
|
||||
assurance.
|
||||
|
||||
## Start
|
||||
|
||||
```sh
|
||||
PYTHONPATH=src python3 -m guide_board serve --host 127.0.0.1 --port 8080
|
||||
```
|
||||
|
||||
External extension repositories use the same top-level option as the CLI:
|
||||
|
||||
```sh
|
||||
PYTHONPATH=src python3 -m guide_board \
|
||||
--extension-dir ../open-cmis-tck \
|
||||
serve --host 127.0.0.1 --port 8080
|
||||
```
|
||||
|
||||
Paths supplied to request bodies are resolved relative to the configured
|
||||
`--root` unless they are absolute paths.
|
||||
|
||||
## Endpoints
|
||||
|
||||
### `GET /health`
|
||||
|
||||
Returns service status, configured root, and in-memory job counts.
|
||||
|
||||
### `GET /extensions`
|
||||
|
||||
Lists bundled and configured external extensions using the same discovery logic
|
||||
as `guide-board extensions list`.
|
||||
|
||||
### `POST /profiles/validate`
|
||||
|
||||
Validates a target or assessment profile.
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "target",
|
||||
"path": "profiles/targets/sample-repository.json"
|
||||
}
|
||||
```
|
||||
|
||||
Use `"kind": "assessment"` for assessment profiles.
|
||||
|
||||
### `POST /assessments/plan`
|
||||
|
||||
Builds a run plan without starting a run.
|
||||
|
||||
```json
|
||||
{
|
||||
"target": "profiles/targets/sample-repository.json",
|
||||
"assessment": "profiles/assessments/sample-noop.json"
|
||||
}
|
||||
```
|
||||
|
||||
An optional `extension_dirs` array can add request-specific external extension
|
||||
locations.
|
||||
|
||||
### `POST /runs`
|
||||
|
||||
Starts an assessment run in a background thread and returns a job record.
|
||||
|
||||
```json
|
||||
{
|
||||
"target": "profiles/targets/sample-repository.json",
|
||||
"assessment": "profiles/assessments/sample-noop.json",
|
||||
"output_dir": "runs/sample-noop"
|
||||
}
|
||||
```
|
||||
|
||||
The HTTP job status is `queued`, `running`, `succeeded`, or `failed`. A
|
||||
`succeeded` job means the guide-board executor completed and wrote its normal
|
||||
run directory; the assessment result itself is still reported separately as
|
||||
`completed`, `failed`, `blocked`, or `infrastructure_error`.
|
||||
|
||||
### `GET /runs`
|
||||
|
||||
Lists known in-memory jobs for the current service process.
|
||||
|
||||
### `GET /runs/{job_id}`
|
||||
|
||||
Returns a single job record with request metadata, result paths, or execution
|
||||
errors.
|
||||
|
||||
### `GET /runs/{job_id}/reports`
|
||||
|
||||
Returns the Markdown report content, assessment package JSON, retention summary,
|
||||
and their filesystem paths after a job has succeeded.
|
||||
|
||||
## Container Mode
|
||||
|
||||
The core container can run the service through the existing entrypoint:
|
||||
|
||||
```sh
|
||||
podman run --rm -p 8080:8080 \
|
||||
-v "$PWD/runs:/runs" \
|
||||
guide-board-core:local \
|
||||
--root /opt/guide-board serve --host 0.0.0.0 --port 8080
|
||||
```
|
||||
|
||||
The service keeps job state in memory. Durable run evidence remains in the
|
||||
mounted output directory.
|
||||
65
docs/schemas/evidence-request-set.schema.json
Normal file
65
docs/schemas/evidence-request-set.schema.json
Normal file
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "Guide Board Evidence Request Set",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"extension_id",
|
||||
"framework_refs",
|
||||
"source_boundary",
|
||||
"evidence_requests"
|
||||
],
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"extension_id": { "type": "string" },
|
||||
"framework_refs": { "type": "array", "items": { "type": "string" } },
|
||||
"source_boundary": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"official_sources",
|
||||
"interpretation_owner",
|
||||
"redistribution_policy",
|
||||
"certification_boundary"
|
||||
],
|
||||
"properties": {
|
||||
"official_sources": { "type": "array", "items": { "type": "string" } },
|
||||
"interpretation_owner": { "type": "string" },
|
||||
"redistribution_policy": { "type": "string" },
|
||||
"certification_boundary": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"evidence_requests": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"title",
|
||||
"requirement_refs",
|
||||
"request_type",
|
||||
"description",
|
||||
"requested_artifacts",
|
||||
"review_roles",
|
||||
"acceptance_criteria",
|
||||
"confidentiality",
|
||||
"renewal"
|
||||
],
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"title": { "type": "string" },
|
||||
"requirement_refs": { "type": "array", "items": { "type": "string" } },
|
||||
"request_type": { "type": "string" },
|
||||
"description": { "type": "string" },
|
||||
"requested_artifacts": { "type": "array", "items": { "type": "string" } },
|
||||
"review_roles": { "type": "array", "items": { "type": "string" } },
|
||||
"acceptance_criteria": { "type": "array", "items": { "type": "string" } },
|
||||
"confidentiality": { "type": "string" },
|
||||
"renewal": { "type": "object" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
43
extensions/_template/evidence-request-set.json
Normal file
43
extensions/_template/evidence-request-set.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"id": "replace-with-request-set-id",
|
||||
"extension_id": "replace-with-extension-id",
|
||||
"framework_refs": [
|
||||
"replace-with-framework-id"
|
||||
],
|
||||
"source_boundary": {
|
||||
"official_sources": [
|
||||
"https://example.invalid/official-source-or-reference"
|
||||
],
|
||||
"interpretation_owner": "replace-with-owner",
|
||||
"redistribution_policy": "Do not copy restricted standard text into this file; use stable IDs and user-held licensed material.",
|
||||
"certification_boundary": "This evidence request set supports assessment preparation only."
|
||||
},
|
||||
"evidence_requests": [
|
||||
{
|
||||
"id": "request-001",
|
||||
"title": "Replace With Evidence Request Title",
|
||||
"requirement_refs": [
|
||||
"replace-with-requirement-ref"
|
||||
],
|
||||
"request_type": "document",
|
||||
"description": "Describe the evidence needed without reproducing restricted standard text.",
|
||||
"requested_artifacts": [
|
||||
"policy document",
|
||||
"review record"
|
||||
],
|
||||
"review_roles": [
|
||||
"control owner",
|
||||
"assessor"
|
||||
],
|
||||
"acceptance_criteria": [
|
||||
"Artifact is current for the assessment period.",
|
||||
"Owner and review date are identifiable."
|
||||
],
|
||||
"confidentiality": "internal",
|
||||
"renewal": {
|
||||
"freshness": "per assessment cycle",
|
||||
"review_interval_days": 365
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -20,6 +20,7 @@ from guide_board.planning import (
|
||||
)
|
||||
from guide_board.retention import build_trend_summary, list_retained_runs
|
||||
from guide_board.schema import assert_valid
|
||||
from guide_board.service import build_server
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
@@ -82,6 +83,11 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
run.add_argument("--output-dir", type=Path)
|
||||
run.set_defaults(func=cmd_run)
|
||||
|
||||
serve = subcommands.add_parser("serve", help="serve the local HTTP API")
|
||||
serve.add_argument("--host", default="127.0.0.1")
|
||||
serve.add_argument("--port", type=int, default=8080)
|
||||
serve.set_defaults(func=cmd_serve)
|
||||
|
||||
runs = subcommands.add_parser("runs", help="run history operations")
|
||||
runs_commands = runs.add_subparsers(required=True)
|
||||
list_runs = runs_commands.add_parser("list", help="list retained run summaries")
|
||||
@@ -160,6 +166,19 @@ def cmd_run(args: argparse.Namespace) -> dict[str, Any]:
|
||||
)
|
||||
|
||||
|
||||
def cmd_serve(args: argparse.Namespace) -> None:
|
||||
server = build_server(args.root, args.extension_dir, args.host, args.port)
|
||||
host, port = server.server_address
|
||||
print(f"guide-board: serving local API on http://{host}:{port}", file=sys.stderr)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("guide-board: stopping local API", file=sys.stderr)
|
||||
finally:
|
||||
server.server_close()
|
||||
return None
|
||||
|
||||
|
||||
def cmd_runs_list(args: argparse.Namespace) -> dict[str, Any]:
|
||||
runs_dir = args.runs_dir or args.root / "runs"
|
||||
return {
|
||||
|
||||
451
src/guide_board/service.py
Normal file
451
src/guide_board/service.py
Normal file
@@ -0,0 +1,451 @@
|
||||
"""Dependency-light local HTTP API for guide-board."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from guide_board.discovery import discover_extensions
|
||||
from guide_board.errors import GuideBoardError
|
||||
from guide_board.execution import run_assessment
|
||||
from guide_board.io import load_json
|
||||
from guide_board.planning import (
|
||||
build_run_plan,
|
||||
validate_assessment_profile,
|
||||
validate_target_profile,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServiceHandle:
|
||||
"""Background service handle for tests and local embedding."""
|
||||
|
||||
server: "GuideBoardHTTPServer"
|
||||
thread: threading.Thread
|
||||
|
||||
@property
|
||||
def host(self) -> str:
|
||||
return str(self.server.server_address[0])
|
||||
|
||||
@property
|
||||
def port(self) -> int:
|
||||
return int(self.server.server_address[1])
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
return f"http://{self.host}:{self.port}"
|
||||
|
||||
def stop(self) -> None:
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(timeout=5)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServiceContext:
|
||||
root: Path
|
||||
extension_dirs: list[Path]
|
||||
jobs: "JobStore"
|
||||
|
||||
|
||||
class JobStore:
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._jobs: dict[str, dict[str, Any]] = {}
|
||||
|
||||
def create(self, request: dict[str, Any]) -> dict[str, Any]:
|
||||
now = _now()
|
||||
job = {
|
||||
"job_id": uuid.uuid4().hex,
|
||||
"status": "queued",
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"request": request,
|
||||
"result": None,
|
||||
"error": None,
|
||||
}
|
||||
with self._lock:
|
||||
self._jobs[job["job_id"]] = job
|
||||
return dict(job)
|
||||
|
||||
def update(self, job_id: str, **updates: Any) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
if job_id not in self._jobs:
|
||||
raise HttpProblem(404, f"run job not found: {job_id}")
|
||||
job = dict(self._jobs[job_id])
|
||||
job.update(updates)
|
||||
job["updated_at"] = _now()
|
||||
self._jobs[job_id] = job
|
||||
return dict(job)
|
||||
|
||||
def get(self, job_id: str) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
if job_id not in self._jobs:
|
||||
raise HttpProblem(404, f"run job not found: {job_id}")
|
||||
return dict(self._jobs[job_id])
|
||||
|
||||
def list(self) -> list[dict[str, Any]]:
|
||||
with self._lock:
|
||||
return [dict(job) for job in self._jobs.values()]
|
||||
|
||||
def summary(self) -> dict[str, int]:
|
||||
counts: dict[str, int] = {}
|
||||
with self._lock:
|
||||
for job in self._jobs.values():
|
||||
counts[job["status"]] = counts.get(job["status"], 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
class HttpProblem(Exception):
|
||||
def __init__(self, status_code: int, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.message = message
|
||||
|
||||
|
||||
class GuideBoardHTTPServer(ThreadingHTTPServer):
|
||||
context: ServiceContext
|
||||
daemon_threads = True
|
||||
|
||||
|
||||
class GuideBoardRequestHandler(BaseHTTPRequestHandler):
|
||||
server: GuideBoardHTTPServer
|
||||
server_version = "GuideBoardLocalAPI/0.1"
|
||||
|
||||
def do_GET(self) -> None:
|
||||
self._handle("GET")
|
||||
|
||||
def do_POST(self) -> None:
|
||||
self._handle("POST")
|
||||
|
||||
def log_message(self, format: str, *args: Any) -> None:
|
||||
return
|
||||
|
||||
def _handle(self, method: str) -> None:
|
||||
parsed = urlparse(self.path)
|
||||
try:
|
||||
response, status_code = self._route(method, parsed.path)
|
||||
except HttpProblem as exc:
|
||||
response = _error_response(exc.message, exc.__class__.__name__, exc.status_code)
|
||||
status_code = exc.status_code
|
||||
except GuideBoardError as exc:
|
||||
response = _error_response(str(exc), exc.__class__.__name__, 400)
|
||||
status_code = 400
|
||||
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
||||
response = _error_response(str(exc), exc.__class__.__name__, 400)
|
||||
status_code = 400
|
||||
except Exception as exc:
|
||||
response = _error_response(str(exc), exc.__class__.__name__, 500)
|
||||
status_code = 500
|
||||
|
||||
self._send_json(status_code, response)
|
||||
|
||||
def _route(self, method: str, path: str) -> tuple[dict[str, Any], int]:
|
||||
if method == "GET" and path == "/health":
|
||||
return self._health(), 200
|
||||
if method == "GET" and path == "/extensions":
|
||||
return self._extensions(), 200
|
||||
if method == "POST" and path == "/profiles/validate":
|
||||
return self._validate_profile(), 200
|
||||
if method == "POST" and path == "/assessments/plan":
|
||||
return self._plan_assessment(), 200
|
||||
if method == "GET" and path == "/runs":
|
||||
return {"runs": self.server.context.jobs.list()}, 200
|
||||
if method == "POST" and path == "/runs":
|
||||
return self._start_run(), 202
|
||||
|
||||
run_match = _match_run_path(path)
|
||||
if method == "GET" and run_match is not None:
|
||||
job_id, suffix = run_match
|
||||
if suffix is None:
|
||||
return self.server.context.jobs.get(job_id), 200
|
||||
if suffix == "reports":
|
||||
return self._run_reports(job_id), 200
|
||||
|
||||
raise HttpProblem(404, f"endpoint not found: {method} {path}")
|
||||
|
||||
def _health(self) -> dict[str, Any]:
|
||||
return {
|
||||
"status": "ok",
|
||||
"service": "guide-board",
|
||||
"root": str(self.server.context.root),
|
||||
"jobs": self.server.context.jobs.summary(),
|
||||
}
|
||||
|
||||
def _extensions(self) -> dict[str, Any]:
|
||||
root = self.server.context.root
|
||||
return {
|
||||
"extensions": [
|
||||
{
|
||||
"id": extension.id,
|
||||
"name": extension.manifest["name"],
|
||||
"version": extension.manifest["version"],
|
||||
"type": extension.manifest["extension_type"],
|
||||
"path": _display_path(root, extension.path),
|
||||
"source": extension.source,
|
||||
}
|
||||
for extension in discover_extensions(root, self.server.context.extension_dirs)
|
||||
]
|
||||
}
|
||||
|
||||
def _validate_profile(self) -> dict[str, Any]:
|
||||
payload = self._read_payload()
|
||||
kind = _required_string(payload, "kind")
|
||||
path = _path_from_payload(self.server.context.root, payload, "path")
|
||||
if kind == "target":
|
||||
profile = validate_target_profile(path)
|
||||
return {
|
||||
"status": "valid",
|
||||
"profile_kind": "target",
|
||||
"profile_id": profile["id"],
|
||||
"path": str(path),
|
||||
}
|
||||
if kind == "assessment":
|
||||
profile = validate_assessment_profile(path)
|
||||
return {
|
||||
"status": "valid",
|
||||
"profile_kind": "assessment",
|
||||
"profile_id": profile["id"],
|
||||
"path": str(path),
|
||||
}
|
||||
raise HttpProblem(400, "kind must be 'target' or 'assessment'")
|
||||
|
||||
def _plan_assessment(self) -> dict[str, Any]:
|
||||
payload = self._read_payload()
|
||||
target = _path_from_payload(self.server.context.root, payload, "target")
|
||||
assessment = _path_from_payload(self.server.context.root, payload, "assessment")
|
||||
extension_dirs = _extension_dirs_from_payload(self.server.context, payload)
|
||||
return build_run_plan(self.server.context.root, target, assessment, extension_dirs)
|
||||
|
||||
def _start_run(self) -> dict[str, Any]:
|
||||
payload = self._read_payload()
|
||||
target = _path_from_payload(self.server.context.root, payload, "target")
|
||||
assessment = _path_from_payload(self.server.context.root, payload, "assessment")
|
||||
output_dir = _optional_path_from_payload(self.server.context.root, payload, "output_dir")
|
||||
extension_dirs = _extension_dirs_from_payload(self.server.context, payload)
|
||||
request = {
|
||||
"target": str(target),
|
||||
"assessment": str(assessment),
|
||||
"output_dir": str(output_dir) if output_dir is not None else None,
|
||||
"extension_dirs": [str(path) for path in extension_dirs],
|
||||
}
|
||||
job = self.server.context.jobs.create(request)
|
||||
thread = threading.Thread(
|
||||
target=_execute_run_job,
|
||||
args=(
|
||||
self.server.context,
|
||||
job["job_id"],
|
||||
target,
|
||||
assessment,
|
||||
output_dir,
|
||||
extension_dirs,
|
||||
),
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
return job
|
||||
|
||||
def _run_reports(self, job_id: str) -> dict[str, Any]:
|
||||
job = self.server.context.jobs.get(job_id)
|
||||
result = job.get("result")
|
||||
if job["status"] != "succeeded" or not isinstance(result, dict):
|
||||
raise HttpProblem(409, f"reports are not available for job status {job['status']}")
|
||||
|
||||
report_path = Path(result["report"])
|
||||
package_path = Path(result["assessment_package"])
|
||||
retention_path = Path(result["retention_summary"])
|
||||
try:
|
||||
report_markdown = report_path.read_text(encoding="utf-8")
|
||||
assessment_package = load_json(package_path)
|
||||
retention_summary = load_json(retention_path)
|
||||
except OSError as exc:
|
||||
raise HttpProblem(404, f"run report artifact is missing: {exc}") from exc
|
||||
|
||||
return {
|
||||
"job_id": job_id,
|
||||
"run_id": result["run_id"],
|
||||
"status": job["status"],
|
||||
"assessment_status": result["status"],
|
||||
"paths": {
|
||||
"report": str(report_path),
|
||||
"assessment_package": str(package_path),
|
||||
"retention_summary": str(retention_path),
|
||||
},
|
||||
"report": {
|
||||
"path": str(report_path),
|
||||
"markdown": report_markdown,
|
||||
},
|
||||
"assessment_package": {
|
||||
"path": str(package_path),
|
||||
"json": assessment_package,
|
||||
},
|
||||
"retention_summary": {
|
||||
"path": str(retention_path),
|
||||
"json": retention_summary,
|
||||
},
|
||||
}
|
||||
|
||||
def _read_payload(self) -> dict[str, Any]:
|
||||
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||
if length == 0:
|
||||
return {}
|
||||
payload = json.loads(self.rfile.read(length).decode("utf-8"))
|
||||
if not isinstance(payload, dict):
|
||||
raise HttpProblem(400, "request body must be a JSON object")
|
||||
return payload
|
||||
|
||||
def _send_json(self, status_code: int, payload: dict[str, Any]) -> None:
|
||||
body = json.dumps(payload, indent=2, sort_keys=True).encode("utf-8")
|
||||
self.send_response(status_code)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Access-Control-Allow-Origin", "http://127.0.0.1")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
|
||||
def build_server(
|
||||
root: Path,
|
||||
extension_dirs: list[Path] | None = None,
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 8080,
|
||||
) -> GuideBoardHTTPServer:
|
||||
server = GuideBoardHTTPServer((host, port), GuideBoardRequestHandler)
|
||||
server.context = ServiceContext(
|
||||
root=root.expanduser().resolve(),
|
||||
extension_dirs=[path.expanduser().resolve() for path in extension_dirs or []],
|
||||
jobs=JobStore(),
|
||||
)
|
||||
return server
|
||||
|
||||
|
||||
def start_service(
|
||||
root: Path,
|
||||
extension_dirs: list[Path] | None = None,
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 8080,
|
||||
) -> ServiceHandle:
|
||||
server = build_server(root, extension_dirs, host, port)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
return ServiceHandle(server=server, thread=thread)
|
||||
|
||||
|
||||
def serve_forever(
|
||||
root: Path,
|
||||
extension_dirs: list[Path] | None = None,
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 8080,
|
||||
) -> None:
|
||||
server = build_server(root, extension_dirs, host, port)
|
||||
try:
|
||||
server.serve_forever()
|
||||
finally:
|
||||
server.server_close()
|
||||
|
||||
|
||||
def _execute_run_job(
|
||||
context: ServiceContext,
|
||||
job_id: str,
|
||||
target: Path,
|
||||
assessment: Path,
|
||||
output_dir: Path | None,
|
||||
extension_dirs: list[Path],
|
||||
) -> None:
|
||||
context.jobs.update(job_id, status="running", started_at=_now())
|
||||
try:
|
||||
result = run_assessment(context.root, target, assessment, output_dir, extension_dirs)
|
||||
except (GuideBoardError, OSError, ValueError) as exc:
|
||||
context.jobs.update(
|
||||
job_id,
|
||||
status="failed",
|
||||
completed_at=_now(),
|
||||
error={"type": exc.__class__.__name__, "message": str(exc)},
|
||||
)
|
||||
except Exception as exc:
|
||||
context.jobs.update(
|
||||
job_id,
|
||||
status="failed",
|
||||
completed_at=_now(),
|
||||
error={"type": exc.__class__.__name__, "message": str(exc)},
|
||||
)
|
||||
else:
|
||||
context.jobs.update(job_id, status="succeeded", completed_at=_now(), result=result)
|
||||
|
||||
|
||||
def _path_from_payload(root: Path, payload: dict[str, Any], field: str) -> Path:
|
||||
return _resolve_path(root, _required_string(payload, field))
|
||||
|
||||
|
||||
def _optional_path_from_payload(root: Path, payload: dict[str, Any], field: str) -> Path | None:
|
||||
value = payload.get(field)
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, str) or not value:
|
||||
raise HttpProblem(400, f"{field} must be a non-empty string")
|
||||
return _resolve_path(root, value)
|
||||
|
||||
|
||||
def _extension_dirs_from_payload(
|
||||
context: ServiceContext,
|
||||
payload: dict[str, Any],
|
||||
) -> list[Path]:
|
||||
extension_dirs = list(context.extension_dirs)
|
||||
value = payload.get("extension_dirs")
|
||||
if value is None:
|
||||
return extension_dirs
|
||||
if not isinstance(value, list) or not all(isinstance(item, str) and item for item in value):
|
||||
raise HttpProblem(400, "extension_dirs must be a list of non-empty strings")
|
||||
extension_dirs.extend(_resolve_path(context.root, item) for item in value)
|
||||
return extension_dirs
|
||||
|
||||
|
||||
def _required_string(payload: dict[str, Any], field: str) -> str:
|
||||
value = payload.get(field)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise HttpProblem(400, f"{field} must be a non-empty string")
|
||||
return value
|
||||
|
||||
|
||||
def _resolve_path(root: Path, value: str) -> Path:
|
||||
path = Path(value).expanduser()
|
||||
if not path.is_absolute():
|
||||
path = root / path
|
||||
return path.resolve()
|
||||
|
||||
|
||||
def _match_run_path(path: str) -> tuple[str, str | None] | None:
|
||||
parts = [part for part in path.split("/") if part]
|
||||
if len(parts) == 2 and parts[0] == "runs":
|
||||
return parts[1], None
|
||||
if len(parts) == 3 and parts[0] == "runs":
|
||||
return parts[1], parts[2]
|
||||
return None
|
||||
|
||||
|
||||
def _display_path(root: Path, path: Path) -> str:
|
||||
try:
|
||||
return str(path.resolve().relative_to(root.resolve()))
|
||||
except ValueError:
|
||||
return str(path.resolve())
|
||||
|
||||
|
||||
def _error_response(message: str, error_type: str, status_code: int) -> dict[str, Any]:
|
||||
return {
|
||||
"error": {
|
||||
"type": error_type,
|
||||
"status": status_code,
|
||||
"message": message,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
@@ -1,13 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
import http.client
|
||||
import json
|
||||
import time
|
||||
import unittest
|
||||
from tempfile import TemporaryDirectory
|
||||
from pathlib import Path
|
||||
|
||||
from guide_board.discovery import discover_extensions
|
||||
from guide_board.execution import run_assessment
|
||||
from guide_board.gates import evaluate_trend_gates
|
||||
from guide_board.io import load_json
|
||||
from guide_board.planning import (
|
||||
build_run_plan,
|
||||
validate_assessment_profile,
|
||||
@@ -15,6 +18,7 @@ from guide_board.planning import (
|
||||
)
|
||||
from guide_board.retention import build_trend_summary, list_retained_runs
|
||||
from guide_board.schema import assert_valid
|
||||
from guide_board.service import ServiceHandle, start_service
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -35,6 +39,15 @@ class CoreArchitectureTests(unittest.TestCase):
|
||||
self.assertEqual(target["id"], "sample-repository")
|
||||
self.assertEqual(assessment["target_profile_ref"], "sample-repository")
|
||||
|
||||
def test_validates_evidence_request_template(self) -> None:
|
||||
request_set = load_json(ROOT / "extensions" / "_template" / "evidence-request-set.json")
|
||||
|
||||
assert_valid(request_set, "evidence-request-set")
|
||||
self.assertEqual(
|
||||
request_set["source_boundary"]["certification_boundary"],
|
||||
"This evidence request set supports assessment preparation only.",
|
||||
)
|
||||
|
||||
def test_builds_sample_run_plan(self) -> None:
|
||||
plan = build_run_plan(
|
||||
ROOT,
|
||||
@@ -164,6 +177,70 @@ class CoreArchitectureTests(unittest.TestCase):
|
||||
self.assertEqual(len(mappings), 1)
|
||||
self.assertEqual(mappings[0]["target_id"], "profile-readiness")
|
||||
|
||||
def test_serves_local_api_run_lifecycle(self) -> None:
|
||||
with TemporaryDirectory() as temporary_directory:
|
||||
service = start_service(ROOT, host="127.0.0.1", port=0)
|
||||
try:
|
||||
health = _request_json(service, "GET", "/health")
|
||||
self.assertEqual(health["status"], "ok")
|
||||
|
||||
extensions = _request_json(service, "GET", "/extensions")
|
||||
self.assertIn(
|
||||
"sample-noop",
|
||||
[extension["id"] for extension in extensions["extensions"]],
|
||||
)
|
||||
|
||||
target_validation = _request_json(
|
||||
service,
|
||||
"POST",
|
||||
"/profiles/validate",
|
||||
{
|
||||
"kind": "target",
|
||||
"path": "profiles/targets/sample-repository.json",
|
||||
},
|
||||
)
|
||||
self.assertEqual(target_validation["profile_id"], "sample-repository")
|
||||
|
||||
plan = _request_json(
|
||||
service,
|
||||
"POST",
|
||||
"/assessments/plan",
|
||||
{
|
||||
"target": "profiles/targets/sample-repository.json",
|
||||
"assessment": "profiles/assessments/sample-noop.json",
|
||||
},
|
||||
)
|
||||
self.assertEqual(plan["target_profile_snapshot"]["id"], "sample-repository")
|
||||
|
||||
job = _request_json(
|
||||
service,
|
||||
"POST",
|
||||
"/runs",
|
||||
{
|
||||
"target": "profiles/targets/sample-repository.json",
|
||||
"assessment": "profiles/assessments/sample-noop.json",
|
||||
"output_dir": str(Path(temporary_directory) / "api-run"),
|
||||
},
|
||||
expected_status=202,
|
||||
)
|
||||
status = _wait_for_job(service, job["job_id"])
|
||||
|
||||
self.assertEqual(status["status"], "succeeded")
|
||||
self.assertEqual(status["result"]["status"], "completed")
|
||||
|
||||
reports = _request_json(
|
||||
service,
|
||||
"GET",
|
||||
f"/runs/{job['job_id']}/reports",
|
||||
)
|
||||
self.assertIn("Guide Board Assessment Report", reports["report"]["markdown"])
|
||||
self.assertEqual(
|
||||
reports["assessment_package"]["json"]["run_id"],
|
||||
status["result"]["run_id"],
|
||||
)
|
||||
finally:
|
||||
service.stop()
|
||||
|
||||
def test_builds_retained_run_trends(self) -> None:
|
||||
with TemporaryDirectory() as temporary_directory:
|
||||
runs_dir = Path(temporary_directory)
|
||||
@@ -293,6 +370,42 @@ def _write_retention_summary(
|
||||
)
|
||||
|
||||
|
||||
def _request_json(
|
||||
service: ServiceHandle,
|
||||
method: str,
|
||||
path: str,
|
||||
payload: dict[str, object] | None = None,
|
||||
expected_status: int = 200,
|
||||
) -> dict[str, object]:
|
||||
connection = http.client.HTTPConnection(service.host, service.port, timeout=5)
|
||||
body = None
|
||||
headers = {}
|
||||
if payload is not None:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
try:
|
||||
connection.request(method, path, body=body, headers=headers)
|
||||
response = connection.getresponse()
|
||||
data = response.read().decode("utf-8")
|
||||
finally:
|
||||
connection.close()
|
||||
if response.status != expected_status:
|
||||
raise AssertionError(f"expected HTTP {expected_status}, got {response.status}: {data}")
|
||||
value = json.loads(data)
|
||||
if not isinstance(value, dict):
|
||||
raise AssertionError(f"expected JSON object response, got {type(value).__name__}")
|
||||
return value
|
||||
|
||||
|
||||
def _wait_for_job(service: ServiceHandle, job_id: str) -> dict[str, object]:
|
||||
for _ in range(50):
|
||||
status = _request_json(service, "GET", f"/runs/{job_id}")
|
||||
if status["status"] in {"succeeded", "failed"}:
|
||||
return status
|
||||
time.sleep(0.05)
|
||||
raise AssertionError(f"job did not finish: {job_id}")
|
||||
|
||||
|
||||
def _write_external_extension(extension_dir: Path) -> None:
|
||||
extension_dir.mkdir(parents=True, exist_ok=True)
|
||||
(extension_dir / "extension.json").write_text(
|
||||
|
||||
@@ -221,7 +221,7 @@ Acceptance:
|
||||
|
||||
```task
|
||||
id: GUIDE-BOARD-WP-0001-T007
|
||||
status: in_progress
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "455f92b0-1d2b-43d0-aa61-464d9dc83a62"
|
||||
```
|
||||
@@ -246,6 +246,8 @@ Progress:
|
||||
- The core now supports external extension repositories via `--extension-dir`
|
||||
and `GUIDE_BOARD_EXTENSION_PATHS`; `open-cmis-tck` has been split into its own
|
||||
extension repo.
|
||||
- Split-repo guide-board CLI discovery, planning, and run smoke tests work with
|
||||
`open-cmis-tck` as an external extension.
|
||||
|
||||
## D1.9 - Containerized Execution Design
|
||||
|
||||
@@ -278,7 +280,7 @@ Progress:
|
||||
|
||||
```task
|
||||
id: GUIDE-BOARD-WP-0001-T009
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "58bd6ec6-2cf3-450f-95a7-b695aaf80609"
|
||||
```
|
||||
@@ -290,11 +292,21 @@ Acceptance:
|
||||
- Long-running jobs are tracked without blocking the API process.
|
||||
- CLI remains the source of truth for execution semantics.
|
||||
|
||||
Progress:
|
||||
|
||||
- Added dependency-light local HTTP service module and `guide-board serve`.
|
||||
- Added endpoints for health, extension listing, profile validation, planning,
|
||||
asynchronous run jobs, job inspection, and report retrieval.
|
||||
- Documented service and container modes in `docs/LOCAL-SERVICE-API.md` and
|
||||
`docs/CONTAINER.md`.
|
||||
- Added lifecycle tests that start the API, run the sample assessment as a
|
||||
background job, and fetch generated reports.
|
||||
|
||||
## D1.11 - Compliance Evidence Pack Strategy
|
||||
|
||||
```task
|
||||
id: GUIDE-BOARD-WP-0001-T010
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "2f845860-ade9-4d31-91c7-cb1c69dc4e1b"
|
||||
```
|
||||
@@ -308,6 +320,14 @@ Acceptance:
|
||||
- Provide a reviewable evidence-request and waiver model suitable for auditor
|
||||
collaboration.
|
||||
|
||||
Progress:
|
||||
|
||||
- Added `docs/COMPLIANCE-EVIDENCE-PACKS.md`.
|
||||
- Added `docs/schemas/evidence-request-set.schema.json`.
|
||||
- Added `extensions/_template/evidence-request-set.json`.
|
||||
- Documented evidence request sets in the extension SDK and architecture
|
||||
blueprint.
|
||||
|
||||
## Definition Of Done
|
||||
|
||||
- The repository identity is `guide-board`.
|
||||
|
||||
Reference in New Issue
Block a user