Superseded by ADR-0099. The diagnosis in this ADR stands and is the foundation ADR-0099 builds on — the four duplications, the measured evidence, and the finding that "zero wire→core imports" was unreachable are all still accurate and should still be read.
What is replaced is the remedy. This ADR reconciles two class hierarchies with a declarative pairing registry and one generic adapter-side translator. ADR-0099 removes the second hierarchy instead, so there is nothing to pair and nothing to translate. Measured against the current tree: of 346 fields that differ across the 27 paired classes, none is a semantic disagreement.
Item 8 of the Decision Outcome below ("the shared base moves to a neutral bottom layer") is the specific item ADR-0099 reverses, and issue #2933 was its implementation.
Until ADR-0099 reached
acceptedthis ADR was only annotated as partially superseded and keptstatus: accepted; it was retired in full and moved here when ADR-0099 reachedaccepted(#3492). The ARCH-23 requirements that record the parts which landed and still hold (disjoint registry keys, derived reference fields, self-round-tripping dumps) now derive from ADR-0099.
Wire/Core Boundary: One Declarative Pairing Registry, One Translator, and Reject Unknown Keys
Context and Problem Statement
Vultron carries two vocabularies for the same concepts. The wire branch (as_*
classes under vultron/wire/as2/vocab/) is the ActivityStreams 2.0 projection:
camelCase keys, AS2 @context, lenient field types. The core branch
(CoreObject subclasses under vultron/core/models/) is the domain model:
snake_case, narrowed types, and — since ADR-0036 — nested dimension objects
where the wire keeps flat fields. ADR-0017 established the two-branch hierarchy
and ADR-0062 established where wire→core normalisation happens.
The boundary between them exists in the type system but almost nothing enforces it, and the knowledge needed to cross it is duplicated four ways.
1. "Which fields are AS2 references" is known in three places. The type
annotations (as_ObjectRef-typed fields) are the truth.
_AS_OBJECT_REF_FIELDS in vultron/adapters/driven/db_record.py is a
hand-maintained frozenset whose own comment describes it as "ActivityStreams
fields typed as as_ObjectRef" — a copy of the annotations that can drift. And
individual to_core() methods call _scalar_ref_id_or_value(...) on those
fields by hand, a third restatement.
2. The core↔wire pairing is known in four places, none of them declarative.
As2WireRenderAdapter.render() resolves a core class to its wire counterpart
with VOCABULARY.get(type(obj).__name__) — that is, it relies on the bare-name
collision between VOCABULARY and CORE_VOCABULARY as an implicit pairing
index. The collision is between registry keys, not class names: the wire class
is as_CaseParticipant, but it registers under the bare key CaseParticipant,
which is also the core class's __name__, so looking the core name up in
VOCABULARY happens to land on the wire counterpart.
_WIRE_ACTOR_TO_CORE in vultron/wire/as2/vocab/objects/vultron_actor.py
is a hand-rolled pairing table for actor types only, built because someone
needed exactly this mapping and no general one existed.
_NORMALIZE_WIRE_TO_CORE in db_record.py is a third hand-maintained list of
paired types. And three registries (VOCABULARY, CORE_VOCABULARY,
CORE_TYPE_MAP) are chained by find_in_vocabulary().
This is why issue #2403 — "make the two type_ namespaces disjoint" — has
looked risky rather than obvious: the collision it proposes to remove is
currently load-bearing. Fifteen wire registry keys shadow a core type's key, and
the render adapter depends on that shadowing to find its counterpart.
3. Projection is declarative in one direction and hand-written in the other.
VultronAS2Object.from_core() is generic: dump, apply the declarative
_field_map renames, validate. to_core() raises NotImplementedError at the
base and is overridden in twelve wire classes — 195 lines across the twelve
overrides plus the base raiser, against 152 lines of from_core(), so 354 lines
is the both-directions total. Six of those overrides are pure boilerplate around
one per-type fact:
data = self._to_core_data()
data.pop("context_", None) # repeated 4x
data["attributed_to"] = _scalar_ref_id_or_value(...) # repeated 3x
data["context"] = _scalar_ref_id_or_value(...) # repeated 3x
return CoreX.model_validate(data) # <- only per-type part
The single per-type element is which core class to validate against — the
pairing from (2). A generic to_core() mirroring the generic from_core() is
blocked only by the absence of a declarative pairing table.
4. Boundary enforcement lives in three places. ADR-0062 named this as its
own negative consequence: "the same projection is expressed in two places, and a
reader can reasonably wonder which one is authoritative." Since then a third
appeared — the per-class reject-guard in CaseParticipant.
Meanwhile the boundary is not actually closed. Pydantic v2 defaults to
extra="ignore", so handing a wire-shaped payload to a core type silently
discards every key whose only spelling is snake_case. That is the #2232 defect:
participantStatuses dropped, the field left empty,
_init_participant_status_if_empty re-seeding one status at RM.START, and a
participant's RM ladder ['START','RECEIVED'] silently becoming ['START'].
Issue #2232 fixed this for CaseParticipant alone; issue #2262 asks for the
general case. CaseParticipant is the only one of the eighteen
CORE_VOCABULARY entries carrying the guard, so the other seventeen registered
core types — and 29 of the 30 CoreObject subclasses overall — still drop
silently.
Separately, test_wire_no_core_model_imports.py carries an
xfail(strict=True) goal test asserting that vultron/wire/ will eventually
have zero vultron.core.models imports. Issue #2670's acceptance criterion
is quoted as "all 29 entries removed"; that count is wrong at the source — the
issue's own enumeration lists 31 files, and KNOWN_VIOLATIONS in the ratchet
test holds 31 entries matching the 31 measured imports exactly. The target is
in any case unreachable while three MUST-level requirements stand: ARCH-12-001
mandates that as_Base inherit VultronBase (which lives in
vultron/core/models/base.py), ARCH-20-002 in its pre-ADR-0082 form mandates
that the rendering port's adapter locate the core object's wire counterpart and
invoke that class's from_core() projection —
so a wire class must keep a from_core() whose parameter is a core type — and
ARCH-12-010 mandates that find_in_vocabulary() consult the core
CORE_TYPE_MAP. An agent working #2670 would clear the easy files, reach the
base classes, and have to choose which MUST to violate. (ARCH-12-005 is easily
read as a fourth blocker and is not one: in its pre-ADR-0082 form it permitted
to_core()/from_core() on wire types and stated that they were not required
for structurally-compatible types.)
Decision Drivers
- Silent data loss is the highest-severity item in scope. #2262 is a silent-drop default across the whole core vocabulary, and ARCH-15-001 / ARCH-15-002 already forbid exactly this failure mode.
- Serialized AS2 output must not change shape. Any namespace or registry change is internal.
- Piecemeal remediation of this area has a documented failure record: #1991 → #2232 → #2260 → #2268 → #2401/#2402, each fix creating the conditions for the next report. ADR-0063 made the same observation and chose to change all eight classes at once for that reason.
- Duplicated knowledge of the boundary is the mechanism behind that record. Each
hand-maintained list (
_AS_OBJECT_REF_FIELDS,_WIRE_ACTOR_TO_CORE,_NORMALIZE_WIRE_TO_CORE,KNOWN_VIOLATIONS) is a place where the true state and the recorded state can diverge silently. - A goal test that asserts an unreachable state is worse than no goal test: it invites an implementer to violate a MUST in order to make it pass.
- ADR-0063 solved "core must not import wire" for rendering by introducing a driven port. The mirror-image rule, "wire must not import core" (ARCH-22), has never been addressed, and the parsing direction of ARCH-01-001 is still violated by core duck-typing a wire capability at three sites.
Considered Options
- Targeted camelCase reject-guard on
CoreObject— generalise the #2232CaseParticipantguard to the shared base. extra="forbid"onCoreObject— reject any unknown key, not just wire-spelled ones.- One declarative pairing registry plus one generic bidirectional translator,
with
extra="forbid"as the structural guarantee — collapse all four duplications and delete the hand-maintained enforcement lists. - Keep the shared namespace and declare the collision intentional — document the bare-name collision as the pairing index, amend VM-01-004 to match, and close #2403 as won't-do.
Decision Outcome
Chosen option: "One declarative pairing registry plus one generic
bidirectional translator, with extra="forbid" as the structural guarantee."
The four duplications share one root cause: there is no single declarative statement of what pairs with what. Every mechanism that needs the pairing either re-derives it from a name collision or hand-maintains a private copy. Introducing one authoritative pairing registry makes the other three duplications collapse rather than requiring separate fixes, and it converts issue #2403 from a risky rename into a consequence.
Concretely:
-
A declarative pairing registry becomes the single source of truth for core↔wire correspondence. It subsumes the bare-name collision that
As2WireRenderAdapterrelies on,_WIRE_ACTOR_TO_CORE, and_NORMALIZE_WIRE_TO_CORE. Once the pairing is explicit,VOCABULARYandCORE_VOCABULARYare free to use disjoint keys — which is what #2403 asked for, obtained as a by-product rather than as a rename with an unmeasured blast radius. -
AS2 ref-field sets are derived from the type annotations, once.
_AS_OBJECT_REF_FIELDSis deleted and the scattered_scalar_ref_id_or_value(...)calls in individualto_core()methods are replaced by the derived set. The annotations were always the truth; nothing else needs to restate them. -
A generic
to_core()mirrors the genericfrom_core(), driven by_field_map, the derived ref-field set, and the pairing registry. Six of the twelve hand-written overrides are deleted outright;as_EmbargoEvent's reduces to a declarative drop-list. Only the genuinely structural projections —as_VulnerabilityCase,as_CaseStatus,as_CaseParticipant, which project nested children — keep bespoke code. -
Projection logic moves off the wire classes into translator modules on the adapter side. This is what makes ARCH-22 achievable rather than redefined: wire classes become pure AS2 shape with no domain knowledge, and the translator modules — which are allowed to see both branches — own the pairing registry and the projection. ARCH-12-005 is amended accordingly. ADR-0063's decision stands; its mechanism is revised, because the render adapter no longer calls
wire_cls.from_core()but delegates to the translator. -
A
WireParsePortcompletes the ADR-0063 symmetry. Core currently reaches for a wire capability by duck-typing —getattr(obj, "to_core", None)— at three sites, one of which is ADR-0062's primary ingress projection (_project_to_core_participantinvultron/core/use_cases/received/case/_helpers.py). These become port calls, which is the remedy ARCH-01-004 already prescribes and the one ADR-0063 applied to the rendering direction. -
extra="forbid"onCoreObjectis the boundary contract. It is strictly stronger than a camelCase-specific guard: a typo, a renamed field, and a stale key are all caught, not only wire spellings. Two central cleanups are prerequisites, each defensible independently: -
computed fields (
embargo_adherence, a@computed_fieldper ADR-0056) must be stripped before re-validation, because they appear inmodel_dump()output but are not settable; - a
model_validator(mode="before")must not inject an alias key beside a field-name key that the incoming payload already carries.VultronBaseresolvesvalidate_by_name=True, so bothidandid_are sanctioned inputs — but only one of them may be present when validation proper begins, or the other is left over asextra. Each injecting validator must therefore normalise the payload to one spelling before writing its computed value.
With extra="forbid" in place, _NORMALIZE_WIRE_TO_CORE, its grow-only
ratchet test, and the per-class camelCase reject-guards are all dead code and
are deleted. The invariant becomes a property of the type system rather than a
list somebody must remember to update.
-
ARCH-22-003 gains an explicit exemption clause. Its "shrink toward empty" aspiration is retained, but the target becomes "empty except the declared structural exemptions", the exemption set is enumerated — one entry, the
find_in_core_type_mapimport invultron/wire/as2/vocab/base/registry.py, mandated by ARCH-12-010 — rather than stated as a count, and thexfail(strict=True)goal test is retargeted to equality with that enumeration. Everything else in the 31-entry ratchet set is a relocation target, not an exemption, and clears only when the relocation that owns it lands: -
the shared-base and shared-primitive imports — the bulk of the set — clear when item 8 lands the branch-neutral module;
- the projection imports in the
as_*classes clear when item 4 movesto_core()/from_core()onto the adapter side; factories/actor.pyandfactories/case.pyclear only because AF-01-005 is amended here: in its pre-ADR-0082 form it mandated that the factory accept the core object and project internally, which pinned those two imports in place permanently. It is inverted to require a complete translator-produced wire object as the argument;vultron/wire/as2/enums.py(VultronActorType) and the semantic extractor (extractor/_instances.py,_builders.py,_pattern.py,_extract.py, takingVultronObjectType,MessageSemantics,VultronEvent,CoreActor,VultronCaseLedgerEntryand the dimension types) are cross-cutting vocabulary rather than domain models, so the ADR-0031 neutral-layer treatment in item 8 is the likely answer — but each is its own relocation with its own task, and this ADR does not pre-judge them.
Until all of those have landed, KNOWN_VIOLATIONS stays a superset of the
exemption set and the two-sided ratchet (ARCH-22-002) stays in force. Stating
the exemption set as an enumeration rather than a total is deliberate: a count
in a requirement drifts the moment a relocation lands, and the residue is only
reachable after all of them.
-
The shared base moves to a neutral bottom layer.
VultronBaseandVultronObjectare shared by both branches, so hosting them invultron/core/models/base.pymakes every wire class a definitional ARCH-22 violation. They move to a neutral layer, following the ADR-0031 precedent that introducedvultron/enums/for cross-cutting enumerations. The shared primitives (NonEmptyString,UriString,VO_type,_now_utc,parse_duration,coerce_cvd_roles,coerce_em_consent_state,compute_genesis_hash) move with them. ARCH-12-001 is amended to name the new home. -
| CoreObjectis removed fromas_ObjectRef. The AS2-faithful part of that union —as_Object | as_Link | str— stays: the standard explicitly permits a property to hold an embedded object or an IRI reference, andrehydrate()(VM-06-001) resolves it at a defined point. The| CoreObjectmember is not AS2; it was added in PR #730 as a migration convenience and places a core type inside a wire annotation. Removing it is also what lets the boundary guard fail loudly:VultronValidationErroris not aValueErrorsubclass, so a guard firing inside union resolution escapes the whole operation instead of being absorbed as a failed union branch.
Measured evidence
The choice between options 1 and 2 was measured, not argued. A guard was
temporarily installed on CoreObject and the unit suite run:
| Configuration | Failing tests |
|---|---|
| Targeted camelCase reject-guard | 25 |
extra="forbid", no other change |
570 (plus 330 errors) |
extra="forbid" + strip computed fields |
180 |
extra="forbid" + strip computed + emit wire names in _to_core_data |
179 |
The initial 570 figure is misleading and was nearly decisive in the wrong
direction. Not one of those failures is a camelCase key. The rejected keys
are embargo_adherence (1096 occurrences) and id_ (110) — our own code
failing to round-trip its own objects. Diagnosing that is what turned
extra="forbid" from "not viable" into "viable and stronger".
Two further findings came out of the same measurement:
- The #2260 sequencing hazard does not exist. Issue #2262 states that a
CoreObjectguard must be sequenced against #2260 becauseParticipantStatuscarriesalias_generator=to_cameland legitimately accepts camelCase. Measured: any class carrying an alias generator yields an empty forbidden-key set. The guard is structurally inert on such classes and arms itself when #2288/#2289 remove the alias. No ordering constraint. - Persisted rows are keyed by Python field name, and one class cannot read its
own back.
Record.from_obj()callsobj.model_dump(mode="json", serialize_as_any=True)with noby_alias, so rows are stored withid_/type_rather thanid/type. That alone is harmless:VultronBaseresolvesvalidate_by_name=True, soid_is a sanctioned input andRecord.from_obj(p).to_obj().id_ == p.id_round-trips. The 110 rejectedid_keys are all one class.CaseLedgerEntry's_set_id_from_caseis amodel_validator(mode="before")that writesdata["id"] = f"{case_id}/log/{log_index}"into a payload that already carriesid_from the dump; the alias satisfies the field, and the field-name key it was meant to replace survives asextra. Underextra="ignore"it is discarded silently; underextra="forbid"it raises. The same inject-an-alias-beside-the-field-name pattern occurs inpending_case_inbox.py:73,pending_create_case_activity.py:97,case.py:130,166,case_participant.py:161,416,465,offer_record.py:82, and — fortyperather thanid—base.py:260. The constraint that follows is not "emit wire-facing names everywhere"; it is "normalise to one spelling before injecting".
Consequences
- Good, because the boundary invariant becomes a property of the type system.
Four hand-maintained lists (
_AS_OBJECT_REF_FIELDS,_WIRE_ACTOR_TO_CORE,_NORMALIZE_WIRE_TO_CORE, and the ratchet that guards it) are deleted rather than maintained. - Good, because
extra="forbid"catches failure modes a camelCase guard cannot: typos, renamed fields, and stale keys from an older schema. - Good, because #2403 stops being a risky rename. Disjoint registry keys become safe once the pairing is explicit.
- Good, because ARCH-22's goal becomes reachable and honest. The ratchet's measured population is 31 files, not the 29 quoted in #2670, and the goal test targets an enumerated exemption set with a citation per entry: on the one exemption ARCH-12-010 mandates today that is 30 of 31 clearing, and any file that turns out not to clear is named and justified rather than pending.
- Good, because wire classes end up with no domain knowledge, which is what ADR-0017's "wire is a projection of core" asserted but never structurally enforced.
- Bad, because it is a wide change: 13 tasks touching the shared base, the vocabulary registries, the persistence round-trip, the render adapter, and the semantic extractor. It partially supersedes one ADR and revises the mechanism of another.
- Bad, because it revises ADR-0063, which was accepted 2026-08-13. The decision there was correct and stands; only the adapter's internal mechanism changes.
- Bad, because moving the shared base out of
vultron/core/models/touches every core and wire model module's imports, even though the change is mechanical. - Neutral, because serialized AS2 output does not change. The pairing registry
and disjoint keys are internal; wire
"type"values are unaffected. - Neutral, because no data migration is implied. Existing rows keyed by
id_still validate (validate_by_name=True), so re-keying rows toidis a consistency choice rather than a correctness requirement, and this code has no production deployment in any case.
Validation
ParticipantStatus.model_validate({"rmState": "RECEIVED"})and every otherCoreObjectsubclass raise on an unknown key rather than silently dropping it, with a test per registered core type.- An architecture test asserts every
CoreObjectsubclass resolvesextra="forbid"through itsmodel_config, with no exemption list. _NORMALIZE_WIRE_TO_CORE,test_normalize_wire_to_core_ratchet.py,_AS_OBJECT_REF_FIELDS, and_WIRE_ACTOR_TO_COREno longer exist; a grep test asserts their absence so they cannot be reintroduced.- A round-trip test asserts that for every entry in the pairing registry,
to_core(from_core(core_obj))equalscore_obj, and that the wire serialization is byte-identical to the pre-change output — this is what guarantees "serialized AS2 output must not change shape". - The pairing registry is asserted complete: every
CORE_VOCABULARYentry with a wire counterpart appears in it, and every wire type claiming a core counterpart resolves. test_wire_core_model_import_boundary_goalXPASSes against the declared exemption set, and itsxfailmarker is deleted (#2673).- No
vultron/core/module containsgetattr(obj, "to_core", None); the three sites route throughWireParsePort.
Pros and Cons of the Options
Targeted camelCase reject-guard on CoreObject
- Good, because the helper already exists and is generic per class
(
reject_wire_spelled_keysinvultron/core/models/_wire_spelling.py), so it is a wiring change rather than new machinery. - Good, because it is the smallest change that stops the known bleeding — 25 failing tests, roughly half of which are genuine silent-drop bugs on the Accept/Reject-Invite actor paths.
- Good, because its error message names the correct remedy ("convert at the wire→core boundary instead").
- Bad, because it catches only camelCase. A typo, a renamed field, or a stale key from an older schema still vanishes silently, so the defect class is narrowed rather than closed.
- Bad, because it leaves all four duplications in place, and therefore leaves the mechanism that produced #1991 → #2232 → #2260 → #2268 intact.
extra="forbid" on CoreObject alone
- Good, because it closes the defect class rather than one spelling of it.
- Bad, because without the accompanying cleanups it fails 570 tests for reasons unrelated to the boundary — computed-field echoes and field-name round-trips — which would be read as evidence against the approach rather than as three bugs.
- Bad, because it does nothing about the pairing duplication, so #2403 and ARCH-22 remain as unresolved as before.
One pairing registry plus one generic translator, with extra="forbid"
- Good, because one new declarative artifact causes three separate duplications to collapse, rather than three separate fixes.
- Good, because it resolves #2262, #2403, and the substance of #2670 under a single design, so the three cannot land inconsistently.
- Neutral, because the amount of new code is small — 354 lines of projection logic relocated and mostly deleted — while the amount of touched code is large.
- Bad, because it is the largest option and sequences 13 tasks, so the benefit arrives late.
Keep the shared namespace; declare the collision intentional
- Good, because it is nearly free: amend VM-01-004 to describe what the code already does, and close #2403.
- Good, because the collision genuinely is semantically meaningful — the same concept in two representations arguably should share a name.
- Bad, because an implicit index is untestable. Nothing asserts that a core type
and its wire counterpart agree on the name, so a rename silently breaks
As2WireRenderAdapter.render()at runtime with "No wire counterpart". - Bad, because it forgoes the collapse: the other three duplications still need three separate fixes, each with its own hand-maintained list.
More Information
- Planning group G02 (#2830), under the planning-group epic #2828.
- Members resolved by this decision: #2262 (silent drop across the core
vocabulary), #2403 (disjoint
type_namespaces). - Members re-scoped rather than resolved: #1895 (ontology direction) and #891
(JSON-LD library evaluation) are converted into dedicated Ideas under epic
#890, carrying the evidence gathered here. This decision records no
adopt-or-decline verdict on PyLD or
activitypubdantic; that is deliberately deferred to those Ideas. - Partially supersedes ADR-0062 (
supersedes:in this ADR's frontmatter,partially_superseded_by:in ADR-0062's; the project has nopartially_supersedesfield). ADR-0062 chose two enforcement points and only the second is replaced. Survives: normalise at ingress, so no core reader ever sees a wire shape — the placement stands, with its mechanism changed from ato_core()call to aWireParsePortcall (item 5), exactly as ADR-0063's mechanism is revised below. Its "readers stay strict" consequence also stands. Replaced: the persistence-boundary backstop —_NORMALIZE_WIRE_TO_CORE,_normalize_to_core(), and the grow-only ratchet — which becomes unnecessary onceextra="forbid"makes the invariant a property of the type system rather than a maintained list. ADR-0062 keepsstatus: acceptedand remains authoritative for the current code until the task set lands. - Revises the mechanism of ADR-0063, whose decision (render core objects through a driven port) stands unchanged.
- Related: ADR-0017 (two-branch hierarchy), ADR-0031 (neutral bottom layer
precedent), ADR-0034 (
dl.read()returns core objects), ADR-0036 (dimension objects), ADR-0056 (embargo_adherenceas a computed field), ADR-0069 (Vultron namespace URI). - Related issues: #2232, #2260, #2268, #2288, #2289, #2401, #2402, #2416, #2670, #2673, #1991. Flag the conclusion to G12 (#2596), which will need whatever contract this establishes.
Generated spec requirements: architecture.yaml ARCH-12-001, ARCH-12-003 and
ARCH-12-005 amended; ARCH-20-002, ARCH-20-008 and ARCH-20-009 re-pointed at the
adapter-side translator and WireParsePort; ARCH-22-001 and ARCH-22-003
amended; new group ARCH-23 (ARCH-23-001 through ARCH-23-006);
activity-factories.yaml AF-01-005 inverted to forbid in-factory projection;
vocabulary-model.yaml VM-01-004 amended.