Agent Event Attributes¶
validate_event_attributes checks one event's attributes against a closed allowlist. It is a shared contract for event producers, not an event store, a policy engine, or an observability backend.
from openmed.agent.event_attributes import EventAttributes
attributes = EventAttributes.from_mapping(
{
"run_id": "run_0123456789abcdef0123456789abcdef",
"execution_stage": "tool_call",
"outcome_class": "success",
"outcome_reason": "completed",
"input_digest": "sha256:" + "0" * 64,
"duration_ms": 12.5,
"retryable": False,
}
)
print(attributes.to_json())
The allowlist¶
Every accepted name has one declared kind, and there is no free-text field at all — so a prompt, tool argument, model output, path, or clinical note has nowhere to go.
| Kind | Attributes | Accepted value |
|---|---|---|
| Correlation | run_id, action_id, parent_action_id | Opaque run_/act_ identifiers from event correlation; the kinds are not interchangeable. |
| Governance | capability_id, policy_id, purpose_id, tool_id, workflow_id | Canonical governance identifiers of that exact kind. |
| Stage | execution_stage | One of planned, authorized, started, tool_call, completed, failed, aborted. |
| Outcome | outcome_class, outcome_reason | An outcome class and a reason code allowed for that class. |
| Digest | input_digest, output_digest, artifact_digest | Lowercase sha256:<64 hex>. |
| Count | sequence_number, attempt_number, retry_count, tool_call_count, artifact_count | Integer in 0..MAX_COUNT_VALUE. |
| Duration | duration_ms | Finite integer or float in 0..MAX_DURATION_MS. |
| Flag | retryable, redacted | A real bool. |
outcome_reason is cross-checked against the outcome_class in the same mapping and fails closed when that class is absent. Counts reject booleans because type(value) is int is checked rather than isinstance, and duration_ms rejects NaN and both infinities.
A schema_version key equal to EVENT_ATTRIBUTES_SCHEMA_VERSION is accepted so a serialized payload round trips; it is not stored as an attribute.
Refusals¶
EventAttributeError carries a stable .code and a .field_name that is only ever an allowlisted name. An unknown or sensitive-looking key is reported with field_name=None, because naming it would echo the submitted key:
unknown_attribute— the key is not on the allowlist.sensitive_attribute_key— the key is not on the allowlist and looks like a credential, prompt, path, URL, message, or direct identifier. It is a separate code so a producer sees why the contract exists, not just that it failed.
Other codes are not_a_mapping, invalid_key_type, too_many_attributes, nested_value_not_allowed, invalid_identifier, unknown_execution_stage, unknown_outcome_class, unknown_outcome_reason, outcome_class_required, invalid_digest, invalid_count, count_out_of_range, invalid_duration, non_finite_number, duration_out_of_range, invalid_flag, invalid_schema_version, duplicate_attribute, malformed_json and payload_too_large.
Mappings, lists, tuples, sets and byte strings are rejected outright, so no nested object can smuggle a payload under an allowlisted name. A rejected identifier is not chained onto the raised error.
Serialization¶
EventAttributes is frozen and its values mapping is read-only. to_dict() puts schema_version first and the attributes in sorted key order; to_json() sorts keys for byte-identical payloads. from_json() bounds the payload at MAX_ATTRIBUTE_JSON_BYTES before parsing and rejects duplicate keys, which JSON permits but the contract does not.
Persisting events, evaluating policy, and storing hashed copies of arbitrary clinical payloads are out of scope. Valid attributes are a shape statement, not a clinical or security approval.
Verification¶
Fixtures are synthetic. Tests validate every allowlisted attribute together, every execution stage, every outcome class against its own reason codes, and cover unknown, sensitive, duplicate, nested, oversized and non-finite cases, including sentinel prompts, bearer values and paths that must never appear in output or exceptions.