Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
216 changes: 216 additions & 0 deletions docs/adr/0065-embargo-invite-rsvp-deadline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
---
status: accepted-provisional
date: 2026-08-12
deciders: Vultron maintainers
consulted: Vultron maintainers
informed: Vultron contributors
---

# Carry the Embargo Invite RSVP Deadline on `Invite.end_time`

## Context and Problem Statement

An embargo invitation can sit unanswered indefinitely. The protocol has no
bounded commitment window, so a coordinator waiting on one non-responsive
invitee cannot tell whether the invite is still live, and coordination stalls
without anyone having decided anything.

A deadline mechanism is in fact already specified. CM-18-002 defines the
**pocket veto**: `INVITED → DECLINED` MAY be driven by timer expiry on "a
configurable policy window (default: 7 days)." That window is *receiver-local
and implicit* — nothing about it appears on the wire, so the two parties have
no shared instant at which the invite lapses. It is also entirely unimplemented:
`PEC_Trigger.DECLINE` is fired from exactly two sites
(`vultron/core/behaviors/embargo/announce_teardown_tree.py`,
`vultron/core/services/embargo_lifecycle.py`), both explicit-`Reject` paths. No
timer exists.

The question is therefore not "should invites expire" — that is settled — but
**where the deadline lives and who is authoritative about it**.

Source: IDEA-2066, under epic #2088 (Protocol vocabulary extension).

## Decision Drivers

- Both parties MUST be able to compute the same lapse instant, ideally without
exchanging any additional message. An invitee who never responds is by
definition the party least likely to send a notification.
- No new AS2 noun or verb should be required. Epic #2088 is vocabulary
extension, not vocabulary invention.
- The existing pocket veto must be *reconciled*, not duplicated. Two parallel
notions of "the invite timed out" would drift.
- A deadline is coercive if unbounded downward: an artificially short window
weaponises the mechanism to exclude a participant who had no realistic
chance to answer.
- Being liberal in what is accepted serves CVD outcomes. An invitee who answers
late is still volunteering to coordinate; the protocol should not eject them.
- No scheduler or Sentinel runtime exists in production, so the design must not
depend on one.

## Considered Options

- **Activity-level `Invite.end_time`** — use the AS2 field the activity already
inherits, on the invitation itself.
- **Object-level RSVP field on `EmbargoEvent`** — add a new field (e.g.
`rsvp_by`) to the embargo object carried as the invite's `object_`.
- **Receiver-local policy only** — implement CM-18-002 as written and put
nothing on the wire.
- **A dedicated RSVP/expiry activity type** — a new message announcing the
deadline separately from the invitation.

## Decision Outcome

Chosen option: **activity-level `Invite.end_time`**.

`as_Object.end_time` (`vultron/wire/as2/vocab/base/objects/base.py`) is
inherited by `as_Activity` and therefore by every Vultron activity, so the
field already exists and needs no extension. On an Activity, AS2 `end_time`
already means "this activity is valid until" — an RSVP-by deadline is the
idiomatic reading, not a repurposing.

Note that the embargo invitation is an **`as_Invite`**, not an `as_Offer`:
`em_propose_embargo_activity()` (`vultron/wire/as2/factories/embargo.py`)
returns `as_Invite`, and `InviteToEmbargoOnCasePattern`
(`vultron/wire/as2/extractor/_instances.py`) matches
`activity_=TAtype.INVITE, object_=AOtype.EVENT, context_=VULNERABILITY_CASE`.
IDEA-2066 was framed as `Offer.end_time`; the mechanism is unchanged but the
field is on the Invite. The generic semantic — activity-level `end_time` on a
response-soliciting activity means "respond by" — applies to both `Invite` and
`Offer`; only `Invite(EmbargoEvent)` is normatively enforced for now, because
it is the only place the domain currently has a timeout concept.

The decision has five parts:

1. **Field and meaning.** `Invite.end_time` is the RSVP-by deadline for the
invitation. It is distinct from `Invite.object_.end_time`, which is the
embargo's own expiry. When `Invite.end_time` is absent, the CM-18-002
policy window applies, preserving current behaviour.

