# Facts-Based Access: KISS system memory

> **Audience: an LLM.** Deep, technical companion to the human "Who Gets In and Why"
> playbook. Paste it into any assistant to give it a complete, accurate model of how the
> KISS platform decides tenant access: the units-table fact schema, the evaluation
> pipeline, the persisted state model, and every path facts take into the system.
>
> - **Surface:** kiss-api (Laravel), the access-control platform behind the KISS tenant
>   and manager apps. Internal rule brand: ONELock.
> - **Version:** v1.1, 2026-07-02. v1.0 consolidated and superseded the JSX reference docs
>   "Access Logic Stack v2.1" and "Units Table Schema v3.1" (Linear KEEP-561); v1.1 adds
>   the V2 partner push mechanism and the apply-line model. The API contract itself is
>   NOT here; it lives in `kiss-api-docs` (docs.keepitsimplestorage.com).
> - **Last reviewed:** 2026-07-02
> - **Owning systems:** kiss-api `UnitAccessEvaluator`, `FacilitySyncService`,
>   `FactsApplyService`, per-PMS `Sync{Pms}FactsJob` + `{Pms}TenantFactsRefresher`,
>   PMS webhook controllers/handlers, `ReEvaluateUnitAccessController`; tables `units`,
>   `unit_overrides`, `pms_sync_runs`, `pms_api_calls`.
> - **Human version:** `/playbooks/facts-based-access` in Hive.

---

## 0. One-paragraph model

