icdev-core 0.2.1__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,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: icdev-core
3
+ Version: 0.2.1
4
+ Summary: ICDEV shared core: domain declaration, path resolution, identity assertion and sensitivity labelling for the ICDEV[IT] and ICDEV[FT] parents.
5
+ Author: Sovanna Chuon
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/icdev-ai/icdev-core
8
+ Keywords: icdev,domain,paths,identity,sensitivity
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.9
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: PyYAML>=6.0
17
+ Requires-Dist: python-dotenv>=1.0
18
+
19
+ # icdev-core
20
+
21
+ The shared core of the ICDEV domain split: the small set of modules **both** parents need,
22
+ carved out of [`icdev-ai/icdev`](https://github.com/icdev-ai/icdev) with history preserved.
23
+
24
+ Distribution name is `icdev-core`; the **import root stays `icdev.core`**, unchanged.
25
+
26
+ ## What is in here, and why only this
27
+
28
+ | module | answers |
29
+ |---|---|
30
+ | `paths` | *where is the repo root?* The ONE resolver. |
31
+ | `domain` | *which parent is this checkout?* Reads `icdev_domain.yaml`. |
32
+ | `context` | *may this process touch this database?* `assert_identity` / `check_identity` / `load_env`. |
33
+ | `sensitivity` | *how sensitive is this table?* The one classification ladder. |
34
+ | `schema/tables.yaml` | the core-owned table manifest. |
35
+
36
+ The boundary was chosen from measurement, not from prose (`xcore-dec-01`). Two facts decided it:
37
+
38
+ - **`icdev/core` has zero dependency on `tools/`.** It imports stdlib, plus `yaml` and
39
+ `dotenv` *locally inside the functions that need them* so importing the module pulls in no
40
+ third-party code. That is what made it separable at all.
41
+ - **ICDEV[FT] already imports `icdev.core` in 32 files** and obtains it by putting the IT
42
+ checkout on `sys.path`. Replacing that `sys.path` coupling with a real dependency is the
43
+ whole point of this repo.
44
+
45
+ Public API, derived from what the parents actually call rather than what was declared:
46
+
47
+ ```
48
+ context.assert_identity 19 paths.repo_root 13
49
+ context.load_env 3 domain.{Domain, DomainError, load_domain} 1
50
+ context.check_identity 1 sensitivity (IT row_security)
51
+ ```
52
+
53
+ ## `icdev` is a namespace package, and that is load-bearing
54
+
55
+ This distribution ships `icdev/core/` and **no `icdev/__init__.py`**, so `icdev` is a PEP 420
56
+ namespace package.
57
+
58
+ Both this distribution and the ICDEV[IT] parent install into the same `icdev` name. A regular
59
+ package has ONE `__path__`, so whichever were found first would win and the other's subpackages
60
+ would silently vanish -- `icdev.core` unimportable in one direction, `icdev.tools` in the other.
61
+ That is exactly what was measured before the fix: with the parent installed editable, `icdev`
62
+ resolved to `C:/AI/ICDev/icdev` and installing this package beside it changed nothing at all.
63
+
64
+ `pkgutil.extend_path` in **both** distributions was tried first and rejected. It merges
65
+ `__path__` correctly, but only ONE `icdev/__init__.py` ever *executes* -- and when this one won,
66
+ the parent's `_alias_tools_namespace()` never ran: the function that makes ~1,900
67
+ `from tools.X import ...` imports resolve inside the parent's published wheel. An
68
+ order-dependent silent break of every installed deployment is worse than the shadowing it was
69
+ meant to fix.
70
+
71
+ Shipping none here makes the parent's the only `__init__.py`, so it runs whatever the path
72
+ order, and `extend_path` on the parent's side pulls `icdev/core/` in beside `icdev/tools/`.
73
+
74
+ Pinned by `tests/test_namespace_package.py` and by a CI step that inspects the built wheel --
75
+ because if setuptools' `namespaces` discovery ever defaults off, this repo publishes a wheel
76
+ with no `icdev.core` in it and nothing here notices; the ImportError surfaces in a parent.
77
+
78
+ ## What is deliberately NOT in here
79
+
80
+ **`shim.py` stayed in the IT parent.** It exists solely to make `tools.X` and
81
+ `icdev.tools.X` resolve to one module object in IT's dual tree — and the FT parent has no
82
+ `tools/` directory at all. Shipping it here would put one parent's layout knowledge inside
83
+ the package both parents install.
84
+
85
+ **The "functional core"** — storage, kanban, llm, genesis — stays in the IT parent. The
86
+ carve-out cards used "core" for both that and this package; they are different by three
87
+ orders of magnitude (3,690 files against 6), and only this one is separable today.
88
+
89
+ ## Why this repo is public
90
+
91
+ Every module here was **already public** in `icdev-ai/icdev`, so publishing them separately
92
+ exposes nothing new. A private core would be strictly worse: the *public* parent depends on
93
+ it, so installing it in public CI would require a deploy token — a genuinely new secret in a
94
+ public workflow, traded for hiding files that are already visible.
95
+
96
+ ## Using it
97
+
98
+ ```bash
99
+ pip install -e ../icdev-core # development, from a sibling checkout
100
+ ```
101
+
102
+ ```bash
103
+ pip install icdev-core # from PyPI (0.2.1+); `pip install icdev` pulls it in
104
+ ```
105
+
106
+ Releases are semver tags. Publishing a GitHub release runs `.github/workflows/pypi-publish.yml`,
107
+ which uploads to PyPI via Trusted Publishing; the tag must equal `v<pyproject version>`.
108
+ For air-gapped installs, mirror the wheel to a local wheelhouse and use
109
+ `pip install --no-index --find-links`. Pure Python, no build step.
110
+
111
+ ## Acceptance — what this package can and cannot prove
112
+
113
+ The original criterion here read *"proven when ICDEV[FT] drops its `sys.path.insert` of the IT
114
+ checkout and installs this package instead."* **That is not reachable, and stating it made a
115
+ finished carve-out look permanently incomplete.** Measured on ICDEV[FT] 2026-08-27:
116
+
117
+ | ICDEV[FT] modules importing | count | supplied by |
118
+ |---|---|---|
119
+ | `icdev.core.*` | 32 | **this package** |
120
+ | `tools.*` | 72 | only the ICDEV[IT] checkout |
121
+
122
+ `tools` exists because ICDEV[IT]'s `icdev/__init__.py` binds it to `icdev.tools`. This package
123
+ ships `icdev/core/` and deliberately nothing else, so it cannot supply it and installing it
124
+ cannot remove that checkout.
125
+
126
+ **What IS achieved, and is verified:**
127
+
128
+ - ICDEV[IT] no longer ships `icdev/core` and depends on this distribution (`xcore-cut-02`).
129
+ - Both parents pin a tag, never a branch, and a parent's own gate fails if it calls a symbol the
130
+ pinned core does not export (`coherence_checker --check core_api`).
131
+ - A change here is proven against ICDEV[IT] **before** merge by `core-compat.yml`, and against
132
+ ICDEV[FT] daily by the matching workflow in that repository (`xcore-compat-01`).
133
+
134
+ **What is still outstanding:** ICDEV[FT]'s 72 `tools.*` imports. Removing that coupling is its
135
+ own piece of work — a second extraction, or repointing those callers — and is not a side effect
136
+ of this package existing.
@@ -0,0 +1,118 @@
1
+ # icdev-core
2
+
3
+ The shared core of the ICDEV domain split: the small set of modules **both** parents need,
4
+ carved out of [`icdev-ai/icdev`](https://github.com/icdev-ai/icdev) with history preserved.
5
+
6
+ Distribution name is `icdev-core`; the **import root stays `icdev.core`**, unchanged.
7
+
8
+ ## What is in here, and why only this
9
+
10
+ | module | answers |
11
+ |---|---|
12
+ | `paths` | *where is the repo root?* The ONE resolver. |
13
+ | `domain` | *which parent is this checkout?* Reads `icdev_domain.yaml`. |
14
+ | `context` | *may this process touch this database?* `assert_identity` / `check_identity` / `load_env`. |
15
+ | `sensitivity` | *how sensitive is this table?* The one classification ladder. |
16
+ | `schema/tables.yaml` | the core-owned table manifest. |
17
+
18
+ The boundary was chosen from measurement, not from prose (`xcore-dec-01`). Two facts decided it:
19
+
20
+ - **`icdev/core` has zero dependency on `tools/`.** It imports stdlib, plus `yaml` and
21
+ `dotenv` *locally inside the functions that need them* so importing the module pulls in no
22
+ third-party code. That is what made it separable at all.
23
+ - **ICDEV[FT] already imports `icdev.core` in 32 files** and obtains it by putting the IT
24
+ checkout on `sys.path`. Replacing that `sys.path` coupling with a real dependency is the
25
+ whole point of this repo.
26
+
27
+ Public API, derived from what the parents actually call rather than what was declared:
28
+
29
+ ```
30
+ context.assert_identity 19 paths.repo_root 13
31
+ context.load_env 3 domain.{Domain, DomainError, load_domain} 1
32
+ context.check_identity 1 sensitivity (IT row_security)
33
+ ```
34
+
35
+ ## `icdev` is a namespace package, and that is load-bearing
36
+
37
+ This distribution ships `icdev/core/` and **no `icdev/__init__.py`**, so `icdev` is a PEP 420
38
+ namespace package.
39
+
40
+ Both this distribution and the ICDEV[IT] parent install into the same `icdev` name. A regular
41
+ package has ONE `__path__`, so whichever were found first would win and the other's subpackages
42
+ would silently vanish -- `icdev.core` unimportable in one direction, `icdev.tools` in the other.
43
+ That is exactly what was measured before the fix: with the parent installed editable, `icdev`
44
+ resolved to `C:/AI/ICDev/icdev` and installing this package beside it changed nothing at all.
45
+
46
+ `pkgutil.extend_path` in **both** distributions was tried first and rejected. It merges
47
+ `__path__` correctly, but only ONE `icdev/__init__.py` ever *executes* -- and when this one won,
48
+ the parent's `_alias_tools_namespace()` never ran: the function that makes ~1,900
49
+ `from tools.X import ...` imports resolve inside the parent's published wheel. An
50
+ order-dependent silent break of every installed deployment is worse than the shadowing it was
51
+ meant to fix.
52
+
53
+ Shipping none here makes the parent's the only `__init__.py`, so it runs whatever the path
54
+ order, and `extend_path` on the parent's side pulls `icdev/core/` in beside `icdev/tools/`.
55
+
56
+ Pinned by `tests/test_namespace_package.py` and by a CI step that inspects the built wheel --
57
+ because if setuptools' `namespaces` discovery ever defaults off, this repo publishes a wheel
58
+ with no `icdev.core` in it and nothing here notices; the ImportError surfaces in a parent.
59
+
60
+ ## What is deliberately NOT in here
61
+
62
+ **`shim.py` stayed in the IT parent.** It exists solely to make `tools.X` and
63
+ `icdev.tools.X` resolve to one module object in IT's dual tree — and the FT parent has no
64
+ `tools/` directory at all. Shipping it here would put one parent's layout knowledge inside
65
+ the package both parents install.
66
+
67
+ **The "functional core"** — storage, kanban, llm, genesis — stays in the IT parent. The
68
+ carve-out cards used "core" for both that and this package; they are different by three
69
+ orders of magnitude (3,690 files against 6), and only this one is separable today.
70
+
71
+ ## Why this repo is public
72
+
73
+ Every module here was **already public** in `icdev-ai/icdev`, so publishing them separately
74
+ exposes nothing new. A private core would be strictly worse: the *public* parent depends on
75
+ it, so installing it in public CI would require a deploy token — a genuinely new secret in a
76
+ public workflow, traded for hiding files that are already visible.
77
+
78
+ ## Using it
79
+
80
+ ```bash
81
+ pip install -e ../icdev-core # development, from a sibling checkout
82
+ ```
83
+
84
+ ```bash
85
+ pip install icdev-core # from PyPI (0.2.1+); `pip install icdev` pulls it in
86
+ ```
87
+
88
+ Releases are semver tags. Publishing a GitHub release runs `.github/workflows/pypi-publish.yml`,
89
+ which uploads to PyPI via Trusted Publishing; the tag must equal `v<pyproject version>`.
90
+ For air-gapped installs, mirror the wheel to a local wheelhouse and use
91
+ `pip install --no-index --find-links`. Pure Python, no build step.
92
+
93
+ ## Acceptance — what this package can and cannot prove
94
+
95
+ The original criterion here read *"proven when ICDEV[FT] drops its `sys.path.insert` of the IT
96
+ checkout and installs this package instead."* **That is not reachable, and stating it made a
97
+ finished carve-out look permanently incomplete.** Measured on ICDEV[FT] 2026-08-27:
98
+
99
+ | ICDEV[FT] modules importing | count | supplied by |
100
+ |---|---|---|
101
+ | `icdev.core.*` | 32 | **this package** |
102
+ | `tools.*` | 72 | only the ICDEV[IT] checkout |
103
+
104
+ `tools` exists because ICDEV[IT]'s `icdev/__init__.py` binds it to `icdev.tools`. This package
105
+ ships `icdev/core/` and deliberately nothing else, so it cannot supply it and installing it
106
+ cannot remove that checkout.
107
+
108
+ **What IS achieved, and is verified:**
109
+
110
+ - ICDEV[IT] no longer ships `icdev/core` and depends on this distribution (`xcore-cut-02`).
111
+ - Both parents pin a tag, never a branch, and a parent's own gate fails if it calls a symbol the
112
+ pinned core does not export (`coherence_checker --check core_api`).
113
+ - A change here is proven against ICDEV[IT] **before** merge by `core-compat.yml`, and against
114
+ ICDEV[FT] daily by the matching workflow in that repository (`xcore-compat-01`).
115
+
116
+ **What is still outstanding:** ICDEV[FT]'s 72 `tools.*` imports. Removing that coupling is its
117
+ own piece of work — a second extraction, or repointing those callers — and is not a side effect
118
+ of this package existing.
@@ -0,0 +1,22 @@
1
+ # CUI // SP-CTI
2
+ """ICDEV core contract — the seam between the domain-neutral kernel and a parent.
3
+
4
+ A parent (ICDEV[IT], ICDEV[FT], ...) declares itself in an ``icdev_domain.yaml``
5
+ at its repository root. This package reads that declaration and answers the
6
+ three questions every kernel module used to answer for itself, 2,054 times
7
+ over, with ``Path(__file__).resolve().parent...``:
8
+
9
+ * :mod:`icdev.core.paths` — where is the repository root and its data?
10
+ * :mod:`icdev.core.domain` — which domain is this, and what did it declare?
11
+ * :mod:`icdev.core.context` — is this process allowed to run HERE, against
12
+ THIS database? (``assert_identity``)
13
+
14
+ Nothing in this package imports anything outside the standard library and
15
+ PyYAML, so it can be imported before ``tools.db.storage`` and from either the
16
+ ``tools.*`` shim or the ``icdev.tools.*`` canonical namespace.
17
+
18
+ Programme: docs/programmes/icdev-domain-split.md (xit-decl-01).
19
+ """
20
+ from __future__ import annotations
21
+
22
+ __all__ = ["paths", "domain", "context"]
@@ -0,0 +1,205 @@
1
+ # CUI // SP-CTI
2
+ """Process identity: is THIS process allowed to run HERE, against THIS database?
3
+
4
+ Two ICDEV parents run on one machine, share one shell and one PostgreSQL
5
+ server, and both read ``<PREFIX>_PG_DATABASE`` / ``<PREFIX>_DATABASE_URL``
6
+ from the process environment. An ICDEV[FT] session that inherits ICDEV[IT]'s
7
+ ``.env`` would open ``icdev`` and write trading rows into it; nothing today
8
+ would notice. :func:`assert_identity` is the refusal, called at the start of
9
+ every long-lived or state-changing entry point (dashboard, genesis daemon,
10
+ ``tools/db/migrate.py``, ``tools/kanban/cli.py``).
11
+
12
+ The check is FAIL-CLOSED ON A DECLARED MISMATCH and NEVER on silence: a
13
+ process whose env names no database at all is reported ``unmeasured`` and
14
+ allowed through, because SQLite-only deployments, CI and tests legitimately
15
+ run without one. A declaration that lists no ``db.databases`` asserts
16
+ nothing and is likewise ``unmeasured``.
17
+
18
+ python -m icdev.core.context --check # human summary, exit 1 on mismatch
19
+ python -m icdev.core.context --check --json
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import json
25
+ import os
26
+ import sys
27
+ from dataclasses import asdict, dataclass
28
+ from pathlib import Path
29
+ from urllib.parse import urlsplit
30
+
31
+ from icdev.core import paths as core_paths
32
+ from icdev.core.domain import Domain, DomainError, load_domain
33
+
34
+ IDENTITY_GUARD_ENV = "ICDEV_IDENTITY_GUARD" # =0 -> report only, never refuse
35
+
36
+
37
+ class IdentityMismatch(RuntimeError):
38
+ """The process environment names a database this parent did not declare."""
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class IdentityReport:
43
+ domain_key: str
44
+ domain_name: str
45
+ domain_source: str
46
+ root: str
47
+ database_declared: tuple[str, ...]
48
+ database_observed: str | None
49
+ database_source: str | None # name_env | dsn_env | None
50
+ verdict: str # match | mismatch | unmeasured
51
+ enforced: bool
52
+ detail: str
53
+
54
+ def to_dict(self) -> dict:
55
+ d = asdict(self)
56
+ d["database_declared"] = list(self.database_declared)
57
+ return d
58
+
59
+
60
+ def _database_from_dsn(dsn: str) -> str | None:
61
+ """Return the database name from a libpq URL, or None when it has none."""
62
+ try:
63
+ parts = urlsplit(dsn)
64
+ except ValueError:
65
+ return None
66
+ if parts.scheme not in ("postgresql", "postgres"):
67
+ return None
68
+ name = parts.path.lstrip("/")
69
+ return name or None
70
+
71
+
72
+ def observed_database(domain: Domain, environ: os._Environ | dict | None = None) -> tuple[str | None, str | None]:
73
+ """Return ``(database_name, which_env_var)`` as the process env declares it.
74
+
75
+ ``name_env`` wins over ``dsn_env`` because that is the precedence
76
+ ``tools/db/storage.py`` gives ``ICDEV_PG_DATABASE`` when both are set for
77
+ the keyword form; a DSN's path is consulted only when the name is absent.
78
+ """
79
+ env = os.environ if environ is None else environ
80
+ name = (env.get(domain.db.name_env) or "").strip()
81
+ if name:
82
+ return name, domain.db.name_env
83
+ dsn = (env.get(domain.db.dsn_env) or "").strip()
84
+ if dsn:
85
+ parsed = _database_from_dsn(dsn)
86
+ if parsed:
87
+ return parsed, domain.db.dsn_env
88
+ return None, None
89
+
90
+
91
+ def load_env(root: Path | None = None) -> Path | None:
92
+ """Load ``<root>/.env`` (the PARENT's, never cwd's). Returns the path loaded."""
93
+ root = root or core_paths.repo_root()
94
+ env_file = root / ".env"
95
+ if not env_file.is_file():
96
+ return None
97
+ try:
98
+ from dotenv import load_dotenv
99
+ except ImportError: # pragma: no cover — dotenv is a declared dependency
100
+ return None
101
+ load_dotenv(env_file, override=False)
102
+ return env_file
103
+
104
+
105
+ def check_identity(
106
+ *,
107
+ anchor: str | os.PathLike[str] | None = None,
108
+ domain: Domain | None = None,
109
+ environ: dict | None = None,
110
+ ) -> IdentityReport:
111
+ """Compute the identity verdict without raising."""
112
+ dom = domain or load_domain(anchor=anchor)
113
+ observed, which = observed_database(dom, environ)
114
+ declared = tuple(dom.db.databases)
115
+ enforced = os.environ.get(IDENTITY_GUARD_ENV, "1").strip().lower() not in ("0", "false", "no", "monitor")
116
+ if not declared:
117
+ verdict, detail = "unmeasured", "the declaration lists no db.databases, so it asserts nothing"
118
+ elif observed is None:
119
+ verdict, detail = "unmeasured", (
120
+ f"neither {dom.db.name_env} nor {dom.db.dsn_env} names a database in this process"
121
+ )
122
+ elif observed in declared:
123
+ verdict, detail = "match", f"{which}={observed} is declared by {dom.key}"
124
+ else:
125
+ verdict, detail = "mismatch", (
126
+ f"{which} names database {observed!r} but domain {dom.key!r} declares only "
127
+ f"{list(declared)} — this process is running against another parent's database"
128
+ )
129
+ return IdentityReport(
130
+ domain_key=dom.key,
131
+ domain_name=dom.name,
132
+ domain_source=dom.source,
133
+ root=str(dom.root),
134
+ database_declared=declared,
135
+ database_observed=observed,
136
+ database_source=which,
137
+ verdict=verdict,
138
+ enforced=enforced,
139
+ detail=detail,
140
+ )
141
+
142
+
143
+ def assert_identity(
144
+ *,
145
+ anchor: str | os.PathLike[str] | None = None,
146
+ domain: Domain | None = None,
147
+ environ: dict | None = None,
148
+ ) -> IdentityReport:
149
+ """Refuse (``IdentityMismatch``) on a declared mismatch; return the report otherwise.
150
+
151
+ Stand it down with ``ICDEV_IDENTITY_GUARD=0`` (the report is still
152
+ computed and returned, so a caller can log it), never with a shell
153
+ neutraliser.
154
+ """
155
+ report = check_identity(anchor=anchor, domain=domain, environ=environ)
156
+ if report.verdict == "mismatch" and report.enforced:
157
+ raise IdentityMismatch(report.detail)
158
+ return report
159
+
160
+
161
+ def describe(anchor: str | os.PathLike[str] | None = None) -> dict:
162
+ """Everything ``icdev status`` prints about where and who this process is."""
163
+ out: dict = {"paths": core_paths.describe(anchor)}
164
+ try:
165
+ dom = load_domain(anchor=anchor)
166
+ except DomainError as exc:
167
+ out["domain"] = None
168
+ out["error"] = str(exc)
169
+ return out
170
+ out["domain"] = dom.to_dict()
171
+ out["identity"] = check_identity(domain=dom).to_dict()
172
+ return out
173
+
174
+
175
+ def main(argv: list[str] | None = None) -> int:
176
+ ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
177
+ ap.add_argument("--check", action="store_true", help="exit 1 on a declared mismatch")
178
+ ap.add_argument("--json", action="store_true")
179
+ ap.add_argument("--no-env", action="store_true", help="do not load <root>/.env first")
180
+ args = ap.parse_args(argv)
181
+
182
+ if not args.no_env:
183
+ load_env()
184
+ try:
185
+ info = describe()
186
+ except DomainError as exc:
187
+ print(f"domain declaration error: {exc}", file=sys.stderr)
188
+ return 2
189
+ if args.json:
190
+ print(json.dumps(info, indent=2))
191
+ else:
192
+ d = info.get("domain") or {}
193
+ ident = info.get("identity") or {}
194
+ print(f"domain : {d.get('key')} ({d.get('name')}) from {d.get('source')}")
195
+ print(f"root : {info['paths']['root']} [{info['paths']['source']}]")
196
+ print(f"database : declared {d.get('db', {}).get('databases')} observed "
197
+ f"{ident.get('database_observed')!r} via {ident.get('database_source')}")
198
+ print(f"identity : {ident.get('verdict', 'unknown').upper()} — {ident.get('detail', info.get('error'))}")
199
+ if args.check and (info.get("identity") or {}).get("verdict") == "mismatch":
200
+ return 1
201
+ return 0
202
+
203
+
204
+ if __name__ == "__main__": # pragma: no cover
205
+ raise SystemExit(main())