2. **Authority.** The wire value is authoritative when present; the local
policy window is the fallback. The pocket veto is not a second mechanism —
it is this mechanism with the deadline left implicit.

3. **Enforcement.** The CaseActor (`CVDRole.CASE_MANAGER`) enforces expiry, by
lazy evaluation: lapse is derived from `(end_time, now)` whenever PEC state
is read or an `Accept`/`Reject` is processed. This requires no scheduler.
The `EmbargoTimerExpired` Sentinel (#1893) is an optional proactive
accelerator, never a prerequisite.

4. **Late `Accept` is not refused.** If the accepted embargo is still
compatible with the case's current embargo, the CaseActor honours it even
after `end_time`. If it is incompatible, the CaseActor issues a **fresh
invite carrying the current embargo** rather than rejecting: the late
accepter has signalled willingness to engage, so the protocol keeps them and
re-syncs their terms. If no current embargo exists (EM `EXITED` or `NONE`),
the `Accept` is acknowledged as a no-op on embargo state and the actor's case
participation MUST NOT be dropped — following the EMB-07-003 precedent for
post-terminal messages.

5. **No new PEC state.** A lapsed invite records `DECLINED`, the same as an
explicit refusal. Nothing branches on the difference: re-invite
(`DECLINED → INVITED`), content gating, and meta-protocol delivery treat the
two identically. The distinction is provenance, and the canonical ledger
already carries it — a `Reject(Invite)` entry versus a CaseActor-authored
lapse entry. Encoding it as state would put path history in the machine and
re-expand the table ADR-0048 simplified.

Abuse mitigation: a minimum RSVP window is specified, and a receiver that gets
a sub-floor deadline **clamps it up to the floor** rather than rejecting the
invite. Clamping keeps both parties deterministic and makes a coercively short
deadline ineffective rather than fatal — rejecting outright would hand the
sender a way to get their own invite discarded.

### Consequences

- Good, because lapse becomes computable by both parties from a shared field,
with no message exchange and no scheduler.
- Good, because no new AS2 noun or verb is introduced.
- Good, because CM-18-002's unimplemented timer gains a concrete, testable
definition instead of remaining aspirational.
- Good, because the liberal late-`Accept` rule means a deadline never silently
ejects a willing participant.
- Bad, because `end_time` now carries two distinct meanings one nesting level
apart in the same JSON document (`Invite.end_time` = RSVP-by,
`Invite.object_.end_time` = embargo expiry). This is the principal
implementation hazard and is called out in CM-28 and in
`notes/participant-embargo-consent.md`.
- Bad, because lazy evaluation means a lapse is recorded when someone next
looks, not at the instant it occurs. Ledger entry ordering can therefore trail
the deadline. Acceptable while the derived state is correct on read; the #1893
Sentinel narrows the window if it is ever a problem.
- Neutral, because the minimum-window floor is a policy number chosen without
operational data. It is configurable and expected to be tuned.

## Validation

- Spec conformance tests for CM-28, EP-07, and EMB-17.
- A test asserting `Invite.end_time` and `Invite.object_.end_time` are read
independently and never conflated on a single invitation.
- A test that a sub-floor deadline is clamped, not rejected.
- A test that a late `Accept` with an incompatible embargo produces a fresh
invite rather than a rejection, and that case participation survives.

This ADR is `accepted-provisional`: the direction is ratified, but nothing
implements it yet. Details — in particular the floor value and the exact
compatibility predicate for a late `Accept` — are expected to converge once the
implementation Tasks land. Revise this ADR in place if they do not hold.

## Pros and Cons of the Options

### Activity-level `Invite.end_time`

- Good, because the field already exists on every activity via `as_Object`.
- Good, because "activity valid until" is the idiomatic AS2 reading.
- Good, because re-invites work naturally: each `Invite` carries its own
independent `end_time`, so a second invitation can set a different deadline
with no ambiguity about which one applies.
- Bad, because it puts a second `end_time` meaning in the same payload as the
embargo's own `end_time`.

### Object-level RSVP field on `EmbargoEvent`

- Good, because a distinct field name would eliminate the collision outright.
- Bad, because the deadline is a property of *this invitation*, not of the
embargo. The same embargo can be offered to several actors with different
deadlines, and to one actor twice.
- Bad, because it requires extending a Vultron object type — more work, and it
writes invitation bookkeeping into the shared embargo object.
- Bad, because `EmbargoEvent.end_time` already exists and means embargo expiry,
so the object would carry two deadline fields with subtly different jobs.

### Receiver-local policy only

- Good, because it requires no wire change at all.
- Bad, because the two parties cannot agree on when the invite lapsed, which is
the actual coordination problem.
- Bad, because the inviter cannot communicate urgency, which is the operational
need motivating IDEA-2066.

### A dedicated RSVP/expiry activity type

- Good, because it separates the two `end_time` meanings completely.
- Bad, because it invents a new message type for data that fits on the
invitation itself.
- Bad, because it introduces a delivery-ordering problem: the deadline could
arrive before or after the invitation it governs.

## More Information

- IDEA-2066 (source), epic #2088 (Protocol vocabulary extension).
- #1893 — `EmbargoTimerExpired` Sentinel, the optional proactive accelerator.
- ADR-0048 — PEC `NO_EMBARGO` is absence, not pre-consent; the precedent for
simplifying the PEC table rather than adding states.
- ADR-0019 — the canonical ledger as the history mechanism, which is why lapse
provenance lives there rather than in a PEC state.
- `notes/participant-embargo-consent.md` § "RSVP Deadlines on Embargo Invites".
- CS-13-001 through CS-13-005 govern UTC handling for all datetimes involved.
- Deferred from this decision: an explicit rescind mechanism
(`Undo(Invite(EmbargoEvent))`). `as_Undo` already exists in the vocabulary
(`vultron/wire/as2/vocab/base/objects/activities/transitive.py`), but adding a
rescind verb to the message catalog, its pattern, extractor entry, and use
case is a distinct design activity. Tracked as its own Idea under epic #2088.

Generated spec requirements: `case-management.yaml` CM-28,
`embargo-policy.yaml` EP-07, `em-behavior.yaml` EMB-17. CM-18-002 is amended to
reference CM-28 so the pocket veto and the explicit deadline remain one
mechanism.
1 change: 1 addition & 0 deletions docs/adr/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ General information about architectural decision records is available at <https:
- [ADR-0062 Normalise Wire → Core at Ingress, and Enforce It Again at the Persistence Boundary](0062-normalise-wire-to-core-at-both-ingress-and-persistence.md)
- [ADR-0063 Render Core Objects to Wire JSON Through a Driven Port; Remove `alias_generator` From All Core-Branch Types](0063-wire-rendering-port-for-core-objects.md)
- [ADR-0064 Enforce Post-Construction Type Safety on the Core Branch Only, in Three Ratcheted Steps](0064-core-branch-validate-assignment.md)
- [ADR-0065 Carry the Embargo Invite RSVP Deadline on `Invite.end_time`](0065-embargo-invite-rsvp-deadline.md) *(provisional)*

## Proposed ADRs

Expand Down
23 changes: 20 additions & 3 deletions docs/reference/draft-vultron-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -773,11 +773,28 @@ Neither `LAPSED` nor `DECLINED` is terminal — both can be re-invited.
retroactively un-given by re-invitation)
- *Source: ADR-0048; `notes/participant-embargo-consent.md`*

