katalyst-forge 4.2.0__tar.gz → 5.2.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.
Files changed (152) hide show
  1. katalyst_forge-5.2.0/.gitignore +31 -0
  2. katalyst_forge-5.2.0/CHANGELOG.md +146 -0
  3. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/PKG-INFO +1 -1
  4. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/pyproject.toml +6 -1
  5. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/schemas/nodes/organizational.schema.json +4 -0
  6. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/__init__.py +1 -1
  7. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/bundle.py +11 -7
  8. katalyst_forge-5.2.0/src/katalyst_forge/metrics/__init__.py +57 -0
  9. katalyst_forge-5.2.0/src/katalyst_forge/metrics/batch.py +174 -0
  10. katalyst_forge-5.2.0/src/katalyst_forge/metrics/billing.py +35 -0
  11. katalyst_forge-5.2.0/src/katalyst_forge/metrics/contracts.py +128 -0
  12. katalyst_forge-5.2.0/src/katalyst_forge/metrics/core.py +385 -0
  13. katalyst_forge-5.2.0/src/katalyst_forge/metrics/dashboard.py +214 -0
  14. katalyst_forge-5.2.0/src/katalyst_forge/metrics/errors.py +122 -0
  15. katalyst_forge-5.2.0/src/katalyst_forge/metrics/generate_dashboard.py +245 -0
  16. katalyst_forge-5.2.0/src/katalyst_forge/metrics/html_report.py +383 -0
  17. katalyst_forge-5.2.0/src/katalyst_forge/metrics/money.py +43 -0
  18. katalyst_forge-5.2.0/src/katalyst_forge/metrics/protocols.py +83 -0
  19. katalyst_forge-5.2.0/src/katalyst_forge/metrics/providers/__init__.py +9 -0
  20. katalyst_forge-5.2.0/src/katalyst_forge/metrics/providers/forge_model_router.py +100 -0
  21. katalyst_forge-5.2.0/src/katalyst_forge/metrics/providers/llm_gateway.py +20 -0
  22. katalyst_forge-5.2.0/src/katalyst_forge/metrics/providers/opencode.py +269 -0
  23. katalyst_forge-5.2.0/src/katalyst_forge/metrics/redaction.py +43 -0
  24. katalyst_forge-5.2.0/src/katalyst_forge/metrics/registry.py +57 -0
  25. katalyst_forge-5.2.0/src/katalyst_forge/metrics/routing_policy.py +97 -0
  26. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/relationships.py +80 -3
  27. katalyst_forge-5.2.0/src/katalyst_forge/wings/_practice_refs.py +58 -0
  28. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/wings/design.py +5 -115
  29. katalyst_forge-5.2.0/src/katalyst_forge/wings/metrics.py +197 -0
  30. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/wings/org.py +11 -5
  31. katalyst_forge-5.2.0/src/katalyst_forge/wings/practices.py +565 -0
  32. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/wings/system.py +13 -2
  33. katalyst_forge-5.2.0/templates/CHANGELOG.md +671 -0
  34. katalyst_forge-5.2.0/tests/fake_providers_opencode.py +38 -0
  35. katalyst_forge-5.2.0/tests/fake_providers_router.py +31 -0
  36. katalyst_forge-5.2.0/tests/forge-reconcile/fixtures/incident-ric1580/expected-modified.txt +2 -0
  37. katalyst_forge-5.2.0/tests/forge-reconcile/fixtures/incident-ric1580/expected-new.txt +16 -0
  38. katalyst_forge-5.2.0/tests/forge-reconcile/fixtures/incident-ric1580/modified-inplace.patch +124 -0
  39. katalyst_forge-5.2.0/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/delivery/work_items/ric-1580-forge-model-routing.yaml +20 -0
  40. katalyst_forge-5.2.0/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/capabilities/CAP-011-per-dispatch-model-tier-routing.md +110 -0
  41. katalyst_forge-5.2.0/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/capabilities/CAP-012-dispatch-parity-and-containment.md +109 -0
  42. katalyst_forge-5.2.0/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/capabilities/CAP-013-per-stage-model-token-telemetry.md +99 -0
  43. katalyst_forge-5.2.0/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/nfrs/dispatched-stage-executes-on-requested-tier.yaml +48 -0
  44. katalyst_forge-5.2.0/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/nfrs/routed-dispatch-preserves-task-parity.yaml +46 -0
  45. katalyst_forge-5.2.0/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/nfrs/tier-resolution-fails-before-any-child-session.yaml +48 -0
  46. katalyst_forge-5.2.0/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/nfrs/zero-committed-vendor-model-identity.yaml +45 -0
  47. katalyst_forge-5.2.0/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/user-needs/UN-024-operator-selects-model-tier-per-forge-run.md +81 -0
  48. katalyst_forge-5.2.0/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/user-needs/UN-025-model-tiers-from-env-vars-no-vendor-pins.md +85 -0
  49. katalyst_forge-5.2.0/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/user-needs/UN-026-routing-tier-recorded-on-work-item.md +73 -0
  50. katalyst_forge-5.2.0/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/user-needs/UN-027-per-stage-model-and-token-telemetry.md +80 -0
  51. katalyst_forge-5.2.0/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/user-needs/UN-028-dispatch-tool-enforces-agent-allowlist.md +76 -0
  52. katalyst_forge-5.2.0/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/user-needs/UN-029-silent-or-aborted-dispatch-yields-blocked.md +83 -0
  53. katalyst_forge-5.2.0/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/user-needs/UN-030-dispatched-write-stage-stays-in-worktree.md +73 -0
  54. katalyst_forge-5.2.0/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/user-needs/UN-031-single-stage-spike-preserves-eleven-stage-rollout.md +76 -0
  55. katalyst_forge-5.2.0/tests/forge-reconcile/reconcile.test.mjs +182 -0
  56. katalyst_forge-5.2.0/tests/test_cost_observability_wing.py +224 -0
  57. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/tests/test_entry_point_discovery.py +2 -2
  58. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/tests/test_forge.py +7 -7
  59. katalyst_forge-5.2.0/tests/test_metrics_batch_hygiene.py +241 -0
  60. katalyst_forge-5.2.0/tests/test_metrics_billing_isolation.py +112 -0
  61. katalyst_forge-5.2.0/tests/test_metrics_content_contract.py +307 -0
  62. katalyst_forge-5.2.0/tests/test_metrics_contracts.py +95 -0
  63. katalyst_forge-5.2.0/tests/test_metrics_dashboard.py +228 -0
  64. katalyst_forge-5.2.0/tests/test_metrics_dashboard_end_to_end.py +203 -0
  65. katalyst_forge-5.2.0/tests/test_metrics_html_report.py +182 -0
  66. katalyst_forge-5.2.0/tests/test_metrics_lazy_providers.py +102 -0
  67. katalyst_forge-5.2.0/tests/test_metrics_money_and_redaction.py +182 -0
  68. katalyst_forge-5.2.0/tests/test_metrics_providers.py +419 -0
  69. katalyst_forge-5.2.0/tests/test_metrics_resolve_compute.py +477 -0
  70. katalyst_forge-5.2.0/tests/test_metrics_routing_policy.py +102 -0
  71. katalyst_forge-5.2.0/tests/test_metrics_wing.py +64 -0
  72. katalyst_forge-5.2.0/tests/test_practice_assignment.py +107 -0
  73. katalyst_forge-5.2.0/tests/test_relationships.py +34 -0
  74. katalyst_forge-5.2.0/tests/test_storage_contract.py +66 -0
  75. katalyst_forge-5.2.0/tests/test_validate_templates_gates.py +259 -0
  76. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/tests/test_wing_schemas.py +15 -0
  77. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/tests/test_wings.py +22 -2
  78. katalyst_forge-5.2.0/tests/test_writer_bundle_schema.py +117 -0
  79. katalyst_forge-4.2.0/.gitignore +0 -8
  80. katalyst_forge-4.2.0/CHANGELOG.md +0 -83
  81. katalyst_forge-4.2.0/src/katalyst_forge/wings/practices.py +0 -216
  82. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/LICENSE +0 -0
  83. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/schemas/CHANGELOG.md +0 -0
  84. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/schemas/README.md +0 -0
  85. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/schemas/agents/agent.schema.json +0 -0
  86. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/schemas/agents/config.schema.json +0 -0
  87. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/schemas/agents/manifest.json +0 -0
  88. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/schemas/agents/skill.schema.json +0 -0
  89. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/schemas/environment.schema.json +0 -0
  90. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/schemas/hooks/hook-config.schema.json +0 -0
  91. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/schemas/katalyst-lock.schema.json +0 -0
  92. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/schemas/nodes/common.schema.json +0 -0
  93. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/schemas/nodes/design.schema.json +0 -0
  94. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/schemas/nodes/portfolio.schema.json +0 -0
  95. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/schemas/nodes/technical.schema.json +0 -0
  96. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/schemas/taxonomy-node.schema.json +0 -0
  97. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/py.typed +0 -0
  98. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/creation/_base.yaml.j2 +0 -0
  99. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/creation/layer.yaml.j2 +0 -0
  100. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/creation/project.yaml.j2 +0 -0
  101. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/creation/stack.yaml.j2 +0 -0
  102. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/creation/system.yaml.j2 +0 -0
  103. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/creation/team.yaml.j2 +0 -0
  104. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/creation/team_member.yaml.j2 +0 -0
  105. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/app-docker/.dockerignore +0 -0
  106. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/app-docker/.env.example +0 -0
  107. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/app-docker/README.md +0 -0
  108. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/app-docker/base/Dockerfile +0 -0
  109. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/app-docker/docker-compose.yaml +0 -0
  110. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/app-docker/env-template/Dockerfile +0 -0
  111. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/app-docker/env-template/docker-compose.override.yaml +0 -0
  112. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/app-docker/env-template/environment.yaml +0 -0
  113. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/app-docker/layer.yaml +0 -0
  114. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/README.md +0 -0
  115. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/composite/terraform/main.tf +0 -0
  116. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/composite/terraform/outputs.tf +0 -0
  117. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/composite/terraform/variables.tf +0 -0
  118. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/env-template/environment.yaml +0 -0
  119. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/env-template/terragrunt.hcl +0 -0
  120. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/global/iac/context.hcl +0 -0
  121. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/global/iac/dev.yaml +0 -0
  122. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/global/iac/prod.yaml +0 -0
  123. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/global/iac/staging.yaml +0 -0
  124. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/layer.yaml +0 -0
  125. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/version.yaml +0 -0
  126. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/k8s-argocd/README.md +0 -0
  127. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/k8s-argocd/base/app/argocd-application.yaml +0 -0
  128. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/k8s-argocd/base/kustomization.yaml +0 -0
  129. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/k8s-argocd/base/namespace.yaml +0 -0
  130. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/k8s-argocd/environment-template/argocd-application.yaml +0 -0
  131. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/k8s-argocd/environment-template/kustomization.yaml +0 -0
  132. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/k8s-argocd/environment-template/patch-argocd-application.yaml +0 -0
  133. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/k8s-argocd/layer.yaml +0 -0
  134. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/README.md +0 -0
  135. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/base/deployment.yaml +0 -0
  136. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/base/kustomization.yaml +0 -0
  137. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/base/namespace.yaml +0 -0
  138. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/base/service.yaml +0 -0
  139. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/environment-template/kustomization.yaml +0 -0
  140. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/environment-template/patch-deployment.yaml +0 -0
  141. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/global/k8s/base/kustomization.yaml +0 -0
  142. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/global/k8s/base/namespace.yaml +0 -0
  143. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/global/k8s/kustomization.yaml +0 -0
  144. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/layer.yaml +0 -0
  145. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/wings/__init__.py +0 -0
  146. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/wings/agentic.py +0 -0
  147. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/wings/delivery.py +0 -0
  148. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/src/katalyst_forge/wings/pipeline.py +0 -0
  149. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/tests/conftest.py +0 -0
  150. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/tests/test_agent_run.py +0 -0
  151. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/tests/test_delivery_doc_consistency.py +0 -0
  152. {katalyst_forge-4.2.0 → katalyst_forge-5.2.0}/tests/test_delivery_traceability.py +0 -0
