ADR-0096: A Protocol Default Embargo Replaces the Pre-Case Phase
Context and Problem Statement
docs/topics/process_models/model_interactions/rm_em.md states that the EM
process MAY begin — the initial propose transition
\(q^{em} \in N \xrightarrow{p} P\) — before the report is sent to a potential
Participant (\(q^{rm} \in S\)). CONCERN-2215 recorded that no implemented
mechanics exist for this, and planning group G08 (#2836) convened to decide
whether Vultron gets a pre-case protocol phase at all.
Scoping the group found the documented behavior is not merely unimplemented. It is unrepresentable, for four independent reasons:
| Fact | Where |
|---|---|
| EM is defined as a global per-case state machine | docs/reference/glossary.md |
EM state exists only as CaseStatus.em (an EmDimension) |
vultron/core/models/case_status.py, dimensions.py |
EmbargoEvent.context is required, and every core construction site sets it to case_id |
vultron/core/behaviors/case/nodes/embargo.py, triggers/embargo/{propose,revise}.py |
EmbargoLifecycle.propose_embargo(case_id=…) raises VultronNotFoundError when the case does not resolve |
vultron/core/services/embargo_lifecycle.py |
So rm_em.md asserts a transition on a machine instance that cannot exist:
there is no Case for that \(N \xrightarrow{p} P\) to occur in. CONCERN-2215's
premise — documented behavior whose implementation lags — is right about the
symptom and wrong about the cause.
That reframes the question. A pre-submission phase is wanted for exactly two situations, and neither is a state-location problem:
- (a) The Receiver has published no default embargo, so a Reporter has no idea what terms they will get.
- (b) The Receiver's published default is unacceptable to the Reporter.
docs/topics/process_models/em/defaults.md already answers (b):
shortest-proposal-wins, with the longer duration registered as a revision, so
whoever wants longer accepts the shorter and negotiates the extension inside it.
The goal of the EM process is to lock in an embargo as early as possible.
(b) therefore needs no new phase — it needs the Reporter to be able to state
terms, which specs/embargo-policy.yaml EP-04-004 records as absent.
(a) turned out to be handled already, in code, wrongly:
_DEFAULT_EMBARGO_DAYS = 90
def _preferred_embargo_duration(...) -> timedelta:
duration = timedelta(days=_DEFAULT_EMBARGO_DAYS)
policies = list(dl.list_objects(VultronObjectType.EMBARGO_POLICY))
if not policies:
return duration
An actor with no published EmbargoPolicy silently gets a 90-day embargo. Three
sources disagree: defaults.md § "No Defaults, No Proposals" says "no embargo
SHALL exist"; notes/embargo-default-semantics.md's own decision table says
EM.NONE remains; and em/principles.md says embargo duration "SHOULD be
limited to the shortest duration possible". Worse, the incentive is inverted: a
Receiver who publishes nothing gets a longer embargo than one who publishes a
considered 30 days.
The mechanism was invisible because nothing distinguishes the two meanings of
"default". _preferred_embargo_duration() returns its fallback into the same
blackboard key a published policy fills (default_embargo_duration), so
downstream no code can tell "the Receiver published 90 days" from "the
Receiver published nothing".
Decision Drivers
- Lock in an embargo as early as possible. This is the stated goal of the EM
process (
em/principles.md), and every rule here is measured against it. - The written rules are part of the work.
defaults.mdis guidance this project authors; where it and the code disagree, either may be the thing that is wrong. - Publishing an
EmbargoPolicymust be the rewarded behavior. Any fallback generous enough to be comfortable removes the reason to publish. - One concept, one name. A word that means two things in the same subsystem produces exactly the conflation above.
- Do not add a protocol phase to solve a problem a rule can solve. A phase costs a state machine, a wire context, a storage shape, and a migration.
- Timers that govern the same interaction must be mutually coherent.
Considered Options
Whether Vultron gains a pre-case phase:
- No — a protocol default embargo plus a sender-side proposal makes the phase unnecessary.
- Yes — generalize EM scope from per-case to per-negotiation-context, so the documented transition becomes literally true.
- Yes — a bilateral negotiation before the report exists, recorded in an actor-scoped register, seeding the Case on submission.
- Yes — revive the proto-case (ADR-0015): the Reporter creates a Case with themselves as sole Participant and transfers ownership on acceptance.
- No, and change nothing else — correct
rm_em.mdand stop.
What happens when no policy and no proposal apply:
- A bounded, configurable protocol default.
- Honor
defaults.mdas written — no embargo at all. - Ratify the existing 90 days and fix only the documentation.
How a Reporter states terms with a Report:
- Embed a proposed
EmbargoEventonOffer(VulnerabilityReport). - An activity-level
end_timeon theOffer. - Inline the Reporter's own
EmbargoPolicy.
Decision Outcome
Chosen: no pre-case protocol phase. A bounded protocol default embargo
removes situation (a), and a sender-side proposal embedded on the report Offer
removes situation (b). With both, a Reporter never faces "no embargo at all" and
can always state the terms they want before disclosing anything — which is
precisely the assurance rm_em.md offers as its motivation. The phase becomes a
no-op.
Two kinds of default, named apart
| Term | What it is | Competes under shortest-wins |
|---|---|---|
| Actor default | A duration from a published EmbargoPolicy; what defaults.md calls a standing proposal |
Yes |
| Protocol default | The fallback applied when no proposal and no actor default applies | No |
The protocol default is the value when the candidate set is empty, never a member of the candidate set. This is load-bearing. A 72-hour protocol default that competed under shortest-wins would win against every longer proposal and cap every embargo in the system at 72 hours; no longer embargo could ever be agreed.
Code must stop conflating them. _DEFAULT_EMBARGO_DAYS and the blackboard key
default_embargo_duration currently carry both meanings, and
EmbargoEvent.end_time's 45-day field default is a third implicit duration
carried nowhere anyone would look for it (#3404).
The protocol default is configurable within a fixed range
The protocol default embargo duration MUST be configurable, and MUST be no less than 72 hours and no more than 5 days. Deployments tune it; none may quietly restore a value generous enough to re-invert the incentive.
The 72-hour floor is not arbitrary — it equals the configured minimum RSVP window already established by EP-07-002. Aligning them means the shortest embargo the protocol will ever produce is exactly as long as the shortest answer window it grants by default, so the two configured numbers move together rather than needing to be reconciled.
The alignment does not remove the relationship entirely, and the next section is why. Because a stated proposal may be shorter than the protocol default, EP-07-002's effective minimum has to become the lesser of the configured window and the time remaining in the embargo. So there is still arithmetic relating the RSVP floor to the embargo duration — the alignment buys that the arithmetic never fires on the protocol-default path, not that it does not exist.
This overturns defaults.md § "No Defaults, No Proposals". That section's rule
— no defaults and no proposals means no embargo — optimizes for formal tidiness
at the cost of the process goal. A Case with no embargo is the outcome the EM
process exists to avoid, and reaching it by silence from both parties is the
least deliberate way to arrive there.
Every eligible Case is created with an Active embargo
A Case is created with an Active embargo unless the vulnerability is already
public. VP-06-001 already forbids proposing or accepting an embargo
negotiation once any of P/X/A is set (EMB-01-002 is the accept half of the same
prohibition), enforced by _assert_pxa_embargo_eligible(); the protocol default
inherits that guard rather than bypassing it. An embargo on an already-public
vulnerability protects nothing.
This narrows which EM states a live Case can be observed in, and the narrowing is
sharper than it first looks. EM.NONE survives: it is where a non-eligible Case
sits from creation. EM.PROPOSED is the state that thins out. Its only entry is
PROPOSE: NONE → PROPOSED (vultron/core/states/em.py), EP-04-002 requires the
creation-time traversal of it to be atomic and never observable, and the Cases
still at NONE are exactly the non-eligible ones where VP-06-001 forbids
proposing. So once a sender-side proposal exists, shortest-wins puts the losing
duration at EM.REVISE, not EM.PROPOSED, and an externally observable
EM.PROPOSED has no remaining producer.
That is an acceptable consequence rather than a hidden one: EM.PROPOSED stays in
the machine because EP-04-002 traverses it, and because a Case that leaves NONE
by becoming eligible later needs it. But the EM state-machine documentation
describes Proposed as a state participants sit in and negotiate from, and that
description is now narrower than it reads. Worth revisiting if a later decision
wants an observable proposal step.
The protocol default is a fallback, not a minimum
A stated proposal wins however short it is. A Reporter may propose 12 hours and get 12 hours. The range in the previous section bounds what the fallback may be configured to; it does not constrain what parties may agree. Constraining agreed terms upward would contradict "shortest duration possible" and a Participant's freedom to set their own terms.
An RSVP deadline may not outlive its embargo
An RSVP deadline MUST NOT fall after the end of the embargo it concerns. Where a computed deadline would, it is clamped down to the embargo's end.
This is a defect independent of everything else above. Nothing today compares
Invite.end_time (or the CM-18-002 policy window) against the embargo's own
end_time, and EP-07-003 clamps the deadline up to a 72-hour minimum with no
upper bound at all. So:
A new Participant is invited to a 24-hour embargo. The
Invite(EmbargoEvent)carries noend_time, so the CM-18-002 policy window applies: 7 days. The Pocket Veto fires on day 7. The embargo ended at hour 24.
The Participant is asked to consent to an embargo that is already over, and their inaction is recorded as a decline six days after it stopped mattering. The same thing happens on day 28 of a 30-day embargo, entirely without a protocol default in the picture.
Consequently EP-07-002's minimum becomes 72 hours, or the remaining embargo, whichever is shorter. An invitee to a 12-hour embargo gets a 12-hour window. That is principled: the minimum exists to prevent an unreasonably short deadline, and 12 hours is not unreasonable when 12 hours is the whole embargo.
A Reporter states terms by embedding a proposed EmbargoEvent
Offer(VulnerabilityReport) carries a proposed EmbargoEvent. Its own docstring
describes it as "the RS message when no case exists" — it is the right activity
to carry pre-case terms.
It does not carry them for free. _RmSubmitReportActivity
(vultron/wire/as2/vocab/activities/report.py) declares only object_, and the
AS2 base model sets no extra policy, so Pydantic's default extra="ignore"
applies: an undeclared keyword passed to the factory is silently dropped. The
proposal therefore needs a declared optional field on the Offer activity
model. That is one field on one existing class — it is not a new object, a new
context type, a new storage shape, or a migration — but it is not zero work, and
an implementer who believes the factory already forwards arbitrary fields will
write a proposal that vanishes without an error.
This also requires widening EmbargoEvent.context from always a Case to the
thing the embargo is about: the Report before a Case exists, the Case
afterwards. At Case creation the context is rewritten to the Case id. The widening
is a semantic change, not a type change — the field is
EmbargoEvent.context in vultron/core/models/embargo_event.py, typed
NonEmptyString, and nothing validates it as a Case reference. (Do not confuse it
with VultronEvent.context in vultron/core/models/events/base.py, a
VultronObject | None holding the inbound activity's resolved context; the casts
to VultronCase in events/embargo.py are on that field, for Announce and
Invite events whose context genuinely is a Case, and are unaffected.)
CreateEmbargoEventPattern's context_=VULNERABILITY_CASE constraint widens
correspondingly, to declare the intent — that pattern matches context_
permissively today, and it keys on TAtype.CREATE, so it never sees an Offer at
all.
This discharges EP-04-004's contingency and makes EP-04-003 — shortest-wins at Case creation — reachable for the first time.
It also settles CONCERN-2215's remaining questions as a side effect. Pre-case embargo terms now have a legitimate home, and the migration path into the Case is a context rewrite. So if a genuine pre-submission negotiation is ever wanted, the object it would negotiate over already exists; what this decision declines is the phase, not the representation.
Consequences
- Good, because the process goal is served directly: an eligible Case always begins with an Active embargo, which is what the EM process exists to produce.
- Good, because publishing an
EmbargoPolicybecomes strictly better than not publishing one, reversing the current incentive. - Good, because it is subtractive at the protocol level — no new state machine, no new wire context type, no new storage shape, no migration.
- Good, because it removes a silent 90-day behavior that three documents deny.
- Good, because the RSVP defect is fixed on its merits, not as a side effect.
- Bad / accepted cost:
defaults.md§ "No Defaults, No Proposals" is overturned, and the formal statement that no embargo exists in that case no longer holds. - Bad / accepted cost: a deployment that wants long default embargoes cannot get
them from the protocol default. It must publish an
EmbargoPolicy, which is the intended pressure. - Bad / accepted cost:
EmbargoEvent.contextbecomes polymorphic in what it points at, so readers must not assume a Case without checking. - Neutral, because
rm_em.md's motivation survives intact; only its mechanism claim is withdrawn.
Validation
- A test enumerates the default-resolution table: for each combination of (Reporter proposal present/absent) × (actor default present/absent) × (P/X/A set/clear), the resulting EM state and duration are pinned. The no-policy-no-proposal row asserts the configured protocol default, not 90 days.
- A test asserts the protocol default cannot be configured outside [72 hours, 5 days].
- A test asserts the protocol default does not participate in shortest-wins: a 30-day actor default with no Reporter proposal yields 30 days, not the protocol default.
- A test asserts a Reporter proposal shorter than the protocol default is honored rather than clamped.
- A test asserts an RSVP deadline computed beyond the embargo's
end_timeis clamped down to it, including the case where the remaining embargo is shorter than 72 hours. - A test asserts a Case created with P/X/A set receives no protocol default embargo.
Pros and Cons of the Options
No pre-case phase — protocol default plus sender proposal (chosen)
- Good, because it removes both motivating situations rather than building machinery to navigate them.
- Good, because both halves are small and independently useful; the protocol default fixes a live defect and the sender proposal discharges a spec contingency that has been open since EP-04 was written.
- Good, because no new concept enters the protocol.
- Bad, because it requires overturning a
SHALLindefaults.md.
Generalise EM scope beyond the Case
- Good, because it is the only option that makes
rm_em.mdliterally true as written. - Bad, because the blast radius is the whole EM subsystem:
EmbargoEvent.contextbecomes polymorphic and EM state must move offCaseStatus, the wire pattern must admit a non-Case context, and the formal model documentation and glossary definition of EM both change. - Bad, because it buys a capability to serve a need that a rule change removes.
Bilateral negotiation before the Report exists
- Good, because it matches CONCERN-2215's words most literally — the Reporter obtains a commitment, not merely information.
- Good, because ADR-0080's ask primitive and outstanding-ask register would host the round-trip without new framework machinery.
- Bad, because it needs a terms object, a register instance, and a Case-seeding path, all to reach an outcome shortest-wins already reaches once the Reporter can state terms.
- Bad, because a negotiation with no deadline and no Case is the longest possible path to the process goal of locking in an embargo early.
Revive the proto-case (ADR-0015)
- Good, because
notes/embargo-default-semantics.mdalready proposed it as one of two design paths for closing the reporter-proposal gap, and it reuses existing machinery. (That section is now § "Resolved: Reporter Embargo Proposal Mechanism (EP-04-004)", carrying a do-not-revive warning.) - Bad, because ADR-0041 supersedes ADR-0015 for precisely this window, and ADR-0089 re-rejected it when deciding where pre-case RM state lives.
- Bad, because it makes every prospective report a real Case, including the ones that are never submitted.
Honor defaults.md as written — no embargo at all
- Good, because the written rule stands unchanged and the 90-day fallback becomes a straightforward defect to delete.
- Bad, because it preserves the one outcome the EM process exists to avoid, and reaches it through mutual silence.
- Bad, because it leaves a live reason for a pre-submission phase, so the question this decision closes would reopen.
Ratify the existing 90 days
- Good, because no behavior changes and the fix is a documentation edit.
- Bad, because the incentive stays inverted — publishing nothing still yields the longest embargo available.
- Bad, because 90 days sits at the far end of "a few days to a few months" as an unchosen value.
An activity-level end_time on the Offer
- Good, because it mirrors ADR-0065's
Invite.end_timeprecedent and needs no new object and no context change. - Bad, because CM-28-001 already names
end_timea critical naming hazard for meaning two different things one nesting level apart. A third meaning on a third activity type makes a known trap worse.
Inline the Reporter's own EmbargoPolicy
- Good, because
EmbargoPolicyis already actor-level, so no context question arises. - Bad, because a policy states a standing preference, not terms for this Report — "I generally prefer 30 days" is not "for this Report I propose 30 days".
- Bad, because it duplicates what dereferencing the Reporter's profile would give (#3258), rather than expressing a per-Report decision.
More Information
Source: planning group G08 (#2836), resolving CONCERN-2215 and IDEA-2075. Two members were ejected from the group as belonging to a different question: #1189 (ActivityPub/WebFinger directory service) and #2060 (recipe x03, Reporter cannot find a Vendor contact). Both are party discovery — how to address a party at all — not embargo state location. Their prerequisites are the actor identity model (G13, #2841, whose own acceptance criteria already name public-key discovery) rather than anything decided here.
Five defects surfaced while scoping and are fixed or recorded here:
- The silent 90-day fallback, contradicting
defaults.md,notes/embargo-default-semantics.mdandem/principles.md, and inverting the incentive to publish a policy. _preferred_embargo_duration()selectspolicies[0]from an unordered list, so which policy applies is arbitrary when an actor's store holds more than one.- An RSVP deadline may outlive the embargo it concerns — unguarded, and a gap in ADR-0065 as it stands.
rm_em.mdasserts an EM transition on a machine instance that cannot exist.- A second undeclared default duration:
EmbargoEvent.end_timecarriesdefault_factory=_45_days_hence(vultron/core/models/embargo_event.py), so anyEmbargoEventconstructed without an explicitend_timesilently acquires 45 days — nine times the 5-day ceiling this decision sets, and as unchosen a number as the 90. EP-04-010 covers it; tracked as #3404.
Defect 3 is the one worth remembering: it was found only because aligning the protocol-default floor with EP-07-002's minimum RSVP window forced a look at how the two timers relate.
Related decisions: ADR-0065 (RSVP deadline and pocket veto as one mechanism),
ADR-0041 (CASE_MANAGER-authoritative case initialisation, superseding ADR-0015),
ADR-0089 (pre-case RM state belongs to ReportCaseLink), ADR-0048 and ADR-0091
(embargo consent states), ADR-0090 (stub objects narrowed to VulnerabilityCase),
ADR-0080 (protocol asks).
Generated spec requirements: specs/embargo-policy.yaml EP-04-001 and EP-04-004
(amended), EP-04-005 through EP-04-010 (new), EP-07-002, EP-07-003 and EP-07-005
(amended), EP-07-006 (new); specs/case-management.yaml CM-28-011 (new),
CM-12-004, CM-14-006, CM-14-010 and CM-28-002 (amended);
specs/vultron-protocol-spec.yaml VP-07-001 (amended);
specs/vultron-as2-mapping.yaml VAM-05-001 (amended). Design notes:
notes/embargo-default-semantics.md.
Amendment — 2026-09-28
The decision named the carrier of a Reporter's terms (a proposed EmbargoEvent on Offer(VulnerabilityReport)) but not how those terms reach the actor that creates the case.
The Offer is received by the vendor; the case is created by the case-actor service when it accepts the vendor's CaseProposal, and nothing carried the terms across that hop.
Resolved in #3392: the CaseProposal carries the Offer itself, whole, as inReplyTo (CP-01-008).
This is the inline twin of the bare provenance CP-01-007 already carried (offerId, offerActorId), and it follows ADR-0107's rule that a sender inlines the objects it introduces — the case-actor has never seen the Offer.
The Reporter's terms therefore arrive as the Reporter stated them, not as the vendor's paraphrase of one field, and the case-actor extracts them with the same extractor the vendor used.
The proposed event keeps its identity at case creation; only its context is rewritten from the report to the case (EP-04-004).
The losing side of shortest-wins is registered as a pending revision through EmbargoLifecycle.propose_embargo, so a contested creation leaves the case at EM.REVISE (EP-04-003).
The decision itself is unchanged.
Amendment — 2026-10-02
The "Actor default" row of the terms table reads "A duration from a published EmbargoPolicy".
That text is replaced: an actor default is a duration from the EmbargoPolicy on the Case Owner's actor profile.
The embargo policy is a field of the actor profile, not a separately stored object, and attributedTo on the case is the CASE_OWNER (ADR-0041, amended 2026-10-02).
An earlier change (commit 0882ed1c7, #3979) edited the row in place without a trail; the 2026-10-02 audit of unsupervised agent decisions (#4195) confirmed the policy-is-a-profile-field position and the original text above is restored so this section is the record of it.
The decision itself is unchanged.
Confirmed by Allen D. Householder, 2026-10-02.