Treat Wire Activities as Immutable Artifacts; Freeze at Receipt and at Factory Seal
Context and Problem Statement
Wire Activity objects (subclasses of as_Object) are used for two distinct
purposes that have conflicting mutability requirements:
-
Inbound: a received Activity is an artifact — it captures the wire state at receipt time. It is stored as
CaseLedgerEntry.payloadSnapshotand replicated to other participants viaAnnounce(CaseLedgerEntry). Mutating it before ledger storage breaks replication fidelity: other actors reconstruct their local state from the replicated form, so they would see a different representation than what was originally received. -
Outbound: a factory-constructed Activity is an artifact — it captures exactly what was sent. The same object must serve as both the delivery payload and the
payloadSnapshotin the ledger entry. Adapter enrichment after the factory seals the blob creates a gap: the ledger records one form while the recipient receives another, breaking the accountability invariant.
Currently nothing in the type system prevents mutation. as_Object uses
ConfigDict(validate_assignment=False) (ADR-0064) to stay lenient for inbound
AS2 data, but has no frozen=True. Downstream code can and does mutate Activity
objects in place — three mutation sites in outbox_delivery.py overwrite
outbound_activity.object_ after the ledger entry has already been written.
Decision Drivers
- Ledger integrity:
CaseLedgerEntry.payloadSnapshotmust be identical to the delivered payload. Post-factory mutation creates a permanent divergence. - Replication fidelity: other actors reconstruct state from the replicated
payloadSnapshot. If the frozen artifact is mutated before ledger storage, replications propagate the mutated form. - Type-level enforcement: "do not mutate" as a convention enforced only by documentation is insufficient; the runtime should raise on accidental mutation during development and testing.
- Orthogonality:
validate_assignment=False(wire branch leniency, ARCH-12-002) andfrozen=True(post-construction immutability) are independent properties that can coexist. ADR-0064 established the former; this ADR establishes the latter.
Considered Options
- Full
frozen=Trueonas_Object+ A/B split for inbound routing - Per-field freeze — mark only
payloadSnapshot-bound fields as frozen - Mutable Activities with a documentation-only immutability convention
Decision Outcome
Chosen option: "Full frozen=True + A/B split", because it is the only
option that enforces the invariant at the type level for both the inbound and
outbound pipelines.
Inbound: A/B split
Two distinct objects serve the two needs of the inbound pipeline:
- A — the frozen artifact: the wire Activity exactly as received. Frozen
at the moment of receipt, before any rehydration or routing logic touches it.
Stored as
CaseLedgerEntry.payloadSnapshot. Replicated unchanged. - B — the hydrated routing copy: a separately constructed object with bare-string references resolved to full objects. Produced independently from A — not by mutating A. Used for semantic dispatch and use-case execution. Never stored in the ledger.
A is never modified to produce B. If rehydration fails, A remains intact.
How A is held changed after this decision.
frozen=True covers only the class that declares it, and ADR-0099 detail 3 made every paired nested object a mutable core class, so a frozen envelope no longer makes the received activity immutable.
A is now the received body itself: parse_activity seals it as JSON text before anything expands or validates it, and ingress rehydration carries that text onto B (#3584).
That mechanism is recorded in ADR-0099's Amends list; the principle, that A is the evidence and B is never it, is unchanged.
This is why status: stays accepted with partially_superseded_by: rather than superseded: the outbound half of this decision is unaffected.
Outbound: frozen blob pipeline
- Factory (
vultron/wire/as2/factories/) constructs the wire object fully populated and returns a frozen blob. - Port (
TriggerActivityPort/SyncActivityPort) returns the frozen blob to core — not amodel_dump()dict. - Core uses the exact same blob for both
CaseLedgerEntry.payloadSnapshotand outbox delivery. - Adapter delivers the blob unchanged — no enrichment, no expansion.
How step 4 gets the blob (amended by #2654/#2655): the outbox queues activity ids, and the activity record is a dehydrated, rehydrated-on-read reconstruction, not the blob.
So the adapter that persists an outbound activity also seals the blob's text as a SealedOutboundBody record keyed by the activity id (vultron/adapters/outbox_sealed_body.py), and the outbox handler delivers that text without reading the activity record at all (OX-07-001).
The ActivityEmitter port carries the sealed JSON text, not an activity object.
The factory is solely responsible for completeness. Adapter enrichment compensates for an incomplete factory and creates a ledger/delivery gap.
Consequences
- Good, because mutation of received Activities raises immediately during
development, rather than corrupting ledger entries silently in production.
Under Pydantic v2 the rejection is a
ValidationErrorcarryingtype=frozen_instance, not aTypeError— code that means to clear a field must build a new object withmodel_copy(update=...)instead (issue #2904). - Good, because the ledger entry and delivery payload are guaranteed identical — the accountability invariant becomes structurally enforced.
- Good, because
frozen=Trueandvalidate_assignment=Falseare orthogonal; no change to the wire branch's lenient field-type policy is required. - Bad, because any code that currently mutates wire Activity objects in place
must be refactored — three known mutation sites in
outbox_delivery.pyand one inEmitInviteActorToCaseNode._call_factory()(renamed from_emit()by #2881). - Bad, because
TriggerActivityPortmethods must change their return type fromdict[str, Any]to a frozen wire object, requiring callers to be updated.
Validation
test/architecture/test_wire_artifact_immutability.py: for every class reachable in a parsed example activity, tampering with its instances leaves the received evidence equal to the delivered body (VM-08-002); and the VM-08-003 trigger-port return-type check.specs/vocabulary-model.yamlVM-08-002 and VM-08-003 (MUST-level requirements).
More Information
Implementation tracked in:
-
2652 — add
frozen=Trueto wire branchas_Object+ ratchet test -
2653 — redesign
TriggerActivityPortto return frozen wire blob (size:L) -
2654 — remove
_drop_bare_inline_refsfrom emit nodes (done: the snapshot isjson.loads(activity_blob); the factory setscontext; the commit boundary accepts a baretargetnaming the entry's own case) -
2655 — remove
outbox_delivery.pyenrichment mutations (done: the handler relays the sealed body) -
2656 — A/B split in inbox pipeline (blocked by #2652)
Source: CONCERN-2545. Design rationale: notes/wire-artifact-immutability.md.
Generated spec requirements: specs/vocabulary-model.yaml VM-08-002, VM-08-003.