kontract 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.4
2
+ Name: kontract
3
+ Version: 0.1.0
4
+ Summary: Cross-repo fetch of a capability's process/contract tier (ADR-BCM-URBA-0016 / ADR-DSN-0002).
5
+ Author: yremy
6
+ License: Proprietary
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: kpack<3,>=2.0.0
10
+ Requires-Dist: pyyaml<7,>=6.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=7; extra == "dev"
13
+ Requires-Dist: build>=1.0; extra == "dev"
14
+
15
+ # kontract
16
+
17
+ Cross-repo fetch of a business capability's **process/contract tier** — the DDD
18
+ tactical model (`aggregates.yaml`, `commands.yaml`, `policies.yaml`,
19
+ `read-models.yaml`, `bus.yaml`, `api.yaml`, JSON schemas) that says HOW a
20
+ capability is actually contracted, plus the `.bcm-provenance.json` pin that says
21
+ which corpus commit it derives from (ADR-DSN-0001).
22
+
23
+ Sibling of [`kpack`](https://github.com/papeete-hub/kpack), sharing its transport
24
+ only: `kontract` depends on `kpack` as a library (`kpack.config`, `kpack.registry`,
25
+ `kpack.remote`, `kpack.provenance`) and adds the one resolution step kpack doesn't
26
+ have — a business context to the *solution* context that `refines` it
27
+ (ADR-GCM-URBA-0006). See
28
+ [`reliever-design/adr/ADR-DSN-0002`](https://github.com/papeete-foundry/reliever-design/blob/main/adr/ADR-DSN-0002-kontract-resolution.md)
29
+ for the design record.
30
+
31
+ ## How it works
32
+
33
+ ```
34
+ CAP_ID (e.g. BNK.RLVR.CAP.SUP.002.BEN)
35
+ -> business context (segs 0-1, e.g. BNK.RLVR) -- kpack's own bootstrap --
36
+ -> ENTERPRISE (seg 0) -> kpack config -> registry repo -> vocab.yaml contexts:
37
+ -> confirm business context is registered -- new: kontract's own step --
38
+ -> scan contexts for kind: solution, refines: <business context> (exactly one expected)
39
+ -> that context's repo -> kpack.remote.resolve_repo_root (fetch + cache, same as kpack)
40
+ -> serve process/<CAP_ID>/ from the resolved checkout
41
+ ```
42
+
43
+ No caching beyond kpack's own git-clone cache (`--cache-dir` / `--no-fetch` /
44
+ `--fresh`) — every call is a fresh fetch by design.
45
+
46
+ ## Install
47
+
48
+ ```bash
49
+ pip install -e ".[dev]"
50
+ ```
51
+
52
+ ## Usage
53
+
54
+ ```bash
55
+ kontract fetch <CAP_ID> [--ref REF] [--repo-root PATH] [--cache-dir DIR]
56
+ [--no-fetch] [--fresh] [--config PATH]
57
+ [--registry-repo SLUG] [--registry-ref REF] [--compact]
58
+ ```
59
+
60
+ Uses the same `~/.config/kpack/config.yaml` / `./.kpack.yaml` / `--config`
61
+ enterprise -> registry bootstrap as `kpack` — no separate kontract config file.
62
+
63
+ ## Develop
64
+
65
+ ```bash
66
+ make test # pytest against a fake business+solution repo pair (tests/conftest.py)
67
+ ```
@@ -0,0 +1,53 @@
1
+ # kontract
2
+
3
+ Cross-repo fetch of a business capability's **process/contract tier** — the DDD
4
+ tactical model (`aggregates.yaml`, `commands.yaml`, `policies.yaml`,
5
+ `read-models.yaml`, `bus.yaml`, `api.yaml`, JSON schemas) that says HOW a
6
+ capability is actually contracted, plus the `.bcm-provenance.json` pin that says
7
+ which corpus commit it derives from (ADR-DSN-0001).
8
+
9
+ Sibling of [`kpack`](https://github.com/papeete-hub/kpack), sharing its transport
10
+ only: `kontract` depends on `kpack` as a library (`kpack.config`, `kpack.registry`,
11
+ `kpack.remote`, `kpack.provenance`) and adds the one resolution step kpack doesn't
12
+ have — a business context to the *solution* context that `refines` it
13
+ (ADR-GCM-URBA-0006). See
14
+ [`reliever-design/adr/ADR-DSN-0002`](https://github.com/papeete-foundry/reliever-design/blob/main/adr/ADR-DSN-0002-kontract-resolution.md)
15
+ for the design record.
16
+
17
+ ## How it works
18
+
19
+ ```
20
+ CAP_ID (e.g. BNK.RLVR.CAP.SUP.002.BEN)
21
+ -> business context (segs 0-1, e.g. BNK.RLVR) -- kpack's own bootstrap --
22
+ -> ENTERPRISE (seg 0) -> kpack config -> registry repo -> vocab.yaml contexts:
23
+ -> confirm business context is registered -- new: kontract's own step --
24
+ -> scan contexts for kind: solution, refines: <business context> (exactly one expected)
25
+ -> that context's repo -> kpack.remote.resolve_repo_root (fetch + cache, same as kpack)
26
+ -> serve process/<CAP_ID>/ from the resolved checkout
27
+ ```
28
+
29
+ No caching beyond kpack's own git-clone cache (`--cache-dir` / `--no-fetch` /
30
+ `--fresh`) — every call is a fresh fetch by design.
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ pip install -e ".[dev]"
36
+ ```
37
+
38
+ ## Usage
39
+
40
+ ```bash
41
+ kontract fetch <CAP_ID> [--ref REF] [--repo-root PATH] [--cache-dir DIR]
42
+ [--no-fetch] [--fresh] [--config PATH]
43
+ [--registry-repo SLUG] [--registry-ref REF] [--compact]
44
+ ```
45
+
46
+ Uses the same `~/.config/kpack/config.yaml` / `./.kpack.yaml` / `--config`
47
+ enterprise -> registry bootstrap as `kpack` — no separate kontract config file.
48
+
49
+ ## Develop
50
+
51
+ ```bash
52
+ make test # pytest against a fake business+solution repo pair (tests/conftest.py)
53
+ ```
@@ -0,0 +1,17 @@
1
+ """kontract — cross-repo fetch of a capability's process/contract tier.
2
+
3
+ Sibling of ``kpack`` (ADR-DSN-0001's "bet"): depends on it as a library for the
4
+ registry/config/remote transport (``kpack.config``, ``kpack.registry``,
5
+ ``kpack.remote``, ``kpack.provenance``) and adds the one resolution step kpack
6
+ doesn't have — a business context (e.g. ``BNK.RLVR``) to the *solution* context
7
+ that ``refines`` it (ADR-GCM-URBA-0006), then serves ``process/<CAP_ID>/`` from
8
+ that resolved repo.
9
+
10
+ See ``reliever-design/adr/ADR-DSN-0002`` for the design record.
11
+ """
12
+
13
+ __version__ = "0.1.0"
14
+
15
+ # Envelope shape contract, mirroring kpack's own Rule 2 (SemVer, independent of
16
+ # corpus/solution content).
17
+ SCHEMA_VERSION = "1.0.0"
@@ -0,0 +1,100 @@
1
+ """kontract command-line interface (plan §"CLI surface").
2
+
3
+ One command, mirroring kpack's own flag conventions for consistency:
4
+
5
+ kontract fetch <CAP_ID> [--ref REF] [--repo-root PATH] [--cache-dir DIR]
6
+ [--no-fetch] [--fresh] [--config PATH]
7
+ [--registry-repo SLUG] [--registry-ref REF]
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import sys
15
+
16
+ from kpack.errors import KpackError
17
+
18
+ from . import __version__, envelope, model, resolver
19
+ from .errors import KontractError
20
+ from .resolver import Flags
21
+
22
+
23
+ def _add_global_flags(p: argparse.ArgumentParser) -> None:
24
+ g = p.add_argument_group("resolution")
25
+ g.add_argument("--ref", default="main", help="solution-repo ref (tag / 40-char SHA / branch)")
26
+ g.add_argument("--repo-root", help="local checkout override for the solution repo")
27
+ g.add_argument("--cache-dir", help="cache dir (default ~/.cache/kpack)")
28
+ g.add_argument("--no-fetch", action="store_true", help="skip per-invocation git fetch")
29
+ g.add_argument("--fresh", action="store_true", help="wipe + re-clone the cache")
30
+ g.add_argument("--config", help="kpack config file (enterprise -> registry)")
31
+ g.add_argument("--registry-repo", help="override the resolved registry repo")
32
+ g.add_argument("--registry-ref", help="override the registry ref (default main)")
33
+ p.add_argument("--compact", action="store_true", help="one-line JSON")
34
+
35
+
36
+ def _flags(args: argparse.Namespace) -> Flags:
37
+ return Flags(
38
+ ref=args.ref,
39
+ repo_root=args.repo_root,
40
+ cache_dir=args.cache_dir,
41
+ no_fetch=args.no_fetch,
42
+ fresh=args.fresh,
43
+ config=args.config,
44
+ registry_repo=args.registry_repo,
45
+ registry_ref=args.registry_ref,
46
+ )
47
+
48
+
49
+ def _emit(obj, compact: bool) -> None:
50
+ if compact:
51
+ json.dump(obj, sys.stdout, separators=(",", ":"), sort_keys=False, default=str)
52
+ else:
53
+ json.dump(obj, sys.stdout, indent=2, sort_keys=False, default=str)
54
+ sys.stdout.write("\n")
55
+
56
+
57
+ def _warn(env: dict) -> None:
58
+ for w in env.get("warnings", []):
59
+ print(f"warning: {w}", file=sys.stderr)
60
+
61
+
62
+ def cmd_fetch(args) -> int:
63
+ resolved = resolver.resolve(args.capability_id, _flags(args))
64
+ body = model.get_model(resolved.solution_root, args.capability_id)
65
+ env = envelope.base_envelope(resolved, "fetch_process_model")
66
+ env.update(body)
67
+ _warn(env)
68
+ _emit(env, args.compact)
69
+ return 0
70
+
71
+
72
+ def build_parser() -> argparse.ArgumentParser:
73
+ parser = argparse.ArgumentParser(
74
+ prog="kontract",
75
+ description="Cross-repo fetch of a capability's process/contract tier.",
76
+ )
77
+ parser.add_argument("--version", action="version", version=f"kontract {__version__}")
78
+ sub = parser.add_subparsers(dest="command", required=True)
79
+
80
+ p = sub.add_parser("fetch", help="fetch a capability's process/contract model")
81
+ p.add_argument("capability_id")
82
+ _add_global_flags(p)
83
+ p.set_defaults(func=cmd_fetch)
84
+
85
+ return parser
86
+
87
+
88
+ def main(argv=None) -> int:
89
+ args = build_parser().parse_args(argv)
90
+ try:
91
+ return args.func(args)
92
+ except (KontractError, KpackError) as exc:
93
+ print(f"error: {exc}", file=sys.stderr)
94
+ return getattr(exc, "exit_code", 1)
95
+ except BrokenPipeError: # pragma: no cover
96
+ return 0
97
+
98
+
99
+ if __name__ == "__main__": # pragma: no cover
100
+ sys.exit(main())
@@ -0,0 +1,36 @@
1
+ """Envelope assembly for kontract, shaped like kpack's own (design consistency).
2
+
3
+ Carries both resolved coordinates: the ``solution`` repo's provenance (reusing
4
+ kpack's git-derived provenance helper) and, unless ``--repo-root`` short-circuited
5
+ resolution, the ``registry`` binding that produced it — so a fetch is replayable.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from kpack.provenance import corpus_provenance
11
+
12
+ from . import SCHEMA_VERSION, __version__
13
+ from .resolver import Resolved
14
+
15
+
16
+ def _solution_block(resolved: Resolved) -> dict:
17
+ prov = corpus_provenance(resolved.solution_root)
18
+ return {
19
+ "enterprise": resolved.business_context.split(".")[0],
20
+ "refines": resolved.business_context,
21
+ "context": resolved.solution_context,
22
+ "repo": resolved.solution_repo,
23
+ **prov,
24
+ }
25
+
26
+
27
+ def base_envelope(resolved: Resolved, tool: str) -> dict:
28
+ env = {
29
+ "schema_version": SCHEMA_VERSION,
30
+ "engine": {"name": "kontract", "version": __version__},
31
+ "solution": _solution_block(resolved),
32
+ "tool": tool,
33
+ }
34
+ if resolved.registry_repo:
35
+ env["registry"] = {"repo": resolved.registry_repo, "ref": resolved.registry_ref}
36
+ return env
@@ -0,0 +1,29 @@
1
+ """Typed errors with stable CLI exit codes (mirrors kpack.errors' convention).
2
+
3
+ Exit codes:
4
+ 0 success
5
+ 1 unexpected error
6
+ 2 unknown capability / bad target / no process model
7
+ 3 context / registry resolution error (including ambiguous refines)
8
+ 4 remote / git failure (raised as kpack.errors.RemoteError, passed through)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+
14
+ class KontractError(Exception):
15
+ """Base class for all kontract errors."""
16
+
17
+ exit_code = 1
18
+
19
+
20
+ class TargetError(KontractError):
21
+ """Bad capability id, or no process model for it."""
22
+
23
+ exit_code = 2
24
+
25
+
26
+ class ResolutionError(KontractError):
27
+ """The business -> solution context resolution failed."""
28
+
29
+ exit_code = 3
@@ -0,0 +1,90 @@
1
+ """Serve ``process/<CAP_ID>/`` from a resolved solution repo (plan step 3).
2
+
3
+ A direct port of ``kpack.process.get_model`` — small and corpus-shape-specific,
4
+ so a port rather than an import — with one addition kpack's version has no reason
5
+ to make: also read and include ``.bcm-provenance.json``. ADR-DSN-0001 makes that
6
+ file mandatory precisely so a consumer can see which corpus commit the contract
7
+ was derived from; a missing one is surfaced as a warning, not silently dropped.
8
+
9
+ Schemas live one level down, under ``process/<CAP_ID>/schemas/*.schema.json``
10
+ (confirmed against the real reliever-design layout) — read explicitly from that
11
+ subdirectory rather than only the model folder's top level.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from pathlib import Path
18
+
19
+ import yaml
20
+
21
+ from .errors import TargetError
22
+
23
+
24
+ def _read_yaml(path: Path) -> dict:
25
+ raw = path.read_text()
26
+ try:
27
+ return {"raw": raw, "parsed": yaml.safe_load(raw), "parse_error": None}
28
+ except yaml.YAMLError as exc:
29
+ return {"raw": raw, "parsed": None, "parse_error": str(exc)}
30
+
31
+
32
+ def get_model(solution_root: Path, capability_id: str) -> dict:
33
+ solution_root = Path(solution_root)
34
+ base = solution_root / "process" / capability_id
35
+ if not base.is_dir():
36
+ raise TargetError(f"no process model for {capability_id}")
37
+
38
+ model, schemas, files, warnings = {}, {}, [], []
39
+ readme = None
40
+ provenance = None
41
+
42
+ for f in sorted(base.iterdir()):
43
+ if f.is_dir():
44
+ continue
45
+ rel = str(f.relative_to(solution_root))
46
+ if f.name == ".bcm-provenance.json":
47
+ try:
48
+ provenance = json.loads(f.read_text())
49
+ except json.JSONDecodeError as exc:
50
+ warnings.append(f"invalid provenance {f.name}: {exc}")
51
+ files.append({"path": rel, "role": "provenance"})
52
+ elif f.suffix in (".yaml", ".yml"):
53
+ model[f.stem] = _read_yaml(f)
54
+ files.append({"path": rel, "role": f.stem})
55
+ elif f.name.endswith(".schema.json"):
56
+ try:
57
+ schemas[f.name] = json.loads(f.read_text())
58
+ except json.JSONDecodeError as exc:
59
+ warnings.append(f"invalid schema {f.name}: {exc}")
60
+ files.append({"path": rel, "role": "schema"})
61
+ elif f.name.lower() == "readme.md":
62
+ readme = f.read_text()
63
+ files.append({"path": rel, "role": "readme"})
64
+
65
+ schemas_dir = base / "schemas"
66
+ if schemas_dir.is_dir():
67
+ for f in sorted(schemas_dir.iterdir()):
68
+ if f.name.endswith(".schema.json"):
69
+ rel = str(f.relative_to(solution_root))
70
+ try:
71
+ schemas[f.name] = json.loads(f.read_text())
72
+ except json.JSONDecodeError as exc:
73
+ warnings.append(f"invalid schema {f.name}: {exc}")
74
+ files.append({"path": rel, "role": "schema"})
75
+
76
+ if provenance is None:
77
+ warnings.append(
78
+ f"no .bcm-provenance.json for {capability_id} — this model cannot prove "
79
+ f"which corpus commit it derives from (ADR-DSN-0001)"
80
+ )
81
+
82
+ return {
83
+ "target": {"kind": "process_model", "id": capability_id},
84
+ "model": model,
85
+ "schemas": schemas,
86
+ "provenance": provenance,
87
+ "readme": readme,
88
+ "files": files,
89
+ "warnings": warnings,
90
+ }
@@ -0,0 +1,127 @@
1
+ """Cross-repo resolution: business capability id -> solution repo checkout.
2
+
3
+ The one resolution step kpack doesn't have (plan §"The actual gap to build", step 2):
4
+
5
+ 1. Derive the capability's business context from segments 0-1 of its id.
6
+ 2. Confirm that context is really registered (``kind: business``, or at least a
7
+ real registered context — never trust an unverified id shape).
8
+ 3. Scan the same registry for the context with ``kind: solution`` whose
9
+ ``refines`` equals that business context (ADR-GCM-URBA-0006). Exactly one is
10
+ expected; zero or more than one is an error, not a silent pick.
11
+ 4. Hand that context's ``repo`` to ``kpack.remote.resolve_repo_root`` — identical
12
+ fetch/cache/ref semantics kpack itself uses, no new transport code.
13
+
14
+ Steps 1 (enterprise/registry bootstrap) and 4 (fetch) are reused verbatim from
15
+ kpack; only the ``refines`` scan in between is new.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+
23
+ from kpack import config as config_mod
24
+ from kpack import registry as registry_mod
25
+ from kpack import remote
26
+
27
+ from .errors import ResolutionError, TargetError
28
+
29
+
30
+ @dataclass
31
+ class Flags:
32
+ ref: str = "main"
33
+ repo_root: str | None = None
34
+ cache_dir: str | None = None
35
+ no_fetch: bool = False
36
+ fresh: bool = False
37
+ config: str | None = None
38
+ registry_repo: str | None = None
39
+ registry_ref: str | None = None
40
+
41
+
42
+ @dataclass
43
+ class Resolved:
44
+ capability_id: str
45
+ business_context: str
46
+ solution_context: str | None # None when --repo-root short-circuits the registry
47
+ solution_repo: str | None
48
+ solution_root: Path
49
+ registry_repo: str | None
50
+ registry_ref: str | None
51
+
52
+
53
+ def business_context_of(capability_id: str) -> str:
54
+ """The two-segment business context prefix of a capability id (ADR-GCM-URBA-0001)."""
55
+ parts = capability_id.split(".")
56
+ if len(parts) < 3:
57
+ raise TargetError(
58
+ f"{capability_id!r} is not a valid capability id "
59
+ f"(expected <ENTERPRISE>.<SCOPE>.<ASSET_TYPE>....)"
60
+ )
61
+ return ".".join(parts[:2])
62
+
63
+
64
+ def find_solution_context(contexts: dict, business_context: str) -> str:
65
+ """The context with kind: solution and refines: business_context. Exactly one expected."""
66
+ business = contexts.get(business_context)
67
+ if business is None:
68
+ known = ", ".join(sorted(contexts))
69
+ raise ResolutionError(
70
+ f"context {business_context!r} is not in the authoritative registry. Known: {known}"
71
+ )
72
+ kind = business.get("kind")
73
+ if kind not in (None, "business"):
74
+ raise ResolutionError(
75
+ f"context {business_context!r} is registered with kind {kind!r}, expected "
76
+ f"'business' (or unspecified) for a capability id's context"
77
+ )
78
+
79
+ matches = sorted(
80
+ code
81
+ for code, entry in contexts.items()
82
+ if entry.get("kind") == "solution" and entry.get("refines") == business_context
83
+ )
84
+ if not matches:
85
+ raise ResolutionError(
86
+ f"no solution context refines {business_context!r} in the registry "
87
+ f"(expected exactly one entry with kind: solution and refines: {business_context})"
88
+ )
89
+ if len(matches) > 1:
90
+ raise ResolutionError(
91
+ f"multiple solution contexts refine {business_context!r}: {', '.join(matches)} "
92
+ f"— ambiguous, expected exactly one"
93
+ )
94
+ return matches[0]
95
+
96
+
97
+ def resolve(capability_id: str, flags: Flags) -> Resolved:
98
+ cache = Path(flags.cache_dir) if flags.cache_dir else None
99
+ business_context = business_context_of(capability_id)
100
+
101
+ # --repo-root short-circuit: skip the registry entirely (dev / single checkout),
102
+ # same convention as kpack's own resolve_context.
103
+ if flags.repo_root:
104
+ solution_root = remote.resolve_repo_root(
105
+ business_context, flags.ref, cache_dir=cache, repo_root_override=flags.repo_root
106
+ )
107
+ return Resolved(capability_id, business_context, None, None, solution_root, None, None)
108
+
109
+ # Full bootstrap: enterprise -> registry -> contexts -> refines scan -> solution repo.
110
+ cfg = config_mod.load_config(flags.config)
111
+ enterprise = business_context.split(".")[0]
112
+ reg_repo, reg_ref = config_mod.resolve_registry(
113
+ cfg, enterprise, repo_override=flags.registry_repo, ref_override=flags.registry_ref
114
+ )
115
+ registry_root = remote.resolve_repo_root(
116
+ reg_repo, reg_ref, cache_dir=cache, no_fetch=flags.no_fetch, fresh=flags.fresh
117
+ )
118
+ contexts = registry_mod.load_contexts(registry_root)
119
+ solution_context = find_solution_context(contexts, business_context)
120
+ solution_repo = registry_mod.context_repo(contexts, solution_context)
121
+ solution_root = remote.resolve_repo_root(
122
+ solution_repo, flags.ref, cache_dir=cache, no_fetch=flags.no_fetch, fresh=flags.fresh
123
+ )
124
+ return Resolved(
125
+ capability_id, business_context, solution_context, solution_repo, solution_root,
126
+ reg_repo, reg_ref,
127
+ )
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.4
2
+ Name: kontract
3
+ Version: 0.1.0
4
+ Summary: Cross-repo fetch of a capability's process/contract tier (ADR-BCM-URBA-0016 / ADR-DSN-0002).
5
+ Author: yremy
6
+ License: Proprietary
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: kpack<3,>=2.0.0
10
+ Requires-Dist: pyyaml<7,>=6.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=7; extra == "dev"
13
+ Requires-Dist: build>=1.0; extra == "dev"
14
+
15
+ # kontract
16
+
17
+ Cross-repo fetch of a business capability's **process/contract tier** — the DDD
18
+ tactical model (`aggregates.yaml`, `commands.yaml`, `policies.yaml`,
19
+ `read-models.yaml`, `bus.yaml`, `api.yaml`, JSON schemas) that says HOW a
20
+ capability is actually contracted, plus the `.bcm-provenance.json` pin that says
21
+ which corpus commit it derives from (ADR-DSN-0001).
22
+
23
+ Sibling of [`kpack`](https://github.com/papeete-hub/kpack), sharing its transport
24
+ only: `kontract` depends on `kpack` as a library (`kpack.config`, `kpack.registry`,
25
+ `kpack.remote`, `kpack.provenance`) and adds the one resolution step kpack doesn't
26
+ have — a business context to the *solution* context that `refines` it
27
+ (ADR-GCM-URBA-0006). See
28
+ [`reliever-design/adr/ADR-DSN-0002`](https://github.com/papeete-foundry/reliever-design/blob/main/adr/ADR-DSN-0002-kontract-resolution.md)
29
+ for the design record.
30
+
31
+ ## How it works
32
+
33
+ ```
34
+ CAP_ID (e.g. BNK.RLVR.CAP.SUP.002.BEN)
35
+ -> business context (segs 0-1, e.g. BNK.RLVR) -- kpack's own bootstrap --
36
+ -> ENTERPRISE (seg 0) -> kpack config -> registry repo -> vocab.yaml contexts:
37
+ -> confirm business context is registered -- new: kontract's own step --
38
+ -> scan contexts for kind: solution, refines: <business context> (exactly one expected)
39
+ -> that context's repo -> kpack.remote.resolve_repo_root (fetch + cache, same as kpack)
40
+ -> serve process/<CAP_ID>/ from the resolved checkout
41
+ ```
42
+
43
+ No caching beyond kpack's own git-clone cache (`--cache-dir` / `--no-fetch` /
44
+ `--fresh`) — every call is a fresh fetch by design.
45
+
46
+ ## Install
47
+
48
+ ```bash
49
+ pip install -e ".[dev]"
50
+ ```
51
+
52
+ ## Usage
53
+
54
+ ```bash
55
+ kontract fetch <CAP_ID> [--ref REF] [--repo-root PATH] [--cache-dir DIR]
56
+ [--no-fetch] [--fresh] [--config PATH]
57
+ [--registry-repo SLUG] [--registry-ref REF] [--compact]
58
+ ```
59
+
60
+ Uses the same `~/.config/kpack/config.yaml` / `./.kpack.yaml` / `--config`
61
+ enterprise -> registry bootstrap as `kpack` — no separate kontract config file.
62
+
63
+ ## Develop
64
+
65
+ ```bash
66
+ make test # pytest against a fake business+solution repo pair (tests/conftest.py)
67
+ ```
@@ -0,0 +1,17 @@
1
+ README.md
2
+ pyproject.toml
3
+ kontract/__init__.py
4
+ kontract/cli.py
5
+ kontract/envelope.py
6
+ kontract/errors.py
7
+ kontract/model.py
8
+ kontract/resolver.py
9
+ kontract.egg-info/PKG-INFO
10
+ kontract.egg-info/SOURCES.txt
11
+ kontract.egg-info/dependency_links.txt
12
+ kontract.egg-info/entry_points.txt
13
+ kontract.egg-info/requires.txt
14
+ kontract.egg-info/top_level.txt
15
+ tests/test_cli.py
16
+ tests/test_model.py
17
+ tests/test_resolver.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ kontract = kontract.cli:main
@@ -0,0 +1,6 @@
1
+ kpack<3,>=2.0.0
2
+ pyyaml<7,>=6.0
3
+
4
+ [dev]
5
+ pytest>=7
6
+ build>=1.0
@@ -0,0 +1 @@
1
+ kontract
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "kontract"
7
+ version = "0.1.0"
8
+ description = "Cross-repo fetch of a capability's process/contract tier (ADR-BCM-URBA-0016 / ADR-DSN-0002)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "Proprietary" }
12
+ authors = [{ name = "yremy" }]
13
+ dependencies = ["kpack>=2.0.0,<3", "pyyaml>=6.0,<7"]
14
+
15
+ [project.scripts]
16
+ kontract = "kontract.cli:main"
17
+
18
+ [project.optional-dependencies]
19
+ dev = ["pytest>=7", "build>=1.0"]
20
+
21
+ [tool.setuptools.packages.find]
22
+ where = ["."]
23
+ include = ["kontract*"]
24
+ exclude = ["tests*"]
25
+
26
+ [tool.pytest.ini_options]
27
+ pythonpath = ["."]
28
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ from kontract.cli import main
6
+ from tests.conftest import CAP_ID
7
+
8
+
9
+ def test_fetch_end_to_end(world, capsys):
10
+ rc = main(["fetch", CAP_ID])
11
+ assert rc == 0
12
+ out = json.loads(capsys.readouterr().out)
13
+ assert out["engine"]["name"] == "kontract"
14
+ assert out["solution"]["context"] == "ACME.SOL"
15
+ assert out["solution"]["refines"] == "ACME.BIZ"
16
+ assert out["registry"]["repo"] == str(world["registry"])
17
+ assert out["target"]["id"] == CAP_ID
18
+ assert "aggregates" in out["model"]
19
+ assert out["provenance"]["capability_id"] == CAP_ID
20
+
21
+
22
+ def test_fetch_unknown_capability_exits_2(world, capsys):
23
+ rc = main(["fetch", "ACME.BIZ.CAP.NOPE.001"])
24
+ assert rc == 2
25
+ assert "no process model" in capsys.readouterr().err
26
+
27
+
28
+ def test_fetch_ambiguous_solution_exits_3(world, capsys):
29
+ from tests.conftest import _commit, _write
30
+
31
+ contexts = [
32
+ {"code": "ACME.GOV", "kind": "governance", "repo": str(world["registry"])},
33
+ {"code": "ACME.BIZ", "kind": "business", "repo": "unused"},
34
+ {"code": "ACME.SOL", "kind": "solution", "repo": str(world["solution"]), "refines": "ACME.BIZ"},
35
+ {"code": "ACME.SOL2", "kind": "solution", "repo": str(world["solution"]), "refines": "ACME.BIZ"},
36
+ ]
37
+ _write(world["registry"], "vocab.yaml", {"contexts": contexts})
38
+ _commit(world["registry"], "registry v2 — add ambiguous second solution context")
39
+
40
+ rc = main(["fetch", CAP_ID, "--fresh"])
41
+ assert rc == 3
42
+ assert "ambiguous" in capsys.readouterr().err
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+
5
+ from kontract.errors import TargetError
6
+ from kontract.model import get_model
7
+ from tests.conftest import CAP_ID
8
+
9
+
10
+ def test_get_model_reads_yaml_readme_schemas_and_provenance(world):
11
+ m = get_model(world["solution"], CAP_ID)
12
+ assert m["target"] == {"kind": "process_model", "id": CAP_ID}
13
+ assert set(m["model"]) == {"aggregates", "commands"}
14
+ assert m["model"]["aggregates"]["parsed"] == {"aggregates": [{"name": "Score"}]}
15
+ assert m["readme"] == "Process model for Scoring."
16
+ assert "CMD.SCORE.schema.json" in m["schemas"]
17
+ assert m["provenance"]["capability_id"] == CAP_ID
18
+ assert m["warnings"] == []
19
+ paths = {f["path"] for f in m["files"]}
20
+ assert f"process/{CAP_ID}/schemas/CMD.SCORE.schema.json" in paths
21
+ assert f"process/{CAP_ID}/.bcm-provenance.json" in paths
22
+
23
+
24
+ def test_get_model_warns_on_missing_provenance(world_no_provenance):
25
+ m = get_model(world_no_provenance["solution"], CAP_ID)
26
+ assert m["provenance"] is None
27
+ assert any("no .bcm-provenance.json" in w for w in m["warnings"])
28
+
29
+
30
+ def test_get_model_missing_capability_raises(world):
31
+ with pytest.raises(TargetError):
32
+ get_model(world["solution"], "ACME.BIZ.CAP.NOPE.001")
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+
5
+ from kontract.errors import ResolutionError, TargetError
6
+ from kontract.resolver import Flags, business_context_of, find_solution_context, resolve
7
+ from tests.conftest import CAP_ID
8
+
9
+
10
+ def test_business_context_of():
11
+ assert business_context_of(CAP_ID) == "ACME.BIZ"
12
+
13
+
14
+ def test_business_context_of_rejects_short_id():
15
+ with pytest.raises(TargetError):
16
+ business_context_of("ACME.BIZ")
17
+
18
+
19
+ def test_find_solution_context_happy_path():
20
+ contexts = {
21
+ "ACME.BIZ": {"kind": "business", "repo": "x"},
22
+ "ACME.SOL": {"kind": "solution", "refines": "ACME.BIZ", "repo": "y"},
23
+ }
24
+ assert find_solution_context(contexts, "ACME.BIZ") == "ACME.SOL"
25
+
26
+
27
+ def test_find_solution_context_unknown_business_context():
28
+ with pytest.raises(ResolutionError, match="not in the authoritative registry"):
29
+ find_solution_context({}, "ACME.BIZ")
30
+
31
+
32
+ def test_find_solution_context_wrong_kind():
33
+ contexts = {"ACME.BIZ": {"kind": "solution", "repo": "x"}}
34
+ with pytest.raises(ResolutionError, match="expected 'business'"):
35
+ find_solution_context(contexts, "ACME.BIZ")
36
+
37
+
38
+ def test_find_solution_context_zero_matches():
39
+ contexts = {"ACME.BIZ": {"kind": "business", "repo": "x"}}
40
+ with pytest.raises(ResolutionError, match="no solution context refines"):
41
+ find_solution_context(contexts, "ACME.BIZ")
42
+
43
+
44
+ def test_find_solution_context_ambiguous():
45
+ contexts = {
46
+ "ACME.BIZ": {"kind": "business", "repo": "x"},
47
+ "ACME.SOL": {"kind": "solution", "refines": "ACME.BIZ", "repo": "y"},
48
+ "ACME.SOL2": {"kind": "solution", "refines": "ACME.BIZ", "repo": "z"},
49
+ }
50
+ with pytest.raises(ResolutionError, match="ambiguous"):
51
+ find_solution_context(contexts, "ACME.BIZ")
52
+
53
+
54
+ def test_resolve_end_to_end(world):
55
+ resolved = resolve(CAP_ID, Flags())
56
+ assert resolved.business_context == "ACME.BIZ"
57
+ assert resolved.solution_context == "ACME.SOL"
58
+ assert resolved.solution_repo == str(world["solution"])
59
+ assert resolved.solution_root.is_dir()
60
+ assert (resolved.solution_root / "process" / CAP_ID).is_dir()
61
+ assert resolved.registry_repo == str(world["registry"])
62
+
63
+
64
+ def test_resolve_repo_root_short_circuit(world):
65
+ resolved = resolve(CAP_ID, Flags(repo_root=str(world["solution"])))
66
+ assert resolved.solution_context is None
67
+ assert resolved.registry_repo is None
68
+ assert resolved.solution_root == world["solution"].resolve()