@@ -0,0 +1,31 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ site/
6
+ node_modules/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+
10
+ # RIC-733 consistency eval run exhaust (transient, not the checked-in baseline)
11
+ docs/consistency-results.json
12
+ .prompt-cache/
13
+
14
+ # Deterministic tooling-gate run artifacts (kata-gate.sh runtime output).
15
+ # Real telemetry per run; regenerated on every gate invocation, not committed.
16
+ .global/taxonomy/delivery/agent_runs/tooling/
17
+
18
+ # Generated orientation packet (RIC-731): produced at install time, not source
19
+ orientation/packet.json
20
+
21
+ # Machine-local orientation fallback config (RIC-731): holds an absolute packetPath,
22
+ # written beside the installed contract; never source, never commit.
23
+ orientation-config.json
24
+
25
+ # bun.lock is the committed lockfile; npm's is not
26
+ package-lock.json
27
+
28
+ # Watchdog runtime state (RIC-1592): markers and ledger entries are produced at runtime
29
+ # outside any repo, under KATALYST_WATCHDOG_STATE_DIR. Ignored so a stray write can never
30
+ # pollute the working tree that RIC-1591 has to diff cleanly.
31
+ .katalyst-watchdog/
@@ -0,0 +1,146 @@
1
+ # Changelog — katalyst-forge
2
+
3
+ All notable changes to the **katalyst-forge** bundle are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ As of 4.2.0 this package lives in its own repository
9
+ (`esimplicityinc/katalyst-forge`) and tags use the plain `vX.Y.Z` prefix.
10
+ Releases 4.1.0 and earlier were cut from the `katalyst-taxonomy` monorepo
11
+ with `forge-vX.Y.Z` tags.
12
+
13
+ The Forge **bundle `schema_version`** (declared in `bundle.py`) is the
14
+ consumer-facing node-model contract and is versioned separately from this
15
+ PyPI package version. A breaking node-type/model change bumps `schema_version`.
16
+
17
+ ---
18
+
19
+ ## [5.2.0] -- 2026-08-08
20
+
21
+ ### Added
22
+
23
+ - **Cost observability as metrics-wing content** (RIC-1590). Run cost is modelled as `metric_definition` plus `signal_source` content rather than bespoke reporting code. Ships `src/katalyst_forge/metrics/` with a pure computation core (no I/O), a deny-by-default projection into `CanonicalSignalBatch`, `SignalProvider` / `PriceCatalogue` / `RoutingPolicy` protocols, lazily resolved providers for OpenCode and forge-model-router, a text dashboard, an HTML report, and the `cost-observability` template bundle with its global action and Just recipe. Metered and subscription-notional spend are kept apart by construction: `MetricResult` is keyed by billing mode and offers no total, and `__add__`, `__radd__` and `__float__` raise.
24
+ - **Routing comparison policy** (RIC-1616). `routing-headroom` and `routing-savings-realized` can now produce a figure. The comparison models come from the environment (`FORGE_COST_ORCHESTRATOR_MODEL`, `FORGE_COST_TARGET_MODEL`, `FORGE_COST_TARGET_MODEL_<STAGE>`), so no model identifier is hard-coded in code or content. An unconfigured environment yields no policy, leaving the previous behaviour of absent figures rather than zeros.
25
+ - **`optional_fields` on a signal source** (RIC-1617). A provider-synthesized value that may legitimately be absent, such as a Forge stage inferred from a session that is not a Forge stage, no longer discards the whole batch.
26
+ - **Bundle content and non-ownership in the writer reference** (RIC-1621). `kata-tax-writer` now has schema entries for template-bundle content, and explicitly disclaims `tests/`, `src/`, `scripts/` and `.github/workflows/`.
27
+
28
+ ### Fixed
29
+
30
+ - **The metered cost path never returned a record** (RIC-1615). The OpenCode adapter read its root sessions from a `query_config` key that only ever existed in a test fixture, so every real run walked an empty session list. Run identity is now an explicit argument on `SignalProvider.fetch`, interpreted per adapter.
31
+ - **The session walk dropped spend it should have counted** (RIC-1615). It began at the root's children, so an orchestrator's own tokens went uncounted and a single-session run reported nothing, and it descended only one level even though the children endpoint returns direct children only. It now yields the root and every transitive descendant once each.
32
+ - **Router telemetry was rejected outright** (RIC-1615). A real run writes `cost_usd` as a JSON number; decoding without `parse_float=Decimal` lost the exactness `parse_money` requires and aborted the dashboard.
33
+ - **The price catalogue was built eagerly** (RIC-1615), demanding OpenCode credentials from users who only wanted router figures. It is now built on first price lookup.
34
+ - **`routing-headroom` reported a fabricated zero** (RIC-1616) when no record had a resolvable comparison target. A rendered `$0.00` reads as routing already being optimal rather than as routing never having been measured, so it now yields no figure unless a comparison was made.
35
+ - **One uncomputable metric aborted the whole report** (RIC-1616). `compute` is now isolated per metric, matching the per-source isolation already in place.
36
+ - **The action schema documentation did not match the loader** (RIC-1619). `katalyst-action-builder` documented `kind: Action` on an unrecognised apiVersion with `spec.type`, `spec.timeoutSeconds` and `spec.tags`; following it produced zero actions and, for the kind and apiVersion errors, zero errors as well.
37
+ - **The writer's Kind Mapping carried the same defect** (RIC-1621). `plugin-action` mapped to `kind: Action` on `katalyst.taxonomy.plugins.actions/v1alpha`, so the single writer would have produced action files that no loader reads.
38
+
39
+ ### Changed
40
+
41
+ - CI now runs `ruff format --check` over `src`, `tests` and `scripts` (RIC-1620), with the pre-existing drift reformatted first so the gate did not turn open branches red.
42
+
43
+ ## [5.1.0] -- 2026-07-28
44
+
45
+ - **Canonical ADR/NFR storage** (RIC-1358) — ADR and NFR definitions now declare flat identity-derived storage under `.global/taxonomy/design/adrs/` and `.global/taxonomy/design/nfrs/`. Forge agents, skills, templates, fixtures, and documentation use the plural directories; legacy multi-document examples are labeled explicitly.
46
+ - Migrated the nine Forge-owned NFR artifacts from `design/nfr/` to `design/nfrs/` and added `NFR-010` to require executable storage consumers to resolve registry metadata instead of hard-coding artifact kinds or paths.
47
+
48
+ ## [5.0.0] -- 2026-07-22
49
+
50
+ Bundle `schema_version` bumped to **5** and manifest version to **5.0** to reflect breaking relationship renames and parent-rule changes below.
51
+
52
+ ### Added
53
+
54
+ - **`practice` node kind** in the Practices wing (RIC-1273). A bundle node that sits between `practice_area` and standards/methods/competencies/tools, enabling strict nesting: `practice_area` → `practice` → `{standard, method, competency, tool}`. Practices are assignable units that reference (do not own) standards and methods.
55
+ - **`INCLUDES_PRACTICE` relationship** (`practice_area` → `practice`): grouping edge for the new strict nesting hierarchy.
56
+ - **`PRESCRIBES_STANDARD` / `PRESCRIBES_METHOD` relationships** (`practice` → `standard` / `method`): practice prescribes which standards and methods it bundles.
57
+ - **`ADHERES_TO_PRACTICE` / `ADHERES_TO_STANDARD` / `ADHERES_TO_METHOD` relationships**: assignment edges allowing consumer nodes (system, stack, layer, library, tool, organization, project, team) to declare which practices, standards, and methods they follow.
58
+ - **Practice assignment fields** (`practices`, `standards`, `methods`) spliced into 8 consumer ("follower") node kinds via shared `_practice_refs.py` helper.
59
+ - **`metric_definition`, `signal_source`, `threshold_profile` node kinds** in the new Metrics wing (RIC-1272). Brings the bundle to **36 node types across 8 wings**.
60
+
61
+ ### Changed
62
+
63
+ - **BREAKING: `competency` re-parented from `practice_area` to `practice`** (RIC-1273). The `ParentRule.allowed_parent_kinds` changed from `{"practice_area"}` to `{"practice"}`. Any existing `competency` instances parented under a `practice_area` must be re-parented under a `practice` node. No known consuming taxonomies have competency instances.
64
+ - **BREAKING: Relationship renames** (RIC-1273). The following relationship types were renamed for naming consistency (all edge families now use verb-first, actor-centric naming):
65
+ - `INCLUDES_METHOD` / `INCLUDES_STANDARD` → removed (replaced by `PRESCRIBES_STANDARD` / `PRESCRIBES_METHOD` on the new `practice` node)
66
+ - Assignment family unified: `FOLLOWS_PRACTICE` → `ADHERES_TO_PRACTICE`, `APPLIES_METHOD` → `ADHERES_TO_METHOD` (`ADHERES_TO_STANDARD` unchanged)
67
+ - **`standard` and `method` relocated from Design wing to Practices wing** (RIC-1273). `nfr` is now a child of `standard`; `adr` is now a child of `method`. Design wing shrinks from 8 → 6 kinds; Practices wing grows from 4 → 9 kinds.
68
+
69
+ ### Migration
70
+
71
+ Consuming taxonomies that reference the removed/renamed relationship types in cross-references or queries must update:
72
+
73
+ | Old | New |
74
+ |-----|-----|
75
+ | `INCLUDES_METHOD` | `PRESCRIBES_METHOD` (on `practice`, not `practice_area`) |
76
+ | `INCLUDES_STANDARD` | `PRESCRIBES_STANDARD` (on `practice`, not `practice_area`) |
77
+ | `FOLLOWS_PRACTICE` | `ADHERES_TO_PRACTICE` |
78
+ | `APPLIES_METHOD` | `ADHERES_TO_METHOD` |
79
+ | `competency` parented under `practice_area` | Re-parent under `practice` |
80
+ | `nfr` / `adr` in design wing | Now in practices wing (no YAML change needed; wing is metadata) |
81
+
82
+ ## [4.2.0] -- 2026-07-06
83
+
84
+ Repo split (RIC-1070). First release from the standalone
85
+ `esimplicityinc/katalyst-forge` repository, extracted from `katalyst-taxonomy`.
86
+ No node-model changes; bundle `schema_version` stays **4**.
87
+
88
+ ### Added
89
+
90
+ - Standalone repository: the Forge schema bundle, the 21 kata-forge agent
91
+ definitions and forge skills (extracted from `katalyst-taxonomy-agents`),
92
+ the creation/scaffold templates, the JSON Schemas, and the Forge lifecycle
93
+ docs now live and version together as one product.
94
+ - JSON Schemas ship as package data in the wheel under `katalyst_forge/schemas/`.
95
+
96
+ ### Changed
97
+
98
+ - Release tags drop the `forge-` prefix: this repo tags plain `vX.Y.Z`.
99
+ - Docs move to https://esimplicityinc.github.io/katalyst-forge/.
100
+
101
+ ## [4.1.0] -- 2026-06-26
102
+
103
+ Delivery-wing evolution (#138 / #140). Bundle `schema_version` stays **4** (additive within the v4 node-model); bundle node count 29 → 30.
104
+
105
+ ### Added
106
+
107
+ - **`agent_run` node kind** (child of `work_item`): per-stage agent telemetry (`stages`, `totals`, `entered_status` / `exited_status`, `started` / `ended`) -- identifiers and counts only, no prompts or PII.
108
+ - **`work_item.status` 10-state lifecycle enum** (`idea, proposed, accepted, planned, in_progress, blocked, delivered, validated, released, retired`), enforced through the engine's `FieldSpec.allowed_values`.
109
+ - **Single `TRACES_TO` relationship** on `work_item`, disambiguated by the source field's target kind, covering `capabilities`, `user_needs`, `nfrs`, `adrs`, `design_specs`, `specifications`. Adding a new traceable target is a one-line field addition.
110
+ - **`change_entry` -> `work_item` link** (`work_item_id` + `REFERENCES_WORK_ITEM`).
111
+
112
+ ### Changed
113
+
114
+ - Minimum `katalyst-engine` raised to `>=4.1` -- the `work_item` status enum relies on `FieldSpec.allowed_values`, added in engine 4.1.0.
115
+ - Replaced the per-target relationship definitions (`implements_capability`, `addresses_need`, `satisfies_nfr`) with the single `TRACES_TO` edge. `road_id` / `REFERENCES_ROAD_ITEM` on `change_entry` retained as a deprecated alias of `work_item_id`.
116
+
117
+ ## [4.0.0] -- 2026-06-16
118
+
119
+ Bundle `schema_version` bumped to **4** and manifest version to **4.0** to reflect the breaking node-kind removals below.
120
+
121
+ ### Removed
122
+
123
+ - **BREAKING: Relocated mechanism kinds out of the Forge bundle** (#79/#82). Six node kinds no longer ship in Forge: `template`, `action`, `template_overlay`, `dx`, `cicd_job_template`, and `cicd_workflow_template`. Scaffolding/CI-CD mechanisms are now provided by the taxonomy runtime and its plugins rather than declared as Forge node types. Documents using these kinds must migrate (`kata tax migrate`); `template_overlay` becomes `node_wiring`.
124
+
125
+ ### Added
126
+
127
+ - **`design_spec` node type** in the Design wing (#44). A pre-executable specification artifact sitting between `user_need` and `specification` in the intent chain, capturing visual structure, interaction flows, and testable design constraints. Includes a `design_specs` field + `IMPLEMENTS_DESIGN` cross-reference on `specification`, and the `DESIGNS_FOR_NEED` / `DESIGNS_CAPABILITY` / `IMPLEMENTS_DESIGN` relationships. Brings the Forge bundle to **29 node types**.
128
+
129
+ ## [3.0.1] -- 2026-06-11
130
+
131
+ ### Changed
132
+ - **Corrected bundle `schema_version` to `3`** to reflect the breaking `user_story` → `user_need` rename that shipped in 3.0.0. The `BundleManifest.version` was bumped to `3.0` and `SchemaDefinition.schema_version` to `3`.
133
+ - First release published independently via the per-package `forge-v*` release pipeline.
134
+
135
+ ## [3.0.0] -- 2026-06-10
136
+
137
+ ### Changed
138
+ - **BREAKING: Renamed `user_story` node type to `user_need`** in the Design wing. The kind (`user_story` → `user_need`), directory (`user_stories/` → `user_needs/`), display labels (`User Story`/`User Stories` → `User Need`/`User Needs`), and Neo4j label (`UserStory` → `UserNeed`) all changed. Creation template `user_story.yaml.j2` → `user_need.yaml.j2`. No backward-compatibility alias is provided. (RIC-616)
139
+
140
+ ### Added
141
+ - **`katalyst-xbrl` bundle** companion node types and the pluggable parser registry landed in the same `3.0.0` release train (see root `CHANGELOG.md` for cross-package detail).
142
+
143
+ ---
144
+
145
+ > Versions prior to 3.0.0 were released in lockstep with `katalyst-taxonomy`
146
+ > under the umbrella `vX.Y.Z` tags. See the root `CHANGELOG.md` for that history.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: katalyst-forge
3
- Version: 4.2.0
3
+ Version: 5.2.0
4
4
  Summary: Katalyst Forge: the delivery-lifecycle bundle (node kinds, wings, agents, templates, schemas)
5
5
  Author: eSimplicity
6
6
  License: Proprietary
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "katalyst-forge"
3
- version = "4.2.0"
3
+ version = "5.2.0"
4
4
  description = "Katalyst Forge: the delivery-lifecycle bundle (node kinds, wings, agents, templates, schemas)"
5
5
  license = { text = "Proprietary" }
6
6
  requires-python = ">=3.12"
@@ -66,6 +66,11 @@ dev = [
66
66
  # test_wing_schemas.py validates the shipped JSON Schemas
67
67
  "jsonschema>=4.22",
68
68
  "referencing>=0.35",
69
+ # test_validate_templates_gates.py loads scripts/validate-templates.py, which
70
+ # imports these. CI's templates-validate job installs them ad hoc; the pytest
71
+ # job needs them in its own environment to exercise the gate logic.
72
+ "pyyaml>=6",
73
+ "jinja2>=3.1",
69
74
  ]
70
75
 
71
76
  [tool.ruff]
@@ -96,6 +96,10 @@
96
96
  "type": "string",
97
97
  "minLength": 1,
98
98
  "description": "Human-readable display name for the competency (e.g. 'Kubernetes Administration')."
99
+ },
100
+ "competency_type": {
101
+ "type": "string",
102
+ "description": "Optional free-form kind of competency: practice, system, or tools. No allowed_values enum is enforced."
99
103
  }
100
104
  }
101
105
  }