The PMS (a facility's property management software) is the source of truth for **facts**
about a unit: occupancy, balance, move-in date, and operational flags. KISS is the source
of truth for the **rules** applied to those facts (per-location `LocationPolicy`) and for
**manager overrides**. Sync mechanisms copy facts into one normalized `units` row per
unit; a single pure engine, `UnitAccessEvaluator`, runs facts + policy + overrides
through an ordered checklist (steps 0 to 10, first match wins) and persists the outcome
to two columns, `access_state` and `access_reason`. Everything downstream (tenant key
assembly, manager unit lists, support answers, SQL filtering) reads those persisted
columns. Same schema, same engine, same states for every PMS, and for facilities with no
PMS at all.

---

## 1. The architecture stack

1. **Data sources.** Six paths into the same facts table: scheduled full sync,
   tenant-scoped refresh, webhooks, V2 partner push, manual re-evaluate, standalone
   manual entry.
   `source_type` + `source_identifier` on each unit record where its facts come from and
   prevent cross-source overwrites.
2. **Facts (`units` table).** One row per unit, normalized snapshot, source-agnostic.
   Identity + Source + Occupancy + Financial + Flags + Evaluated State + Metadata
   (schema in section 2).
3. **Policy (`LocationPolicy`).** ONELock business rules per location:
   `balance_threshold` (default 0.00), `grace_period_days` (default 0),
   `respect_pms_lockout` (default true), `respect_pms_lock_exempt` (default true),
   `blanket_delinquency` (default false), `timezone` (default UTC),
   `pms_lockout_move_in_grace_days` (default 2).
4. **Overrides.** Manager commands on specific units: exempt (force access on) or
   lockout (force access off). Stored in `unit_overrides` (tracks
   `access_state_before`/`after` and a `revoke_reason`). Always checked first.
5. **Evaluation engine.** `UnitAccessEvaluator` (pure class: facts + policy + context
   in, `AccessDecision` out). Step 0 precondition plus steps 1 to 10, first match wins.
   Same engine for every source type.
6. **Persisted state.** `access_state` + `access_reason` written to the `units` row on
   every sync/evaluation. Enables SQL filtering and sorting (manager unit list).
   Composite index on `(location_id, access_state)`. Null when step 0 skips evaluation.
7. **Hardware key assembly.** Two parallel consumers of the persisted state:
   - **7a. Unit locks:** `access_state = tenant_permitted` AND `has_unit_lock = true`
     → look up the unit's bundle → return the lock key.
   - **7b. Entry points:** at least one `tenant_permitted` unit in the zone AND
     `has_entry_point = true` → look up the zone's entry-point bundles → return gate keys.
8. **API response.** Combined payload: units with states + unit lock keys + entry point
   keys. The app caches it for offline use. A tenant may legitimately get 0 unit keys and
   1 gate key (their unit has no lock, but the zone has a gate).

---

## 2. Units table schema (by category)

Key columns: `access_state` + `access_reason` persist the evaluator's output so reads
never re-evaluate in PHP. Composite index on `(location_id, access_state)`.

### Identity (constant regardless of occupancy or source)
| Column | Type | Notes |
|---|---|---|
| `id` | integer | Internal primary key |
| `ulid` | string | Public unique identifier |
| `company_id` | integer | Which customer/operator |
| `location_id` | integer | Which facility |
| `name` | string | Display name (e.g. "B204"); synced from PMS `crm_unit_name` when integrated |
| `zone_id` | integer? | Native KISS zone grouping; not PMS data |
| `has_unit_lock` | boolean | Lock bundled directly to this unit; denormalized from bundles |
| `has_entry_point` | boolean | Unit's zone has at least one entry point with a bundled lock; denormalized from bundles + zones |

### Source (how facts get in; prevents overwrites)
| Column | Type | Notes |
|---|---|---|
| `source_type` | enum | `pull` (adapter sync), `push` (API), `standalone` (manual UI) |
| `source_identifier` | string? | Which adapter or API client (e.g. `storedge`); null for standalone |
| `crm_unit_id` | string? | PMS's unit identifier; null in standalone |
| `pms_tenant_id` | string? | PMS's tenant identifier; null in standalone |
| `ledger_id` | string? | PMS unit/tenant relationship id; used by some PMS payment endpoints |

### Facts: occupancy
| Column | Type | Notes |
|---|---|---|
| `tenant_id` | integer? | Internal tenant reference (denormalized); null when vacant |
| `occupied` | boolean | Is there a tenant? Default false |
| `move_in_date` | date? | Current tenant's move-in date |

### Facts: financial
| Column | Type | Notes |
|---|---|---|
| `balance_due` | decimal | Amount owed; positive means owes money; default 0.00 |
| `paid_through_date` | date? | Date the account is paid through; drives grace-period math |

### Facts: flags
| Column | Type | Notes |
|---|---|---|
| `pms_lockout` | boolean | PMS says deny access (overlock, lien, legal hold); default false |
| `pms_lock_exempt` | boolean | "Never lock out" flag; default false |
| `pms_auction` | boolean | Unit is in the auction process; default false |
| `pms_unrentable` | boolean | Unit cannot be rented (maintenance, damage); default false |
| `pms_status_raw` | string? | Original PMS status string; audit/debug only |

### Evaluated state (the engine's persisted output)
| Column | Type | Notes |
|---|---|---|
| `access_state` | string? | `vacant`, `tenant_permitted`, `tenant_denied`, `auction`, `unrentable`; null when not evaluable (step 0) |
| `access_reason` | string? | `active`, `pms_exempt`, `system_exempt`, `delinquent`, `pms_lockout`, `system_lockout`, `future_move_in`, `blanket_delinquency`; null unless state is `tenant_permitted` or `tenant_denied` |

### System metadata
| Column | Type | Notes |
|---|---|---|
| `last_accessed_at` | timestamp? | Last physical NFC access at the lock |
| `last_synced_at` | timestamp? | When facts were last updated from source |
| `created_at` / `updated_at` | timestamp | Standard bookkeeping |

**Legacy `status`:** the old per-PMS status string was NOT renamed away; `access_state` /
`access_reason` were added alongside it. `Unit::status` is now a derived Eloquent
attribute computed from the persisted decision (so stale-status bugs cannot recur), and
the deprecated legacy columns are removed only in the cross-cutting teardown
(KEEP-563), after every PMS is at 100% facts-based.

---

## 3. The evaluation pipeline (`UnitAccessEvaluator`)

Pure engine: `UnitFacts` + `LocationPolicy` + `UnitContext` in, `?AccessDecision` out.
Steps run in order; **first match wins**. The result is persisted to `access_state` +
`access_reason`.

| Step | Check | Result |
|---|---|---|
| 0 | Neither `has_unit_lock` nor `has_entry_point` | Skip entirely; return null; both columns stay null |
| 1 | Override exists | `EXEMPT` → permitted · `system_exempt`; `LOCKOUT` → denied · `system_lockout` |
| 2 | `pms_unrentable` | `unrentable` |
| 3 | `pms_auction` | `auction` |
| 4 | not `occupied` | `vacant` |
| 4b | occupied but `tenant_identity_resolved = false` | `vacant` |
| 5 | `move_in_date` is a future calendar date | denied · `future_move_in` |
| 6 | `pms_lock_exempt` AND `policy.respect_pms_lock_exempt` | permitted · `pms_exempt` (financial checks skipped) |
| 7 | `pms_lockout` AND `policy.respect_pms_lockout` AND not within move-in lockout grace | denied · `pms_lockout` |
| 8 | `balance_due > policy.balance_threshold` AND past grace period | denied · `delinquent` |
| 9 | `policy.blanket_delinquency` AND tenant denied on another unit at this location | denied · `blanket_delinquency` |
| 10 | Everything passed | permitted · `active` |

Semantics worth preserving exactly:

- **Step 4b (identity resolution).** `tenant_identity_resolved` is a `UnitContext` flag
  (not a column) that **defaults to true**. It goes false when the PMS unit feed reports
  a customer the PMS customer roster omits (an orphaned reference): no KISS user is
  linked, so nobody could act on the unit, and claiming `tenant_permitted` would be
  wrong. Callers that never set it, and PMSs that resolve identity lazily (SiteLink's
  live fallback), are unaffected.
- **Step 5 (future move-in).** Compares **calendar dates in the facility timezone**
  (`policy.timezone`), not instants. A lease starting today grants access all day today;
  comparing timestamps would drift the cutoff by the UTC offset. Null move-in date is
  not a future move-in.
- **Step 7 exception (move-in lockout grace).** A recent past move-in within
  `policy.pms_lockout_move_in_grace_days` (default 2) forgives the PMS gate-lock flag,
  because some PMSs (SiteLink especially) leave a new unit's gate locked until the office
  finishes provisioning. The evaluation falls through to the financial checks, so an
  independently provable delinquent is still denied. A future move-in gets no grace
  (already denied at step 5); a null move-in gets no grace; a non-positive window
  disables the grace.
- **Step 8 (delinquency math).** Past grace means
  `paid_through_date + grace_period_days < now` (end of day). Null `paid_through_date`
  means "cannot determine", treated as NOT past due, so the balance alone never denies.
- **Step 9 (blanket delinquency)** is the only step that looks beyond the current unit:
  it checks whether the same tenant is denied on any other unit at this location
  (`UnitContext.is_tenant_delinquent_elsewhere`).

---

## 4. States, reasons, permissions

**Five states** (`UnitAccessStatus` enum): `vacant`, `tenant_permitted`,
`tenant_denied`, `auction`, `unrentable`. Only `tenant_permitted` grants tenant access.
`auction` and `unrentable` are first-class states rather than denial reasons because
operators browse them as categories and **employee role permissions key on state**
(`allow_vacant`, `allow_auction`, `allow_unrentable`, etc.), so "which staff can open
this unit" changes with the state.

**Eight reason codes** (`AccessReason` enum), persisted only when the state is
`tenant_permitted` or `tenant_denied`:

| Code | State | Meaning |
|---|---|---|
| `active` | permitted | All checks passed, good standing |
| `pms_exempt` | permitted | PMS lock-exempt flag; financial checks skipped |
| `system_exempt` | permitted | Operator exempted via override |
| `delinquent` | denied | Past balance threshold + grace period |
| `pms_lockout` | denied | PMS lockout flag triggered denial |
| `system_lockout` | denied | Operator locked out via override |
| `future_move_in` | denied | Lease signed, move-in date not yet arrived |
| `blanket_delinquency` | denied | Tenant delinquent on another unit at this location |

**Support heuristic:** `pms_`-prefixed reasons are resolved in the PMS; `system_`-prefixed
reasons are resolved in KISS (override/config); unprefixed reasons are computed from
synced financial/date facts.

**Permitted reasons are live, not target-state** (verified 2026-07-02 against
`AccessDecision`, which requires a reason whenever the state is `tenant_permitted` or
`tenant_denied`): permitted units carry `active`, `pms_exempt`, or `system_exempt` in
persisted rows and API responses alike. The KEEP-561 change inventory's
"target-state, not current" label predates this and is superseded. Note the surface
naming split: partner endpoints (V2 `UnitResource`, the `GET /access` bundle) emit
`access_state`/`access_reason`; the staff surface (`ManagerUnitResource`) emits
`state`/`reason` from the persisted columns.

---

## 5. How facts arrive (six mechanisms)

All six feed the same facts table and the same pipeline; every write path re-runs the
evaluator and persists `access_state` + `access_reason`.

1. **Scheduled full sync (pull).** One `Sync{Pms}FactsJob` per PMS per location owns the
   whole pass: authenticate once, fetch the full rent-roll, map to `TenantSnapshot` +
   `UnitSnapshot` DTOs, apply atomically via `FacilitySyncService`, stamp
   `last_synced_at`. Cadence: every 15 minutes for Cubby, RSS, Hummingbird, SSM (and
   SiteLink's gold-standard job, config-gated). **Storedge is the exception:** nightly
   reconciliation plus webhooks, no 15-minute loop. SiteLink additionally runs an
   interleaved tenant delta poll roughly twice per 15-minute window (the schedule marks
   are deliberate; see `routes/console.php` before touching them). The scheduled sweep is
   also the reconciliation layer: it catches whatever webhooks and tenant-scoped
   refreshes missed. Long-term target (per the migration initiative): event-driven
   mechanisms plus a nightly safety net replace the 15-minute cron as the primary
   mechanism.
2. **Tenant-scoped sync (on-demand, non-blocking).** The tenant app's sync flow
   (`GET /access` → `POST /sync` → `GET /access`): the read returns the last-known
   persisted state immediately; a background per-PMS `{Pms}TenantFactsRefresher` pulls
   that tenant's facts, updates only their unit rows, re-evaluates, persists. The next
   request reflects the update. Client-orchestrated (cold launch, cache expiry,
   pull-to-refresh), never a blocking middleware on reads.
3. **Webhooks (push events).** Supported by Stora, Hummingbird, Cubby, Storedge. Events
   (unit updated, tenancy created, move-in, overlock applied/removed) arrive at the PMS
   webhook controllers, are processed via `ProcessPmsWebhookJob` (queued, retryable),
   and handlers (`HandleOverlockWebhook`, `HandleUnitUpdatedWebhook`,
   `HandleTenancyCreatedWebhook`, `HandleMoveInWebhook`) map events to fact updates
   applied through `FactsApplyService` (single-unit path), then re-evaluate.
4. **V2 partner push (API).** `source_type = 'push'`. An external system (a PMS or any
   partner-built integration) writes facts directly through the V2 API with a
   company-scoped token: bulk `PATCH /units` (matched on `crm_unit_id`),
   `PATCH /units/{ulid}`, and the tenancy endpoints
   (`PUT`/`DELETE /units/{ulid}/tenancy`). Writes apply through `FactsApplyService`
   with push authorship, re-evaluate, and persist like every other path. Ownership
   rules: `UnitPushGuard` rejects a push write to a pull-owned unit (409); a push write
   to a `standalone` unit adopts it (re-stamps `source_type = 'push'`); pull syncs skip
   push-owned rows. Live examples: the email-scraper integration, the Extra Space
   pilot, CC Storage (onboarding).
5. **Manual re-evaluate.** `ReEvaluateUnitAccessController` lets a manager force a
   re-run of the evaluator on current facts + policy from the dashboard. It does NOT
   pull from the PMS; it recomputes only. Useful after manual changes or for debugging.
6. **Standalone (no PMS).** `source_type = 'standalone'`. Operators maintain facts via
   the manager UI; the same pipeline evaluates and persists identically. Actions map to
   writes: add unit (identity fields), assign tenant (`occupied`, `tenant_id`,
   `move_in_date`), remove tenant, set/clear auction (`pms_auction`), set/clear
   unrentable (`pms_unrentable`), deny/restore access (override create/remove).

**Sync observability:** `pms_sync_runs` (one row per job: location, type, trigger,
status, timing; expanded for the sync dashboard; V2 push writes record a run with
mechanism `V2Push`) and `pms_api_calls` (one row per PMS API call: endpoint, params,
response, duration, errors). `locations.last_unit_sync_at` is the honest per-facility
heartbeat.

### The apply line (above, at, below)

The write side in one rule (framed 2026-07-02):

- **Below the line: connectors, swappable.** The six mechanisms above are adapters.
  Each one's only job is to produce normalized unit facts plus authorship (who is
  writing). They vary freely: SOAP pull, REST pull, webhook, V2 push, admin form.
- **At the line: one apply boundary.** A shared step builds the fact columns, runs
  `UnitAccessEvaluator`, and persists facts and `access_state` / `access_reason`
  together, stamping `source_type` / `source_identifier`. Today this boundary has two
  hand-synced implementations (`FactsApplyService` and `FacilitySyncService`);
  KEEP-678 collapses them into one shared builder.
- **Above the line: identical for every unit.** The schema, the pipeline, the
  persisted state, key assembly, and every consumer (tenant app, manager UI, locks,
  grants) behave the same regardless of which connector wrote the facts.

The payoff is connector swappability: when a facility switches management systems, the
company, users, units (ULIDs), locks, and grants all persist, and only the
below-the-line connector changes. The KISS `ulid` is the durable unit identity across
that swap; `crm_unit_id` is per-connector reference metadata that gets rebuilt. Status
of the swap story: the clean cutover flow (disconnect the old integration with one
audited action, release `source_type` ownership, successor adopts the same rows) is
KEEP-1077 and does NOT exist yet. Today a pull-owned unit rejects push writes
indefinitely, and the field workaround has been rebuilding the company, which is
exactly what this architecture is meant to end. A temporary mixed-source location
mid-cutover (some units still pull-stamped, some push-adopted) is expected and
acceptable; known hazard until KEEP-1078 lands: the pull sweep's stale-archival pass
does not exclude push-owned rows.

---

## 6. Entry points (gate/door access)

Relationship chain: **Unit → belongs to → Zone → contains → Entry Point → bundled with
→ Lock.**

Rule: a tenant gets entry-point access when they have **at least one
`tenant_permitted` unit in that zone** and the zone has an entry point with a bundled
lock.

Key insight: a tenant can rent a unit with **no lock on it** and still need gate access,
so evaluation runs on every rented unit regardless of `has_unit_lock`. `has_unit_lock`
gates whether a unit lock key is returned; `has_entry_point` gates whether the unit's
evaluation feeds entry-point key assembly (and whether step 0 evaluates it at all).

Assembly flow: evaluate all the tenant's units → unit-lock path returns a key per
permitted unit that has a lock → entry-point path groups permitted units by zone and
returns each qualifying zone's entry-point keys → one combined API response (cached by
the app for offline use).

