# Calee event occurrence identity — contract version 2

**v1 is frozen and stays executable.** This directory does not replace
[`../v1/`](../v1/README.md); it sits beside it. Every rule in the v1 README still
governs, word for word, except where §2 below states otherwise. Read v1 first —
it is the document that explains *why* this contract exists in the shape it
does, and v2 is deliberately a small delta on it rather than a rewrite.

| | |
|---|---|
| Contract | `calee.event-occurrence-identity` |
| Version | 2 |
| Fixture | [`contract.json`](contract.json) |
| Fixture SHA-256 | `b392100c19e5e841b787117d29545d1cf1adbedfbd8abd2a1dba9633e0a2fd73` |
| Supersedes | v1 (frozen, still executable) |
| v1 fixture SHA-256 | `930d09c6760b88bb335c550afa52d100e19b7c888d72f35743653e2b0e1028f3` |
| Issue | [CaleeAdmin/calee-hub-core#558](https://github.com/CaleeAdmin/calee-hub-core/issues/558) B2b |
| Mapping artifact | `windows-timezones.v1.json`, SHA-256 `b174be059cf8194bbf4747a6b617ed409131848a0c98228352e7d6cf3f621704` |

---

## 1. Why there is a v2 at all

v1 §6.1 refuses Windows timezone names outright, and says why:

> This contract does **not** add a Windows timezone database and does **not**
> guess offsets for unknown names. That is a timezone-mapping project whose
> answers would have to agree in PHP and in Dart forever.

#558 B2a did that project. It produced one generated, versioned artifact —
Unicode CLDR `windowsZones.xml` release-47 at territory `001`, resolved through
IANA tzdb 2026a backward links — and wired it into Hub Core's **source
interpretation** only: display, per-property zones, and the public
`timeZoneResolution` state. It deliberately stopped before occurrence identity,
because promoting a mapped zone to shared identity is a change to this contract
and a change to this contract is a change to three repositories at once.

So B2a left a measured split. For a Windows-qualified detached override:

```
direct parser:      replacement replaces the correct occurrence
                    cancellation cancels the correct occurrence

subscription cache: replacement survives as an ORPHAN
                    cancellation suppresses NOTHING
```

The cache was not wrong to fail closed — it reconciles through this contract,
and this contract refused Windows names. It was the contract that had to move.

v2 is that move, and nothing else.

---

## 2. The semantic delta, in full

**v1 semantics plus one deliberate expansion: an IDENTITY-ELIGIBLE Windows
timezone ID may be resolved through the pinned Calee Windows→IANA mapping before
canonical UTC occurrence identity is computed.**

That is the whole change. Specifically:

| | v1 | v2 |
|---|---|---|
| `TZID` is a Windows ID in the artifact **and identity-eligible** | `unsupported_tzid`, identity `null` | `ok`, identity of the mapped zone |
| `TZID` is a Windows ID in the artifact but **identity-ineligible** | `unsupported_tzid`, identity `null` | `unsupported_tzid`, identity `null` — **unchanged** |
| everything else | — | **unchanged** |

### 2.0 Identity eligibility is NOT mapping membership

Being in the mapping answers **which IANA zone does this Windows ID mean?**
It does **not** answer **may this mapped clock mint a portable cross-client
identity?** Those are different questions, and conflating them is how a client
mints a *wrong* identity instead of refusing:

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

A Windows ID is identity-eligible under this contract version when it is
present in the pinned mapping artifact **and** its `windowsId` does not appear
in `windowsIdentityExclusions`. Presence in the mapping alone is not sufficient.

**Why `Paraguay Standard Time` is excluded.** It maps to `America/Asuncion` in
all three clients, and that zone *resolves* in all three. Paraguay abolished DST
in 2024: the PHP clients' tzdata knows that and CaleeMobile's pinned
`package:timezone` 0.9.4 (tzdb 2024a) does not. Measured, for
`DTSTART;TZID=Paraguay Standard Time:20260710T100000`:

| Client | Canonical identity |
|---|---|
| Hub Core | `20260710T130000Z` |
| CalEmbed | `20260710T130000Z` |
| CaleeMobile | `20260710T140000Z` |

CaleeMobile did **not** fail closed — `tz.getLocation('America/Asuncion')`
succeeds there, so it resolved the zone and minted an identity an hour away from
the one the server would mint. An Event Link shared from such a feed would open
the wrong hour on somebody else's screen.

**This is not an interpretation failure.** For Paraguay:

```
clock / source interpretation   resolved_windows   (correct, and unchanged)
portable occurrence identity    unsupported_tzid   (withheld, deliberately)
```

That is the same distinction #558 B2a established between **clock authority**
and **identity eligibility** — now with a second reason to sit on the ineligible
side of it.

**The exclusion is in the shared contract, not in each client.** All three read
`windowsIdentityExclusions` from this fixture and each asserts its own runtime
set equals it, so no client can refuse a name another accepts. A per-client
denylist would be three rules wearing one name.

**The mapping artifact is NOT reduced.** An excluded ID stays in
`windows-timezones.v1.json`: deleting it would break source interpretation for a
zone Calee reads correctly, and would make the artifact a function of one
client's timezone database rather than of CLDR.

**Scope limit, stated plainly.** This rule governs the *Windows-resolution rung*
only. A source naming the same destination with a portable IANA `TZID`
(`TZID=America/Asuncion`) is resolved by the portable rung, which **v1 has
always accepted and v2 does not change** — so an excluded destination remains
reachable that way, and remains subject to the same database divergence.
Closing that door would change a v1 answer and break links already minted, which
this contract version must not do. Closing it properly is the client
timezone-data prerequisite (§9), not an exclusion list.

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 The resolution ladder

v1 §4.1 with one rung inserted. For a timed value:

1. A trailing `Z` designates UTC, and **outranks** any `TZID` on the same
   property. A Windows `TZID` is no exception.
2. The property's own `TZID`, as a portable IANA name (v1 §6.1).
3. **NEW.** The property's own `TZID`, as an **identity-eligible** Windows
   timezone ID (present in the pinned mapping artifact and absent from
   `windowsIdentityExclusions`) — resolved to its IANA zone, which is then
   resolved exactly as rung 2 resolves a portable name. A mapped but
   **ineligible** ID falls through to `unsupported_tzid`.
4. For `RECURRENCE-ID` and `EXDATE` only: the component's `DTSTART` timezone.
5. The explicitly declared calendar/source timezone context.
6. Refuse: `floating_without_context`.

A stated `TZID` that resolves at neither rung 2 nor rung 3 is
`unsupported_tzid`, and the ladder **still stops there** — the calendar context
does not rescue it, because the source named a zone and substituting a
different one invents an instant.

### 2.2 Precedence, stated explicitly

```
portable IANA name
  > this source generation's embedded VTIMEZONE definition
  > pinned Windows mapping
  > unresolved
```

The mapping is consulted **only** when the source named something the platform
cannot resolve and the source did not define the zone itself. A source that
names a portable zone, or ships its own `VTIMEZONE`, has already said what it
means.

> **Embedded-`VTIMEZONE` boundary, stated rather than assumed.** v1's
> `resolutionOrder` does not name an embedded-definition rung, and the v1
> fixture never supplies one: every v1 case resolves with no source-zone
> registry. Hub Core's #558 B1b support for embedded definitions therefore sits
> *outside* what the v1 fixture pins, and v2 does not change that either way.
> v2 places the Windows rung **below** any embedded definition a client
> supports, so a client with no embedded-zone support and a client with it
> agree on every case the fixture does cover. Making embedded definitions part
> of the cross-client contract is separate work and needs its own issue.

### 2.3 What the mapping must never rescue

A Windows mapping is evidence about a *zone name*. It is not a repair for
anything else, and the fixture pins each of these:

- malformed values (`20260230T100000`, `20260818T253000`) — still `malformed_value`;
- unknown Windows-shaped names (`Definitely Not A Windows Standard Time`) — still `unsupported_tzid`;
- bare abbreviations (`AWST`, `EST`) — still `unsupported_tzid`;
- numeric offsets (`+08:00`) — still `unsupported_tzid`;
- arbitrary strings and `Area/Location`-shaped names no database knows;
- all-day values, whose `TZID` is meaningless (RFC 5545 3.3.4) and must not move
  the date.

**Unknown still fails closed.** v2 widens the map, not the guessing.

---

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

```
Unicode CLDR windowsZones.xml, release-47, territory="001"
  + IANA tzdb 2026a backward links
  = 139 mappings
```

| | |
|---|---|
| File | `windows-timezones.v1.json` |
| SHA-256 | `b174be059cf8194bbf4747a6b617ed409131848a0c98228352e7d6cf3f621704` |
| Entries | 139 |
| Identity-eligible entries | 138 (see §2.0) |
| Territory | `001` (CLDR global/default) |
| 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. The fixture pins it too, so a repository whose
artifact drifted would fail its own contract suite before any cross-repository
comparison is needed.

**What must not be used**, in any client: an ICU runtime mapping, the host OS
mapping, the device locale, the customer's country, the account timezone, fuzzy
matching, or string similarity. Territory is never inferred — an ICS source
states a Windows timezone ID and no territory, so CLDR's global/default mapping
is the only defensible reading, and it is the same reading on every device.

A client MAY hold a generated language-specific representation (a Dart `const`
map, say) instead of loading the JSON at runtime, but only if it also proves
mechanically that the representation regenerates from these exact bytes. A
manually transcribed map without an equality test is **not** conformance.

---

## 4. Migration for links minted under v1

v1 §13 requires a stated migration. Here it is.

**None is needed.**

The governing property is:

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

v2 only ever turns a v1 **refusal** into a success. It never changes an
identity v1 could already mint, because the new rung is reached only after
rung 2 has already failed — and if rung 2 failed under v1, v1 minted nothing at
all. 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** against its **v2 implementation** and asserts the property above,
case by case:

| Repository | Test |
|---|---|
| `calee-hub-core` | `tests/event_occurrence_identity_contract_v1_compatibility_test.php` |
| `calee-hub-calembed` | `tools/check-event-occurrence-identity-contract.php --compat` |
| `CaleeMobile` | `test/features/local_subscriber/event_occurrence_identity_v1_compatibility_test.dart` |

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

### 4.1 The three entries v1 answered differently

For auditability, the complete list of fixture entries whose answer changes
between the two versions — all of them refusals becoming successes:

| v1 entry | v1 | v2 |
|---|---|---|
| identity case `windows-tzid` | `unsupported_tzid` | `ok` |
| identity case `windows-tzid-with-calendar-context` | `unsupported_tzid` | `ok` |
| scenario `unsupported-tzid-series` | `unsupported_tzid` | `ok` |

Nothing else in the v1 fixture changes meaning. The v1 adapter in each
repository continues to execute the v1 ladder explicitly and continues to
assert the v1 answers above.

---

## 5. Contract versions are explicit in code

A build that implements v2 semantics while declaring version 1 is lying to the
fixture that pins it. Each client therefore models both versions truthfully:

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

The v1 ladder stays reachable — every client's canonical resolver takes an
explicit contract version — so the v1 fixture keeps executing against real v1
behaviour rather than being quietly re-run against v2.

---

## 6. Display is unchanged

#558 B2a corrected how a Windows feed's **clock** is read. v2 changes
**identity and reconciliation** and nothing else. For a supported Windows feed
`startsAt` and `endsAt` are byte-for-byte what B2a produced, and no new
user-visible time shift is introduced.

v1 §2 still governs: display **MAY** guess where canonical identity **MUST
NOT**, and the two are still allowed to disagree. A scenario whose
`canonicalStatus` is not `ok` still carries a `displayFallback` block asserting
the occurrences continue to display — failing closed on identity must not blank
the calendar.

---

## 7. Calee-authored events are NOT widened

This is the boundary most easily lost, so it is stated here and has regression
coverage in Hub Core.

An **external source's** Windows `TZID` may now 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
```

`AUS Eastern Standard Time` and `W. Australia Standard Time` are rejected by the
authoring API exactly as before. Reading a Windows feed correctly is
interoperability; writing a non-IANA zone into a Calee-owned event would emit a
name every other Calee client has to read back.

The separation holds by construction rather than by a duplicated list: the
authoring validator calls the **portable-name gate**, which v2 did not widen,
while the mapping is consulted inside the **occurrence-identity ladder**, above
that gate. Hub Core's `tests/client_caldav_event_timezone_test.php` pins this
both ways — the same Windows names that mint identity are refused for
authoring — so a future change that merges the two helpers fails there.

---

## 8. Out of scope for v2

Everything v1 §12 lists remains out of scope, and is not re-litigated here:
`RDATE`; `RECURRENCE-ID;RANGE=THISANDFUTURE`; `SEQUENCE` tie-breaking;
`BYSETPOS`/`BYMONTHDAY`/`BYMONTH`/`WKST`/ordinal `BYDAY`; `DURATION` as a
`DTEND` substitute; `STATUS:CANCELLED` on a master or one-off; duplicate-UID
masters; the spring-forward gap hour and the ambiguous fall-back hour; and the
`VALUE=DATE` disagreement case.

Added to that list by v2:

- **Windows timezone IDs outside the pinned artifact.** The map's reach is
  exactly the map's contents. Adding an entry is a new artifact version and a
  new contract version.
- **Territory-specific CLDR mappings.** Only `001` is used. A source that means
  a territory-specific zone must say so with an IANA name.
- **Embedded `VTIMEZONE` as a cross-client rung** (§2.2).
- **Client timezone-database equivalence beyond the supported horizon.** See §9.

---

## 9. Client timezone-database equivalence

The mapping answers *which IANA zone*. Producing the same **instant** from that
zone is then the client's timezone database's job, and the three clients do not
ship the same database. This is a real conformance obligation, not a footnote:
a client that maps correctly and then resolves the zone with stale rules mints
a wrong identity rather than failing closed.

Each client MUST therefore prove, mechanically, that every one of the 139
mapped destinations resolves to the same instant its peers resolve it to, over
the contract's supported horizon. Where a client's pinned database cannot do
that, the correct response is to fix the database — **not** to drop the mapping
entry, and **not** to weaken this contract.

CaleeMobile's matrix, its two known gaps and the prerequisite tracked to close
them are recorded in that repository at
`docs/architecture/OCCURRENCE_IDENTITY_TIMEZONE_MATRIX.md`.

---

## 10. Fixture structure

Identical to v1 §11, plus these top-level keys:

| Key | Purpose |
|---|---|
| `supersedes` | the version this one supersedes, and its frozen fixture SHA-256 |
| `semanticDelta` | the widening, and the explicit list of what is unchanged |
| `v1Compatibility` | the migration invariant, the migration statement, and the test each client runs |
| `mappingArtifact` | the pinned Windows map: version, SHA-256, entry count, CLDR and tzdb provenance |

`resolutionOrder` gains `property_tzid_windows_mapping`; `timezoneRule` gains a
`windowsMapping` block. Both are declared rather than inferred, as in v1.

---

## 11. Cross-repository parity

#558 B2b stays **open** until all three adapters pass.

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

Each repository commits the fixture and the mapping artifact locally, and CI
**must not** fetch either over the network.

---

## 12. Versioning

v1 §13 applies to v2 unchanged, with v2 in v1's place:

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