Repository navigation
[ON HOLD — Gate-routed redesign pending] feat(odoo): live Odoo CRM auto-discovery for /api/v1/scan - #213
[ON HOLD — Gate-routed redesign pending] feat(odoo): live Odoo CRM auto-discovery for /api/v1/scan#213cryptoxdog wants to merge 5 commits into
Conversation
Add optional source_system / source_resource to CRMField and FieldMapping so fields with identical technical names from different resources (Odoo res.partner.phone vs crm.lead.phone) stay distinguishable through the scanner. Provenance propagates to matched and unmapped mappings, is exposed additively in scan_result_to_dict, and participates in scan_hash. Legacy CRMField(name=...) callers are unchanged; missing entries carry no provenance. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BZDbPoPruZTHGhRt7tkFCm
Add the smallest read-only CRM source seam (CRMSource protocol, CRMSourceError) and one Odoo implementation. OdooCRMSource inspects res.partner and crm.lead independently and mandatorily: one fields_get plus one bounded search_read per model (4 requests total), converting live metadata into CRMField with source_resource provenance. Sampling is bounded (default 25, hard max 100), follows no relations, excludes binary/one2many/reference, keeps at most 5 distinct values per field, and reports a sample fill rate (None when no rows). Boolean False is populated; numeric 0 is populated. Transport is httpx with a 10s timeout, bearer API-key auth, optional X-Odoo-Database, and a fixed model/method allowlist so arbitrary Odoo RPC is impossible. Errors name provider/model/method/status only, never credentials or response bodies. New setting ODOO_API_KEY (documented in .env.example and env-contract.yaml); username/password remain for legacy XML-RPC paths. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BZDbPoPruZTHGhRt7tkFCm
POST /api/v1/scan now accepts exactly one of fields[] (manual, unchanged) or source="odoo" (live discovery), plus sample_limit (1..100, 422 otherwise). Both intake paths converge on the single existing scan_crm_fields(). Missing ODOO_URL/ODOO_API_KEY yields 503 only when the Odoo source is requested; upstream Odoo failures (including crm.lead inaccessible after res.partner succeeded) yield 502 rather than a partial or empty success. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BZDbPoPruZTHGhRt7tkFCm
Recursive Alignment kernel finding: the adapter hard-coded "2.3.0", duplicating the version owned by pyproject.toml. Read it via importlib.metadata with an explicit "unknown" fallback so the version string has a single owner. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BZDbPoPruZTHGhRt7tkFCm
PR Size Report
Best Practices for Large Changes
This PR is blocked from merging until size limits are met. |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
L9 Audit ReviewStatus
Policy
Blocking Findings
Advisory Findings
... truncated 1194 additional findings ... Generated by L9 Audit Engine ( |
| @pytest.fixture | ||
| def odoo_configured(monkeypatch: pytest.MonkeyPatch) -> None: | ||
| monkeypatch.setattr(converge_mod, "_domain_specs", {"plasticos": PLASTICOS_SPEC}) | ||
| monkeypatch.setattr(discover_mod, "get_settings", lambda: _odoo_settings()) |
| class CRMSource(Protocol): | ||
| """Smallest useful source contract: produce the live ``CRMField`` list.""" | ||
|
|
||
| async def fields(self, *, sample_limit: int = DEFAULT_SAMPLE_LIMIT) -> list[CRMField]: ... |
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The OpenAPI contract pack (docs/contracts/api/openapi.yaml) is not updated to reflect the new /api/v1/scan request XOR shape and 502/503 responses, leaving contracts out of sync with the implementation.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 1
Open (1)
What changed in this PR
Adds a live, read-only Odoo 19 “JSON-2” adapter so POST /api/v1/scan can auto-discover CRM fields (from res.partner and crm.lead) and feed them into the existing scan_crm_fields() pipeline, while extending scan output to carry per-field provenance.
Changes:
- Introduces
CRMSourceboundary +OdooCRMSourceimplementation to fetchfields_getmetadata and boundedsearch_readsamples for both Odoo models. - Extends scanner models/results with optional
source_system/source_resource, and updates scan hashing to distinguish identical field names across different source resources. - Extends
/api/v1/scanto support XOR input modes: manualfields[]or livesource="odoo"with boundedsample_limit, plus config/docs/tests forODOO_API_KEY.
| File | Description |
|---|---|
app/api/v1/discover.py |
Adds XOR request validation and Odoo-backed intake path for /api/v1/scan, including 502/503 handling. |
app/services/crm_field_scanner.py |
Adds provenance to mappings/results and includes provenance in scan_hash identity. |
app/services/crm_source.py |
Defines CRMSource protocol and CRMSourceError, plus sampling bounds constants. |
app/services/odoo_crm_source.py |
Implements live Odoo JSON-2 discovery + bounded sampling for res.partner and crm.lead. |
app/core/config.py |
Adds odoo_api_key setting for the new discovery path. |
.env.example |
Documents ODOO_API_KEY for live scan configuration. |
docs/contracts/config/env-contract.yaml |
Adds ODOO_API_KEY to the env contract. |
tests/contracts/test_config_env_contract.py |
Extends env-contract assertions to include ODOO_API_KEY. |
tests/test_crm_field_scanner.py |
Adds unit tests for provenance propagation + scan hash behavior. |
tests/test_discover_scan_endpoints.py |
Adds end-to-end API tests for source="odoo" and error handling paths. |
tests/test_odoo_crm_source.py |
New hermetic adapter tests (respx-mocked) + opt-in real Odoo integration test. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| class ScanRequest(BaseModel): | ||
| fields: list[CRMFieldInput] | ||
| """CRM scan request. Exactly one of ``fields`` (manual) or ``source`` (live) is required.""" | ||
|
|
||
| fields: list[CRMFieldInput] | None = None | ||
| source: Literal["odoo"] | None = None | ||
| sample_limit: int = Field(default=DEFAULT_SAMPLE_LIMIT, ge=1, le=MAX_SAMPLE_LIMIT) | ||
| domain: str | ||
| tenant_id: str | ||
|
|
||
| @model_validator(mode="after") | ||
| def require_fields_xor_source(self) -> ScanRequest: | ||
| if (self.fields is None) == (self.source is None): | ||
| raise ValueError("exactly one of 'fields' or 'source' must be supplied") | ||
| return self |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2338430947
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| for model in self.RESOURCES: | ||
| collected.extend(await self._fetch_resource(client, model, sample_limit)) |
There was a problem hiding this comment.
Count unique domain matches in live coverage
When both Odoo resources expose common fields such as name and phone, this loop preserves both mappings but the scanner still computes coverage as len(matched) / total_domain_properties. The included live-scan fixture therefore produces six matches for four domain properties and returns coverage_ratio=1.5, which can become a 150% discovery-report coverage value; preserve both provenance-bearing mappings but calculate coverage from unique matched domain keys.
Useful? React with 👍 / 👎.
| fields: list[CRMFieldInput] | None = None | ||
| source: Literal["odoo"] | None = None | ||
| sample_limit: int = Field(default=DEFAULT_SAMPLE_LIMIT, ge=1, le=MAX_SAMPLE_LIMIT) |
There was a problem hiding this comment.
Publish the live-source request in OpenAPI
For consumers generated or validated against the repository's canonical OpenAPI document, the new request is unusable: docs/contracts/api/openapi.yaml still requires fields, omits source and sample_limit, and sets additionalProperties: false. Such clients reject the exact {..., "source": "odoo"} request now accepted by this model, so update and version the API contract alongside this public interface change.
Useful? React with 👍 / 👎.
| # Odoo 19 JSON-2 bearer API key — authoritative for live CRM field discovery | ||
| # (POST /api/v1/scan with source="odoo"). Username/password stay for legacy XML-RPC. | ||
| odoo_api_key: str = "" |
There was a problem hiding this comment.
CONTRACT C-09 VIOLATION — Prefix the Odoo API key
CONTRACT C-09 VIOLATION — All environment variables must use the L9_ prefix unless explicitly approved as infrastructure-standard. The newly introduced odoo_api_key setting is loaded as ODOO_API_KEY, which has no documented exception; rename it and its .env.example and env-contract references to an L9_-prefixed name.
AGENTS.md reference: AGENTS.md:L173-L173
Useful? React with 👍 / 👎.
|
Status: ON HOLD (draft) — operator decision 2026-09-19. This PR implements the development pack as written: EIE reads Odoo's JSON-2 API directly ( What stays valid from this PR (transport-neutral):
What is superseded:
A Gate-routed development pack (Gate action + Generated by Claude Code |
With source provenance, two resources can both match one domain property (res.partner.phone and crm.lead.phone). Both mappings survive, but coverage must count the domain property once; the previous len(matched) numerator could exceed 1.0 (6 mappings / 4 properties = 1.5). Use the unique matched domain keys already tracked for the missing computation. Regression test added. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BZDbPoPruZTHGhRt7tkFCm
|
CI + review triage on head Pushed in this round
Failing checks that are not this PR's (each reproduces on
No fix PR exists yet for any of these that I have read; several open PRs (#204–#210, #212) may already carry some of them, so I am not porting fixes here to avoid duplicating that work. All five are logged to L9 memory for downstream pickup. I have no permission to re-run jobs from this surface. Failing checks that are this PR's
Review findings
Generated by Claude Code |
|
|
Closing — superseded. Per the operator's Gate-only egress decision, the direct Odoo JSON-2 adapter in this PR is retired. The transport-neutral scanner provenance slice (commits Generated by Claude Code |




Summary
EIE can now scan live Odoo Contacts (
res.partner) and CRM (crm.lead) fields directly viaPOST /api/v1/scanwith"source": "odoo", removing the need for a human to export field lists — the smallest read-only Odoo 19 JSON-2 adapter feeding the existingscan_crm_fields().Type of change
What changed
Scanner provenance (
app/services/crm_field_scanner.py)CRMFieldandFieldMappinggain optionalsource_system/source_resource.scan_result_to_dict; missing (domain-owned) entries carry none.scan_hashnow hashes(source_system, source_resource, name, field_type)sores.partner.phoneandcrm.lead.phonecontribute independently. LegacyCRMField(name=...)stays deterministic and compatible.Odoo adapter (
app/services/crm_source.py,app/services/odoo_crm_source.py)CRMSourceprotocol + singleCRMSourceError; one implementation, no registry/factory.OdooCRMSourceinspectsres.partnerandcrm.leadindependently and both mandatorily: onefields_get+ one boundedsearch_readper model = 4 requests per scan.domain=[], no pagination, no relation traversal, binary/one2many/reference excluded, ≤5 distinct values per field, sample fill rate (Nonewhen no rows). BooleanFalseand numeric0count as populated.httpx.AsyncClient, 10s timeout,Authorization: bearer <key>, optionalX-Odoo-Database,User-Agentversioned from package metadata, fixed model/method allowlist (arbitrary RPC impossible; no mutating methods exist). Errors name provider/model/method/status only — never credentials or response bodies.API (
app/api/v1/discover.py)ScanRequest: exactly one offields[](manual, unchanged) orsource: "odoo";sample_limit1..100 (422 otherwise).ODOO_URL/ODOO_API_KEY→ 503 (only when the Odoo source is requested; EIE startup unaffected). Odoo upstream failure, includingcrm.leadinaccessible afterres.partnersucceeded → 502, never a partial or empty success.Config / docs
ODOO_API_KEYsetting (app/core/config.py), documented in.env.exampleanddocs/contracts/config/env-contract.yaml. Username/password remain for legacy XML-RPC paths.Checklist
ruff checkpassesruff formatappliedmypypasses on all touched files (repo-widemypy apphas 40 pre-existing errors in 22 untouched files)tests/test_odoo_crm_source.pynew;tests/test_crm_field_scanner.py,tests/test_discover_scan_endpoints.py,tests/contracts/test_config_env_contract.pyextended)pytestpasses locally — 1663 passed, 1 skipped (opt-in real-Odoo test, gated onEIE_TEST_ODOO_URL/EIE_TEST_ODOO_API_KEY), 4 xfailed.env.exampleupdated if new env vars addedAI-generated code notice
ScanRequestXOR validator + boundedsample_limit;fields_get/search_readresponse shapes validatedtest-api-keyagainst respx mocks onlyScope attestation
Pre-existing findings (not addressed here, outside the pack's scope)
tests/test_pr21_packet_router.py:17— ruff F401 unusedTransportPacketimport (failsruff check .onmaintoo).tools/verify_contracts.py— SHA256 mismatch forapp/services/gate_client.py(pinned hash predates Migrate EIE to Gate-only egress; retire direct peer transport #203).tests/services/test_gate_registration.py— importsstart_reregistration_loop, which no longer exists inapp/main.py(collection error)./api/v1/scanresolves domains fromconverge._domain_specs, which startup configures empty (development pack §79 "PRE-EXISTING DOMAIN REGISTRY GAP"); tests inject the domain spec via the existing seam.Related issues
🤖 Generated with Claude Code
https://claude.ai/code/session_01BZDbPoPruZTHGhRt7tkFCm