---

## 7. Worked scenarios

1. **Tenant pays, opens app immediately.** App returns last-known state instantly;
   background refresher pulls `balance_due = 0`; evaluator reaches step 10; row updated
   to `tenant_permitted` · `active`; next request returns keys.
2. **Two units, one delinquent, blanket on.** C101: balance 150, 34 days past due,
   threshold 20, grace 5 → step 8, denied · `delinquent`. B204 (clean): step 9 sees the
   C101 denial → denied · `blanket_delinquency`. Zero permitted units → no unit keys, no
   gate keys.
3. **Owes $15 under a $20 threshold.** Step 8 fails to match (15 is not > 20) → step 10,
   permitted · `active`, full keys.
4. **Lease starts tomorrow.** Step 5 → denied · `future_move_in`, regardless of balance.
5. **No lock on unit, zone has a gate.** `has_unit_lock = false`,
   `has_entry_point = true`: step 0 lets it evaluate → permitted · `active` → no unit
   key, gate key returned.
6. **Occupied but identity unresolved.** Step 4b → `vacant`; the unit appears in lists
   but nobody gets access until the tenant matches to a KISS account.
7. **Storedge overlock webhook.** Event → `ProcessPmsWebhookJob` →
   `HandleOverlockWebhook` sets `pms_lockout = true` → step 7 → denied · `pms_lockout`,
   persisted within seconds of the PMS change.
