# Calee event occurrence identity — contract version 3

**v1 is frozen. v2 is frozen. Both stay executable.** This directory does not
replace [`../v1/`](../v1/README.md) or [`../v2/`](../v2/README.md); it sits
beside them. Every rule in the v1 README still governs, every rule in the v2
README still governs, and v3 changes exactly one thing, stated in §2. Read v1
first (it explains *why* this contract exists in the shape it does), then v2
(it explains the Windows mapping and why identity eligibility is not mapping
membership). v3 is deliberately the smallest possible delta on v2.

| | |
|---|---|
| Contract | `calee.event-occurrence-identity` |
| Version | 3 |
| Fixture | [`contract.json`](contract.json) |
| Fixture SHA-256 | `dae97141c6e24a7d99e9974419cd6aacd1740f0b310490f34865ad406be35031` |
| Supersedes | v2 (frozen, still executable) |
| v2 fixture SHA-256 | `b392100c19e5e841b787117d29545d1cf1adbedfbd8abd2a1dba9633e0a2fd73` |
| v1 fixture SHA-256 | `930d09c6760b88bb335c550afa52d100e19b7c888d72f35743653e2b0e1028f3` |
| Issue | [CaleeAdmin/calee-hub-core#573](https://github.com/CaleeAdmin/calee-hub-core/issues/573) (follow-up to #558 B2b) |
| Prerequisite | [CaleeAdmin/CaleeMobile#607](https://github.com/CaleeAdmin/CaleeMobile/issues/607), merged as CaleeMobile PR #608 |
| Mapping artifact | `windows-timezones.v1.json`, SHA-256 `b174be059cf8194bbf4747a6b617ed409131848a0c98228352e7d6cf3f621704` — **unchanged** |

---

## 1. Why there is a v3 at all

v2 §2.0 withheld portable identity from exactly one Windows timezone ID:

```
source interpretation mappings   139
v2 identity-eligible mappings    138
explicitly excluded                1   Paraguay Standard Time → America/Asuncion
```

The reason was never the mapping. `Paraguay Standard Time` maps to
`America/Asuncion` in all three clients and that zone resolves in all three.
The reason was **timezone data**: Paraguay abolished DST in 2024, the PHP
clients' tzdata knew that, and CaleeMobile's then-pinned `package:timezone`
0.9.4 (tzdb 2024a) did not — so for part of every year the phone minted
`20260710T140000Z` where the servers minted `20260710T130000Z`, with
`status = ok`. v2 made that fail closed by listing the name in
`windowsIdentityExclusions`, and v2 §2.0 said what would lift it:

> `resolutionRequires`: CaleeMobile timezone data carrying tzdb 2025b or later,
> proven by its occurrence-identity timezone matrix. Removing this entry
> afterwards is a contract change and needs a new contractVersion.

CaleeMobile#607 did that. `package:timezone` moved to 0.10.1 (tzdb 2025b) and
the matrix now reproduces the PHP reference for **139 / 139** mapped
destinations with **zero** timezone-data divergences. The exclusion was
nevertheless retained in that change, because v2 is frozen and removing an
exclusion widens what can be minted (v2 §12).

v3 is that removal, and nothing else.

---

## 2. The semantic delta, in full

**v2 semantics plus ONE deliberate expansion: `Paraguay Standard Time` is
identity-eligible. `windowsIdentityExclusions` is empty, and every one of the
139 pinned mappings may mint portable identity.**

| | v2 | v3 |
|---|---|---|
| `TZID` = `Paraguay Standard Time` (case-insensitive, trimmed), timed value, no trailing `Z` | `unsupported_tzid`, identity `null` | `ok`, identity of the mapped `America/Asuncion` clock |
| every other Windows ID in the artifact | `ok` | `ok` — **unchanged, byte-identical** |
| every other v2 refusal | refused | refused — **unchanged** |
| everything else | — | **unchanged** |

Concretely, for the source the exclusion was measured on:

```
DTSTART;TZID=Paraguay Standard Time:20260710T100000

  v1   unsupported_tzid   null                (no Windows rung)
  v2   unsupported_tzid   null                (windowsIdentityExclusions)
  v3   ok                 20260710T130000Z    (mapped Asuncion clock)
```

and the fixture pins that this is not a one-date coincidence: January and
February controls (Paraguay's *former* DST season, when stale data would have
agreed with the reference) and the July case (when it would not) all resolve
10:00 Asuncion to 13:00Z.

### 2.0 What v3 keeps from v2, stated so nobody has to infer it

- **The eligibility RULE is unchanged.** A Windows ID is identity-eligible when
  it is present in the pinned mapping artifact **and** absent from
  `windowsIdentityExclusions`. v3 changes the *list* the rule reads, not the
  rule. An implementation must keep evaluating the list of the contract version
  it is **executing** — see §5.
- **The mapping artifact is unchanged.** Same 139 entries, same bytes, same
  SHA-256, same CLDR release-47 / tzdb 2026a provenance. v2 §2.0 said an excluded
  ID must never be *deleted* from the artifact; v3 is the reason: the artifact
  was always right about *which zone*, and only the eligibility answer moves.
- **The resolution ladder is unchanged** (v2 §2.1). `resolutionOrder` is
  byte-identical to v2's. The Windows rung is still reached only from the
  property's own stated `TZID`, still sits below the portable name and below
  any embedded `VTIMEZONE` a client supports, and still never rescues a
  malformed value, an all-day value, a floating value or a calendar-context
  fallback.
- **The calendar-context rung still accepts portable names only.** A floating
  value whose calendar context is `Paraguay Standard Time` is still
  `unsupported_tzid`: the source never stated that zone on the property. The
  fixture pins this (`windows-paraguay-calendar-context-not-mapped`).
- **Unknown still fails closed.** `Definitely Not A Windows Standard Time` is
  still `unsupported_tzid`. v3 empties an exclusion list; it does not widen the
  map, and it does not widen the guessing.
- **Explicit `Z` still outranks.** `20260710T130000Z` with
  `TZID=Paraguay Standard Time` was `ok` under v2 (the exclusion applied only
  to the Windows-resolution rung) and is `ok` under v3 with the byte-identical
  identity.
- **All-day values still ignore `TZID`** (RFC 5545 3.3.4).

Unchanged, and asserted unchanged by the fixture: UID semantics (v1 §3), the
canonical identity formats (§4), explicit-`Z` precedence (§4.1 rung 1),
property-`TZID` precedence (§4.1 rung 2), `RECURRENCE-ID`/`EXDATE` inheritance
(§4.1 rung 3), calendar-context resolution (§5), floating-without-context
refusal (§5), malformed-value handling (§6.2), all-day semantics (§4), detached
override identity (§7), cancellation semantics (§8), `EXDATE` semantics (§9),
recurrence-and-DST stepping (§10), and the identity triple and its separate
transport (§1.1).

### 2.1 Paraguay recurrence, end to end

Scalar eligibility is not the acceptance criterion; reconciliation is. The
fixture carries a Paraguay recurring master

```
UID:paraguay-v3-series
DTSTART;TZID=Paraguay Standard Time:20260710T100000
DTEND;TZID=Paraguay Standard Time:20260710T110000
RRULE:FREQ=DAILY;COUNT=3
```

and pins, under v3, on every client and on every Hub Core engine (direct,
subscription cache, request-time fallback):

| Scenario | What must happen |
|---|---|
| `windows-paraguay-series` | three occurrences, identities `20260710T130000Z`, `20260711T130000Z`, `20260712T130000Z`; the stale-DST twins (`…T140000Z`) and the wall-time-as-UTC readings (`…T100000Z`) appear nowhere |
| `windows-paraguay-detached-move` | a `RECURRENCE-ID;TZID=Paraguay Standard Time:20260711T100000` override with `DTSTART;TZID=Australia/Perth:20260711T160000` **replaces exactly** `20260711T130000Z`; the moved DTSTART's zone (`20260711T080000Z`) never identifies the original; no stale twin, no orphan |
| `windows-paraguay-detached-cancelled` | `STATUS:CANCELLED` suppresses exactly `20260711T130000Z`; both neighbours survive with their own titles |
| `windows-paraguay-adversarial-neighbour-move` | the override's moved DTSTART lands on the **real** 12 July occurrence's identity; the 11 July occurrence is the one replaced, and 12 July keeps its own title |
| `windows-paraguay-adversarial-neighbour-cancel` | the cancelling component's moved DTSTART names 12 July; only 11 July is suppressed |
| `windows-paraguay-detached-inherited-tzid` | a `RECURRENCE-ID` with no `TZID` of its own is read on the master's mapped Paraguay clock (v2 §2.1 rung 4; Hub Core #570 for the cache) |

### 2.2 The same source under v2 still refuses

This is load-bearing and each client asserts it directly: the same Paraguay
source, executed **explicitly under contract version 2**, must still report

```
source interpretation   resolved_windows / America/Asuncion / 13:00Z   (correct, unchanged)
canonical identity      unsupported_tzid / null                        (withheld)
```

A runtime that lifted the exclusion for v2 as a side effect of adding v3 has
retroactively changed a frozen contract. The version argument selects the
historical rule; "latest" is never aliased onto v2.

---

## 3. The mapping artifact is one cross-client object — unchanged

| | |
|---|---|
| File | `windows-timezones.v1.json` |
| SHA-256 | `b174be059cf8194bbf4747a6b617ed409131848a0c98228352e7d6cf3f621704` |
| Entries | 139 |
| Identity-eligible entries under v3 | **139** |
| Identity-eligible entries under v2 | 138 (frozen; v2 is not edited) |
| Territory | `001` |
| Lookup | case-insensitive on the Windows ID, ASCII, after trimming surrounding whitespace |

All three repositories carry these exact bytes and each asserts this SHA-256 in
its own test suite. v3 does **not** regenerate the artifact: contract
eligibility changed, the mapping did not.

---

## 4. Migration for links minted under v1 and under v2

v1 §13 and v2 §12 each require a stated migration. Here they are.

**None is needed, for either.**

```
v1 status = ok  =>  v3 status = ok  AND  v3 identity == v1 identity
v2 status = ok  =>  v3 status = ok  AND  v3 identity == v2 identity
```

v3 only ever turns a v2 **refusal** into a success, and only one: the Paraguay
Windows-resolution case. The new answer is reached only after the Windows rung
has mapped the name — a rung v2 also reached, and refused at, for this name.
Every identity v1 or v2 could mint is produced by an earlier rung that v3 does
not touch. So:

- every Event Link already in the wild keeps resolving to the occurrence it
  always named;
- no stored identity needs rewriting;
- no link needs reissuing;
- there is no data migration in any of the three repositories.

This is **not** left as an argument. Each repository executes the **frozen v1
fixture** and the **frozen v2 fixture** against its **v3 implementation** and
asserts the property above, case by case, and additionally pins the exact set
of v2 cases whose answer changed:

| Repository | v1 → v3 | v2 → v3 |
|---|---|---|
| `calee-hub-core` | `tests/event_occurrence_identity_contract_v1_to_v3_compatibility_test.php` | `tests/event_occurrence_identity_contract_v2_to_v3_compatibility_test.php` |
| `calee-hub-calembed` | `tools/check-event-occurrence-identity-compatibility.php --from=1 --to=3` | `tools/check-event-occurrence-identity-compatibility.php --from=2 --to=3` |
| `CaleeMobile` | `test/features/local_subscriber/event_occurrence_identity_v1_to_v3_compatibility_test.dart` | `test/features/local_subscriber/event_occurrence_identity_v2_to_v3_compatibility_test.dart` |

If any previously successful v1 or v2 identity ever changes, that is a far
larger migration than this contract describes and **must not** be hidden
inside it.

### 4.1 The v2 entries v3 answers differently — the complete list

| v2 entry | v2 | v3 |
|---|---|---|
| identity case `windows-tzid-identity-ineligible` | `unsupported_tzid` | `ok`, `20260710T130000Z` |
| identity case `windows-tzid-identity-ineligible-not-rescued-by-calendar` | `unsupported_tzid` | `ok`, `20260710T130000Z` |
| scenario `windows-identity-ineligible-series` | `unsupported_tzid` | `ok` |

```
v2 → v3 widened Windows IDs:   exactly one   Paraguay Standard Time
```

Nothing else in the v2 fixture changes meaning, and nothing in the v1 fixture
changes meaning beyond what v2 §4.1 already listed. Each repository's
compatibility suite asserts the widened set **by name**, so a v3 that widened
anything else — another Windows ID, a malformed value, a floating value —
fails there even though it would satisfy the forward invariant.

---

## 5. Contract versions are explicit in code

Each client models all three versions truthfully, and the version argument
continues to select the **actual historical rules**:

| Repository | Executes | Mints |
|---|---|---|
| `calee-hub-core` | `client_caldav_occurrence_contract_versions()` → `[1, 2, 3]` | `client_caldav_occurrence_contract_version()` → `3` |
| `calee-hub-calembed` | `calee_calendar_occurrence_contract_versions()` → `[1, 2, 3]` | `calee_calendar_occurrence_contract_version()` → `3` |
| `CaleeMobile` | `localCalendarOccurrenceIdentitySupportedContractVersions` → `{1, 2, 3}` | `localCalendarOccurrenceIdentityMintContractVersion` → `3` |

The exclusion set is therefore **per contract version** in every client —
`{2: [Paraguay Standard Time], 3: []}` — and each client's tests assert the v2
entry equals the frozen v2 fixture's `windowsIdentityExclusions` *and* the v3
entry equals this fixture's (empty) one. A single list that quietly changed
meaning would fail both.

---

## 6. Rollout: resolver capability before minter capability

An Event Link carries an identity, not a contract version. CalEmbed is the
resolver: it re-derives candidate identities from the source under the version
**it** mints and matches them by string equality against the identity in the
link. Hub Core (signed-in) and CaleeMobile (signed-out) are minters, and
CalEmbed's mint endpoint signs the identity it is handed without re-deriving.

So a v3 identity emitted before the resolver can execute v3 is a link that
resolves to **nothing** (`unavailable` / `not_in_source`) — never to a wrong
occurrence, because every non-Paraguay identity is byte-identical across v2 and
v3 and a v2 resolver mints no Paraguay identity to collide with. Safe, but
broken, and avoidable:

```
CODE MERGE ORDER         any order, as one coordinated set; no repository's
                         PR may merge in isolation until all three are verified

DEPLOYMENT / ENABLEMENT  1. calee-hub-calembed   (resolver executes [1,2,3])
                         2. calee-hub-core       (minter, signed-in path)
                         3. CaleeMobile          (minter, signed-out path; app release)
```

The compatibility suites are the code-level gate: every repository carries this
fixture and its contract suite requires the runtime to **execute** the fixture's
version, so a repository cannot hold the v3 fixture with a `[1, 2]` runtime.

---

## 7. Calee-authored events are NOT widened

v2 §7 still governs, unchanged. An **external source's** Windows `TZID` may mint
occurrence identity; a **Calee-authored** event still may not be written in one:

```
POST  /client/v1/events      timeZone: UTC or canonical IANA only
PATCH /client/v1/events/...  timeZone: UTC or canonical IANA only
```

`Paraguay Standard Time` is rejected by the authoring API exactly as
`AUS Eastern Standard Time` is. The separation still holds by construction: the
authoring validator calls the portable-name gate, which neither v2 nor v3
widened; the mapping is consulted inside the occurrence-identity ladder, above
that gate. Hub Core's `tests/client_caldav_event_timezone_test.php` pins this.

Nothing in v3 touches Calee-authored timezone behaviour, recurrence intent,
account timezone defaults or the event timezone selector (#559), and nothing in
v3 touches the legacy authored-series audit (#555).

---

## 8. Out of scope for v3

Everything v1 §12 and v2 §8 list remains out of scope. In particular v3 does
**not** implement any of #558 D1/D2: sub-daily `RRULE`, ordinal `BYDAY`,
`BYSETPOS`, `BYMONTH`, `BYMONTHDAY`, `WKST`, month-end qualification, Google
DST fixtures.

Added to that list by v3:

- **Any further exclusion-list change.** The list is now empty. A future
  divergence that needs an exclusion is a new contract version (v4), not an
  edit to this one.
- **Client timezone-database equivalence beyond the supported horizon** (v2 §9)
  remains each client's obligation; v3 relies on CaleeMobile's matrix staying
  at 139 / 139 with zero divergences, and that matrix is a two-way gate.

---

## 9. Fixture structure

Identical to v2 §10, plus:

| Key | Purpose |
|---|---|
| `supersedes.lineage` | both frozen predecessors and their fixture SHA-256s |
| `semanticDelta.widenedWindowsIds` | the exact widened set, by name |
| `v2Compatibility` | the v2 → v3 invariant, the named widened cases and scenarios, the migration statement (NONE), and the mechanical test |
| `windowsIdentityRule.v3Note` | the per-version evaluation requirement |

`windowsIdentityExclusions` is `[]`; `mappingArtifact.identityEligibleCount` is
`139`. `resolutionOrder`, `timezoneRule` and `mappingArtifact.sha256` are
byte-identical to v2's.

---

## 10. Cross-repository parity

#573 stays **open** until all three adapters pass and the coordinated PR set is
independently verified.

| Part | Repository | Adapter |
|---|---|---|
| 1 | `calee-hub-core` | `tests/event_occurrence_identity_contract_v3_test.php` |
| 2 | `calee-hub-calembed` | `tools/check-event-occurrence-identity-contract.php --version=3` |
| 3 | `CaleeMobile` | `test/features/local_subscriber/event_occurrence_identity_v3_contract_test.dart` |

Each repository commits the fixture and the mapping artifact locally, and CI
**must not** fetch either over the network. The three `contract.json` files are
byte-identical; the SHA-256 above is pinned in every adapter.

---

## 11. Versioning

v1 §13 and v2 §12 apply to v3 unchanged, with v3 in their place:

- Adding a case all three adapters already satisfy is additive and MAY land in v3.
- Changing an expected identity, a status, a resolution rule, or the exclusion
  list requires **v4**: a new `contracts/event-occurrence-identity/v4/`
  directory, a new `contractVersion`, and a stated migration for links minted
  under v1, v2 **and** v3.
- v3 MUST NOT be silently rewritten, and neither must v2 or v1.

Referenced throughout, unchanged from v1: `floating` values (§5),
`unsupported_tzid` (§6.1), `RECURRENCE-ID` (§7), `STATUS:CANCELLED` (§8) and
`EXDATE` (§9).