#### 6.4.4 Pocket Veto (Timer-Based Transitions)
#### 6.4.4 Pocket Veto and RSVP Deadlines (Timer-Based Transitions)

- `INVITED → DECLINED` and `LAPSED → DECLINED` are timer-based
- Timeout window is a configurable policy option (per-case or global)
- *Source: `notes/participant-embargo-consent.md` §"Pocket Veto"*
- An `Invite(EmbargoEvent)` MAY carry an activity-level `end_time` giving an
explicit RSVP-by deadline. When present it is authoritative; when absent the
configurable policy window applies (default 7 days). The pocket veto is the
implicit form of the same mechanism, not a second one
- `Invite.end_time` (RSVP-by) MUST NOT be confused with
`Invite.object_.end_time` (embargo expiry) — the same invitation carries both
- A minimum RSVP window (default 72h) MUST be enforced; a receiver getting a
sub-minimum deadline MUST clamp it up rather than reject the invitation
- Enforcement authority is the CaseActor (`CVDRole.CASE_MANAGER`), evaluated
lazily from `(end_time, now)`; no scheduler is required
- A lapse records `DECLINED` — the same state as an explicit refusal. The
distinction is provenance, carried by the canonical ledger, not by a
dedicated PEC state
- A late `Accept` MUST NOT be refused on deadline grounds: honour it if the
terms are current, re-invite with current terms if they are stale, or
acknowledge as a no-op (retaining case participation) if no embargo remains
- *Source: `notes/participant-embargo-consent.md` §"Pocket Veto" and
§"RSVP Deadlines on Embargo Invites"; ADR-0065; CM-18-002, CM-28, EP-07,
EMB-17*