@@ -1,6 +1,6 @@
1
1
  """katalyst-forge — The Forge bundle for katalyst-engine.
2
2
 
3
- Provides 33 node-type definitions across 8 wings for software delivery taxonomy.
3
+ Provides 36 node-type definitions across 8 wings for software delivery taxonomy.
4
4
  """
5
5
 
6
6
  from __future__ import annotations
@@ -1,6 +1,6 @@
1
1
  """Forge bundle — the pre-installed taxonomy for software delivery.
2
2
 
3
- Assembles 34 node types across 8 wings from the wing modules into
3
+ Assembles 36 node types across 8 wings from the wing modules into
4
4
  a single ``ForgeBundle`` that implements the engine's ``Bundle``
5
5
  protocol. Discovered via the ``katalyst_engine.bundles`` entry point.
6
6
 
@@ -9,12 +9,13 @@ protocol. Discovered via the ``katalyst_engine.bundles`` entry point.
9
9
  Wing modules:
10
10
  - ``wings/system.py`` — system, environment, stack, layer, library, capability
11
11
  - ``wings/org.py`` — organization, program, project, team, team_member, role
12
- - ``wings/practices.py`` — practice_area, competency, skill, tool
13
- - ``wings/design.py`` — business_domain_model, user_type, user_need, design_spec, adr, nfr, specification, assertion
12
+ - ``wings/practices.py`` — practice_area, practice, competency, skill, standard, nfr, method, adr, tool
13
+ - ``wings/design.py`` — business_domain_model, user_type, user_need, design_spec, specification, assertion
14
14
  - ``wings/delivery.py`` — work_item, change_entry, agent_run
15
15
  - ``wings/pipeline.py`` — cicd_stage
16
16
  - ``wings/pipeline.py`` — cicd_stage, cicd_job_template, cicd_workflow_template
17
17
  - ``wings/agentic.py`` — agent, agent_skill
18
+ - ``wings/metrics.py`` — metric_definition, signal_source, threshold_profile
18
19
  """