8. **Standalone operator denies access.** UI writes a lockout override → step 1 →
   denied · `system_lockout`.

---

## 8. Explicitly out of scope here

- **`access_grants`** (tenant_primary / tenant_shared / employee_shared): belongs to the
  API V2 / Access Grants work, not the facts-based migration or this doc.
- **The V2 API contract** (endpoints, auth, request/response schemas): lives in
  `kiss-api-docs` → docs.keepitsimplestorage.com. This doc is the internal
  architecture; the developer portal documents only the observable contract.
- **Per-PMS migration status and rollout flags:** tracked in Linear (Facts-Based Access
  Migration initiative), not here; the per-company Pennant flags and legacy read paths
  are being torn down (KEEP-900/901/563/564).

---

## 9. When this file is wrong, fix these

Update this `.md` and the `.html` in the **same PR** as the change:

- `app/Integrations/Shared/Services/UnitAccessEvaluator.php`: step order, grace
  windows, timezone semantics.
- `app/Integrations/Shared/Enums/Unit/UnitAccessStatus.php` + `AccessReason.php` +
  `OverrideStatus.php`: state/reason vocabulary.
- `app/Integrations/Shared/DataObjects/LocationPolicy.php`: policy knobs and defaults.
- `units` table migrations (fact columns, `access_state`/`access_reason`) and
  `unit_overrides`.
- `FacilitySyncService` / `FactsApplyService` / per-PMS `Sync{Pms}FactsJob` +
  `{Pms}TenantFactsRefresher` + webhook handlers: how facts arrive.
- The V2 unit write endpoints + `UnitPushGuard`: push ownership and adoption rules.
- `routes/console.php`: sync cadences (the SiteLink interleave comments especially).
- `kiss-api-docs`: if the public contract starts exposing more of this model, keep the
  internal/external boundary described in section 8 honest.
