MIGRATION_PLAN — lock-integration-service
Bundle: SERVICE_OVERVIEW · DOMAIN_MODEL · APPLICATION_LOGIC · DATA_MODEL · SERVICE_READINESS · SERVICE_RISK_REGISTER
Cross-cutting: docs/architecture/ADR-0004, docs/09 §10 Migration paths.
This service is greenfield (no predecessor monolith), but it must support two recurring kinds of migration from day one: vendor onboarding (a new property choosing a vendor we already support) and legacy lock migration (replacing an incumbent system at an existing property). This document also covers internal schema/event evolution.
1. Internal evolution (schema + events)
1.1 Database migrations
- Driven by Drizzle ORM migrations under
services/lock-integration-service/src/infra/db/migrations/. - Forward-only; rollback by writing a new forward migration that reverses the change.
- Per SERVICE_TEMPLATE and 50-data, follow the expand → migrate → contract pattern:
- Expand: add new columns/tables nullable; deploy.
- Backfill: dual-write or batch backfill via Cloud Run Job; verify.
- Switch reads: deploy code reading new columns.
- Contract: drop old columns / deprecate after one full release cycle.
- All migrations run via
db-migrateCloud Run Job before the application traffic shift in DEPLOYMENT_TOPOLOGY §8. - RLS policies must be re-asserted after every column addition that affects access control.
1.2 Event schema versioning
- Subjects use
melmastoon.lock.<aggregate>.<verb>.v<n>per NAMING. - Adding optional fields → same
v<n>(consumers tolerate unknown ignored fields). - Removing/renaming/breaking semantics → new
v<n+1>published in parallel; producers dual-publish for one full release window; consumers migrate; old version retired. - The schema registry enforces compatibility checks on every PR (04 §schema registry).
1.3 API versioning
- REST is
/api/v1. Breaking changes go to/api/v2; both run in parallel for ≥ one release. - OpenAPI spec is the source of truth; client SDKs regenerated on every release.
2. Vendor onboarding (new vendor adapter)
When the platform adds support for a new lock vendor (e.g., Onity, Kaba):
- Spike — research vendor SDK, capability matrix, latency/cost. Output: ADR-0004 amendment.
- Adapter — implement
LockAdapterinterface (DOMAIN_MODEL §4) with full per-method coverage; declarecapabilities(). - Simulator — implement matching simulator in
packages/lock-vendor-simulators/so all higher-tier tests can run without sandbox. - Contract suite — adapter contract tests pass on simulator; opt-in green run against vendor sandbox.
- Webhook ingress — register handler under
/webhooks/v1/{vendor}; signature scheme implemented. - Secrets — add vendor-cred shape to Secret Manager schema; rotation cadence published.
- Observability — add per-vendor labels to existing metrics (no new metrics needed); add nightly synthetic check.
- Docs — update DOMAIN_MODEL §4, SECURITY_MODEL §5.1, SERVICE_RISK_REGISTER.
- Pilot — enable for a single property as
provisional(manual ops oversight) for 30 days. Promote to GA per SERVICE_READINESS §1.
No data migration required — new vendor onboarding is additive.
3. Vendor switch at an existing property (legacy → new vendor, or vendor → vendor)
This is the harder case: a property is replacing its physical lock hardware (or commercial decision to switch vendors). The cutover must avoid a window where guests can't open their door.
3.1 Phases
| Phase | Duration | Action |
|---|---|---|
| Plan | 1–2 weeks | Inventory of rooms × vendors; reservation horizon analysis; staff-shift overlap schedule; legacy-vendor revocation plan |
| Dual-vendor enable | 1 day | Add new VendorAdapter row for the new vendor (status active); update KeyKindPolicy so preferredOrder lists new vendor first, old vendor second as fallback |
| Hardware cutover (per room) | rolling | Field engineer installs new lock on the room; LockDevice.status for old marked decommissioned; new device registered as active |
| Dual-issue window | overlap with hardware cutover | For any reservation whose stay overlaps a still-not-cutover room, new credentials are issued on the legacy vendor; once room is cutover, new credentials issue on the new vendor; live credentials on the legacy vendor remain valid until checkout |
| Drain | until last legacy credential's validUntil | No new issuance against legacy vendor; webhooks still ingested for safety |
| Decommission | 1 day after drain | VendorAdapter for old vendor → disabled; vendor_credentials rotated and then destroyed; documentation update |
3.2 Tooling
pnpm lock:cli vendor-switch script automates phases 2–6:
pnpm lock:cli vendor-switch \
--property ppt_01... \
--from salto --to ttlock \
--plan ./switch-plan.json # generated
Plan file lists per-room cutover dates and a sanity check on overlapping reservations.
3.3 Saga behavior during dual-vendor
The IssueSaga consults LockDevice.vendor (after the room cutover) rather than KeyKindPolicy.vendor directly to avoid mis-issuing on the wrong vendor mid-cutover. The use case IssueKeyCredentialUseCase selects vendor as:
vendor = property.lockDevice(roomId).vendor // physical truth wins
3.4 Risk
R-LOCK-23 in SERVICE_RISK_REGISTER. Pilot-tested on one room before any rolling cutover.
4. Legacy lock migration (no prior platform — first vendor adoption)
A property joining the platform with existing physical locks but no platform integration yet.
4.1 Two cases
- Vendor cloud already in use (e.g., Salto KS already operational at the property): import existing locks via
POST /api/v1/lock-devices/bulk-import?vendor=salto&adapterId=...which calls vendorlistDevices()and registers each as aLockDevice. Existing vendor credentials at the vendor are not ingested — guest credentials going forward are managed by the platform; legacy credentials remain managed by the vendor portal until they expire naturally. - No vendor cloud / standalone locks (Generic Wiegand): pair the Electron desktop with the encoder; for each room, run the desktop "register lock" flow which creates a
LockDevicerow withvendor='generic-wiegand'and a syntheticvendor_device_ref.
4.2 Backfill of existing reservations
If the property has live reservations imported from a PMS migration, the platform's reservation-service migration emits reservation.imported.v1 events. The lock-integration-service does not auto-issue keys for imported reservations whose check-in is in the past — operators may explicitly call POST /api/v1/key-credentials per imported reservation if needed.
5. Data migration (intra-platform reshape)
If a future schema change requires data reshape (e.g., splitting key_credentials.scope.areas[] into a child table):
- Add new table; deploy.
- Backfill via Cloud Run Job batched by tenant; emit
lock.key_credential.scope_migrated.v1(operational event, not regulated). - Switch reads on next deploy.
- Contract column after one release.
Backfill jobs are idempotent and resumable from a checkpoint table migration_checkpoints(name, last_id, updated_at).
6. Rollback strategy
- Code: Cloud Run keeps last 10 revisions;
gcloud run services update-traffic --to-revisions <prev>=100rolls back. - DB: forward-only; rollback = forward migration. Never
DROP COLUMNimmediately. - Events: dual-publish window means consumers can downgrade by stopping consumption of
v<n+1>; producers continue publishing both. - Vendor adapter:
vendor_adapters.status='disabled'halts an adapter at runtime without a deploy.