19
20
 
20
21
  from __future__ import annotations
@@ -36,6 +37,7 @@ from katalyst_forge.relationships import ALL_RELATIONSHIP_DEFINITIONS
36
37
  from katalyst_forge.wings.agentic import AGENTIC_WING, AGENTIC_WING_NODES
37
38
  from katalyst_forge.wings.delivery import DELIVERY_WING, DELIVERY_WING_NODES
38
39
  from katalyst_forge.wings.design import DESIGN_WING, DESIGN_WING_NODES
40
+ from katalyst_forge.wings.metrics import METRICS_WING, METRICS_WING_NODES
39
41
  from katalyst_forge.wings.org import ORG_WING, ORG_WING_NODES
40
42
  from katalyst_forge.wings.pipeline import PIPELINE_WING, PIPELINE_WING_NODES
41
43
  from katalyst_forge.wings.practices import PRACTICES_WING, PRACTICES_WING_NODES
@@ -53,6 +55,7 @@ ALL_NODE_DEFINITIONS: list[NodeDefinition] = [
53
55
  *DELIVERY_WING_NODES,
54
56
  *PIPELINE_WING_NODES,
55
57
  *AGENTIC_WING_NODES,
58
+ *METRICS_WING_NODES,
56
59
  ]
57
60
 
