This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
See also:
AGENTS.md(deep architecture map + high-signal rules) andINSTRUCTIONS.md(contributor scope per sub-project).
uv sync
source .venv/bin/activate# All tests (unit/fake, no live server required)
pytest tests/
# Single test file or test function
pytest tests/micro-tests/test_v2.py
pytest tests/micro-tests/test_v2.py::test_name
# Filter by marker (unit | integration | slow | auth | format_sets | mcp)
pytest -m unit
pytest -m integration
# Live Egeria server required
PYEG_LIVE_EGERIA=1 pytest tests/Note: --live-egeria as a CLI flag is referenced in tests/micro-tests/conftest_full.py's
skip messages, but that file isn't the active conftest.py (a separate,
minimal tests/micro-tests/conftest.py is) and no addoption for it is
registered — the flag doesn't currently work; only the env var does.
asyncio_mode = auto — async test functions need no event-loop boilerplate.
Tests live in three folders under tests/:
micro-tests/— unit/formatter/MCP tests, no live serverfunctional-tests/— per-OMVS testsscenario-tests/— full lifecycle (Create → Link → Delete) tests
Each OMVS module has a corresponding test file in its matching folder.
# Dr. Egeria — process a markdown file
dr_egeria <file> # default: validate
dr_egeria <file> --validate
dr_egeria <file> --process
dr_egeria <file> --process --debug # prints every Egeria API call + body
dr_egeria <file> --process --summary-only # suppress per-command analysis
# Dr. Egeria — process every markdown file in a folder, one CLI call
dr_egeria_folder <folder> # default: validate, every *.md in the folder
dr_egeria_folder <folder> --process # real writes; continues past per-file failures, reports all
dr_egeria_folder <folder> --results-file out.txt # also write the full per-file report to a file
# Regenerate report specs after adding/changing compact command JSON
refresh_specs # both Basic + Advanced (default)
refresh_specs --usage-level Basic # restrict to a single usage level
# Validate compact command JSON files
validate_compact_specs
# Generate Markdown command template files (for user authoring)
gen_md_cmd_templates # Basic attributes only
gen_md_cmd_templates --advanced # All attributes
# Local web editor for compact command JSON (attributes/bundles/commands) —
# replaces Tinderbox for editing existing families. One-time: uv sync --extra spec-editor
dr_egeria_spec_editor # http://localhost:8420
dr_egeria_spec_editor --port 8500 # custom port
dr_egeria_spec_editor --dir <path> # target a different compact_commands directorycommands/tech/spec_editor.py — a local FastAPI + vanilla-JS tool for editing
md_processing/data/compact_commands/*.json directly, without Tinderbox.
Reuses parse_compact_export, validate_compact_json, and
compact_spec_validator rather than reimplementing their logic. Writes
straight to the JSON files on disk; review changes with git diff the same
way you'd review a Tinderbox export.
Scope: create/edit/delete attributes, bundles, and commands within an existing family, plus a "New Family" action that scaffolds an empty compact-JSON file from a template. Every attribute/bundle/command reference in the UI is a picker over real names (never free text), so it's not possible to create a dangling reference from the editor.
commands/cat/dr_egeria_folder.py — runs every Dr.Egeria markdown file in a
folder through process_md_file_v2 with one shared EgeriaTech client (one
bearer token for the whole run, not one process per file). Ordering and
manifest semantics were agreed 2026-08-19 with the equivalent folder-batch
runner in egeria-workspaces-fs (bootstrap_batches.py, documented there in
PORTAL_STARTUP.md) so "run all commands in a folder" means the same thing
in both repos:
- An optional
_batch.jsonmanifest ({"files": ["a.md", "b.md", ...]}) at the folder root gives the explicit order for those files; any other*.mdfile not listed is appended after, alphabetically. No manifest → pure alphabetical. A manifest entry naming a file that no longer exists on disk is silently dropped, not an error. - Only
*.mdfiles are processed;_batch.jsonitself is always excluded. - Continues through every file regardless of earlier failures and reports a full per-file summary at the end (exit code 1 if anything failed) — an interactive CLI invocation is the "someone explicitly triggered this and wants full visibility" case. (The peer's own unattended auto-heal path makes the opposite, stop-on-first-failure, choice for its own use case — not implemented here.)
- Every file is expected to be upsert-safe, so the command is safe to re-run against the same folder repeatedly — no staleness tracking, only presence/absence, matching the peer implementation's own assumption.
One deliberate difference from the peer: this command defaults to
--validate (matching this repo's single-file dr_egeria CLI's own
default), not straight to process — pass --process for real writes.
This is Claude's edit path, not just a human UI, and Tinderbox is
retired. Confirmed 2026-08-05: when Claude needs to change a compact
command spec, it makes the edit itself through this app's REST API (start
it with dr_egeria_spec_editor if not already running, then
GET/POST/PUT/DELETE against
http://localhost:8420/api/families/{family}/attributes|bundles|commands/{name},
POST /api/validate/{family} to check the result) — not by hand-editing
the JSON text directly (skips the API's structural validation:
bundle-chain resolution, unknown-attribute checks, duplicate-name checks).
The compact JSON files in this repo are now the sole system of record —
the user confirmed the same day they will not maintain the Tinderbox file
going forward, so there's no mirroring step: once the API edit +
refresh_specs regeneration + processor wiring are done and verified,
that's the complete change.
scripts/omvs_audit.py reconciles every _async_* method in pyegeria/omvs/
against the .http collections, which are the ground truth for URLs, verbs,
and request-body classes.
# pyegeria/http clients/ is GITIGNORED — absent in fresh worktrees.
# Point the script at a checkout that has it:
export PYEGERIA_HTTP_DIR="/path/to/egeria-python/pyegeria/http clients"
python scripts/omvs_audit.py --quiet # full audit -> omvs_audit_report.md
python scripts/omvs_audit.py --service location-arena # one serviceExit status is 1 when any confirmed defect is found, so it can gate CI.
What it checks, and why each check exists — every category below was added after a real defect slipped through an earlier, weaker version of this script:
- VERB — compares the actual HTTP verb. Helper verbs are discovered by
parsing
_server_client.pyrather than hardcoded: every_async_*_requesthelper is POST, and an earlier hardcoded table wrongly mapped theget_*helpers to GET, which is how a batch of GET-vs-POST defects went undetected. A direct_async_make_requestwins over a helper, because some methods call a GUID-resolution helper first. - PATH — compares full normalised paths. It deliberately does not strip
trailing verbs (
/attachvs/detach) or the leading service segment; an earlier version did, and that masked exactly the bugs worth finding. - BODY — compares the request-body class (
FilterRequestBodyvsResultsRequestBody, etc.). Heuristic: it takes the first*RequestBodyidentifier in the method, which may be a signature annotation, so treat body findings as lower-confidence than verb/path. - LINT — structural URL defects detectable without ground truth: double
slashes, a path parameter with no separator before it (
/agreements{guid}), and underscores in path segments (Egeria uses hyphens).
URL extraction is AST-based, not regex — multi-line f-strings silently produced empty paths under the regex version, which made whole modules look clean.
When triaging a finding, confirm it against the .http file before changing
code, and prefer the SDK's existing helpers (_async_get_name_request,
_async_get_results_body_request, …) over hand-rolled _async_make_request
calls, so verb and body shape stay correct by construction.
- Always use
git commit -sto sign off commits. This appendsSigned-off-by: Dan Wolfson <dan.wolfson@pdr-associates.com>— DCO is enforced on this repo and unsigned commits will be rejected. - This rule is strictly enforced for all automated and manual commits.
- Do not add
Co-Authored-By:lines to commit messages.
pyegeria/ → commands/ (hey_egeria CLI)
pyegeria/ → md_processing/ (Dr. Egeria)
Changes to pyegeria/ (SDK API, config, report formatting) can break both.
pyegeria/core/ — transport, auth, config
_base_platform_client.py→_base_server_client.py→_server_client.py: layered HTTP stackconfig.py: Pydantic-settings config; precedence = explicit args > OS env >.env>config.json> defaultsutils.py: shared helpers (body_slimmer,make_format_set_name_from_type, etc.)- Gotcha — a request-body Pydantic model (
pyegeria/models/models.py) missing a field silently drops it, not an error. Every model inheritsPyegeriaModel'sextra='ignore', so a caller-supplied dict (or a raw no-body-provided construction in_server_client.py'svalidate_delete_*_request-style helpers) with a field the model doesn't declare validates successfully and just vanishes before serialization — no exception, no warning. Confirmed live impact:DeleteElementRequestBodyhad neithercascadeDeletenordeleteMethodfor a long time despite both being real, documented fields on the actual Egeria DTO (.httpground truth) — every caller passingcascade_delete=Truethrough_async_delete_element_requesthad silently been sending it as unset (PYEGERIA_ISSUES.md ISSUE-62). When adding/using a request-body model, check its fields against the real body inpyegeria/http clients/Egeria-api-*.httprather than assuming "it validated without error" means the field will actually be sent.
pyegeria/omvs/ — 40+ service-specific clients (one file per OMVS)
- Every public method has an
_async_*implementation and a sync wrapper callingasyncio.get_event_loop().run_until_complete(...). - All public methods decorated with
@dynamic_catch. - Ground truth for API URLs and request bodies is in
pyegeria/http clients/Egeria-api-*.http— check these files before constructing URLs.
pyegeria/egeria_tech_client.py — EgeriaTech facade
- Uses
__getattr__to lazily proxy attribute access across all OMVS subclients; do not eagerly instantiate. create_egeria_bearer_token()/set_bearer_token()propagate tokens to every subclient.
pyegeria/view/ — output formatting and report spec registry
base_report_formats.py: two separate dicts merged byget_report_registry()—generated_format_sets(auto-generated byrefresh_specs; do not hand-edit) andbase_report_specs(hand-maintained BUILTINS, e.g.Referenceable; safe to edit directly,refresh_specsnever touches it).get_report_registry()also auto-loads a CONFIG tier on first call, fromsettings.Environment.pyegeria_report_spec_modules(config.json's"Pyegeria Report Spec Modules"list) or thePYEGERIA_REPORT_SPEC_MODULESenv var — each entry a.jsonpath or a"pkg.mod:func"loader — plus every*.jsonin the user report specs directory (PYEGERIA_USER_REPORT_SPECS_DIR> config.json"Pyegeria User Report Specs Dir">~/.pyegeria/report_specs). Each entry loads independently: a failing or label-colliding entry is skipped with a warning (and returned byrefresh_report_specs()), never blocking the others. Gotcha:config/config.jsonis only auto-discovered ifPYEGERIA_CONFIG_DIRECTORY(orPYEGERIA_ROOT_PATH) is set — a bare shell with neither falls back to defaults silently, so a probe script that just importssettingsmay not see config.json values at all; use thePYEGERIA_REPORT_SPEC_MODULESenv var directly when in doubt.output_formatter.py:generate_output()— materializes elements into MD/LIST/DICT/REPORT formats._output_format_models.py: Pydantic modelsColumn/Format/FormatSet/ActionParameter— define new specs with these, not raw dicts.ActionParameterhas two independent execution paths:function/find_method(element-query, formatted per-column) andanalytic_function/extra_find(a plain function returning an already-aggregated result — seeoutput-formats-and-report-specs.md's "Analytic functions" section).format_set_executor.py:exec_report_spec()— runs either path;SERIES/BAR/PIEoutput formats wrap an analytic function's result as a Vega-Lite chart (dispatched before normal Format-row lookup, so noFormatSetneeds to declare those as aFormatrow).analytic_registry.py/analytic_demo_specs.py: the catalog of analytic functions (AnalyticFunctionSpec.genericmarks whether what it counts is a parameter or hardcoded) and one real, executable demoFormatSetper registered function._output_dashboard_sheet_models.py:DashboardSheet/Placement— a local (not yet Egeria-native) model for user-authored dashboards, built via Dr.Egeria'sCreate Dashboard Sheet/Link Report to Dashboard Sheet/Add Text on Dashboard Sheet(md_processing/v2/dashboard_sheet.py).- Generated report specs follow
{Type}-DrE-{Basic|Advanced}naming; carry an optionalfamilystring for discovery.
Full pipeline for one process_md_file_v2() call:
Markdown file
↓ UniversalExtractor (extraction.py)
splits on ## headers / horizontal rules → DrECommand objects
↓ setup_dispatcher() (dr_egeria.py)
loads COMMAND_DEFINITIONS from compact JSON specs
registers {command_key → ProcessorClass} in V2Dispatcher
↓ V2Dispatcher.dispatch_batch() (dispatcher.py)
sequential; shared context["planned_elements"] for inter-command GUID resolution
alias resolution → fuzzy verb-stripping → subtype fallbacks
↓ AsyncBaseCommandProcessor.execute() (processors.py)
1. AttributeFirstParser.parse()
2. derive qualified name
3. fetch_as_is (cache → Egeria lookup)
4. CommandRewriter: Create↔Update upsert transitions
5. resolve reference GUIDs for all attributes
6. validate_only() → markdown analysis table
7. apply_changes() ← abstract; implemented per processor
8. render_result_markdown(guid)
↓ dr_egeria.py: assemble final_output, write processed-*.md
Key files:
| File | Role |
|---|---|
dr_egeria.py |
Entry point; setup_dispatcher(); process_md_file_v2() |
v2/extraction.py |
UniversalExtractor → DrECommand dataclass |
v2/dispatcher.py |
V2Dispatcher — routing, alias resolution, fallbacks |
v2/processors.py |
AsyncBaseCommandProcessor — base parse/validate/apply logic |
v2/rewriters.py |
CommandRewriter — Create↔Update auto-transitions |
md_processing_utils/md_processing_constants.py |
COLLECTION_SUBTYPES, PROJECT_SUBTYPES, COMMAND_DEFINITIONS, verb groups |
md_processing_utils/common_md_utils.py |
Body builders (set_element_prop_body, domain helpers) |
data/compact_commands/*.json |
Compact command specs — ground truth for attributes; system of record as of 2026-08-05 (Tinderbox retired) |
Compact command JSON format — three sections per file:
attribute_definitions— full metadata per attribute (style, labels, valid values, etc.)bundles— reusable attribute groups; single inheritance via"inherits"commands— references a bundle + adds custom attributes; expands to a full command spec at load time
Never hand-edit the JSON text directly. Use the Dr.Egeria Spec Editor's
REST API (dr_egeria_spec_editor, http://localhost:8420 — see the
"Dr.Egeria Spec Editor" section above) to make the change — it does
structural validation (bundle-chain resolution, unknown-attribute checks)
that raw editing skips. These files were originally Tinderbox exports, but
as of 2026-08-05 that's retired: the user confirmed they will not maintain
the Tinderbox file going forward, so this repo's compact JSON is the sole
system of record — no mirroring/re-export step needed after an edit. After
any change, run refresh_specs to regenerate base_report_formats.py.
Dispatcher registration — how new commands get wired in:
COLLECTION_SUBTYPESandPROJECT_SUBTYPESdrive automaticCreate/Updaterouting toCollectionManagerProcessor/ProjectProcessor— adding a type to these lists is all that's required.- Families driven entirely by compact spec (Actor Manager, Governance, Solution Architect, Curation) use a
register_*_processors()helper loop indr_egeria.py. register_curation_processors()routes byOM_TYPE, not verb —"Update"is ambiguous between a classification update and a relationship update, so it looks up each command'sOM_TYPEinCLASSIFICATION_METHODS(md_processing/v2/curation.py) →CurationClassifyProcessor, or in the localCURATION_LINK_OM_TYPESset →CurationLinkProcessor; anything in neither (currently: Class Word/Modifier/Policy Management Point — real Egeria classification types confirmed live viaget_all_classification_defs, but with no backing pyegeria SDK method) is left unregistered on purpose and stays parse-only.CLASSIFICATION_METHODSkeys must be the real Egeria classification type name (confirmed against a live server), not a Dr.Egeria-side display convention — a prior Tinderbox export usedImpactClassification/ConfidenceClassification/etc., whichvalidate_compact_specs'OM_TYPE_INVALIDcheck caught since the real names are unsuffixed (Impact,Confidence, ...).register_governance_processors()is family-name-gated, not automatic — it only registers commands whose compact-specfamilyis literally"Governance Officer"(or, as of theAction Authorfamily, also"Action Author"). Adding a brand-new family whose commands should reuseGovernanceProcessor/GovernanceLinkProcessormeans adding its name to that check explicitly — nothing is wired up just because the compact JSON exists and validates. WithinAction Author, the fourLinkcommands are routed byOM_TYPE, not by verb alone, across two dedicated processors inmd_processing/v2/action_author.py(neither uses the generic peer-link mechanism, since both need relationship propertiesPeerDefinitionPropertieshas no room for):Link First/Next Process Step(OM_TYPEGovernanceActionProcessFlow/NextGovernanceActionProcessStep) →ActionProcessStepLinkProcessor, callingaction_author.setup_first/next_action_process_stepwithGovernanceActionProcessFlowProperties(guard/requestParameters) /NextGovernanceActionProcessStepProperties(guard/mandatoryGuard). Verified end-to-end against a live server — relationship properties persist correctly.Link Action to Action Executor/Target(OM_TYPEGovernanceActionExecutor/TargetForGovernanceAction) →ActionExecutorTargetLinkProcessor, callingaction_author.link_governance_action_executor/link_target_for_governance_actionwithGovernanceActionExecutorProperties(requestType/requestParameters/requestParameterFilter/requestParameterMap/actionTargetFilter/actionTargetMap) /TargetForGovernanceActionProperties(actionTargetName). Added 2026-07-15 — not yet verified against a live server.
- Everything else is an explicit
reg("Verb Object", ProcessorClass)call insetup_dispatcher().
Body builders — all flow through one function:
set_element_prop_body()incommon_md_utils.pyis the base inner-properties builder for all element types.- Every domain helper (
set_collection_manager_body,set_actor_manager_prop_body,set_gov_prop_body,set_data_field_body) calls it and adds type-specific fields on top. - To add a new
Referenceable-level property, add it once toset_element_prop_body().
Adding a new processor — minimum steps:
- Add the command spec to the relevant compact JSON via the Dr.Egeria Spec Editor's REST API (see above — Tinderbox is retired, this repo's compact JSON is the system of record).
- Add the type to
COLLECTION_SUBTYPES/PROJECT_SUBTYPESif it's a collection/project subtype (auto-routes). - Otherwise implement
apply_changes()in a newAsyncBaseCommandProcessorsubclass and register it insetup_dispatcher(). - Run
refresh_specsto generate the result-table format spec. - If any downstream app has its own copy of dispatcher/command-registration logic, verify it too — don't assume registering here is sufficient. egeria-workspaces-fs's
PyegeriaWebHandler/dr_egeria_md.pycarried a hand-duplicatedsetup_dispatcher()(pre-dating this session, ~3+ months old) that silently missedCreate Reportand the entire Dashboard Sheet family added here, because it was never updated in parallel — the commands worked perfectly viadr_egeria --processin this repo's own dev venv while being completely unrecognized ("No processor registered") through that app's actual web UI. That local duplicate has since been deleted in favor of importing this module'ssetup_dispatcherdirectly (2026-07-31), but the general risk — a consumer wiring its own copy of dispatch logic instead of importing it — isn't structurally prevented, so re-check after any new command lands if a consumer app's behavior doesn't match a workingdr_egeriaCLI run. - If the new command introduces a local (non-Egeria) persisted store — e.g.
dashboard_sheet.py's~/.pyegeria/dashboard_sheets.json— tell the user it needs a bind mount in every consuming app's deploy config, or it's silently ephemeral (wiped on the consumer's next container rebuild). This repo has no deploy config of its own to fix; egeria-workspaces-fs'sAGENTS.mdhas the checklist item for its compose files.
Creating a type with no dedicated OMVS wrapper — pick the right generic endpoint, don't assume MetadataExpert. MetadataExpert._async_create_metadata_element (the raw /metadata-expert/metadata-elements endpoint) looks like the obvious generic fallback, but it requires the verbose typed ElementProperties/propertyValueMap body shape (confirmed live: passing the flat properties dict every other processor uses gets OMAG-COMMON-400-006 ... qualifiedName parameter ... is null, a 400, even though qualifiedName was set — the flat value is silently not read). For an Asset subtype (Report, and presumably other DataSet/Asset-derived types with no bespoke OMVS class), use AssetMaker._async_create_asset(["<Type>Properties"], body) / _async_update_asset(guid, body) instead (asset-maker/assets) — it accepts the same flat set_create_body()/set_element_prop_body() shape as every bespoke wrapper. See md_processing/v2/report.py (Create Report) for the worked example, verified end-to-end against a live server including the create→update upsert transition.