#### 6.4.5 Embargo Meta-Protocol Delivery to Non-Signatories

Expand Down
3 changes: 2 additions & 1 deletion docs/reference/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ fix not ready) are structurally impossible, per SM-09-002 and CSB-17-001.
| **Embargo Consent** | A **Participant**'s individual agreement to or rejection of an **Embargo** (a personal commitment, distinct from the **Embargo** itself); tracked as a per-participant 5-state machine over NO_EMBARGO, INVITED, SIGNATORY, LAPSED, and DECLINED. NO_EMBARGO means *no embargo is in scope for this participant* — not "has not consented yet" — so ACCEPT and DECLINE are valid directly from it, without an intervening invitation (ADR-0048, CM-18-003) | Embargo acceptance, embargo stance |
| **Active Embargo** | The currently in-force **Embargo** for a **Case**; there is at most one | Current embargo, ongoing embargo |
| **Proposed Embargo** | An **Embargo** that has been offered but not yet accepted by all parties | Embargo offer, pending embargo |
| **Pocket Veto** | A timer-based transition in the **Embargo Consent** state machine where a **Participant** in INVITED or LAPSED state automatically transitions to DECLINED if they do not respond within a configurable timeout window (inaction = rejection) | Embargo invitation timeout, implicit rejection |
| **Pocket Veto** | A timer-based transition in the **Embargo Consent** state machine where a **Participant** in INVITED or LAPSED state automatically transitions to DECLINED if they do not respond within a configurable timeout window (inaction = rejection). The window is the *implicit* form of the **RSVP Deadline**: when an invitation carries an explicit `Invite.end_time` that value supersedes it (CM-27-002); the policy default (7 days, EP-07-001) applies only when it is absent. The two are one mechanism, not two (ADR-0065) | Embargo invitation timeout, implicit rejection |
| **RSVP Deadline** | The activity-level `end_time` on an `Invite(EmbargoEvent)`, giving the invitee an explicit respond-by instant after which the invitation is no longer open. Distinct from the nested `Invite.object_.end_time`, which is when the **Embargo** itself ends — the same invitation carries both, one nesting level apart. Enforced lazily by the **CaseActor** (CM-27-003); a late **Accept** is never refused outright (EMB-17). Introduced by ADR-0065 | Invite expiry, respond-by deadline, invite end_time |

---

Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ nav:
- Normalise Wire to Core at Ingress, and Enforce It Again at the Persistence Boundary: 'adr/0062-normalise-wire-to-core-at-both-ingress-and-persistence.md'
- Render Core Objects to Wire JSON Through a Driven Port: 'adr/0063-wire-rendering-port-for-core-objects.md'
- Enforce Post-Construction Type Safety on the Core Branch Only: 'adr/0064-core-branch-validate-assignment.md'
- Embargo Invite RSVP Deadline on Invite.end_time: 'adr/0065-embargo-invite-rsvp-deadline.md'
- About:
- Contributing: 'about/contributing.md'
- FAQ: 'about/faq.md'
Expand Down
Loading