58
61
  ALL_KINDS: frozenset[str] = frozenset(nd.describes_kind for nd in ALL_NODE_DEFINITIONS)
@@ -65,6 +68,7 @@ ALL_WING_DEFINITIONS: list[WingDefinition] = [
65
68
  PRACTICES_WING,
66
69
  PIPELINE_WING,
67
70
  AGENTIC_WING,
71
+ METRICS_WING,
68
72
  ]
69
73
 
70
74
  ALL_WING_NAMES: frozenset[str] = frozenset(wd.identity.name for wd in ALL_WING_DEFINITIONS)
@@ -76,7 +80,7 @@ ALL_WING_NAMES: frozenset[str] = frozenset(wd.identity.name for wd in ALL_WING_D
76
80
 
77
81
 
78
82
  class ForgeBundle:
79
- """The Forge bundle — 34 node types across 8 wings.
83
+ """The Forge bundle — 36 node types across 8 wings.
80
84
 
81
85
  This is the pre-installed taxonomy bundle that ships with
82
86
  Katalyst Taxonomy. It models software delivery organizations
@@ -89,10 +93,10 @@ class ForgeBundle:
89
93
  def manifest(self) -> BundleManifest:
90
94
  return BundleManifest(
91
95
  identity=Identity(kind="bundle", name="forge"),
92
- version=Version(major=4, minor=0),
96
+ version=Version(major=5, minor=1),
93
97
  provided_kinds=ALL_KINDS,
94
98
  wing_names=ALL_WING_NAMES,
95
- description="Forge taxonomy — 30 node types for modeling software delivery organizations",
99
+ description="Forge taxonomy — 36 node types for modeling software delivery organizations",
96
100
  )
97
101
 
98
102
  def node_definitions(self) -> list[NodeDefinition]:
@@ -109,7 +113,7 @@ class ForgeBundle:
109
113
  identity=Identity(kind="schema", name="forge"),
110
114
  describes_kind="taxonomy",
111
115
  api_version="v2alpha",
112
- schema_version=Version(major=4),
116
+ schema_version=Version(major=5),
113
117
  node_definitions=tuple(nd.identity for nd in ALL_NODE_DEFINITIONS),
114
118
  wing_definitions=tuple(wd.identity for wd in ALL_WING_DEFINITIONS),
115
119
  )
@@ -0,0 +1,57 @@
1
+ """Public API for the cost/observability metrics adapter.
2
+
3
+ Must NOT import anything from .providers, which would defeat the registry's
4
+ lazy provider resolution (see registry.py and providers/__init__.py).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from katalyst_forge.metrics.batch import CanonicalSignalBatch, project_batch
10
+ from katalyst_forge.metrics.billing import BillingMode
11
+ from katalyst_forge.metrics.core import MetricResult, compute, resolve
12
+ from katalyst_forge.metrics.errors import (
13
+ BatchConstructionError,
14
+ ContractViolationError,
15
+ CrossBillingModeError,
16
+ MetricsAdapterError,
17
+ MissingCredentialError,
18
+ MissingMappedFieldError,
19
+ MoneyPrecisionError,
20
+ PriceUnavailableError,
21
+ ProviderPayloadError,
22
+ ProviderUnavailableError,
23
+ UnknownBillingModeError,
24
+ UnknownProviderError,
25
+ UnmappedFieldError,
26
+ )
27
+ from katalyst_forge.metrics.money import parse_money
28
+ from katalyst_forge.metrics.protocols import PriceCatalogue, SignalProvider
29
+ from katalyst_forge.metrics.redaction import redact
30
+ from katalyst_forge.metrics.registry import SignalProviderRegistry
31
+
32
+ __all__ = (
33
+ "BillingMode",
34
+ "CanonicalSignalBatch",
35
+ "project_batch",
36
+ "MetricResult",
37
+ "resolve",
38
+ "compute",
39
+ "SignalProviderRegistry",
40
+ "SignalProvider",
41
+ "PriceCatalogue",
42
+ "parse_money",
43
+ "redact",
44
+ "MetricsAdapterError",
45
+ "BatchConstructionError",
46
+ "UnmappedFieldError",
47
+ "MissingMappedFieldError",
48
+ "ContractViolationError",
49
+ "MoneyPrecisionError",
50
+ "PriceUnavailableError",
51
+ "UnknownBillingModeError",
52
+ "CrossBillingModeError",
53
+ "UnknownProviderError",
54
+ "MissingCredentialError",
55
+ "ProviderUnavailableError",
56
+ "ProviderPayloadError",
57
+ )
@@ -0,0 +1,174 @@
1
+ """Canonical signal batch: deny-by-default projection and a non-bypassable type.
2
+
3
+ CanonicalSignalBatch can only be produced by project_batch. The constructor
4
+ requires a private guard sentinel that lives only in this module, so no
5
+ outside code, including copy.copy, copy.deepcopy, and pickle, can forge or
6
+ clone an instance. Projection itself is deny-by-default: only canonical
7
+ fields named in a source's field_mapping are ever copied out of a provider
8
+ record, so any field a provider returns but nobody mapped (including
9
+ prompt-shaped fields such as "instructions" or "system_prompt") is dropped,
10
+ never passed through.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Mapping, Sequence
16
+ from types import MappingProxyType
17
+ from typing import Final, cast
18
+
19
+ from katalyst_forge.metrics.billing import BillingMode
20
+ from katalyst_forge.metrics.errors import BatchConstructionError, MissingMappedFieldError
21
+
22
+ __all__ = ("CanonicalSignalBatch", "project_batch")
23
+
24
+
25
+ class _ProjectionGuard:
26
+ """Sentinel type; only _GUARD below is a valid instance of it."""
27
+
28
+ __slots__ = ()
29
+
30
+
31
+ _GUARD: Final[_ProjectionGuard] = _ProjectionGuard()
32
+
33
+
34
+ class CanonicalSignalBatch:
35
+ """An immutable, already-projected batch of canonical signal records."""
36
+
37
+ __slots__ = ("_billing_mode", "_run_id", "_source_name", "_records")
38
+
39
+ def __init__(
40
+ self,
41
+ _guard: _ProjectionGuard,
42
+ *,
43
+ billing_mode: BillingMode,
44
+ run_id: str,
45
+ source_name: str,
46
+ records: tuple[Mapping[str, object], ...],
47
+ ) -> None:
48
+ if _guard is not _GUARD:
49
+ raise BatchConstructionError("only project_batch may construct a CanonicalSignalBatch")
50
+ self._billing_mode = billing_mode
51
+ self._run_id = run_id
52
+ self._source_name = source_name
53
+ self._records = records
54
+
55
+ @property
56
+ def billing_mode(self) -> BillingMode:
57
+ return self._billing_mode
58
+
59
+ @property
60
+ def run_id(self) -> str:
61
+ return self._run_id
62
+
63
+ @property
64
+ def source_name(self) -> str:
65
+ return self._source_name
66
+
67
+ @property
68
+ def records(self) -> tuple[Mapping[str, object], ...]:
69
+ return self._records
70
+
71
+ def __copy__(self) -> CanonicalSignalBatch:
72
+ raise BatchConstructionError("CanonicalSignalBatch cannot be copied")
73
+
74
+ def __deepcopy__(self, memo: dict[int, object]) -> CanonicalSignalBatch:
75
+ raise BatchConstructionError("CanonicalSignalBatch cannot be deepcopied")
76
+
77
+ def __reduce__(self) -> tuple[object, ...]:
78
+ raise BatchConstructionError("CanonicalSignalBatch cannot be pickled")
79
+
80
+
81
+ def _as_mapping(value: object) -> Mapping[str, object] | None:
82
+ """Narrow value to a concretely-typed Mapping, or None if it is not one."""
83
+ if isinstance(value, Mapping):
84
+ return cast("Mapping[str, object]", value)
85
+ return None
86
+
87
+
88
+ def _get_mapping(doc: Mapping[str, object], key: str) -> Mapping[str, object]:
89
+ """Read doc[key] as a Mapping, defaulting to empty rather than raising."""
90
+ mapped = _as_mapping(doc.get(key))
91
+ return mapped if mapped is not None else {}
92
+
93
+
94
+ def _str_frozenset(value: object) -> frozenset[str]:
95
+ """Read a YAML string list as a set, tolerating a bare string or absence."""
96
+ if isinstance(value, str):
97
+ return frozenset({value})
98
+ if isinstance(value, (list, tuple)):
99
+ items = cast("Sequence[object]", value)
100
+ return frozenset(item for item in items if isinstance(item, str))
101
+ return frozenset()
102
+
103
+
104
+ def _read_path(record: Mapping[str, object], path: str) -> object | None:
105
+ """Walk a dotted path through record, returning None on any non-Mapping hop."""
106
+ current: object = record
107
+ for segment in path.split("."):
108
+ current_mapping = _as_mapping(current)
109
+ if current_mapping is None:
110
+ return None
111
+ current = current_mapping.get(segment)
112
+ return current
113
+
114
+
115
+ def project_batch(
116
+ *,
117
+ signal_source_doc: Mapping[str, object],
118
+ records: Sequence[Mapping[str, object]],
119
+ billing_mode: BillingMode,
120
+ run_id: str,
121
+ source_name: str,
122
+ ) -> CanonicalSignalBatch:
123
+ """Deny-by-default projection: keep only canonical fields named in field_mapping.
124
+
125
+ field_mapping is canonical_input_name -> provider.dotted.path. For every
126
+ provider record, a new dict is built containing only those canonical
127
+ keys, each pulled from the mapped path. A field the provider returns but
128
+ no mapping references is never copied. A mapped path missing from one
129
+ record is simply omitted from that record's projection; a mapping
130
+ that resolves for zero records across the whole batch is an error, since
131
+ that indicates the mapping itself is wrong rather than sparse data, unless
132
+ the source names the field in optional_fields.
133
+
134
+ optional_fields exists for a value the provider synthesizes and may
135
+ legitimately not have, such as a Forge stage inferred from a session that is
136
+ not a Forge stage at all. Holding such a field to the zero-resolution guard
137
+ discarded a run's entire cost rather than only its stage attribution, so a
138
+ total vanished because one attribution dimension was absent. The trade is
139
+ that a typo in an optional field's path stops being caught, which is why it
140
+ is declared per source rather than assumed.
141
+ """
142
+ spec = _get_mapping(signal_source_doc, "spec")
143
+ field_mapping = _get_mapping(spec, "field_mapping")
144
+ optional_fields = _str_frozenset(spec.get("optional_fields"))
145
+
146
+ canonical_paths: tuple[tuple[str, str], ...] = tuple(
147
+ (canonical_key, path)
148
+ for canonical_key, path in field_mapping.items()
149
+ if isinstance(path, str)
150
+ )
151
+
152
+ resolved_counts: dict[str, int] = {canonical_key: 0 for canonical_key, _ in canonical_paths}
153
+ projected_records: list[Mapping[str, object]] = []
154
+
155
+ for record in records:
156
+ projected: dict[str, object] = {}
157
+ for canonical_key, path in canonical_paths:
158
+ value = _read_path(record, path)
159
+ if value is not None:
160
+ projected[canonical_key] = value
161
+ resolved_counts[canonical_key] += 1
162
+ projected_records.append(MappingProxyType(projected))
163
+
164
+ for canonical_key, count in resolved_counts.items():
165
+ if count == 0 and canonical_key not in optional_fields:
166
+ raise MissingMappedFieldError(canonical_field=canonical_key, source_name=source_name)
167
+
168
+ return CanonicalSignalBatch(
169
+ _GUARD,
170
+ billing_mode=billing_mode,
171
+ run_id=run_id,
172
+ source_name=source_name,
173
+ records=tuple(projected_records),
174
+ )
@@ -0,0 +1,35 @@
1
+ """Billing mode enum and strict parsing (metered vs. subscription-notional)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import StrEnum
6
+
7
+ from katalyst_forge.metrics.errors import UnknownBillingModeError
8
+
9
+
10
+ class BillingMode(StrEnum):
11
+ """The two ways a signal source's cost figures are billed.
12
+
13
+ METERED means the provider charges per unit consumed. SUBSCRIPTION_NOTIONAL
14
+ means the spend is a flat subscription and the figure is a notional
15
+ allocation, not an invoice line. These are different quantities and must
16
+ never be combined (see core.MetricResult).
17
+ """
18
+
19
+ METERED = "metered"
20
+ SUBSCRIPTION_NOTIONAL = "subscription_notional"
21
+
22
+
23
+ def parse_billing_mode(raw: object) -> BillingMode:
24
+ """Parse a billing mode from a document value, never defaulting.
25
+
26
+ A missing or unrecognized billing mode is a contract error, not a hint to
27
+ guess metered as a safe default; guessing wrong silently mislabels cost.
28
+ """
29
+ if isinstance(raw, BillingMode):
30
+ return raw
31
+ if isinstance(raw, str):
32
+ for mode in BillingMode:
33
+ if mode.value == raw:
34
+ return mode
35
+ raise UnknownBillingModeError(raw)