Overview  ·  Design map  ·  Project plan  ·  Lead spec
Build source of truth · v1 · living doc

System design: how the whole machine fits together

The Overview (/) is what we promised. The lead-comms spec is how the comms behave. This page is how it's all built: every component, every data flow, every interface contract, and the order of the build. Any session building Foothold backend reads this first and keeps the statuses current.

0. How to use this doc

For future build sessions (Claude Code included): (1) Read this page before touching any backend piece. (2) Every engine on the Overview maps to a numbered flow below; build to the flow, not to memory. (3) The Constitution (§2) is binding; violating a rule needs Brent's explicit sign-off, not a judgment call. (4) When you build or change something: update the component status here, add a changelog line at the bottom, and update Marketing/30_Foothold/CONTEXT.md. Diagrams are Mermaid source in this file's HTML; edit them as text.
LIVE in production · BUILT exists, not serving real traffic · IN PROGRESS being worked · BLOCKED waiting on someone else · DECIDED spec locked, not started · PLANNED intended, spec open · PARKED deliberately deferred

1. The system map

Every box that exists or will exist, and what talks to what. The one architectural fact that shapes everything: Entrata only accepts calls from the VPS's static IP (5.78.139.104), and that box is outbound-only. So nothing else ever calls Entrata; everything Entrata-bound goes through an outbox the VPS pulls.

flowchart LR
  subgraph FRONT["Front doors (renters & residents)"]
    SITE["footholdboise.com
Astro · CF Pages"] ILS["Apartments.com / Zillow
via Entrata ILS syndication"] NUM["Ownership # + per-property #
Twilio SMS · calls fwd to LAG"] end subgraph EDGE["Cloudflare edge (Workers)"] WAIT["/api/waitlist"] AGENT["AI leasing agent
Claude · v1 chat, v2 Agents SDK"] ENG["Recognition + review engines
cron Workers"] KV[("KV
availability cache")] OUTBOX[("Entrata outbox
Queues, pull-consumer")] end subgraph VPS["VPS 5.78.139.104, the only IP Entrata sees"] POLL["Availability poller
12-min, serial gate"] RELAY["Entrata relay
outbox consumer, deterministic"] end subgraph SAAS["Owned SaaS"] GHL["GoHighLevel
lead store · tags · drips"] SUPA[("Supabase
agent datastore")] HW["Handwrytten
mailed cards + gift cards"] TRE["Tremendous
digital gift cards"] end ENT["ENTRATA
Liberty's system of record"] LAG["Liberty Asset Group
works natively in Entrata"] FD["FlightDeck
owner analytics"] SITE --> WAIT --> GHL SITE --> AGENT ILS -- "lead email (1 of 6)" --> AGENT ILS -- "native CoStar feed" --> ENT NUM --> AGENT AGENT --> GHL AGENT -- "reads" --> KV AGENT -- "state" --> SUPA AGENT -- "intents" --> OUTBOX ENG --> HW ENG --> TRE ENG -- "asks via Twilio" --> NUM ENG -- "log notes" --> OUTBOX POLL -- "getUnitsAvailabilityAndPricing" --> ENT POLL -- "publish" --> KV KV -- "/rentals reads" --> SITE OUTBOX -- "HTTPS pull" --> RELAY RELAY -- "sendLeads / updateLeads / sendWorkOrders" --> ENT ENT --- LAG ENT -- "financials + WOs + leases" --> FD GHL -- "funnel metrics" --> FD classDef live fill:#e2efe5,stroke:#1f6b3a,stroke-width:1.5px classDef built fill:#e6e9f3,stroke:#3a4a86,stroke-width:1.5px classDef planned fill:#fff,stroke:#7a7264,stroke-dasharray:5 4 classDef ext fill:#f7ede4,stroke:#bb6238,stroke-width:1.5px class SITE,WAIT,GHL live class POLL,RELAY built class AGENT,ENG,KV,OUTBOX,SUPA,HW,TRE,FD,NUM planned class ENT,ILS,LAG ext

Component inventory

ComponentRuns onRepo / locationStatusNotes
Public site + /rentalsCloudflare Pagesfoothold-webLIVEGated (NPIfoothold) + noindex until launch. /rentals still static; wires to KV in Phase 1.
Waitlist → GHLCF Workerfoothold-web src/pages/api/waitlist.tsLIVEUpsert + tags; ?ref=<email> referral capture. Proves the GHL write path.
Ops binderCloudflare Pagesfoothold-opsLIVEThis site. Push = deploy (~30s).
Entrata service (poller + relay)VPS 5.78.139.104/opt/foothold-entrata, systemdBUILTPoller still in mock mode. Serial gate + rate-limit capture in. Live client stubbed; config.js still maps old username/password vars, wire ENTRATA_API_KEY. The 2026-07-31 live probe ran read-only from this box through a standalone harness, not through the poller, so the box and the key are proven, the service path is not. 🚨 Two things the client must do when it is finally written, both measured 2026-08-05 and both easy to get wrong: set an explicit User-Agent (Entrata's Cloudflare edge returns 403 / error code: 1010 to a default one) and classify a non-JSON 403 as an edge block, not an auth failure (§4).
Entrata accessn/aAgreement + dev portal + API keyLIVEVERIFIED LIVE by read-only probe 2026-07-31, re-probed 2026-08-03. 28 services permissioned (22 on 07-31, 7 plumbing-only on 07-30), 22 reads verified callable (17 as of 08-03; the last five probed 2026-08-05). 20 of the 22 return rows, since getAmenities and getSpecials answer 200 with no data at all (§4.1). Org libertyassetgroup, id 19530. Exactly one property on the key: Owyhee Park, PropertyID 1311115, 52 units, 4 buildings, 4 floor plans, office hours M-F 9:00–5:00 with no weekend coverage. Aspen and Summerset are NOT on this key, so nothing can be built or validated against them. Second grant landed 2026-08-03 (Alex Costa), six new reads: getWorkOrders + getWorkOrderPickLists (maintenance, a group that was absent entirely), getCalendarAvailability (properties), getMitsLeases (leases), getMarketingPreferences + getMarketingPreferencePickList (communications, also previously absent). Still blocked 400/1403: getPropertyMedia, getExpiringLeases, getCustomers (the customers group is absent from the key entirely, not switched off per method). Still read-only per Constitution rule 2: writes are unchanged at exactly two (sendLeads, updateLeads) and Brent declined on 2026-07-31 to have them removed, so the code-side allowlist is the only thing stopping a write and permanently will be. The 08-03 grant adds no write capability, see §4.2. Third probe 2026-08-05: the five remaining 08-03 grants were exercised and all five returned HTTP 200 with real data (getWorkOrderPickLists, getMitsLeases, getCalendarAvailability, getMarketingPreferences, getMarketingPreferencePickList). All reads, issued serially from the VPS, no writes attempted. Nothing on this key is permissioned-but-unknown any more, and a third pass the same day closed the last corners: the oauth trio and getAppAdditionalServices were probed (all dead ends, §4.2) and the final two rate limits were captured, so §4.3 is complete. Four headlines: the tour calendar carries real configuration (and caps a query at 7 days), resident date of birth is absent from every lease method but present on getLeads for 65% of current residents (measured by full historical sweep 2026-08-05, §8), per-resident consent state is readable per channel, which gives the comms engines an opt-out gate, and, from the second round the same day, getLeases carries named per-pet records on 30 of the 52 leases, which unblocks the pet-gift engine (§8). Also from that round: never ingest an SSN. getMitsLeases defines SocialSecurity in its schema and it is on our key; Liberty's data leaves it empty, and it gets stripped at the transport boundary regardless (new Constitution rule 12). Detail in §4 and reference/.
KV availability cacheCloudflare KVnamespace TBDPLANNEDCF token is Pages-scope only; namespace creation needs dashboard.
Entrata outboxCF Queues (recommended)TBDPLANNEDPull-consumer over HTTPS keeps the VPS outbound-only. Decide transport at build (§13).
AI leasing agentCF Worker → Agents SDKspec: strategy/10-ai-agents-spec.mdDECIDEDv1 chat, v2 multi-channel. Tools: check_availability, capture_lead, book_tour, start_application. Daily Opus QA cron.
Agent datastoreSupabasenot provisionedDECIDEDLead-comms §8: tiered freshness. Conversation state, resident facts, recognition ledger.
TelephonyTwilio directnot provisionedDECIDEDBrand # + per-property 208 #s, A2P 10DLC, STOP handling. GHL stays for bulk drips only.
Recognition engineCF cron Workern/aDECIDEDHandwrytten (mail + gift) / Tremendous (digital). Touch map locked in lead-comms §4. 2026-08-13: budget ~$75/door/year (#12); Welcome Profile goes out as a mailed Handwrytten packet, Thanksgiving card is the no-DOB fallback (#6) (§8).
Review / social-proof engineCF cron Workern/aDECIDEDEvent-triggered, never sentiment-gated. Smart links Google + Apartments.com. 2026-08-13 (#17): Google + Apartments.com only, permanently; Entrata's testimonial object is skipped (§9).
Liberty weekly digestCF cron Workern/aDECIDEDNew surface, decided 2026-08-13 (DECISIONS.md #1+#5). Brent's principle: live inside Liberty's ecosystem, never make them open another system. Liberty's two surfaces are Entrata itself (every Foothold AI conversation logged onto the lead's own record) plus this five-line weekly email: touches made, leads worked, new tenants missing a DOB, anything needing their action. No standalone Liberty-facing Foothold page. Resident-side touches surface here until sendLeaseActivities is granted (#19, tabled).
Unit media packagesn/aLiberty marketing teamIN PROGRESSPhotography + 2D floor plans requested of Liberty 2026-07-20 (3D later, on turns). No API path: getPropertyMedia is blocked (1403), so unit photos stay a human delivery. Floorplan JPGs are API-readable via getFloorPlans, CDN-hosted, usable on the site directly (pin the URLs, a PM edit can rotate them).
Lead magnet + funnel ladderGHL workflowslead-magnets/ (Drive)IN PROGRESSGuide outlined, not drafted. Tags: foothold-guide-rent, foothold-sms-optin.
FlightDeck wiringCloudflareflightdeck-crePLANNEDPulls Entrata financials + engine metrics per property. Phase 5.
AI voicen/an/aPARKEDv1 = all calls forward to LAG. Same brain points at voice later (sequencing call, not a liability one).

2. The constitution (binding rules, every build)

These came out of research, the Entrata agreement, and the EliseAI post-mortem. They are not preferences. A build that violates one is wrong even if it works.

  1. Only the VPS talks to Entrata. Its static IP is what Entrata allowlists. The box stays outbound-only (no inbound ports); Workers reach it only via the pull-based outbox. One serial gate per the whole account: Entrata forbids concurrent calls.
  2. READ-ONLY against any live production property until Brent lifts it for that property, in writing. No write calls before that, ever. Any occupied building with real residents, real prospects, and real staff is read-only by default, whoever owns or manages it. 📌 DECISION, Brent, 2026-08-05: this rule WILL be lifted on NPI-OWNED properties when Foothold goes live on them (Aspen, then Summerset). That is a planned change, not a present one, and it does not loosen anything today. Three things must all be true before a single write is issued anywhere: (1) NPI owns the property, (2) Foothold is live on it, and (3) Brent has signed off in writing naming that property and those methods. Until all three hold, the answer is no. Owyhee Park is NOT covered by that lift and is not expected to be. It is Liberty's building, not NPI's, and a property NPI does not own stays read-only regardless of what we are permissioned for. Everything verified so far was measured there, so do not read a probe result as a licence to write. The allowlist does not go away when the rule lifts, it becomes per-property. It stays deny-by-default and fail-safe at the single request choke point; what changes is that a named property can carry a named set of permitted write methods, rather than the list being empty everywhere forever. A missing, unparseable, or unrecognised property configuration still means read-only. Build it that way from the first line of client code, because retrofitting a per-property gate onto a global bar is exactly where the accident happens. No sendLeads, updateLeads, sendWorkOrders, updateWorkOrders, sendMitsLeads, or any other send/update/delete method. Having the permission is not authorization to use it: Liberty enabled sendLeads and updateLeads on 2026-07-31, so the vendor-side guard we assumed does not exist. The code-side guard is the only one. (sendWorkOrders and updateWorkOrders are still not on the key as of 2026-08-03, and that is now a stronger fact than it was: the maintenance group is present on the key since the 08-03 grant, and Liberty enabled exactly the two reads from it, getWorkOrders and getWorkOrderPickLists, and neither write. So their absence is a deliberate per-method state, not a side effect of a missing group. They are banned here regardless, and a permission change on Liberty's side could add them at any time without telling us.) And the two live writes are staying. Brent declined on 2026-07-31 to ask Liberty to uncheck sendLeads and updateLeads, accepting the risk deliberately, on the reasoning that the permission being enabled is not what causes a write, our code calling a write method is. So assume write capability is permanently present on this key, treat it as a standing condition rather than a gap someone will close, and understand that the code-side allowlist is the only thing that ever prevents a write, and always will be. That makes this rule and that allowlist more load-bearing, not less: nobody on the vendor side is going to catch our mistake for us. Enforce as a deny-by-default read-method allowlist at the single function that issues requests, not per call site, and fail safe (flag missing or unparseable → read-only). Verify a permission landed with getAccessibleServices or a get* call, never by issuing a real write to see if it works. A test guest card puts a leasing agent on the phone chasing a prospect who doesn't exist; a test work order sends a real tech to a real unit. Lifting this for a property needs Brent's explicit written sign-off naming the property and the methods.
  3. The AI never calls the Entrata API. The access application states no AI touches Entrata; integration code is deterministic. The agent emits structured intents; the relay validates and executes them. Gate: before the agent answers prospects from Entrata-derived data (availability), do the Entrata AI-disclosure review. That crosses into their AI/ML rules.
  4. Every 1:1 touch logs to Entrata (updateLeads note): recognition sends, escalation replies, agent messages, both directions. Liberty sees everything. Only bulk marketing (drips/broadcasts) doesn't log per-contact. Precedence: rule 2 wins on conflict. updateLeads is a write, so while a property is read-only this logging obligation is deferred, not active: touches log locally to the outbox and stay there, and the relay replays them into Entrata only once writes are signed off for that property. Deferred logging is not permission to skip logging; the local record has to be complete enough to replay. 📌 Refined 2026-08-13 (DECISIONS.md #1/#5/#19): the note lands as a real lead event on the lead's own record (updateLeads events array, numeric typeId resolved from getLeadPickLists), so a Liberty agent pulling up the guest card sees the history in place, and Liberty's second surface is a five-line weekly digest email, never a separate Foothold page. Resident-level touches have no appender on the key (sendLeaseActivities is not granted and the ask is tabled), so they log to Foothold's own store and surface in the weekly digest until it exists.
  5. Minimum-info gate: no guest card without first name + one contact method. Until then the conversation lives in our stack only. Affirmed 2026-08-13 (DECISIONS.md #3): whether a prospect "expressed interest" is the AI's judgment call, but this gate is hard either way: (phone OR email) AND at least a first name. No guest card without a way to follow up.
  6. Never state availability or price without a fresh read. Stale cache or API down → "let me confirm that and get right back to you." This is the EliseAI-proof and the reason Liberty fired the last vendor.
  7. Review asks trigger on events and go to everyone at that event. Sentiment-gating is prohibited (FTC, Google, Apartments.com). Never re-ask within ~12 months of a public review.
  8. Fair housing: no steering, same facts to everyone, no demographic characterizations, no promises. The move-in-timing priority rule stays written, neutral, and mechanical. HUD treats AI answers as FHA-subject.
  9. All Entrata dates/times are Mountain Time. Parse as explicit MT; the VPS runs Pacific, and a lazy parse is a one-day error on dates near midnight.
  10. Handoff line: ownership owns through tour-booked or application-submitted (plus one post-tour feedback text); LAG owns everything after. The agent owns the conversation channel; LAG works from the logged notes: two people never reply to the same renter.
  11. Secrets live in /opt/foothold-entrata/.env (600) and Worker secrets. Never in repos, never echoed in logs or error paths, never in this binder.
  12. Minimize PII at the transport boundary, and never ingest a Social Security number. Binding data rule, set by Brent 2026-08-05: NPI has no use for an SSN and does not want the liability. One is never ingested, never persisted, never logged, and never passed to an AI model. The same single function that enforces the read-method allowlist (rule 2) strips SocialSecurity, SSN and taxId out of every response on read, before anything is written to a store, written to disk, emitted to a log, or handed to a model. This is a scrub at the transport layer, not cleanup downstream, and it fails safe: a response that cannot be parsed well enough to scrub is dropped, not stored. It applies whether or not the field is populated. getMitsLeases r1 defines SocialSecurity in its response schema and it is on our key; Liberty leaves it empty today (§4.1), which is luck, not a control. (The same field appears in getMitsCollections and getVendors response schemas, neither of which is permissioned.) The rule governs document ingestion too: Aspen's 44 executed leases each carry an SSN field (§4.2), so a lease-document or DD pipeline scrubs on the way in on the same terms. Its sibling, already binding: birth dates store month and day only, year dropped at ingest (§3, §8). Entrata's Secure PII Delivery requirements sit under both: never log raw response bodies from getLeads, getMitsLeases or getGlTransactions, and keep no resident data in this repo.
  13. Watch the scoreboard, not the plays. Promoted from design intent to Constitution rule by Brent, 2026-08-13 (DECISIONS.md #14). Aggregate KPIs (rating trend, review velocity, renewals, maintenance responsiveness) are measured and reported; individual plays, a single ticket, a single agent response, are never alerted on or chased with Liberty. Two clarifications are part of the decided rule, not exceptions to it: measuring an aggregate is not policing, so the maintenance-responsiveness metric (§10) is squarely inside this rule; and a per-ticket outbound message to a RESIDENT (the post-service follow-up, §9) is not policing a vendor: nothing per-ticket is sent to, demanded of, or logged against Liberty.

3. Data topology: four stores, four jobs

Each store owns one kind of truth. When two docs disagree about "where data lives," this table governs.

StoreOwns (the truth for)Written byRead byFreshness
EntrataOperational record: units, availability, guest cards, leases, residents, GL actuals. Work orders are readable as of 2026-08-03 (getWorkOrders granted and verified returning production requests, §4.1): read only, creating one is still a write and still barred; amenities and specials are not in the PMS at all (both return zero).VPS relay only (our side); LAG nativelyVPS pollerSource of truth
Cloudflare KVAvailability cache the site + agent readVPS poller/rentals, agent check_availability≤12 min + live re-check at commit
GoHighLevelMarketing: leads, tags, drips, referral attributionWaitlist Worker, agent capture_lead, GHL workflowsAgent, FlightDeck funnel metricsReal-time
SupabaseAgent state: conversations, resident facts (pets, birthdays, tenure), recognition ledger. Birthdays store month and day only, never the year (§8): month/day is all the recognition engine needs and dropping the year materially cuts PII exposure. Pets now arrive from Entrata rather than a form: getLeases r1 carries per-pet records with name, joined to the resident on customerId, and isAssistanceAnimal is stored and honored (§8). Never store an SSN (rule 12): SocialSecurity / SSN / taxId are stripped at the transport boundary, so they never reach this store, a log, or a model.Agent, engines, daily VPS pullAgent, enginesTiered (lead-comms §8)
Reconciliations this table settles: (1) "GHL is the lead store, no second DB" (CONTEXT.md) was the waitlist-era answer and still holds for leads; Supabase is the agent's datastore per lead-comms §8: different truths, both stand. (2) The authoritative conversation transcript is ours (agent datastore); Entrata gets the complete mirror. (3) Applications are ProspectPortal links, not API writes: the Overview's "files the application" means "sends the apply link and follows up," per the locked edge case.

Contracts to pin at build time

InterfaceProposed shape (finalize in Phase 1–2)
KV keysavail:<propertyId> → {updatedAt, propertyName, units:[{unitId, unitNumber, floorPlan, beds, baths, sqft, rent, availableDate, status}]} + avail:index (property list + last-sync). Mock poller already writes this shape to cache/availability.json.
Outbox message{intent: "logNote"|"sendLead"|"sendWorkOrder", propertyId, payload, dedupeKey, createdAt}. Relay validates against a whitelist of intents: the deterministic boundary of rule 2.
VPS env (names only)MODE, ENTRATA_API_KEY, ENTRATA_DOMAIN, ENTRATA_PROPERTY_IDS, POLL_INTERVAL_MIN, CACHE_DIR, CF_ACCOUNT_ID, CF_KV_NAMESPACE_ID, CF_API_TOKEN, GHL_API_KEY, GHL_LOCATION_ID. (ENTRATA_USERNAME/PASSWORD are legacy from the pre-gateway guess; remove when wiring the key.) ENTRATA_DOMAIN = libertyassetgroup; ENTRATA_PROPERTY_IDS = 1311115 today. Keep it a list and never hardcode the ID anywhere (config defaults, KV key construction, fixtures): adding a property is a Liberty permission change plus a getProperties re-read, not a code change.
GHL tagsfoothold-guide-rent (lead magnet), foothold-sms-optin (Insider list), waitlist source tags per foothold-web src/lib/ghl.ts (code is SoT).

4. The Entrata protocol (applies to every flow below)

Established by live probes (2026-07-28 auth shape, 2026-07-31 full read probe, 2026-08-03 permission re-probe, 2026-08-05 method probe of the five remaining grants) + the captured OpenAPI spec. 18 groups, 128 methods, 302 schemas. Vendored in this repo: reference/entrata-api-context.md (narrative + real data shapes), reference/entrata-method-index.md (per-method params / read vs WRITE / permissioned, plus the permissioned-service roster. Both were re-captured 2026-08-05 and carry the current 28-service roster), reference/entrata-openapi-2026-07-28.yaml (full vendor spec), reference/entrata-webhooks-2026-03-04.md (verbatim vendor webhook doc, see §4.5). Also on the VPS at /opt/foothold-entrata/entrata-openapi.yaml and in Drive.

FactDetail
Base URLhttps://apis.entrata.com/ext/orgs/<subdomain>/v1/<group>. Subdomain is the client's: libertyassetgroup, CONFIRMED live 2026-07-30 (org id 19530; the property slug owyheeparkapartments is not the org segment). Group is the URL, method goes in the body. POST only.
AuthHeader X-Api-Key + Content-Type: application/json. Body envelope: {"auth":{"type":"apikey"},"requestId":"n","method":{"name":"...","version":"r1","params":{...}}}.
🚨 User-Agent (build trap)Entrata's edge is Cloudflare and it 403s a default User-Agent. Measured 2026-08-05: the same request that succeeded through curl came back HTTP 403 with a plain-text body reading error code: 1010 when issued by Python urllib with its stock UA: a Cloudflare browser-signature ban, not an Entrata JSON error. Set an explicit User-Agent on every request (name it after the service), and treat a 403 whose body is not JSON as an edge block, never as an auth or permission failure. See the callout below: this one bites the client that has not been written yet.
Method → group map (the gotcha)getUnitsAvailabilityAndPricing, getPropertyUnits, getUnitTypes, getAmenities, getSpecials → propertyunits. getProperties, getFloorPlans, getWebsites, getPropertyMedia, getCalendarAvailability → properties. sendLeads, updateLeads, getLeads, getLeadPickLists → leads. getWorkOrders, sendWorkOrders, updateWorkOrders, getWorkOrderPickLists → maintenance. getLeases, getLeaseDetails, getExpiringLeases, getMitsLeases → leases. getMarketingPreferences, getMarketingPreferencePickList → communications. getCustomers → customers. getGlTransactions, getBudgets, getBudgetActuals, getBankAccounts → financial. Full 128-method map in reference/entrata-method-index.md. (Several of the methods named here are not permissioned for us, see §4.2.)
DiscoverygetAccessibleClients on /ext/orgs/rsync/v1/appinfo (literal rsync) returns every org the key can reach: {subdomain, id, name}. Confirms subdomain + scope. getAccessibleServices (on /v1/appinfo under the client's subdomain) is the authoritative "what am I allowed to call" probe and the verification loop after any permission change; it is also the only safe way to check a write permission, since rule 2 bars testing one by issuing a write. Run 2026-08-03: 28 services permissioned (22 on 07-31, 7 on 07-30 and all plumbing). The 08-03 delta is six reads and zero writes. It is the proof that Liberty's second grant landed exactly as asked and widened nothing else. getProperties returned the single property and populates ENTRATA_PROPERTY_IDS (1311115). Health: getStatus → status works with no permissions at all, but see the rate-limit trap below: it is capped at 6/minute and cannot be a heartbeat. Use the availability poll itself, or getProperties, as the liveness signal.
Rate limitsPer method, not per key, returned in every response's x-ratelimit-* headers, and they vary by two orders of magnitude. Measured table below. No concurrent calls, ever (serial gate). Separate burst governor: x-spike-arrest-limit: 200 over a 100ms slice, which the serial gate already satisfies. Poll cadence 10–15 min per plan; ~8–12K calls/mo declared in the application. Complete as of 2026-08-05: every method the build uses now has measured headers, the last two being getWorkOrders and getWorkOrderPickLists (a handful of daily buckets are simply never returned, §4.3).
Query windows are per method⚠️ Never generalize one method's window cap to another. getCalendarAvailability caps at 7 days (8 or more returns 400/308). getLeads accepts 730 days in a single call, measured 2026-08-05 by sweeping 2021-01-01 to 2026-08-05, the entire 5.5-year history, in three calls. So a historical lead backfill is cheap and a calendar read has to be paged, and the two facts say nothing about each other. Measure the cap on any new method rather than inheriting a number from a neighbour.
Maintenance windowsScheduled: Tue late night → Wed early morning MST, weekly. Unscheduled: late evening/early morning Sun/Mon/Wed/Thu. The poller treats downtime as normal: back off, serve last-good cache, no alarms unless stale > 2h.
TimezoneEverything MT unless stated. Parse explicit; display MT (Boise is MT).
Error vocabulary400/1415 missing Content-Type · 400/1404 method not found (almost always the wrong group in the path, not a bad method name) · 401/405 client exists, key not permissioned · 403/115 key valid, webservice not enabled · 400/1403 "the method is not allowed to the requested 'orgs'" = that web service is not checked on our API user (Entrata publishes no error-code table; this mapping is inferred from behaviour and corroborated by getAccessibleServices) · 400/107 method requires OAuth, apikey auth not supported · 400/308 requested window is wider than the method allows (measured 2026-08-05 on getCalendarAvailability: 8 days or more fails, 7 is the ceiling) · 400/310 a required param is missing (measured 2026-08-05 on getLeads r1 called without propertyId + fromDate + toDate) · 404/111 no such client · 405/1405 wrong verb · 🚨 403 with a NON-JSON body reading error code: 1010 = Cloudflare edge block on the User-Agent, not Entrata at all (measured 2026-08-05 on default Python urllib; same request via curl returned 200) · 403/1403 "You're not allowed to access this resource" = an OAuth-only method called with apikey auth (measured 2026-08-05 on getAccessToken, getJwtAccessToken, getUserInfo). Note it is 1403, not 107, and it arrives as a real 403 rather than the 400 that carries the permission fault. 308 and 310 are both parameter faults, not permission faults: do not read either as a lost service. And do not read a 403 as a permission fault until you have checked whether the body is JSON: the edge block and the OAuth block look alike on the status line and are nothing alike underneath.
Method versionsversion is not always r1. Several methods only accept r2, and getSpecials is r4. Guessing fails. The authoritative per-method version list is reference/entrata-method-index.md.
🚨 The trap waiting for whoever writes the real client: Entrata sits behind Cloudflare, and Cloudflare 403s a default User-Agent. Measured 2026-08-05. A request issued by Python urllib with its stock UA came back HTTP 403 with a plain-text body reading error code: 1010, Cloudflare's browser-signature ban. The identical request through curl returned 200. Nothing about the key, the permissions, or the payload was different. Two requirements on the Node client, which does not exist yet and so has never hit this: (1) set an explicit User-Agent header on every request, named after the service (the poller, the relay, and any probe harness all count); (2) classify a 403 by its body, not its status: a non-JSON 403 is an edge block, not an auth or permission failure. Get (2) wrong and the symptom reads exactly like a revoked key: the natural reactions are to re-issue with a "fresh" key, to re-run getAccessibleServices, or to escalate to Liberty, and all three chase a fault that is not there. Entrata's own permission fault is 400/1403 with a JSON body; the OAuth-only fault is 403/1403 with a JSON body; the edge block is a 403 with no JSON at all. Put the classification in the same single function that enforces the read-method allowlist (rule 2), so there is exactly one place that decides what a failure means. The same choke point already carries the property-ID allowlist (§4.4) and the PII scrub (rule 12).
Webhooks: answered 2026-07-31, and the answer is "keep polling." This was an open architecture question and it is now closed on the research half. Entrata's webhook documentation is real, captured verbatim in reference/entrata-webhooks-2026-03-04.md, and it publishes exactly 5 events. None of them is availability. None is pricing. There is no documented lead-created event. So the 12-minute poll survives unchanged and is no longer provisional. What webhooks actually add is a push layer over work-order and application events, not a replacement for the poller. Re-scoped 2026-08-03: now that getWorkOrders reads, the work-order half is reachable by polling too, so the webhook buys latency and a clean trigger rather than access. Catalog, payload shape, and the provisioning path are in §4.5.

4.1 What's actually readable (verified live 2026-07-31, getWorkOrders added 2026-08-03, the last five verified 2026-08-05)

Headline only. Per-method params, versions, and read/WRITE flags for all 128 methods are in reference/entrata-method-index.md; real response shapes, sample payloads, traps, and the PII map are in reference/entrata-api-context.md. Build against those, not against the vendor spec alone.

Method (group)What it gives us
getStatus (status)Liveness. Works with zero permissions. Re-verified 2026-08-05: 200, "API service is available and running." Free in the sense that it needs nothing, but 6/min cap, so not a heartbeat.
getAccessibleServices / getAccessibleClients (appinfo)The permission truth: 28 services on the key as of 2026-08-03 (22 on 07-31); org libertyassetgroup / 19530. All 28 are now accounted for: 22 reads verified callable against production as of 2026-08-05 (20 of them returning rows; getAmenities and getSpecials return 200 with none), the rest being the two banned writes, the three OAuth-only methods, and getAppAdditionalServices. So the usable surface is 24 of 28, and the useful-data surface is 20. getAccessibleClients re-run 2026-08-05 on the literal rsync path: 200, {"subdomain":"libertyassetgroup","id":19530,"name":"Liberty Asset Group"}, one org, unchanged since 07-30. Nothing on this key is unprobed any more: every service on the roster has been called except the two writes, which are banned by rule 2 and will never be called to find out.
getProperties (properties)The one property (1311115), post months, 24 lease terms, and PropertyHours (M-F 9–5, no weekends). Read hours, never hardcode them.
getFloorPlans (properties)4 plans / 52 units, market rent by plan, CDN-hosted floorplan JPGs usable on the site. Authoritative source for square footage.
getWebsites (properties)Site configs, including a dedicated owyheeparkapartments site. Config read, not a hot path (10/min). See the scope note below.
getPropertyUnits (propertyunits)All 52 units with building, floor, sq ft, address, and rent for all 24 terms (only the 12-month term is isWebVisible). Property-level pet policy is populated ($300 deposit, $50/mo, cats + dogs); unit-level occupant/pet fields are all 0, meaning absent, not "none allowed."
getUnitsAvailabilityAndPricing (propertyunits)The poller's method. Available units only, with AvailableOn + MakeReady dates. 2 available on probe day (09/04 and 09/08 move-ins). Its SquareFeet block is broken (Avg 0, Max 0, Min 1024): use getFloorPlans.
getUnitTypes (propertyunits)6 unit types with market rent by lease term + sold-out flags. Finer grain than floorplan: display by plan, price by type.
getAmenities (propertyunits)Returns ZERO. 200 OK, no data. Permission works, the data does not exist.
getSpecials (propertyunits)Returns ZERO. Property stub only, no specials.
getLeadPickLists (leads)The ID vocabulary: 155 event types, 62 lead statuses, 6 event results, 15 named Liberty leasing agents, 17 lead sources. Pin these now, while reads are the only thing allowed, so outbox intents carry real IDs and don't need a rewrite before replay.
getLeads (leads)July 2026 = 109 leads. Source mix Zillow 87, Website 11, Unknown 6, everything else single digits. Full prospect record incl. createdOnDateTime (the speed-to-lead clock), status/statusId, and googleClickId. r1 requires propertyId + fromDate + toDate; without them it returns 400/310. ⚠️ It is also the only working source of date of birth on this key, while every lease-side method returns none. DOB is captured at the lead/application stage and never propagates to the resident record (§8). ⚠️ CORRECTION 2026-08-05 (third pass): the "122 of 122" figure this row used to carry was wrong and is withdrawn. Entrata omits null fields, so counting birthDate across the returned records only ever counted records that already had the key, a denominator that excludes exactly the records you are worried about. The full sweep is the real number: 2021-01-01 to 2026-08-05 returned 787 distinct lead-customers, 286 of them with a birthDate, which is 36%. ✅ The sweep was cheap: getLeads accepts a 730-day window, so 5.5 years came back in three calls, but that cap is this method's, not the API's (§4, query windows). ✅ And the join is proven: getLeads customerId and getLeases customer id are the same namespace, matched on 70 of 88 current residents. Resident-side coverage lands at 65% (§8). Highest-PII method on the key: names, emails, phones, and now birth dates in one response. 🐾 Also carries pets: 126 pet records on the 2026-08-05 pull (name, petType, breed, color, gender, age, weight, count, id, licenseCity), useful for prospect context, but getLeases is the source for resident pets because it is scoped to the current roll and carries isAssistanceAnimal (§8).
getLeases (leases)With no status filter you get exactly the current rent roll: 52 leases, 49 Current, 3 Notice, with scheduled charges, lease intervals, and move-in dates. Trap: filtering leaseStatusTypeIds: "2" returns 190 Cancelled leases (historical application churn), not the roll. 🐾 NEW 2026-08-05: this is the best pet source on the key. 30 of the 52 leases have pets, 34 pet records in all, and each one carries name plus petType, breed, color, gender, age, weight, moveInDate, customerId and isAssistanceAnimal. Pets join to the resident on customerId. getPetTypes is not on the key and is not needed: petType comes inline. ⚠️ isAssistanceAnimal is load-bearing, not decoration (§8). DECISION (Brent, 2026-08-05): capture the WHOLE pet record, not just the name. Persist name, petType, breed, color, gender, age, weight and moveInDate on the resident profile, on the same recognition-data job as the birthday feed since it is the same call. The payoff is Handwrytten copy: "we hope Bella is loving the new dog park" reads like a landlord who knows the resident, "your dog" reads like a mail merge. Breed, color and age are what make the copy specific; age plus moveInDate is what lets a card land on a pet's move-in anniversary. Pet attributes are not sensitive PII, so rule 12 and the month/day DOB rule do not bind them, but the assistance-animal exclusion governs everything generated from this data.
getLeaseDetails (leases)Per-lease recurring charge detail. Returns a near-empty stub on a Cancelled lease, so filter to Current first. r2 verified 2026-08-05: 200, takes propertyId only, and the result carries two keys, currencyCode and leases. Limits measured on r2 (§4.3).
getWorkOrders (maintenance)NEW 2026-08-03, granted and verified functional, not just permissioned. Read-only call (r1, propertyId 1311115) returned HTTP 200 with real production work orders: maintenanceRequestId 3435/3436, buildingName "3030", unitNumber "3030-201", status Completed, problem "Make Ready", maintenanceRequestFrom "Entrata PaaS", MDT timestamps, and parent/child structure via parentMaintenanceRequestId (so a turn is a parent request with children, not a flat list: walk the tree, don't assume one row per job). Make-ready progress is now a readable signal (§7). 🐾 It also carries petInfo on the request, which is worth surfacing to a technician being sent to a unit: a dog behind the door is a safety and access fact, not a marketing one (§10). Reading is allowed; sendWorkOrders is still both unpermissioned and banned by rule 2.
getWorkOrderPickLists (maintenance)VERIFIED 2026-08-05. The maintenance ID vocabulary, property-scoped: Locations (each carrying its unitTypes), Priorities, StatusTypes, Categories, Problems, SubMaintenanceProblems, WorkOrderTypes (5), and InspectionStatuses (5). The surprise is CompanyEmployees: 12 rows, Liberty's actual maintenance/staff roster, usable for work-order assignment and routing. This is the counterpart to getLeadPickLists: pin these IDs now so outbox intents and any status-mapping carry real values (§9, §10). Reading a pick list is a read; it authorizes nothing.
getMitsLeases (leases)VERIFIED 2026-08-05. The MITS-standard lease export: 100 Tenant records against 52 LA_Lease records, so it resolves the roommates and guarantors that getLeases flattens away. Carries Finances, GuarantorFor, Spouse, Reference (emergency contact) and Residence. It also supplies the IDType: "Customer ID" values that other methods want (§4.1, marketing preferences). ⚠️ NO birth dates in the live data despite BirthDate existing in the schema: the field is there and empty on this property, which is exactly the "permissioned is not populated" failure mode getAmenities already demonstrated. DOB comes from getLeads instead (§8). 🚫 It also defines SocialSecurity in its response schema, and this is the one SSN-bearing method that IS on our key. The live probe returned no SSN keys: Liberty leaves it empty, so there is no exposure today. Scrub it at the transport boundary regardless (rule 12): empty-in-practice is not a control. Do not use it for pets: its pet block is only Count, PetType, Weight, with no name; getLeases is the pet source (§8).
getCalendarAvailability (properties)VERIFIED 2026-08-05, and it returns real configuration, not an empty stub. ⚠️ The query window maxes out at 7 DAYS, not 60: 8 or more returns 400/308. What comes back for this property: 30-minute appointments, 120-minute minimum lead time, agent-guided tours Mon–Fri 08:00–16:00 MST with no simultaneous limit, self-guided tours all 7 days including weekends, 06:00–18:00 MST, limit 1 at a time, plus per-date availableHours. Self-guided touring is already configured in Entrata, which corroborates Alex's lock-box answer. Note the mismatch worth carrying into any promise the agent makes: tour hours (08:00–16:00) are not the documented office hours (M-F 9:00–5:00). See the tour note below and §14.
getMarketingPreferencePickList (communications)VERIFIED 2026-08-05. 24 rows. Recipient types LEAD (1) and RESIDENT (2); channels email, phone, postal_mail, sms; five availability-alert frequencies. ⚠️ Tightest hourly bucket on the key: 60/hour (10/min, 1,500/day). The tightest daily one belongs to getWorkOrderPickLists at 1,000/day, and the tightest per-minute one to getStatus at 6/min (§4.3). Cache it once and never call it per lead or per resident. Parse defensively: some rows come back without a consentType.
getMarketingPreferences (communications)VERIFIED 2026-08-05 with recipientTypeId=2 (RESIDENT). Returns optIn per consentType per channel for the requested customers. ✅ The customerIds worry is resolved: the IDs come from getMitsLeases (IDType: "Customer ID"), so the absent customers group is not required to use this. This is a real consent and opt-out gate in front of every outbound touch: recognition mail, gift texts, review asks (§8, §9). ✅ The LEAD side (recipientTypeId=1) was probed 2026-08-05 and also returns 200. ⚠️ The finding that matters for the gate: the same customer can carry BOTH a LEAD and a RESIDENT preference set. recipientType is a lens, not a partition: querying one does not tell you what the other says. So a consent check that reads only one side is not a complete check: read both for anyone who exists on both sides, and fail closed on the union, not on whichever set happened to be queried.
getGlTransactions (financial)90 GL accounts for June 2026 with unit-tagged line items. Drop-in for NPI's monthly reconciliation. PII warning: AR Payment memos carry resident names.
What this unlocks with zero writes: in-place rent vs market is computable today (sample: $1,500 base + $60 utilities against a $1,625 market 2x2), so loss-to-lease, the full rent roll, renewal exposure by month, and GL actuals are all readable now. That is the fastest path to something a partner can look at, and it depends on no agent, no Twilio, no GHL, and no write permission. Budget is the gap (see §11).
🔄 CORRECTION 2026-08-05: the tour-calendar warning on this page was wrong, and it is withdrawn. Until 08-05 this doc said getCalendarAvailability was likely meaningless and that no book_tour design should rest on it, on the reasoning that Liberty books tours manually. The probe overturns that. The calendar holds real, current, usable configuration: 30-minute appointments, a 120-minute minimum lead time, agent-guided windows Mon–Fri 08:00–16:00 MST, and self-guided touring configured for all 7 days, 06:00–18:00 MST, one at a time, which independently confirms the lock-box answer Alex gave on 08-03. Draw the line in the right place: what is manual is the booking, not the configuration. So Entrata may not reflect the appointments Liberty has actually booked, and double-booking prevention is still ours (that part of the 08-03 finding stands), but the availability windows and tour parameters are real and are safe to design against. Three things to carry into the build: the query window is capped at 7 days (400/308 beyond that), so page the calls; self-guided tours are a product Liberty already runs, not something to invent; and the tour window (08:00–16:00) is not the office window (M-F 9:00–5:00), so never derive one from the other: read both.

4.2 What's blocked

Method (group)FailureWhat the design loses
getPropertyMedia (properties)400/1403Property photo library. Floorplan images still arrive via getFloorPlans, so this is a gap, not a wall.
getExpiringLeases (leases)400/1403Convenience only. getLeases with no filter carries lease end dates, so expiries are computable. Stop specifying getExpiringLeases in flows.
getCustomers (customers)400/1403Standalone resident lookup. Resident data still arrives through getLeases and getLeads.
getAppAdditionalServices400/107Re-probed 2026-08-05, unchanged: "Auth type 'apikey' is not supported." Requires OAuth. Not a permissions problem and not on our path.
getAccessToken, getJwtAccessToken, getUserInfo (oauth)403/1403PROBED 2026-08-05, and this closes the last unprobed corner of the key. All three return HTTP 403, error 1403, "You're not allowed to access this resource." ⚠️ Note the two ways this differs from the familiar permission fault: the code is 1403 and not 107, and it arrives as a real 403 rather than the 400 that carries 1403 elsewhere. They are the OAuth plumbing for a different auth mode we do not use, they are on the roster only as roster filler, and nothing designed here needs them. Recorded so nobody re-probes them or reads the 403 as a key problem. Do not confuse it with the Cloudflare 403 (§4, error vocabulary): that one has no JSON body.
getBudgets, getBudgetActuals, getBankAccounts (financial)absent from getAccessibleServicesThe financial group is only partly enabled: GL actuals yes, budget comparison no. FlightDeck's NOI-vs-budget tile sources budget from NPI's model until Liberty enables it.
getLeaseDocuments, getLeaseDocumentsList (leases)absent from getAccessibleServicesExecuted lease documents are not reachable (confirmed 2026-08-05 while chasing date of birth). Both live in the leases group in the spec, the group is on the key, and neither method is. Not needed for anything currently designed. DOB came from getLeads instead (§8), so this is recorded, not queued as an ask. Reinforced 2026-08-05 by reading the documents we already have: all 44 Aspen executed leases were text-extracted (7.4M characters) and contain zero occurrences of "date of birth" or "birth". Each carries one SSN field and one "18 years" clause, nothing more. Lease documents record screening results, never DOB, so chasing getLeaseDocuments would not have produced a birth date even if it were granted. It would, however, produce SSNs, which rule 12 strips at ingest.
getLeadEvents (leads)absent from getAccessibleServicesNo dedicated lead-event feed. Mitigation: getLeads r1 itself accepts eventDateFrom, eventDateTo, and eventTypeIds (max 5) and returns the events inline, so lead-event deltas stay pollable on a method we do have. Nuisance, not blocker.
Everything else not on the 28-service rosterabsent from getAccessibleServices104 of the 128 spec methods are not permissioned (128 minus the 24 permissioned spec methods; the other 4 roster entries are the oauth trio and getAppAdditionalServices, which the spec does not document). Nine whole groups are still absent, not individual methods: applications, arcodes, arpayments, artransactions, customers, leasingcenter, pricing, queue, vendors. (maintenance and communications came off this list on 2026-08-03 and are now on the key, read-only.) The enabled groups are enabled to exactly the methods in §4.1 and nothing more.
Say the block at the right altitude: customers is still missing as a whole group. maintenance is no longer. The 2026-07-31 roster capture established that neither group had a single service on the key. The 2026-08-03 grant closed the maintenance half, and it closed communications too: Liberty granted a group they had never granted, so the group-grant ask is a conversation that can succeed, not a wall, and that is worth remembering the next time an ask looks too big to make. getCustomers is unchanged: no service in the customers group is on the key at all, so its 1403 is consistent with absence, not a separate fact, and unblocking it would still mean a group grant. (It is also deliberately not being asked for, §14 item 6: no birth-date field, so it never served the use case that motivated it, and as of 2026-08-05 that case is closed twice over, since date of birth turned out to be readable on getLeads, a method already on the key. Do not re-raise getCustomers.) Practical rule, unchanged: the roster is the permission truth. If a method is not on it, it is not permissioned, and nothing needs probing to find out. Per-method status for all 128 is in reference/entrata-method-index.md (its roster was re-captured 2026-08-05, carries the current 28, and agrees with §4.2 above).
getWorkOrders is UNBLOCKED as of 2026-08-03, and it is verified, not just permissioned. The top ask to Liberty was made on 2026-07-31 and granted on 08-03. A read-only call (group maintenance, r1, propertyId 1311115) came back 200 with real production work orders: requests 3435/3436, unit "3030-201", status Completed, problem "Make Ready", MDT timestamps, parent/child linkage on parentMaintenanceRequestId. The two engines specced against PMS maintenance data now have their data source. What changes: Recovered: make-ready progress for the turn engine ("turn underway," "make-ready open vs completed"), so Flow C can see how a turn is going and not just that one is happening (§7); the work-order-complete review trigger, Flow E's missing third trigger, which now has a real event to fire on (§9); and the ability to read a ticket back in Flow F and confirm a routed one was resolved (§10). Still true, and do not blur it: this is a read grant only. sendWorkOrders and updateWorkOrders are not on the key even now that the maintenance group is, and they are banned by Constitution rule 2 regardless. We can see maintenance; we can never create or change it. Liberty's ready-date confirmation stays a designed human gate in Flow C (the pre-inspection walk, nothing goes out before it), but it is no longer the only signal that exists. The downstream trigger choice is still Brent's, and this page does not pick one. What has changed is that it is no longer constrained by a missing permission. The two standing constraints hold and now cut the other way: keep the maintenance source pluggable (the webhook in §4.5 is the lower-latency alternative to polling this method), and do not build a parallel maintenance record in Supabase: Entrata is the record, we cache and trigger off it. One thing left to validate before building on it: the parent/child shape (a turn appears to be a parent request with children, so walk the tree rather than assuming one row per job). ✅ The rate-limit gap is CLOSED as of 2026-08-05, and it did not close where anyone expected: getWorkOrders is roomy at 150/min, 1,500/hr, 25,000/day, but getWorkOrderPickLists is 60/min, 500/hr and just 1,000/day, the tightest DAILY bucket on the whole key (§4.3). It is a static vocabulary, so cache it and never call it per work order; a per-ticket lookup would exhaust the day inside an afternoon. Updated 2026-08-05: its companion getWorkOrderPickLists is verified now, and it returns the full maintenance ID vocabulary plus a 12-row CompanyEmployees roster (§4.1), so status/priority/problem mapping no longer has to be guessed from the work-order rows themselves.
Amenities and specials do not exist in the PMS. Both methods are permissioned and both return zero, so amenity content and concession display cannot be "synced from Entrata", there is nothing to sync. Amenity copy is our content, curated in foothold-web with a named owner and a review date, and it needs its own staleness policy, since Constitution rule 6's fresh-read guarantee covers price and availability but not "is the laundry room still open." Pets are the exception and should be API-sourced: the property-level pets block in getPropertyUnits is populated. Concessions are an out-of-band conversation with Liberty: if they run a special that isn't in Entrata, the agent quotes list price and is wrong.

4.3 Rate limits, measured

From x-ratelimit-* headers on the 2026-07-31 probe, extended by the 2026-08-05 probes (rows marked 08-05). Per method, not per key, and the spread is enormous. Full table in reference/entrata-api-context.md §6 (re-captured 2026-08-05 and carrying these same rows), and the build-facing version in reference/entrata-touchpoints.md. ✅ The table is COMPLETE as of 2026-08-05: every method the build uses has a measured limit, the last two being getWorkOrders and getWorkOrderPickLists, though a few daily buckets never came back in the headers at all (getLeases, getUnitsAvailabilityAndPricing), and "none returned" in the table below means exactly that, not "unlimited." ⚠️ Read the two tightest buckets separately: they are different methods. Tightest hourly is getMarketingPreferencePickList at 60/hour (10/min). Tightest daily is getWorkOrderPickLists at 1,000/day (60/min, 500/hr). Both are static vocabularies, so cache both and call neither inside a loop.

MethodPer minutePer hourPer day
getStatus6 (tightest on the API)3608,640
getWebsites101001,000
getMarketingPreferencePickList 08-051060 (tightest hourly on the key)1,500
getAmenities601,00010,000
getMarketingPreferences 08-05601,00010,000
getWorkOrderPickLists 08-05605001,000 (tightest daily on the key)
getUnitTypes1002,00010,000
getCalendarAvailability 08-051202,00048,000
getLeads1355,00027,000
getProperties / getPropertyUnits / getGlTransactions1501,000–6,00010,000–15,000
getWorkOrders 08-051501,50025,000
getLeadPickLists2003005,000
getMitsLeases 08-052004,50020,000
getFloorPlans3001,00010,000
getUnitsAvailabilityAndPricing60036,000none returned
getLeaseDetails (r2 re-measured 08-05)1,0005,00050,000
getLeases1,07264,320none returned
The trap: getStatus at 6/minute is the tightest limit on the whole API, tighter than any business method. It cannot be a heartbeat. Any design that pings status every 30 seconds burns the quota and gets throttled while the real methods sit idle. Use the availability poll itself, or getProperties, as the liveness signal; if an explicit health check is wanted, cap getStatus at every 15 minutes (4/hr) so there is headroom for the retry storms that come with maintenance windows. getWebsites at 10/min is next tightest, so it is a config read, never a hot path. Added 2026-08-05: getMarketingPreferencePickList sits in that same tier and is worse on the hour: 10/min, 60/hour, 1,500/day, the tightest hourly bucket measured on this key. It is a static vocabulary of 24 rows, so cache it once per day and never call it per lead or per resident; the per-resident call is getMarketingPreferences, which is a comfortable 60/min. Burning the pick-list quota inside a send loop would throttle the consent gate itself, which fails closed and stops the touch. Refined later on 2026-08-05, and the correction matters: "the tightest bucket" is two different methods depending on which bucket you mean. getMarketingPreferencePickList is tightest hourly (60/hr). getWorkOrderPickLists is tightest daily at 1,000/day (60/min, 500/hr), roomier per minute, but it runs out of a whole day's quota in under twenty minutes of loop. Both are pick lists, both are static, and both get cached once and read from cache. The pattern generalizes: on this API the vocabulary endpoints are the scarce ones and the data endpoints are not, which is backwards from the intuition that the big reads are what you have to ration.
Availability polling is effectively unconstrained. 600/min and 36,000/hr means the 12-minute poll of one property (~120 calls/day) is nowhere near the ceiling, and getLeases at 1,072/min is free too. So quota is not the reason for the 12-minute cadence and never was. The real constraints are the serial gate (every extra poll competes with real work), good-neighbor behavior on somebody else's live org, and the ~8–12K calls/mo NPI declared in the access application, which is a good-faith number worth honoring. Constitution rule 6 does the correctness work anyway: the live re-check at the moment of commitment is what keeps the promise, not a faster poll. Keep 10–15 min, spend the headroom on the daily getLeases / getGlTransactions pulls, and revisit the whole cadence if webhooks turn out to exist.

4.4 Scope of the key (bigger than our authorization)

getWebsites leaks Liberty's entire portfolio. getProperties is scoped to the one property we were granted, but getWebsites enumerates 98 properties by ID and name on the main site config (and 40 more on a second one), including buildings owned by other owners. Whether those property IDs work as params on other methods is untested, and probing another owner's building is out of scope, so do not test it. The key is org-scoped; the authorization is one property; the true blast radius is unmeasured. Recommendation carried in from reference/entrata-api-context.md §1: enforce a property-ID allowlist (only 1311115) at the same single choke point as the read-method allowlist, deny-by-default and fail-safe, so a bug cannot read (or one day write to) somebody else's building. This does not change Constitution rule 2, it is a second lock on the same door.

4.5 Webhooks BLOCKED (not provisioned)

Researched 2026-07-31 from Entrata's own dev-portal Webhooks page, vendored verbatim to reference/entrata-webhooks-2026-03-04.md (vendor updated_at 2026-03-04; the portal is a Gravitee SPA but its backing API at developer-api.entrata.com/portal is public and unauthenticated, so the page is fetchable without a login). Re-fetch commands are in that file's header. Narrative version and the design consequences: reference/entrata-api-context.md §6.1.

📌 DECIDED 2026-08-13 (DECISIONS.md #16): provision, bundled into the Aspen/Summerset onboarding already in motion (#18). Liberty ticks the webhook boxes on the same Web Service Permission screen used for API grants (the two-gate path below); we stand up a receiver URL and observe what arrives during normal Owyhee activity, read-only, zero risk. Per rule 1 and the receiver requirements below, the receiver is a Worker, never the VPS: the box stays outbound-only. Observation answers the fires-on-guest-card question (§14); the signature discrepancy goes to Entrata support in the same exchange. The 12-minute poll stays the design either way: 5 published events, none of them availability.
EventPayload fields
lease.completedClientId, PropertyId, LeaseId, CustomerId, ApplicationStatusId, ApplicationStageId
lease.partially.completedsame six
lease.startedsame six
oa.application.eventsthe above plus ProductId, ApplicationId, NewApplicantId, TriggeredByApplicantId, TriggeredByCompanyUserId, EventName. "oa" = online application.
workorder.status.updateClientId, PropertyId, LeaseId, CustomerId, MaintenanceRequestId, MaintenanceStatus. Statuses are client-defined; status types are suspended / completed / cancelled.
workorder.status.update became useful on 2026-08-03. It hands us a MaintenanceRequestId and a status, and that ID is now resolvable: getWorkOrders is on the key and returning production requests (§4.1), so the event is a real trigger-then-re-read pair rather than an ID that goes nowhere. This flips the priority: the webhook is now a latency and cleanliness improvement over polling getWorkOrders, not the thing that makes work-order data reachable at all. It still needs the two-gate provisioning below (Entrata email + a Liberty checkbox), and it is still not provisioned. Note the standing shortcut: a "work order N completed, here is CustomerId" event is enough to fire a service-call review touch without reading the work-order body at all (Flow E, §9). Make-ready progress for Flow C does need the read, and now has it.
FactDetail
Payload shapeAmazon SNS shaped: Type, MessageId, TopicArn (format client-4823:workorder.status.update:9), Message, Timestamp (MT), SignatureVersion, Signature. Message is a JSON string and needs a second parse. Every event carries IDs and a status only, never the record: a webhook says something changed and hands you a key, it does not hand you the data.
Auth to our receiverRSA public key supplied by Entrata at provisioning. SignatureVersion 1 = SHA1withRSA, 2 = SHA256withRSA, base64 either way. ⚠️ The doc contradicts itself on what gets signed: its prose says Message + MessageID + Subject + Type + Timestamp, its own sample code says Message + MessageId + Type + Timestamp + TopicArn. Build to the sample, verify against a real message, expect to try both.
Delivery guaranteesAt-least-once, stated explicitly. MessageId is stable across retries of the same message, so dedupe on it. Deliveries queue and survive receiver downtime. Retry count, backoff, give-up window, ordering, and dead-lettering are all undocumented. Assume no ordering: treat every event as a trigger to re-read, never as the state itself, which also satisfies Constitution rule 6 for free.
Receiver requirementsMinimal: a public HTTPS endpoint returning 2xx. Entrata pushes to us, so Constitution rule 1 is untouched and the VPS stays outbound-only. This belongs on a Cloudflare Worker. Plan to handle an SNS-style SubscriptionConfirmation from day one even though the doc never mentions one; the payload shape is SNS's, so the handshake probably is too.
How to get subscribedNot self-serve. No API, no portal UI, and two gates, both required. (1) Email apisupport@entrata.com with the Entrata client name, the client subdomain, the event types, and the receiving URL(s); they reply with the RSA public key. (2) Liberty ticks checkboxes: Apps → API Access → our company → Manage → Web Service Permission tab → bottom of the page → select webhooks → Save.
ScopePer client/org, per event type, and per property, explicitly bound to the same property grant as API access. So adding Aspen and Summerset later extends webhooks by the same mechanism that extends API reads. No second integration to build, and one ask covers both (§14).
CostNot documented anywhere. Partner tiers cap API requests monthly (Incubator under 250k) with no statement about whether webhook deliveries count. Treat "free" as unverified.
Caution about this vendor's ecosystem. Third-party integrator docs (Tray.ai, Nango) claim Entrata has no native webhooks. They are wrong and stale, most likely written against the gateway Entrata deprecated 2025-04-15, and the vendored vendor doc supersedes them. General lesson: the integrator ecosystem's public documentation lags Entrata's platform badly. Go to developer.entrata.com (or its portal API) first and treat anything else as a rumor to verify.

5. Flow A: availability pipeline BUILT (mock) IN PROGRESS (live)

Feeds: /rentals, the agent's check_availability, ILS freshness. KPI: apps-per-tour + vacancy days. The EliseAI failure this design exists to prevent lives here.

Unblocked 2026-07-31. getUnitsAvailabilityAndPricing is permissioned and returns real data for property 1311115 (2 units available on probe day, 09/04 and 09/08 move-ins), at 600/min, so the poll is nowhere near a quota ceiling (§4.3). Two parse rules for the live wiring: take square footage from getFloorPlans, not from this method (its SquareFeet block returns Avg 0 / Max 0 / Min 1024, which is broken), and read availability as given, never recompute it from lease status (see the known unknowns in §14).
📌 DECIDED 2026-08-13 (DECISIONS.md #15): the manual availability contingency for Aspen gets SPECced, not built. Named trigger: if Aspen is not on the key two weeks before go-live, build it. Liberty's onboarding of Aspen + Summerset is already underway (#18), which shrinks the window this covers but does not remove it. Spec only; nothing is being coded, per the standing no-build instruction.
sequenceDiagram
  autonumber
  participant T as VPS timer (12 min)
  participant G as Serial gate
  participant E as Entrata
  participant K as CF KV
  participant W as /rentals
  participant A as AI agent
  T->>G: tick (skip inside maintenance windows)
  G->>E: POST /{org}/v1/propertyunits, getUnitsAvailabilityAndPricing (per property)
  E-->>G: units + pricing (MT dates) + rate-limit headers
  G->>K: publish avail:{propertyId} + avail:index
  W->>K: read on page view (never hits Entrata)
  A->>K: check_availability tool
  Note over A,E: Commit moment (quote / apply link):
one targeted live re-check via the relay (sparingly, it spends rate budget) Note over G,E: Backoff ladder: rate-header low → stretch interval
5xx/maintenance → serve last-good, alert only if stale > 2h

6. Flow B: lead capture & speed-to-lead PLANNED

KPI 1 (apps per tour). The agent answers in seconds on its own number; Entrata gets the complete mirror. Handoff at tour-booked or application-submitted.

sequenceDiagram
  autonumber
  participant P as Prospect
  participant I as Apartments.com / Zillow
  participant A as AI agent
  participant H as GHL
  participant Q as Outbox
  participant V as VPS relay
  participant E as Entrata
  participant L as Liberty
  P->>I: inquiry (real name, email, phone, move-in date)
  I->>E: native CoStar feed → guest card
  I->>A: lead email (one of 6 destinations)
  A->>P: reply in seconds, own number, 24/7
  Note over A: Min-info gate: first name + one contact method
before anything touches Entrata A->>H: capture_lead + tags + source A->>Q: intent: logNote / sendLead Q->>V: HTTPS pull (outbound-only box) V->>E: getLeads by email, dedupe first alt card exists (ILS path) V->>E: updateLeads, note on existing card else no card (site / chat / SMS lead) V->>E: sendLeads, create guest card end Note over V,E: EVERY message both directions logs as a note.
Notes are record-only: no send API exists, so no duplicate risk. A->>P: book tour or ProspectPortal apply link Note over A,L: ── HANDOFF: tour booked OR application submitted ── L->>E: tour, leasing, move-in (natively) A->>P: one post-tour feedback text (~1–2 hrs)
Write legs are BLOCKED by Constitution rule 2. The sendLeads and updateLeads steps above are design-only until Brent signs off writes for that specific property. On a live production property the relay runs the read half (getLeads dedupe) and holds every write intent in the outbox unsent. The diagram shows the finished loop, not what the code is allowed to do today.
📌 DECIDED 2026-08-13 (DECISIONS.md #1+#5, #2, #3): how the mirror actually lands, and what Liberty sees. Brent's principle: live inside Liberty's ecosystem, never make them open another system. Liberty's two surfaces are the lead's own Entrata record (every Foothold AI conversation logged as lead events, so a leasing agent pulling up the guest card sees the history right there) and the five-line weekly digest email (§1); no standalone Liberty-facing Foothold page. The event mechanics (#5): numeric typeId resolved from getLeadPickLists (pin the 155 IDs now, while reads are all that is allowed), a one-line summary on top, full transcript and true channel in the free-text comments, enum-mapping as the runtime fallback, and never learned by issuing a test write. One unknown rides into build: comments has no documented length cap, so if a full transcript bounces at go-live, fall back to summary-only. Guest-card creation (#3): "expressed interest" is the AI's judgment, gated hard by rule 5, (phone OR email) AND a first name. Scope of the first write sign-off (#2): sendLeads + updateLeads at go-live; lead-event/transcript logging rides on updateLeads, so it is inside that same signature, and work orders / lease activities take a second signature later. 🚫 Rule 2 is untouched: nothing is authorized today; all three conditions still gate every write.
Abandoned applications: poll getLeads for an in-progress status gone stale, then the agent re-engages. No test application is needed to learn the status IDs: getLeadPickLists returns 62 LeadStatuses and the July pull already shows the real strings in use ("Application Started," "Application Partially Completed," "Application Archived"). Read the pick list first (§14). ILS listing freshness rides on Entrata's native syndication (Liberty flips it on; Marketing Hub → ILS Portal), not our API problem, but our weekly report card grades it.
What the July lead pull actually says (109 leads, probe 2026-07-31). Source mix: Zillow 87 (80%), Website 11 (10%), Unknown 6, then single digits. That is the measured before-picture for the demand engine on a comparable Boise Class C building, and 10% direct is the floor to beat, not zero. Two design consequences. (1) Lead intake has to work from thin ILS input: a Zillow lead arrives as name, email, phone, desired floorplan, target move-in date, with no conversational context, and the speed-to-lead clock starts at the API-visible createdOnDateTime. The one sampled lead came in at 3:05 AM Mountain, against office hours of M-F 9–5 with no weekend coverage, so roughly 76% of the week has no human response and that gap is where the agent creates day-one value. (2) Attribution has to be reconciled, not written. Tagging a lead as Foothold-originated means sendLeads or updateLeads, both barred by rule 2, and there is no Foothold lead source in Liberty's pick list at all (the 17 sources are Liberty's; leadSourceId 67822 "Website" is Liberty's own site, not ours). Near-term the design keeps Foothold attribution in GHL and reconciles against getLeads by email and phone. Adding a Foothold source is a Liberty-side config change nobody has asked for yet (§14).

7. Flow C: turn engine DECIDED

KPI 2 (vacancy days → ~0). Ready date = move-in date; the only downtime is the ~3-day make-ready.

flowchart LR
  N["Notice to vacate
(Entrata: getLeases, no status filter,
statusTypeId 5 = Notice)"] --> D["Available + MakeReady dates
(getUnitsAvailabilityAndPricing):
read them, don't recompute"] D --> C["Liberty confirms ready date
(pre-inspection walk; nothing goes out before)"] C --> M["Match waitlist on optional profile
beds / budget / pet / timing"] M --> W1["1. Matched Insiders: 48-hr early window
texted first, ordered by stated timing"] W1 --> W2["2. Broader waitlist"] W2 --> W3["3. Public listing: ILS syndication fires"] W1 -.-> APP["Applications"] W2 -.-> APP W3 -.-> APP APP --> R["Allocation rule (written, neutral, mechanical):
among QUALIFIED applicants, completed app
closest to the ready date wins"] R --> S["Lease signed → move-in = ready date"] classDef lag fill:#f7ede4,stroke:#bb6238 class C lag
Fair-housing guardrails baked in: priority attaches at application, not interest; screen everyone by the same criteria first; timing rule applies among the qualified; stay open to reasonable accommodations; never set the window so tight it predictably excludes families/disabled.
What the 2026-07-31 probe changed here, updated 2026-08-03. (1) getExpiringLeases is blocked (400/1403), so the notice/expiry read comes from getLeases with no status filter, which returns the full current rent roll with end dates. (2) getWorkOrders is UNBLOCKED as of 2026-08-03, so make-ready progress is readable: the probe returned real "Make Ready" requests on this property with open/completed status and parent/child structure (§4.1). The turn engine can now see both that a unit is turning and how the turn is going. Updated 2026-08-05: getWorkOrderPickLists is verified too (§4.1), so the StatusTypes, Categories, Problems and Priorities vocabulary that make "make-ready open vs completed" a reliable read is available to pin now, rather than being inferred from the handful of rows the 08-03 probe returned. Liberty's ready-date confirmation stays in the flow as the designed human gate (the pre-inspection walk), but it is no longer the only signal available. What the trigger downstream of it should be is still an open design question, deliberately left open, and it is now a choice rather than a constraint. (3) The ~3–4 day turn SLA in the diagram is an assumption the data does not yet support: in the two available units on probe day, MakeReady was one day before AvailableOn (09/03 → 09/04 and 09/07 → 09/08). One sample, so do not generalize, but the number in this flow needs checking with Liberty before any vacancy-day math rests on it. (4) Read availability, never derive it from lease status: 3 Notice leases against 2 available units do not reconcile (§14), and Entrata's availability is the field to trust.

8. Flow D: recognition engine DECIDED

KPI 3 (renewal 57% → 70%). Real cards in the mail, zero staff tasks. Touch map + tone rules locked in lead-comms §4.

sequenceDiagram
  autonumber
  participant V as VPS (daily pull)
  participant S as Supabase
  participant C as Recognition cron
  participant HW as Handwrytten
  participant TR as Tremendous
  participant T as Twilio (ownership #)
  participant Q as Outbox → Entrata
  V->>S: resident facts, leases, renewal dates, move-ins (getLeases + getMitsLeases, verified 08-05. getCustomers blocked 1403)
  V->>S: birth MONTH + DAY only, from getLeads birthDate, joined on customerId (year dropped at ingest)
  V->>S: pets from getLeases r1, per-pet name + type + breed, joined on customerId
  Note over V,S: Ingest scrub, one choke point (rules 12 + 8).
Strip SocialSecurity / SSN / taxId before anything is stored or logged.
Drop isAssistanceAnimal records: a service animal is not a pet.
DOB reaches 65% of current residents (measured 08-05). The Welcome Profile covers the rest. C->>S: read trigger matrix (move-in, birthday, pre-renewal −90d, tenure, pet gift, life events) C->>S: consent gate: getMarketingPreferences optIn per channel (recipientTypeId 2) Note over C,S: Opted out on a channel = no touch on that channel.
Gate fails closed: unknown consent is treated as no. alt mailed touch (welcome / birthday / pre-renewal / tenure / sympathy) C->>HW: handwritten card (+ $25 gift card where the map says) else digital gift C->>TR: gift card by text end C->>T: follow-up text the night it lands: "make sure you got your package" C->>Q: log note: "mailed birthday card + $25 gift card" Note over Q: PM sees every touch in the Entrata thread (rule 4) Note over C: Pre-renewal card lands ~90 days out, AHEAD of the ~60-day offer.
Gift-then-offer reads genuine, offer-then-gift reads like a bribe.
The Entrata note leg is BLOCKED by Constitution rule 2. Logging a recognition touch is an updateLeads write, so on a live production property it stops at the outbox. Cards and gifts still go out; the Entrata mirror is deferred and replays once writes are signed off for that property. Rule 4's "Liberty sees everything" is on hold until then, not waived. Build those queued intents against the real ID vocabulary now (getLeadPickLists: 155 event types, 62 lead statuses, 15 leasing agents, 17 lead sources), because an outbox full of invented IDs has to be rewritten before it can ever be replayed. ⚠️ Updated 2026-08-13 (DECISIONS.md #1/#19): updateLeads appends to a LEAD record, and a recognition touch is a resident-level event whose only Entrata appender, sendLeaseActivities, is not on the key and whose ask is tabled. So resident-side touches log to Foothold's own store and surface in the weekly Liberty digest (§1); their Entrata mirror waits on that grant, not just on the rule-2 lift. Lead-side touches still ride updateLeads per rule 4.
Read sources, corrected 2026-07-31, updated 2026-08-03, rewritten 2026-08-05. getCustomers is blocked (400/1403) and no longer wanted. Resident facts come from getLeases (names, emails, phones, move-in dates, scheduled charges, and pets: see the pet callout below) and getMitsLeases, now verified, which adds the household shape this engine actually needs: 100 Tenant records against 52 leases, so roommates and guarantors stop being invisible, plus Spouse, Reference (emergency contact), GuarantorFor and the Customer ID values the consent gate keys on. Two touches per household is a real failure mode now that both occupants are visible: dedupe on the lease, not the person, for anything mailed.
🔄 REVERSAL 2026-08-05: birthdays DO have an Entrata source, and the Welcome Profile is no longer the only path. This page said until now that no Entrata method carried a resident birth date and that the birthday touch had to run on our own form. That is wrong and is withdrawn. What the probe found: getLeases r1 and r2 return zero birth keys, getMitsLeases returns zero populated birth dates even though its schema defines BirthDate, but getLeads r1 returns a real birthDate. The mechanic: DOB is captured at the lead / application stage and never propagates to the lease or resident record. It is readable today, on a method already on the key, with no new permission. The design path: read DOB from getLeads (r1 needs propertyId + fromDate + toDate, else 400/310), join to the resident on customerId, and store month and day only. ✅ The join is proven, not assumed: getLeads customerId and getLeases customer id are the same namespace: 70 of 88 current residents matched on it. ⚠️ PII rule, binding on this engine: store the month and the day, drop the year at ingest. Month/day is everything birthday recognition needs; a full DOB is identity-theft-grade data and there is no reason to hold it. Entrata's Secure PII Delivery requirements apply to this feed the same way they apply to the GL (§11): redact at the transport layer, never log raw getLeads bodies, and keep none of it in this repo. 🚫 Second PII rule, new 2026-08-05 and binding on every engine: never ingest, persist or log a Social Security number (rule 12). NPI has no use for one and does not want the liability. getMitsLeases defines SocialSecurity in its response schema and it is on our key; the live probe found the field empty, so there is no exposure today, but the strip happens at the same choke point that enforces the read-method allowlist, on read, before anything is persisted, logged or passed to a model, fail-safe, and independent of whether the field is populated. Pet details are no longer ours to collect, see the pet callout below. Owyhee Park is the only property on the key and it is Liberty's, not NPI's, so this engine still has no NPI-owned property to run against until Aspen or Summerset are added.
📏 DOB COVERAGE IS MEASURED, 2026-08-05, and the number is 65%. This page carried a known-unknown saying coverage was unmeasured and that birthdays must not be reported as solved until it existed. It exists now, and the birthday touch is viable but not complete. ⚠️ First, the figure this page used to quote was wrong. "birthDate populated on 122 of 122 lead records" overstated it badly, because Entrata omits null fields: counting the key across returned records only counts records that already had the key, so the denominator silently excluded every record without one. The honest sweep, 2021-01-01 to 2026-08-05: 787 distinct lead-customers, 286 carrying a birthDate. That is 36% on the lead side. Watch for this shape anywhere else on this API: a key-presence count on a payload that drops nulls is not a coverage measurement, it is a tautology. What matters here is the resident side, and it is better than 36%. The rent roll holds 103 customer records across 52 leases, 88 of them Current. 70 of those 88 appear in getLeads at all, and 57 of the 88 carry a birth date. So DOB covers 65% of current residents. The gap splits two ways and they are different problems: 18 residents never appear in getLeads (no reachable lead record, so no sweep will find them) and 13 appear with no birth date (the lead record exists and the field was left empty). Widening the sweep cannot fix either: the sweep is already the full history. So the Welcome Profile is not decorative and is not going away: it covers roughly the other third. Design the engine to expect a null birthday for about one resident in three, never to guess, and never to send a birthday touch off an inferred date. The 730-day window makes the backfill trivial (the entire 5.5-year history swept in three calls, §4), so run it once at ingest rather than trickling windows.
New gate 2026-08-05: consent is checkable before we message anyone. getMarketingPreferences is verified with recipientTypeId=2 (RESIDENT) and returns optIn per consentType per channel (email, phone, postal_mail, sms), keyed on the Customer ID values getMitsLeases supplies. So the recognition engine can ask Entrata whether a resident has opted out of a channel before a card, a gift text, or a follow-up goes out, instead of relying only on our own STOP handling. Build it as a gate that fails closed: unknown or unreadable consent means no touch on that channel. Cadence rule from §4.3: getMarketingPreferences is 60/min and fine to call per batch; getMarketingPreferencePickList is 10/min and 60/hour, so cache the vocabulary daily and never call it inside a send loop. Parse defensively, some pick-list rows have no consentType. ⚠️ Sharpened later on 2026-08-05: the LEAD side (recipientTypeId=1) was probed and it works too, and the same customer can carry BOTH a LEAD and a RESIDENT preference set. recipientType is a lens, not a partition. A resident who opted out as a lead and never touched the resident-side settings will look clean if you only ask recipientTypeId=2. Read both sides for anyone who exists on both, and honour the union of the opt-outs, which is just what failing closed means here.
🐾 REVERSAL 2026-08-05: pets have an Entrata source too, and the pet-gift engine is unblocked. This page said pet details had to come from our own pet form. Withdrawn. getLeases r1 is the source and it is a good one: 30 of the 52 leases have pets, 34 pet records in total, each carrying name (the field the whole program turns on, since a card addressed to "your dog" is not the touch), plus petType, breed, color, gender, age, weight, moveInDate and customerId. Join to the resident on customerId, the same key the birthday path uses. Secondary sources and one dead end: getLeads r1 returns 126 pet records (adds count, id, licenseCity) and is prospect-side context, not the resident roll; getMitsLeases carries only Count, PetType and Weight with no name, so do not use it for pets; and getPetTypes is not on the key and is not wanted, because petType comes inline. No new permission is needed for any of this. The pet-gift.html resident-material mockup already existed; it now has a real data source behind it. ⚠️ isAssistanceAnimal is load-bearing and this is not a matter of tact. Assistance and service animals are not pets under fair housing. Filter every record with isAssistanceAnimal true out of the pet-gift program and out of any pet-themed marketing, segment or message. Treating a service animal as a pet is a fair-housing risk (rule 8), not a style miss. Do the filter at ingest, so a downstream query cannot reintroduce the records, and fail closed: a record whose isAssistanceAnimal flag is missing or unparseable is excluded, not included. The consent gate still applies on top: a pet gift is an outbound touch like any other and runs through getMarketingPreferences first.
📌 DECIDED 2026-08-13 (DECISIONS.md #6, #11, #12): delivery, consent, and the budget. (#6) The Welcome Profile reaches a resident as a mailed Handwrytten welcome packet (Handwrytten integration is on). The no-birthday fallback is a Thanksgiving "thankful you're here" card, keeping the annual touch count for the ~35% with no DOB. Two backfill levers: the property business plan asks Liberty to enter DOBs at move-in, and the weekly digest flags new tenants missing a DOB so the gap shrinks over time. (#11) Consent is both: the live getMarketingPreferences check on the send path is the load-bearing gate, and a nightly sync of Entrata opt-outs into GHL runs purely as hygiene so a GHL broadcast never hits someone who opted out with Liberty. Cache the pick list; a missing preference means do-not-send. (#12) Budget: ~$75/door/year. Shape: Handwrytten welcome packet at move-in; birthday card with a $25 gift card (Amazon, or a local Boise merchant for a more thoughtful touch); Thanksgiving card as the no-DOB fallback; $25 Chewy gift card on the pre-renewal pet card, or a plain pre-renewal card with $25 for petless households. Roughly $30–32 all-in per mailed touch.

9. Flow E: review / social-proof engine DECIDED

KPI 4 (3.9★ → 4.5★+). Event-triggered asks at goodwill peaks; reviews feed ILS rank and the site funnel.

sequenceDiagram
  autonumber
  participant E as Entrata events
  participant S as Supabase
  participant C as Review cron
  participant T as Twilio
  participant R as Resident
  participant G as Google / Apartments.com
  E->>S: move-in +2wk · renewal (getLeases) · WO completed (getWorkOrders, unblocked 2026-08-03)
  S->>C: recognition sends (from the ledger)
  C->>C: suppression: public review in last ~12 mo? spaced enough? → skip
  C->>C: consent gate: getMarketingPreferences optIn for sms/email → skip if opted out
  C->>T: the ask, from the ownership number
  T->>R: text + smart link (or the service-call card, left on site)
  R->>G: public review
  G-->>C: monitor → ILS scorecard + site testimonials
  C->>E: log the ask (outbox note)
  Note over C,R: Trigger on EVENTS, ask EVERYONE at that event.
Sentiment-gating is prohibited: that's rule 7, not a style choice.
"Log the ask" is BLOCKED by Constitution rule 2. That leg is an updateLeads write, so on a live production property the ask still sends over Twilio but the Entrata note queues in the outbox until writes are signed off for that property. Suppression and monitoring are reads and run normally.
The third trigger is UNBLOCKED as of 2026-08-03. getWorkOrders was the missing permission and Liberty granted it; the probe returned real requests carrying a Completed status on this property (§4.1), so the work-order-complete ask now has an event to fire on. All three triggers have a data source: move-in +2 weeks and renewal from getLeases, work-order complete from getWorkOrders. The resident-facing pieces are unchanged and still work on their own terms: the physical service-call card the tech leaves on site carries the ask without any API. Two implementation choices remained open until 2026-08-13; both are now made (end of this note). (1) Poll or push: polling getWorkOrders works today with no provisioning; the workorder.status.update webhook carries MaintenanceStatus and CustomerId (§4.5) and fires the ask without reading the work-order body at all, but needs the two-gate provisioning. Push is cleaner, poll is available now. (2) Which statuses count as "complete enough to ask": statuses are client-defined, so read Liberty's real values rather than assuming. Verified 2026-08-05: getWorkOrderPickLists returns them, along with Categories, Problems, SubMaintenanceProblems, Priorities, property-scoped Locations, 5 WorkOrderTypes, 5 InspectionStatuses, and a 12-row CompanyEmployees roster (§4.1), so the status mapping is a lookup now rather than a guess. Watch the parent/child structure: a make-ready parent with children could fire several asks for one visit if you trigger on every completed row. New constraint 2026-08-05: run the consent gate before the ask. getMarketingPreferences is verified and returns per-channel optIn for a resident (§4.1), so a review ask now has an authoritative "may we text this person" check in front of it. This does not soften rule 7. Trigger on events and ask everyone at that event stands; consent is a channel permission, not a sentiment filter, and a resident who has opted out of SMS is not skipped because we guessed they would leave a bad review. Suppression (12-month spacing) and consent are two separate gates and both are mechanical. 📌 Both implementation choices were made 2026-08-13 (DECISIONS.md #7): (1) poll getWorkOrders, with the webhook staying a latency upgrade (§4.5, provisioned via the Aspen/Summerset onboarding, #16); (2) the qualifying statuses are named in config off Liberty's real StatusTypes. And #17 closes the destination question for good: Google + Apartments.com only. Entrata's testimonial object is skipped permanently: it is an Entrata-internal surface on Liberty's website, would cost a new permission ask plus a write authorization, and moves no KPI we are measured on.
💬 DECIDED 2026-08-10 (Brent): what the resident follow-up actually is. His words: "an AI generated personal message about their specific issue, 'Did your back door get fixed?'" It is NOT the survey lead-comms-spec §4 cut, and that cut STAYS cut. This is the lighter personal check-in, and it is the version that survives the objection that killed the survey: specific, personal, and from the ownership number rather than a form. It does not cross the "watch the scoreboard, not the plays" line either, and the reason is the one that matters: the message goes to the RESIDENT, not to Liberty. Nothing per-ticket is sent to, demanded of, or logged against Liberty. Per-ticket outbound to a resident and per-ticket policing of a vendor are different acts. What it reads, all on the key today with no new permission: maintenanceProblem, maintenanceLocation and maintenanceDescription off getWorkOrders. Four mandatory filters, inherited from decisions already made: skip maintenanceRequestFrom: "Entrata PaaS" (a system make-ready must never fire a resident message); collapse parent/child on parentMaintenanceRequestId so one turn sends one message; clear the getMarketingPreferences consent gate on both the LEAD and RESIDENT lens, with a missing preference meaning don't send; and triage on maintenanceProblem / maintenanceLocation, never maintenancePriority (780 of 785 are Medium). ⚠️ NEW RISK, and it needs a written rule: the message quotes the resident's own issue back to them, so a wrong or stale detail is worse than a generic message. The pick-list values are terse and don't always render into natural language ("Make Ready" is the obvious case, and it is exactly the system-generated record the first filter excludes). The rule: the AI renders from the actual field values and never invents detail. If the problem and location won't form a natural, confident sentence, fall back to a generic check-in or don't send. Specificity is the point, but a confident wrong guess costs more than it earns. Rule 8 (fair housing) applies, as to any AI-generated resident-facing message. 📌 It cannot be logged into Entrata, and the reason is the RESIDENT record specifically. sendLeaseActivities, the only method in the 128 that appends an activity to a resident / lease record, is not on the key. So the touch logs to Foothold's own store, and this strengthens the case for that ask: a resident-level service touch is exactly what it would carry. ⚠️ CORRECTED 2026-08-10: this line used to open with "no lead-event write method exists anywhere in the 128-method spec." That was wrong and it is withdrawn. updateLeads r2 and sendLeads r1 both carry an events array (typeId, date, type, free-text comments) and both are on the key. The decision and the destination are unaffected, because this message goes to a resident, not a lead. 🚫 Appending a lead event is a WRITE and rule 2 governs it completely, same three conditions. A correction about what the API can do is not a permission change. 🚫 Scope: NPI-owned properties once Foothold is live on them. NOT Owyhee Park, which is Liberty's building with Liberty's residents; reading their tickets does not licence messaging their residents. Closed 2026-08-13 (DECISIONS.md #7, #8), all three: the completion trigger is a poll of getWorkOrders (the webhook stays a latency upgrade, §4.5), with the maintenanceStatus values that count as complete enough to ask named in config off Liberty's real StatusTypes; timing is next business morning, never an hour after the tech leaves; and this message and the review ask are TWO touches: follow-up first, review ask a few days behind, fired on the event and never on the reply. 🆕 Plus a gate the corpus never had (#7): an AI-judgment pass decides which work orders deserve a follow-up at all, not every ticket gets one, with its rules worked out against real Owyhee work-order examples during build-spec. See §14.

10. Flow F: escalation & edge handling DECIDED

flowchart TD
  M["Message on the ownership channel"] --> GD{"Guardrail check
(Haiku pre-pass)"} GD -- "leasing, in scope" --> RE["Agent replies
logs note via outbox"] GD -- "service ticket" --> SR["Acknowledge instantly + redirect gently
'Liberty's responsibility, we passed it on'"] SR --> WO["sendWorkOrders via relay
(emergency → maintenancePriorityId from
getWorkOrderPickLists, mapped per property)"] WO --> LAG["Liberty resolves natively, their SLA"] GD -- "out of its depth" --> ESC["'Let me get a person' → email Liberty's leasing/manager
team direct (adrienne@libertyassetgroup.com today)
+ urgent note on the guest card → Liberty takes over"] GD -- "agent / API down" --> FB["Fallback email to the Liberty PM
with lead + full context, nothing lost"] LAG -.-> AGG["Foothold watches the AGGREGATE
(rating trend, review velocity, renewals)
never per-ticket policing"] classDef crit fill:#f3e0dd,stroke:#9a3b2b class FB crit
sendWorkOrders and the urgent-note escalation are BLOCKED by Constitution rule 2. A test work order sends a real tech to a real unit, so on a live production property those legs are design-only until Brent signs off writes for that property. Interim path: the fallback email to the Liberty PM (bottom of the diagram) carries service tickets and escalations, and the outbox holds the write intents for replay. Two independent blocks stack here, down from three as of 2026-08-03: rule 2 bars the write; and sendWorkOrders is still not permissioned, so lifting rule 2 tomorrow would still leave it returning 1403. That second block is now a sharper fact, not a softer one: the maintenance group is on the key since 08-03 and Liberty granted the two reads from it and neither write (§4.2). The third block is gone: getWorkOrders reads, so we can read a ticket back and confirm a routed one was resolved. The loop is now asymmetric rather than closed: out-of-band email going in, real Entrata data coming back. The workorder.status.update webhook would make the return leg push instead of poll (§4.5); it carries only a MaintenanceRequestId and a status, but that ID now resolves. Updated 2026-08-05: the routing vocabulary in the diagram is verified. getWorkOrderPickLists returns real Priorities (so the emergency mapping in the WO node has actual IDs to map to), StatusTypes, Categories, Problems, property-scoped Locations, and (the useful surprise) a 12-row CompanyEmployees roster, Liberty's own staff, which is an assignment/routing signal we did not know we had. None of that changes what we are allowed to do: reading who Liberty would assign a ticket to is a read; assigning one is a write, and a write is barred. It informs the escalation email and any future intent payload, nothing more. Also read PropertyHours before promising a response: the office is M-F 9–5 with no weekend coverage, so an escalation "to a person" on a Friday night reaches nobody until Monday. And do not substitute the tour hours for the office hours: getCalendarAvailability reports agent-guided touring at M-F 08:00–16:00 MST, an hour earlier at each end than PropertyHours (§4.1). Two different windows, two different reads, neither derivable from the other. Whether the agent holds the conversation or the handoff gets a business-hours queue was a real fork. 📌 DECIDED 2026-08-13 (DECISIONS.md #9): hybrid. The agent holds the lead by default (the Friday-night lead gets offered a self-guided tour tomorrow morning); an emergency or an explicit "I want a person now" escalates immediately with the hours stated plainly. On a genuine emergency the agent steps out of the loop immediately and hands over the phone number, "please call this number now": never a queued callback, never a chat handoff, never an email.
Where the escalation actually lands: ANSWERED 2026-08-03 by Alex Costa. Human escalation goes to Liberty's direct leasing / manager team by email, and adrienne@libertyassetgroup.com is the right address today. They do use Google / Breakroom chats internally, but they prefer direct email, so build the notification leg as email and do not invent a chat integration. This settles the out-of-band half of the two-leg design (notification a human watches, plus the Entrata note for the record), which was already buildable today because it has no Entrata dependency and never waits on a permission. Two cautions. (1) The address will change: Alex says the assistant-manager / leasing team is being restructured and more addresses are coming, so put the recipient list in config, never in code, and expect it to become a list per property rather than one address. (2) The office-hours fork above is untouched by this: knowing the address does not tell you who reads it at 10 PM on a Friday. 🐾 Added 2026-08-05: getWorkOrders carries petInfo on the request (§4.1). Put it in front of whoever is being sent to the unit: a technician walking into an apartment with a dog in it should know before the door opens. This is an access and safety fact, and it is separate from the pet-gift program in §8: no isAssistanceAnimal filtering here, because a service animal is still an animal in the room. It rides the escalation email we already send; nothing about it touches rule 2, since reading a work order is a read. Where it should land inside Liberty's own dispatch is theirs to decide (§14).
📊 The AGG node is AFFIRMED, and it now has a metric behind it. DECIDED 2026-08-10 (Brent). He chose aggregate over per-ticket policing, so "watch the scoreboard, not the plays" stands exactly as the diagram draws it, and it is now a decision with a date and a name on it rather than an unattributed design-intent line. (Promoted to Constitution rule 13 on 2026-08-13, DECISIONS.md #14: no longer just design intent.) What is new: a maintenance responsiveness metric SHOULD now be built, and the design had none. Reconcile it with lead-comms-spec §5's "no nightly SLA-policing" plainly, and do not delete that line: measuring the aggregate is not policing; alerting on or chasing an individual ticket still is. ⚠️ It is dual-purpose, and that is a build constraint. Brent: "If that KPI is genuinely good we can use it in marketing, and if it is bad then it is an accountability metric for LAG." Both uses demand the same thing: one definition, written down once, computed the same way every time, and never cherry-picked by window. A number used in both directions cannot be redefined when it is inconvenient. It computes read-only, with no new permission and nothing to ask Liberty for: getWorkOrders carries completedOn (OBSERVED 2026-08-05) and createdOn (SPEC, not yet observed), plus createdOnFromDate / createdOnToDate / completedOnFromDate / completedOnToDate request filters. Build cautions: skip Entrata PaaS records (7 of 785, turns not resident issues); collapse parent/child (312 of 785 are children) before counting; do not segment on maintenancePriority (780 of 785 are Medium), use problem and location; ⚠️ createdOn is SPEC, so verify it comes back populated before building on it, because if it is empty the metric needs a different clock and the design changes; and consider reporting in-hours versus out-of-hours separately, since the office is M-F 9–5 with no weekend coverage and roughly 76% of the week has no staff. Closed 2026-08-13 (DECISIONS.md #13): NO target is set, deliberately. Track it for a while and display it on the scoreboard; no SLA negotiation with Liberty unless the data eventually says there should be one. (The 24hr / 4hr scorecard number stays what it was: a FIRST CONTACT target, parked at Phase 2, not a resolution target and not this metric's.) None of this touches rule 2: it is all reads.

11. FlightDeck & reporting PLANNED

The shared cockpit from the Overview: per-property KPIs, NOI vs budget, DSCR, flags. Sources, per property:

Board metricSource
Phys/econ occupancy, NOI vs budget, DSCR, delinquencyEntrata financial group via VPS pulls. getGlTransactions works (90 GL accounts, unit-tagged line items, drop-in for NPI's monthly reconciliation, and PII-bearing: AR Payment memos carry resident names). getBudgets, getBudgetActuals, getBankAccounts are absent from the accessible list, so budget has to come from NPI's own model until Liberty enables them. Actuals yes, budget no.
Apps/tour, speed-to-lead, cost per leaseGHL funnel + Entrata getLeads source tagging (createdOnDateTime is the speed-to-lead clock). Attribution is reconciled from GHL, not written into Entrata: there is no Foothold lead source in Liberty's pick list and writes are barred (§6).
Renewal rate, vacancy days, loss-to-leaseEntrata getLeases with no status filter (52-lease rent roll with end dates and scheduled charges) + getFloorPlans / getUnitTypes for market rent + turn-engine ledger. getExpiringLeases is blocked, and not needed.
Rating, review velocityReview-engine monitor (Google + Apartments.com)
ILS grade (weekly report card to Liberty)Listing-freshness checks + review presence + photo completeness, the scorecard the agent grades weekly. getPropertyMedia is blocked, so photo completeness cannot be graded from the API; floorplan images do come through getFloorPlans.
The asset-management half of FlightDeck is buildable right now. In-place vs market rent by unit, loss-to-lease in dollars and percent, renewal exposure by month, and NOI actuals from the GL are all pure reads against data that is already returning. No writes, no agent, no Twilio, no GHL, so it carries the least dependency risk of anything in the build. Worth considering earlier than Phase 5. Two caveats: budget is not available (above), and the GL feed is a PII feed, so redact at the transport layer, never log raw response bodies, and keep resident data out of this repo.

12. Scope traceability: the Overview's promises → this design

Everything promised on / maps here. If a promise has no flow, the design is incomplete; if a flow serves no promise, cut it.

Overview promiseKPI targetFlowComponents on the critical path
Demand engine: owned list, 24/7 agent, Insider blast, sight-unseen media0.4 → 3.0 apps/tourB (+ A)Agent, GHL funnel, Twilio, availability cache, unit media packages, ProspectPortal links
Turn engine: pre-leased before empty~41 → ~0 vacancy daysC (+ A)Lease pulls, waitlist matching, Insider 48-hr window, Liberty ready-date confirm
Recognition engine: auto-mailed cards + gifts57% → 70% renewalDResident-facts pull, recognition cron, Handwrytten, Tremendous, Twilio, Entrata logging
Review engine: ask at goodwill peaks3.9 → 4.5★+EEvent triggers, suppression ledger, smart links, service-call card
Econ occupancy (rollup)~90% → 95%+A–EAll of the above + FlightDeck delinquency/concession visibility
Brand-rent premium (the proof)+5–8%allThe system working; measured in FlightDeck vs market comps (HelloData)
Escalations as clean tickets; full PM visibilityn/aFGuardrails, work-order relay, note logging
FlightDeck, one board both sides seen/a§11Financial pulls, engine metrics, ILS grade

13. Build sequence & gates

PhaseDeliverableStatusGate / dependency
0Site, waitlist→GHL, binder, VPS + mock service, Entrata agreement + portal + key + full OpenAPI specDONEn/a
1Availability live: wire ENTRATA_API_KEY into config, live parse (MT dates), KV publish, /rentals reads KV, backoff + maintenance windows.IN PROGRESSAccess gate CLEARED 2026-07-31, widened 2026-08-03. getAccessibleServices → getProperties both run: 28 services permissioned (22 on 07-31), 22 verified returning production data as of 2026-08-05, property ID 1311115 known. Remaining work is wiring, not access: swap config.js off the legacy username/password vars, take sq ft from getFloorPlans, parse MT explicitly. Webhooks no longer gate this: researched 2026-07-31, 5 events, none of them availability, so build the poller as designed (§4.5). There is no test company. Dev happens read-only against Owyhee Park, a live production building (rule 2), with the read-method allowlist and a property-ID allowlist (§4.4) in place at the single request choke point.
2Lead loop: outbox transport (Queues recommended), relay intents, sendLeads/updateLeads with dedupe, CoStar lead-email destination pointed at the agent inbox, ILS syndication flipped on by LibertyPLANNEDPhase 1 + Brent's written per-property write sign-off (rule 2). There is no test company to validate writes against, so build the outbox and relay first and leave the write legs unexercised. Liberty flips ILS Portal on. Scope of that first sign-off decided 2026-08-13 (DECISIONS.md #2): sendLeads + updateLeads at go-live. Lead-event/transcript logging rides on updateLeads, so it is inside the same signature; work orders and lease activities take a second signature later.
3Agent v1: /api/chat Worker + widget, tools (check_availability on KV, capture_lead, book_tour, start_application), transcripts + daily Opus QA cron, Twilio numbers + A2PDECIDEDPhase 1 (availability data) + Entrata AI-disclosure review (rule 3) + A2P registration lead time. book_tour is no longer designing blind, as of 2026-08-05: getCalendarAvailability is verified and returns real tour configuration: 30-minute slots, 120-minute minimum lead time, agent-guided M-F 08:00–16:00 MST, self-guided 7 days 06:00–18:00 MST at 1 concurrent, per-date availableHours, 7-day maximum query window (§4.1). Design the tool to those parameters. What the calendar still does not give you is Liberty's actual booked appointments, because booking is manual, so treat it as configuration truth and appointment guesswork, and keep double-booking prevention on our side (§14). Booking semantics decided 2026-08-13 (DECISIONS.md #10): book_tour PROPOSES for agent-guided tours and COMMITS for self-guided. Liberty's agents book manually around their own drive time, so that calendar can be silently wrong; the self-guided limit (1 at a time) is real and nobody has to drive anywhere. Committing a booking is a write and waits on the #2 sign-off.
4Recognition + review engines: Supabase provisioned, resident-facts pull, Handwrytten/Tremendous accounts, trigger matrix, suppression ledgerDECIDEDPhase 1 (lease/resident reads on real properties). Blocked on property scope, not on access: only Owyhee Park is on the key, and it is Liberty's building, so this needs Aspen/Summerset added to the key post-PMA (a Liberty permission change, lead time unknown). Resident facts come from getLeases; getCustomers is blocked. Unblocked 2026-08-03: the third review trigger (work-order complete) now has a data source, getWorkOrders (§9). Unblocked 2026-08-05: getMitsLeases is verified (household shape, roommates, guarantors, Customer IDs) but carries no birth dates: birthdays come from getLeads birthDate instead, month and day only, with the Welcome Profile now the fallback rather than the only source (§8). Size that fallback properly when you build: DOB covers 65% of current residents (measured 2026-08-05), so about one resident in three needs the form. 18 never appear in getLeads and 13 appear with the field empty, and neither gap closes by sweeping wider. Also new here: getMarketingPreferences gives both engines a per-channel consent gate before any touch goes out, so build that gate in Phase 4 rather than bolting it on later. Delivery decided 2026-08-13 (DECISIONS.md #6): the Welcome Profile is a mailed Handwrytten welcome packet, and the no-DOB annual touch is a Thanksgiving card; the weekly digest flags new tenants missing a DOB (§8).
5FlightDeck wiring + weekly ILS report cardPLANNEDPhase 1 financial reads; report card can start semi-manual earlier. The asset-management tiles are unblocked today (rent roll, loss-to-lease, GL actuals are all reading), so this can move earlier than its phase number implies. Budget data is not available from Entrata (§11).
6Voice (same brain, Vapi/Retell-class building block) + applications-push if ever neededPARKEDAgent proven on chat first, a maturity decision, not a liability one
🏁Launch: remove gate + noindex on both sites (gate.js here; Base.astro + robots.txt on foothold-web)PLANNEDBrent's call

14. Open questions (decide at the marked phase)

QuestionPhaseCurrent lean
Property scope: Aspen and Summerset are not on the key1The biggest open item after the probe. Exactly one property is granted: Owyhee Park, 1311115, which Liberty manages and NPI does not own. Nothing can be built or validated against Aspen or Summerset today, and every engine that needs an NPI-owned building waits on this. Ask Liberty what adding them requires and what the lead time is (same question for North River when it moves onto Entrata). Be honest in status reporting about which parts are proven and which are only designed. Note: Aspen does not inherit writes on arrival, rule 2 is per-class. 📌 OBE 2026-08-13 (DECISIONS.md #18): Liberty is already working on setting up Aspen and Summerset. No email needed; it'll be a minute, monitor progress. The no-inherit-writes note stands; the #2 signature governs.
The asks for Liberty (one consolidated list)1GRANTED 2026-08-03 ALL SIX VERIFIED 2026-08-05 The permission asks are DONE and every method granted has now been exercised against production. Alex Costa granted all six on 2026-08-03 (08:47 AM PT); the 08-03 re-probe confirmed them on the key (28 services, six new reads, no new writes) and the 2026-08-05 probe confirmed all six return HTTP 200 with real data. Brent sent them on 2026-07-31 (cc Taylor Grange) in the "Entrata API user for Liberty" thread. Nothing below is a to-do. Do not re-send, do not chase, and do not widen the list without reading item (6) first. Items (1), (3) and (4) are closed and kept as record; (2) is a standing decline; (5) is what is genuinely still open. (1) ✅ GRANTED 2026-08-03. The maintenance group: getWorkOrders + getWorkOrderPickLists. This was the top ask and it was the expensive one (a group grant, not one checkbox, since the entire group was absent from the key), and Liberty made it. getWorkOrders is verified returning production work orders, not merely permissioned (§4.1). Reads only: sendWorkOrders and updateWorkOrders were not included, which is the correct outcome and is now a deliberate per-method state rather than a missing group (rule 2). Lesson worth keeping: the group-grant ask succeeded, so do not treat "they'd have to grant a whole group" as a reason not to ask. When webhook provisioning happens, still pair it with workorder.status.update (§4.5), now for latency, not for access. (2) Write cleanup: ❌ DECLINED BY BRENT 2026-07-31. Off the asks list. Do not ask, do not re-raise, do not re-draft the email. The ask would have been "uncheck sendLeads and updateLeads on the API user," two checkboxes and not four (sendWorkOrders and updateWorkOrders were never granted, so there was nothing to turn off there). The email carrying it was drafted 2026-07-30, never sent, and deleted on 2026-07-31. It no longer exists and there is no pending action here. Brent is accepting the risk and leaving both write permissions enabled, because the permission being on is not what causes a write, our code calling a write method is, and it isn't worth a second round-trip with the PM. Rule 2 is unchanged and undiluted. What changes is only the mitigation posture: there will be no vendor-side guard, permanently and by choice, so the code-side deny-by-default read-method allowlist is the sole control forever (rule 2). Kept on this list as a closed record, not deleted, so it does not get re-raised in three months. (3) ✅ GRANTED 2026-08-03, PROBED 2026-08-05, and the answer is not the one we expected. getMitsLeases works but has no birth dates. The method returns real data (100 Tenant records against 52 leases, guarantors, spouses, emergency contacts, Customer IDs) and its schema does define BirthDate, but the field is empty on this property, the same permissioned-is-not-populated trap getAmenities sprang. The birthday problem is answered anyway, and by a method we already had: getLeads r1 carries birthDate. DOB is captured at the lead/application stage and never propagates to the lease. So the recognition engine reads DOB from getLeads, joins on customerId (join proven, 70 of 88 current residents matched), and stores month/day only (§8). "Answered" is not "complete": coverage is 65% of current residents, measured 2026-08-05 (the old "122 of 122" claim was an artifact of Entrata omitting null fields and is withdrawn), so the Welcome Profile covers the remaining third and stays in the build. Nothing further to ask Liberty for on this: the gap is missing and empty lead records, not a permission. getCustomers remains irrelevant: no birth-date field at all, and now doubly moot. (4) ✅ GRANTED 2026-08-03, ALL THREE PROBED 2026-08-05 and all three work. getCalendarAvailability (group properties) and getMarketingPreferences + getMarketingPreferencePickList (group communications, a whole-group grant Liberty made). ✅ The getCalendarAvailability caveat carried here since 08-03 is WITHDRAWN. It does not come back empty. It returns real configuration: 30-minute appointments, 120-minute lead time, agent-guided M-F 08:00–16:00 MST, self-guided touring configured for all 7 days, 06:00–18:00 MST, 1 at a time, and it caps a query at 7 days (400/308 past that). What is manual is the booking, not the configuration, so book_tour can be designed on these parameters while double-booking prevention stays ours (§4.1, and the tour-booking row below). ✅ The customerIds question on getMarketingPreferences is also resolved: IDs come from getMitsLeases, the absent customers group is not needed, and the comms engines get a real per-channel consent gate (§8, §9). (5) Still open, and this is the only live part of this row: is budget data (getBudgets / getBudgetActuals) available at all, webhook subscriptions (§4.5), a Foothold lead source, the per-property ProspectPortal URL, property scope for Aspen and Summerset, and the application-link question Brent put to Alex on 2026-08-03 that is still unanswered (does each property have an application link so a prospect can apply any time? See its own row below). 🆕 Two write asks were added on 2026-08-05 by Brent's decisions and neither has ever been made: sendWorkOrders on NPI-owned properties, so the agent can file a verified resident's service ticket itself, and sendLeaseActivities, so Foothold's own resident touches land in Entrata instead of Liberty's inbox (deferred earlier that day, reversed the same day). They go out in ONE email, since sendWorkOrders is the larger and more consequential grant and the activity note costs nothing extra to name in the same conversation. ⚠️ A grant on either one authorizes nothing. Both are writes, so rule 2 still requires the per-property lift plus Brent's written sign-off naming the property and the methods, and at Owyhee Park sendLeaseActivities stays unused even if granted. So the near-term list to Liberty is four: Aspen and Summerset onto the key, the application link, sendWorkOrders, and sendLeaseActivities. Detail in foothold-design-scope.md asks #7 / #17 / #18 / #19. ⚠️ Updated 2026-08-13: the write-ask email is TABLED (DECISIONS.md #19), not sent, with the Foothold lead-source ask (#20) bundled into it whenever it goes; build the GHL-to-getLeads reconciler regardless. The Aspen/Summerset half is OBE (#18): Liberty is already onboarding both, nothing to send there either. Consequence while #19 stays tabled: resident-side touches log to Foothold's own store and surface in the weekly digest (§8). (6) ❌ Deliberately NOT asked, recorded so nobody re-adds them without thinking. getLeadEvents (the getLeads r1 eventDateFrom / eventDateTo / eventTypeIds filters cover the need and return events inline). getExpiringLeases (getLeases carries lease end dates). getPropertyMedia and the testimonial methods (not urgent). Added 2026-08-05: getLeaseDocuments / getLeaseDocumentsList. Checked while chasing DOB, both are in the leases group and neither is on the key, so executed lease documents are not reachable; nothing currently designed needs them, so this is a recorded fact, not an ask (§4.2). Settled harder later the same day: all 44 Aspen executed leases were text-extracted (7.4M characters) and contain zero occurrences of "birth" or "date of birth" (lease documents record screening results, never DOB), while each carries one SSN field. So the method would not have answered the question and would have handed us data rule 12 makes us strip. Do not re-raise it. And getCustomers, which is dropped from consideration entirely rather than deferred: its response schema has no birth-date field, so it would not serve the birthday-card use case even if unblocked, and as of 2026-08-05 the use case is served by getLeads anyway, so there is now no version of this ask worth making. Resident data already arrives via getLeases, getMitsLeases and getLeads. Full reasons in foothold-design-scope.md and entrata-api-context.md §9.
Webhooks vs 12-min polling1RESEARCH SETTLED 2026-07-31 (§4.5). The polling design survives. Entrata publishes 5 events (3 lease, 1 online-application, 1 work-order status) and none of them covers availability or pricing, so the 12-minute poll is not provisional and should stop being described as such. What remains open is provisioning, not research, and it has two gates: email apisupport@entrata.com (client name, subdomain, event types, receiving URLs) and Liberty ticking the webhook checkboxes under Apps → API Access → Manage → Web Service Permission. Receiver goes on a Worker, never the VPS (rule 1 is untouched, Entrata pushes to us). Worth doing anyway for the application/lease event layer and for the work-order layer. And the work-order case changed on 2026-08-03: the maintenance read grant landed, so workorder.status.update now hands us an ID that resolves (§4.5). The webhook is now a latency upgrade over polling getWorkOrders, not the thing that unlocks the data. 📌 DECIDED 2026-08-13 (DECISIONS.md #16): provision, bundled into the Aspen/Summerset onboarding already in motion; receiver on a Worker per rule 1, then observe what arrives during normal Owyhee activity, read-only. The 12-minute poll stays the design either way (§4.5).
Does any webhook fire on lead / guest-card creation?1NEW, and the highest-value unanswered webhook question. The 5 published events include no lead-created event. But Entrata models a lead and an application as the same object: the lease events carry ApplicationStageId and ApplicationStatusId, and getLeadEvents treats "Application/Lead ID" as one identifier. So lease.started may fire at the guest-card stage. Undocumented either way. Must be asked (apisupport@entrata.com, in the same email as provisioning). If it does fire, the speed-to-lead clock starts on a push instead of a getLeads poll, which is the single biggest thing webhooks could buy this build. Path set 2026-08-13 (#16): observation answers it once the receiver is up during normal Owyhee activity; the signature discrepancy goes to Entrata support in the same exchange (§4.5).
No Foothold lead source exists in Liberty's pick list2The 17 lead sources on the property are Liberty's own (Zillow, Apartments.com, ApartmentGuide, Facebook, Drive By, Referrer, and so on); leadSourceId 67822 "Website" is Liberty's site, not ours. So a Foothold-originated lead has nowhere correct to land, which matters for attribution and for the whole demand-side thesis being measurable. Adding one is a Liberty-side config change nobody has asked about yet. Until then, attribution lives in GHL and is reconciled against getLeads by email and phone. Build the reconciler regardless: it stays useful after writes are authorized, as the audit path. 2026-08-13 (DECISIONS.md #20): bundle the ask into the #19 email whenever it goes; nothing sent now (#19 is tabled). The reconciler gets built either way.
Outbox transport2CF Queues pull-consumer (HTTPS pull keeps the VPS outbound-only). Alternatives: D1-backed table + tiny Worker API. Current CF token is Pages-scope: either needs a dashboard visit.
Subdomain written confirmation1SETTLED 2026-07-30: getAccessibleClients returned libertyassetgroup / id 19530. Re-confirmed on the 2026-07-31 probe.
Web-service permissions on our API user1SETTLED 2026-07-31 by read-only probe at 22 services; RE-PROBED 2026-08-03 at exactly 28, 17 verified returning production data; RE-PROBED 2026-08-05 with the last five methods exercised, taking verified to 22 (§4.1; the roster in reference/entrata-method-index.md and entrata-api-context.md §2.1 were re-captured 2026-08-05 and agree with §4.2 on this page). The 08-03 delta is six reads and zero writes, exactly the six asked for on 07-31: getWorkOrders, getWorkOrderPickLists, getCalendarAvailability, getMitsLeases, getMarketingPreferences, getMarketingPreferencePickList. Two groups that were absent entirely, maintenance and communications, are now on the key. What got enabled includes two writes: an email asking Liberty to skip the write services was drafted 2026-07-30, never sent, and deleted on 2026-07-31, so sendLeads and updateLeads are live-enabled against an occupied building. That draft no longer exists and nothing is pending on it. Both are confirmed callable and both are banned. Writes are UNCHANGED at exactly two after the 08-03 grant. sendWorkOrders and updateWorkOrders are still not enabled, and that reads harder now than it did on 07-31: back then the whole maintenance group was absent, so their absence was a side effect. The group is on the key now and Liberty granted only the two reads from it, so their absence is deliberate and per-method. The write cleanup would still have been a two-checkbox ask, and Brent declined to make it on 2026-07-31: both writes stay enabled permanently, by choice. None of this softens anything, it sharpens it: there is no vendor-side guard on the two that did land and there never will be, so the deny-by-default read-method allowlist in our code is the only one there is (rule 2), sitting next to a property-ID allowlist (§4.4). The 28-service accounting closes: the original 22 (16 verified-working reads and plumbing + 2 banned writes + 3 oauth methods, unused + getAppAdditionalServices, OAuth-only 400/107) plus the 6 reads granted 2026-08-03, all six of which are now verified against production (getWorkOrders on 08-03, the other five on 2026-08-05, every one HTTP 200 with real data, all reads, issued serially from the VPS). So the key has no permissioned-but-unknown methods left. ✅ Closed completely on 2026-08-05 by a third pass: the last unprobed methods were called and the last two rate limits were captured. getWorkOrders is 150/min, 1,500/hr, 25,000/day and getWorkOrderPickLists is 60/min, 500/hr, 1,000/day, the tightest daily bucket on the key (§4.3, now complete). The three oauth methods (getAccessToken, getJwtAccessToken, getUserInfo) return 403 / error 1403 and getAppAdditionalServices still returns 400/107, so all four are confirmed dead ends rather than assumed ones (§4.2). Nothing on this key is unprobed. Writes are still exactly two, no probe attempted one, and none ever will: the two writes are the only services on the roster that have not been called, deliberately, because rule 2 bars finding out. ⚠️ Entrata's onboarding email says "leaving the defaults is recommended": the default is none, so never tell a PM admin the defaults are fine; hand them the explicit method list.
Abandoned-app status IDs2Downgraded 2026-07-31: check the pick list first, this probably is not blocked at all. getLeadPickLists returns 62 LeadStatuses and the July getLeads pull already shows the real strings in use ("Application Started," "Application Partially Completed," "Application Archived"). Read that list and map the IDs. Only if it does not answer the question do the old options apply (have Liberty read the IDs off their config, or defer to Aspen onboarding). No test application, ever: it is barred by rule 2 and there is no test company.
North River onto Entrata4Moving to LAG/Entrata (2026-07-20), then all three properties ride this one integration. PMS adapter stays anyway. Onboarding a property should be a permission change plus a getProperties re-read, not a code change.
Resident life-event data source (birthdays, pets)4BIRTHDAYS ANSWERED 2026-08-05 COVERAGE MEASURED 2026-08-05 PETS ANSWERED 2026-08-05 Birthdays have an Entrata source after all: getLeads birthDate. Not the lease (getLeases and getMitsLeases both return zero birth dates), because DOB is captured at the lead/application stage and never propagates. Read it from getLeads, join on customerId, store month and day only (§8). ⚠️ Coverage is 65% of current residents, and the "122 of 122" figure this row used to carry is withdrawn: Entrata omits null fields, so that count only ever counted records that already had the key. Real numbers: the full 2021-2026 sweep returns 787 lead-customers, 286 with a birth date (36%); against the roll, 57 of 88 current residents have one (65%), with 18 absent from getLeads entirely and 13 present but empty. So the Welcome Profile covers roughly the other third and is not optional. 🔄 Pets are ANSWERED too, and the old "our own pet form" line is withdrawn. getLeases r1 carries per-pet records with name, petType, breed, color, gender, age, weight, moveInDate, customerId and isAssistanceAnimal: 30 of 52 leases have pets, 34 records. Join on customerId, the same key as birthdays. getLeads adds 126 prospect-side pet records; getMitsLeases has no pet name and is not the source; getPetTypes is not on the key and is not needed. ⚠️ Filter isAssistanceAnimal out of the gift program and any pet marketing: fair housing, not tact (§8). getCustomers is blocked and no longer wanted. Pet policy is API-readable at property level ($300 deposit, $50/mo, cats and dogs); the unit-level pet and occupant fields are all 0, meaning unpopulated, not "none allowed." Liberty call transcripts idea is consent-gated: ask Liberty before touching.
Amenities and specials are empty in the PMS3Both methods work and both return zero. Data-entry gap, or genuinely nothing configured? If Liberty runs concessions, where do they live and how does Foothold learn about them? Until answered, amenity copy is our curated content and concessions are an out-of-band conversation (§4.2).
Office hours and after-hours handoff3PropertyHours says M-F 9–5, no weekend coverage. Confirm that is current and not seasonal, since the whole after-hours speed-to-lead argument rests on it. New wrinkle 2026-08-05: getCalendarAvailability reports tour windows that do not match: agent-guided M-F 08:00–16:00 MST, and self-guided 7 days a week, 06:00–18:00 MST, weekends included. So the building is tourable well outside the hours anyone is in the office, which strengthens the after-hours argument rather than weakening it and gives the agent something concrete to offer a Friday-night lead. Ask Liberty which set is authoritative when they disagree. Then decide the fork rule 10 creates: a Friday-night lead that hands off at 10 PM hands off to nobody until Monday, so either the agent holds the conversation or the handoff gets an explicit business-hours queue. 📌 DECIDED 2026-08-13 (DECISIONS.md #9): hybrid. The agent holds by default; an emergency or an explicit "I want a person now" escalates immediately with the hours stated plainly, and a genuine emergency gets the phone number handed over on the spot, never a queued callback (§10). The which-hours-are-authoritative question to Liberty still stands.
How does a prospect who wants a person reach Liberty's team?3ANSWERED 2026-08-03 Alex Costa: by email, direct to Liberty's leasing / manager team, and adrienne@libertyassetgroup.com is the right address today. They use Google / Breakroom chats internally but prefer direct email, so the notification leg is email: do not build a chat integration. Flow F's escalation keeps its two legs, an out-of-band notification a human actually watches plus an Entrata note for the record; the notification leg has no Entrata dependency and was already buildable today, and now it has a destination. Build the recipient as config, not code: Alex says the assistant-manager / leasing team is being restructured and more addresses are coming, so expect a per-property list rather than one address. Does not settle the after-hours fork (rule 10 / office hours row below). Detail in foothold-design-scope.md §1.5 and §10 above.
🐾 Pet info on a dispatched service call3DATA VERIFIED 2026-08-05 getWorkOrders r1 carries petInfo on the request (§4.1), so a ticket routed out of band to Liberty can say whether there is an animal in the unit. Worth surfacing to a technician being sent to a unit: it is an access and safety fact, not a marketing one, and it costs nothing because we are already reading the work order. What is not settled is where it lands: the escalation email is a Foothold artifact, and Liberty's own dispatch is Liberty's. Decide with them rather than assuming they want our formatting. Reading it is a read; nothing here touches rule 2.
📊 Maintenance responsiveness: the target, and where the number lives4METRIC DECIDED 2026-08-10 Brent decided the metric is an AGGREGATE and that it should be built (§10). The design had no resolution-time metric at all before this. It is dual-purpose by his own framing: good numbers are marketing proof, bad numbers are an accountability metric for Liberty, and both uses demand one definition, written down once, computed the same way every time, never cherry-picked by window. Source is getWorkOrders (completedOn OBSERVED, createdOn SPEC, plus the four date filters), read-only, no permission needed. Two things were deliberately left open on 2026-08-10; both closed 2026-08-13 (end of this row). (a) No target or threshold is set. ⚠️ The 24hr / 4hr number on the scorecard is a FIRST CONTACT target, parked at Phase 2 pending Liberty's commitment, and is not a resolution target; do not conflate them. (b) Whether it joins the six-KPI hero scoreboard on / or stays a feeder line on the project plan is undecided. ⚠️ Build gate before anything else: createdOn is SPEC, not observed. One read-only getWorkOrders call settles whether it comes back populated. If it does not, the metric needs a different clock and the design changes, so check it first rather than after. Both open halves closed 2026-08-13 (DECISIONS.md #13): (a) NO target is set, deliberately, track it for a while, no SLA negotiation with Liberty unless the data eventually says there should be one; (b) it goes on the scoreboard (§10). The createdOn build gate stands.
💬 The resident follow-up: trigger, timing, and one touch or two4CONTENT DECIDED 2026-08-10 What the message IS is settled: an AI-generated personal message about the resident's specific issue ("Did your back door get fixed?"), not the survey lead-comms-spec §4 cut, which stays cut (§9). Three things stayed open past 2026-08-10; all three closed 2026-08-13 (end of this row). (1) The completion trigger: poll getWorkOrders (works today, no provisioning) versus the workorder.status.update webhook (cleaner, needs the two-gate provisioning, §4.5), and which maintenanceStatus values count as complete enough to ask: statuses are client-defined, and getWorkOrderPickLists returns Liberty's real StatusTypes, so this is a lookup rather than a guess. (2) Timing after completion is unset. (3) 🆕 Whether this message and the §9 review ask are ONE touch or TWO. They fire off the same event, at the same resident, through the same channel, and nothing in the corpus has ever addressed it. ⚠️ Two things bound the answer: rule 7 means a follow-up must never become a sentiment filter in front of the ask, and the "don't spam" rule means touches get spaced so it doesn't read like a campaign. 🚫 Scope regardless: NPI-owned properties once live, never Owyhee. All three closed 2026-08-13 (DECISIONS.md #7, #8): poll, with qualifying statuses named in config off Liberty's real StatusTypes; next business morning; and TWO messages, follow-up first, review ask a few days behind, fired on the event never the reply. Plus the new AI-judgment gate on which work orders get a follow-up at all, its rules worked out against real Owyhee examples during build-spec (§9).
How does Liberty book tours today?3ANSWERED 2026-08-03 CALENDAR VERIFIED 2026-08-05 Alex Costa: manually. There is no system doing it. An agent takes the call or email and schedules the tour themselves, because each agent runs a portfolio of properties and has to arrange their own drive time, or they hand out lock-box info for a self-guided tour. 🔄 The design implication was rewritten on 2026-08-05, and the earlier version of it was wrong. This row used to say getCalendarAvailability probably returned nothing meaningful and that book_tour should not be designed on it. The probe says otherwise. The calendar carries real, current configuration: 30-minute appointments, 120-minute minimum lead time, agent-guided M-F 08:00–16:00 MST with no simultaneous cap, self-guided tours all 7 days including weekends, 06:00–18:00 MST, 1 at a time, plus per-date availableHours, with a 7-day maximum query window (400/308 beyond it). Self-guided touring is already configured in Entrata, which is the same thing Alex described as handing out lock-box info. Draw the line here: the booking is manual, the configuration is not. So Entrata may not reflect the appointments Liberty has actually made, and double-booking prevention is still ours (nothing in the spec says writing an Appointment consumes a slot, and a calendar that lives in an agent's head has no slot to consume), but the availability windows and tour parameters are real and book_tour should be designed against them (§4.1, Phase 3). Also carry the mismatch: tour hours are not office hours (08:00–16:00 vs M-F 9:00–5:00), so read both and derive neither. Brent's follow-up, 2026-08-03, explicitly NOT critical: he wants to explore self-service tour scheduling later: Calendly-style booking with drive-time buffers on either side of each slot, and an AI agent nudging leads toward adjacent slots so an agent's day routes efficiently. Parked as a later exploration, not a v1 requirement, and it would be a change to how Liberty works rather than an integration. 📌 Booking semantics decided 2026-08-13 (DECISIONS.md #10): propose for agent-guided, commit for self-guided (§13 Phase 3); committing is a write and waits on the #2 signature.
Does each property have a standing application link?2ASKED 2026-08-03 Brent asked Alex on 2026-08-03 (02:41 PM PT), still unanswered. The question: does every property have an application link a prospect can use to apply any time, without an agent in the loop? This is the operational half of the ProspectPortal decision already locked in §3: applications are links we send, never API writes (the applications group is not on the key and writes are barred by rule 2 regardless), so the agent's start_application tool is only as good as the URL it hands over. If the answer is yes, the after-hours story gets materially stronger: the same 76% of the week with no human coverage is when a self-serve apply link does its work. If it is no, or if the link is agent-generated per prospect, start_application becomes a handoff rather than a self-serve action and Flow B's terminal step changes shape. Do not guess the URL pattern from getWebsites: wait for the answer.
Cyber-liability insurance (agreement §3.2 rep)n/aConfirm carried; Brent.

Known unknowns (unresolved, do not treat as answered)

Things the 2026-07-31 probe surfaced without settling. Recorded so nobody builds on them by accident. One row (DOB coverage) was answered on 2026-08-05 and is kept here, marked, as the record of how it got measured: everything still unmarked is still unresolved.

UnknownState of play
3 Notice leases vs 2 available unitsgetLeases shows 3 leases on Notice (statusTypeId 5); getUnitsAvailabilityAndPricing shows 2 available units. They do not reconcile and we do not know why (one on-notice unit is presumably pre-leased or otherwise withheld). Availability is a computed field with rules we cannot see. Read it, never derive it from lease status.
The ~3–4 day make-ready assumptionThe turn engine (§7) assumes a 3–4 day turn. In the probe data MakeReady is one day before AvailableOn on both available units. One sample, so it proves nothing, but it does not support the assumption either. Check with Liberty before any vacancy-day math depends on it.
Whether July's 109 leads is a full monthThe probe ran 2026-07-31, so the count may or may not include the last day, and the pull window itself was not verified as a clean calendar month. Treat 109 and the 80/10/6 source mix as directionally right, not audited. Re-pull a closed month before it goes in front of a partner.
How much of the resident base the getLeads DOB path actually coversANSWERED 2026-08-05 The number exists now: 65% of current residents. Raised earlier the same day, when this row said coverage was unmeasured and that birthdays must not be reported as solved. The historical sweep it asked for was run (2021-01-01 to 2026-08-05, three calls, because getLeads takes a 730-day window) and it answers the question in both directions. Lead side: 787 distinct lead-customers, 286 with a birthDate, 36%. Resident side, which is the one that matters: the roll holds 103 customer records across 52 leases, 88 of them Current; 70 of the 88 appear in getLeads and 57 of the 88 carry a birth date (65%). The gap is 18 residents who never appear in getLeads at all plus 13 who appear with the field empty, and neither closes by sweeping wider, because the sweep is already the whole history. ⚠️ The correction inside the answer, worth more than the answer: this row's own "122 of 122" was an artifact: Entrata omits null fields, so counting a key across returned records counts only the records that already had it. A key-presence count on a null-omitting payload measures nothing. Practical outcome: birthdays are viable, not complete, and the Welcome Profile covers roughly the other third (§8).
The x-read-only response headerUndocumented. Returns 111 on most methods and 100 on the propertyunits/floorplan ones. Meaning unknown. Reported as observed. Do not interpret it and do not key any behavior off it.
Whether other property IDs work on our keygetWebsites exposes 98 Liberty property IDs (§4.4). Whether they are accepted as params on other methods is untested and will stay untested: probing another owner's building is out of scope. The blast radius of the key is therefore unmeasured, which is exactly why the property-ID allowlist exists.

15. Doc registry & supersessions

DocOwns
/ (Overview)Scope + the promises. What "done" means. Update it first, align everything else to it.
This pageBuild SoT: architecture, contracts, statuses, build order.
lead-comms-specComms behavior: channels, sequence, touch map, edge cases, freshness tiers. Still iterating.
project-planExecution: Gantt to the Aug-31 close, who-owns-what, KPI scorecard, deliverables hub.
entrata-integrationConnection points + lead flow, in plain English. Rewritten 2026-08-05 against the live probes: the connection-points table now carries the real read/blocked/absent status per method, the write legs point at the outbox instead of Entrata, and Access & Cost reflects the executed agreement ($0 Year 1, signed 2026-07-20). Still subordinate: trust §4 here and entrata-touchpoints.md over it on anything API-mechanical.
reference/entrata-api-context.mdEntrata ground truth from the 2026-07-31 live probe: real data shapes, sample payloads, rate limits, traps, PII map, open questions for Liberty, design implications. The deep reference for anyone designing against the API. Subordinate to this page: if it and the Constitution disagree, the Constitution wins. ✅ Re-captured 2026-08-05 and current through all three passes that day: the verified method shapes, the completed rate-limit table (§6), error codes 308 / 310 / 403-1403, the Cloudflare error code: 1010 User-Agent trap, the per-method window caps, and the getLeads birthDate finding with its measured 65% coverage are all in it. It keeps its dated correction blocks on purpose: that trail is the point of the file, not staleness. For the current state with no history attached, read entrata-touchpoints.md instead.
reference/entrata-touchpoints.md🟢 START HERE. The current-state API reference, written 2026-08-05. Transport and auth, the five client-build traps, the full error table, the safety boundary (2 banned writes, 4 methods unusable on this auth), all 22 usable reads with required params, window caps, measured rate limits and verified response shapes, the derived answers (DOB, pets, the PII rules) and what is not available. Zero history and no correction blocks by design, so it can be read straight through and trusted. It exists so no future agent has to re-explore the API. Where it disagrees with any other doc, this one wins; the others carry the reasoning and the exploration trail.
reference/entrata-method-index.mdAll 128 methods in one table: group, version, required params, read vs WRITE, and whether it is permissioned for us. Also carries a captured getAccessibleServices roster, which settles permission for every method without probing. ✅ That roster was re-captured 2026-08-05 and lists the current 28 (six new reads), and it agrees with §4.2 here. Nothing to re-capture. Signatures and read/WRITE flags are unaffected.
reference/entrata-webhooks-2026-03-04.mdVerbatim vendor webhook documentation from Entrata's dev portal (vendor updated_at 2026-03-04, captured 2026-07-31), with re-fetch commands in its header. Do not edit the body. The summary, the design consequences, and the provisioning path are §4.5 here and entrata-api-context.md §6.1.
reference/entrata-openapi-2026-07-28.yamlThe full vendor OpenAPI spec (2.8 MB, 18 groups, 128 methods, 302 schemas). Only when you need a field-level schema the other two do not cover.
Drive: strategy/10-ai-agents-spec.mdAgent architecture, guardrails, QA loop.
Drive: CONTEXT.mdOperational state + changelog, cross-project. More current than repo docs when they disagree.
Project memory: entrata-api-auth-shape.mdThe probe-established protocol facts + error vocabulary (Claude Code sessions).

Changelog

2026-08-13
design locked
All twenty open decisions answered by Brent (DECISIONS.md, answers on the → ANSWER: lines) and filed into this page. The design is LOCKED. Locked is not buildable: the nine build-spec areas at the bottom of DECISIONS.md get written next, before any code, and nothing here moved to buildable or in-progress. The Constitution gains rule 13, "watch the scoreboard, not the plays" (promoted from design intent per #14), and rules 4 and 5 carry dated refinements (#1/#5 lead-event logging plus the tabled resident-side appender; #3 affirming the min-info gate under AI-judged interest). Filed by section: Liberty's surfaces = the lead's own Entrata record + a five-line weekly digest email, no Liberty-facing Foothold page (#1+#5: §1, §6, §8); first write sign-off = sendLeads + updateLeads at go-live, second signature for work orders / lease activities (#2: §13); guest-card gate = AI judgment + (phone OR email) AND first name (#3: rule 5, §6); Welcome Profile = mailed Handwrytten packet, Thanksgiving card as the no-DOB fallback, digest flags missing DOBs (#6: §8, §13); work-order follow-up = poll + config statuses + next business morning + an AI-judgment gate on which tickets get one (#7: §9, §14); two messages, event-fired (#8: §9); hybrid after-hours, emergency = agent steps out immediately and hands over the phone number (#9: §10, §14); propose agent-guided / commit self-guided (#10: §13, §14); consent = live check load-bearing + nightly GHL sync (#11: §8); ~$75/door/year gift budget (#12: §8); maintenance metric on the scoreboard with NO target (#13: §10, §14); manual-availability contingency SPECced not built, trigger = Aspen not on the key two weeks before go-live (#15: §5); webhooks provisioned via the Aspen/Summerset onboarding, poll stays (#16: §4.5, §14); Entrata's testimonial object skipped for good (#17: §9); the Aspen/Summerset email OBE, Liberty already onboarding both (#18: §14); #19/#20 tabled, so resident touches log to Foothold's own store + digest meanwhile and the lead-source ask bundles into #19 whenever it goes (§8, §14). 🚫 Constitution rule 2 does not move: nothing is authorized today, writes are still exactly sendLeads and updateLeads, banned, and the three conditions still gate every write.
2026-08-10
corrections
Two factual corrections, no decisions, no permission change. Constitution rule 2 is untouched by both.

(1) ⚠️ CORRECTION, and it is the important one: "there is no lead-event write method anywhere in the 128-method spec" was FALSE and is withdrawn everywhere. Where it came from: reference/entrata-touchpoints.md §6.4, written 2026-08-05, which is the doc this corpus designates as the tie-breaker. How far it got: six files (entrata-touchpoints.md, foothold-design-scope.md, this page, lead-comms-spec.html, design-map.html, CLAUDE.md). How it was disproved: direct inspection of the vendored spec on 2026-08-10. In reference/entrata-openapi-2026-07-28.yaml, updateLeads-r2-request-schema runs lines 11672-12023 and carries an events array at 11923 with eventId (11931), typeId (11935, "Event type ID"), date (11943), type (11947, enum at 11951) and a free-text comments field (11988, "Event comments"). sendLeads-r1-request-schema carries an events array too (10604). Both methods are permissioned on the key. ✅ Supporting evidence, recorded as evidence and NOT as a recommendation to use it: sendMitsLeads r1's EventType enum includes Note, IncomingText and OutgoingText (11202-11204), so the vocabulary exists in Entrata's model. ⚠️ The accurate limitation is much narrower than the claim it replaces: the r2 type enum has no native text, chat, or note value, so logging a text or a chat means passing a numeric typeId resolved from getLeadPickLists (155 event types, readable today), which is unverified and cannot be verified without issuing a write. That is foothold-design-scope.md open question Tier 3 #8, live again rather than moot. 🚫 NOTHING HERE AUTHORISES ANYTHING. Appending a lead event is a WRITE and Constitution rule 2 governs it completely, under the same three conditions. This corrects what the API can do, not what we are allowed to do. Two decisions rested partly on the false claim and both were corrected in the open rather than quietly patched: the 2026-08-10 resident-follow-up decision (its lease-level half survives, sendLeaseActivities really is the only appender for a RESIDENT record and really is not on the key, so the decision and its destination are unaffected and only the rationale was partly wrong) and the sendLeaseActivities ask (kept as written, since resident-level touches are what it is for, with the "and nothing exists on the lead side either" part of its reasoning withdrawn). Standing lesson: the tie-breaker doc was wrong once. Check the spec, not the summary.

(2) ⚠️ CORRECTION: the blanket "dates are MM/DD/YYYY" rule was build-breaking and is now stated by DIRECTION. entrata-touchpoints.md and entrata-method-index.md both presented one format as universal. Requests take MM/DD/YYYY (datetimes MM/DD/YYYYTHH:MM:SS, literal T); responses come back ISO YYYY-MM-DD, per live samples already in entrata-api-context.md ("createdOn":"2026-07-30", "startDate":"2025-08-30", "moveInDate":"2023-11-01"). ⚠️ Response formats are not uniform across methods either: getWorkOrders returned 03/11/2024 23:58:35.168295 MDT. Format strictly on the way out, parse tolerantly on the way in. A parser built to the old wording fails on every date it reads. Mountain Time handling is unchanged and was already correct.

(3) ⚠️ CORRECTION on the Overview: the ~3-day turn was stated as fact in three places on index.html and is now gone from all three. It was an assumption the data contradicts (§7 note 3 and the known-unknowns table both record MakeReady landing one day before AvailableOn on both available units), and Liberty ask #10, confirm the make-ready SLA, has never been sent. lead-comms-spec.html's "Liberty's agreed turn SLA" was corrected too: nothing has been agreed. The turn engine's logic is untouched, since ready-date-equals-move-in-date never depended on the number. No replacement number was invented and none should be until Liberty gives one in writing.
2026-08-10
two decisions
Two decisions from Brent, both closing open questions, both running entirely on reads already on the key. Constitution rule 2 does not move: writes are still exactly sendLeads and updateLeads, banned, and neither decision authorises one. (1) 📊 Maintenance responsiveness is an AGGREGATE KPI, and it serves two purposes. Brent chose aggregate over per-ticket policing, so "watch the scoreboard, not the plays" is AFFIRMED, not overturned: lead-comms-spec §5 step 3 stands exactly as written, and it is now a decision he has actually made rather than an unattributed design-intent line. What is new is that a responsiveness metric SHOULD now be built, and the design had none. The "no nightly SLA-policing" line is qualified, not removed: measuring the aggregate is not policing; alerting on or chasing an individual ticket still is. ⚠️ Dual-purpose, in his words: "If that KPI is genuinely good we can use it in marketing, and if it is bad then it is an accountability metric for LAG." Both uses demand one definition, written down once, computed the same way every time, and never cherry-picked by window. A number used in both directions cannot be redefined when it is inconvenient. It computes off getWorkOrders: completedOn OBSERVED 2026-08-05, createdOn SPEC, plus createdOnFromDate / createdOnToDate / completedOnFromDate / completedOnToDate filters. Read-only, no new permission, no ask to Liberty. Cautions recorded with it: skip Entrata PaaS records (7 of 785); collapse parent/child (312 of 785 are children); do not segment on maintenancePriority (780 of 785 Medium), use problem and location; ⚠️ verify createdOn is populated before building on it, because if it is not the metric needs a different clock; and consider splitting in-hours from out-of-hours, since ~76% of the week has no staff. Open: no target set (the 24hr / 4hr scorecard number is FIRST CONTACT, parked at Phase 2, not resolution) and hero-scoreboard placement undecided. §10, §14, project-plan, foothold-design-scope.md §3.1. (2) 💬 The resident follow-up is an AI-generated personal message about their specific issue. Brent: "an AI generated personal message about their specific issue, 'Did your back door get fixed?'" It is NOT the survey lead-comms-spec §4 cut, and that cut STAYS cut. It is the lighter personal check-in, and it is the version that survives the objection that killed the survey. The posture question is resolved: the message goes to the RESIDENT, not to Liberty, nothing per-ticket is sent to, demanded of, or logged against Liberty, and per-ticket outbound to a resident is a different act from per-ticket policing of a vendor. Reads maintenanceProblem / maintenanceLocation / maintenanceDescription, all on the key today. Four inherited filters: skip Entrata PaaS; collapse parent/child; clear consent on both the LEAD and RESIDENT lens (missing = don't send); triage on problem and location, never priority. ⚠️ New risk with a new rule: the message quotes the resident's own issue back to them, so a wrong or stale detail is worse than a generic message. The AI renders from the actual field values and never invents detail; if problem and location will not form a natural, confident sentence, fall back to a generic check-in or do not send. Rule 8 (fair housing) applies. 📌 It cannot be logged into Entrata (sendLeaseActivities, the only appender for a resident / lease record, is not on the key), so it logs to Foothold's own store, and that strengthens the sendLeaseActivities ask. ⚠️ CORRECTED 2026-08-10: the parenthetical used to read "no lead-event write method in the whole 128-method spec," which was wrong and is withdrawn (see the correction entry below). The decision is unaffected, only its stated rationale was partly wrong, since this touch goes to a resident and not to a lead. 🚫 NPI-owned properties once live, never Owyhee: reading Liberty's tickets does not licence messaging Liberty's residents. Open: the completion trigger (poll versus workorder.status.update, and which statuses count), the timing, and 🆕 whether this and the review ask are one touch or two, which nothing had addressed. §9, §14, foothold-design-scope.md §3.2 (Tier 3 #7 closed, #20/#21/#22 opened). Also updated: design-map.html, where "what the resident follow-up actually is" moved from blocked on you to buildable now (buildable 62 → 63, you 12 → 11, total still 94).
2026-08-05
DOB sweep + last methods
Third pass the same day. One number on this page was wrong and is corrected, one known unknown is answered, one build trap was found that would have cost a day, and the key now has nothing left on it that nobody has called. All reads. Constitution rule 2 does not move: writes are still exactly sendLeads and updateLeads, banned, and they remain the only services on the roster deliberately never called. (1) ⚠️ CORRECTION: "birthDate populated on 122 of 122 lead records" was an artifact and is withdrawn. Entrata omits null fields, so counting a key across returned records counts only the records that already carried it. The honest number, from the full sweep 2021-01-01 to 2026-08-05: 787 distinct lead-customers, 286 with a birth date, 36%. General lesson worth more than the number: a key-presence count on a null-omitting payload is not a coverage measurement. Corrected in §4.1, §8, §14 and the known-unknowns row. (2) ✅ ANSWERED: DOB coverage is 65% of current residents. The known-unknown that said "do not report birthday coverage as solved until the number exists" now has its number. The roll holds 103 customer records across 52 leases, 88 Current. 70 of the 88 appear in getLeads; 57 of the 88 carry a birth date. The gap splits two ways and neither half closes by sweeping wider: 18 never appear in getLeads at all, 13 appear with the field empty. So birthdays are viable but not complete, and the Welcome Profile covers roughly the other third: it stays in the build as a real component, not a courtesy fallback. §8 gains a coverage callout; §13 Phase 4 sizes the fallback. (3) ✅ The lead→resident join is PROVEN, not assumed. getLeads customerId and getLeases customer id are the same namespace, matched on 70 of 88 current residents. Both the birthday feed and the pet feed key on it (§8). (4) ✅ Backfill is cheap, and window caps are PER METHOD. getLeads accepts a 730-day window, so the entire 5.5-year history swept in three calls. Set that against getCalendarAvailability, which caps at 7 days. Never generalize one method's cap to another: new row in the §4 protocol table. (5) 🚨 NEW BUILD TRAP, and it is aimed at code nobody has written yet: Entrata's edge is Cloudflare and it 403s a default User-Agent. Python urllib with its stock UA got HTTP 403 with a plain-text body reading error code: 1010; the identical request via curl returned 200. The Node client must set an explicit User-Agent, and must treat a non-JSON 403 as an edge block rather than an auth or permission failure: misread, it looks exactly like a revoked key and sends you to Liberty over nothing. Classification belongs at the same single function that enforces the read-method allowlist (rule 2). New row in the §4 table, new crit callout under it, new error-vocabulary entry, and a pointer on the Entrata-service row in the component inventory. (6) ✅ Every remaining method probed. Nothing on the key is unprobed. The oauth trio getAccessToken / getJwtAccessToken / getUserInfo return 403 / error 1403 "You're not allowed to access this resource" (note 1403, not 107, and a real 403 rather than a 400); getAppAdditionalServices still 400/107; getAccessibleClients 200 with {"subdomain":"libertyassetgroup","id":19530,"name":"Liberty Asset Group"}; getStatus 200, "API service is available and running"; getLeaseDetails r2 200, propertyId only, result keys currencyCode + leases. And getMarketingPreferences on the LEAD side (recipientTypeId=1) returns 200 too: ⚠️ the same customer can carry BOTH a LEAD and a RESIDENT preference set, so recipientType is a lens, not a partition. A consent check that reads one side is not a complete check; honour the union (§4.1, §8). (7) ✅ Rate limits COMPLETE. The last two landed: getWorkOrders 150/min, 1,500/hr, 25,000/day and getWorkOrderPickLists 60/min, 500/hr, 1,000/day. Also re-measured: getLeads (135/5,000/27,000), getLeases (1,072/min, 64,320/hr), getLeaseDetails r2 (1,000/5,000/50,000). ⚠️ "The tightest bucket" is two different methods depending on the bucket: getMarketingPreferencePickList is tightest hourly (60/hr), getWorkOrderPickLists is tightest daily (1,000/day). Both are static pick lists, so cache both. The pattern is backwards from intuition: on this API the vocabulary endpoints are the scarce ones, not the big data reads. §4.3 refined; the "§4.3 is incomplete" warnings are removed from §4.2 and §14.
2026-08-05
SSN rule + pet data
A second round on the same day: one new binding data rule from Brent, and a dataset nobody knew we had. All of it came out of reads. Constitution rule 2 does not move: writes are still exactly sendLeads and updateLeads, banned. (1) 🚫 NEW BINDING DATA RULE: never ingest, persist or log a Social Security number. Constitution rule 12. Brent's call, 2026-08-05: NPI has no use for an SSN and does not want the liability. Where it shows up: SocialSecurity / taxId sits in the response schemas of getMitsCollections r1, getVendors r1, and (the one that matters) getMitsLeases r1, which IS on our key. The live probe returned no SSN keys at all; Liberty leaves the field empty, so there is no exposure today. The rule holds regardless, because empty-in-practice is not a control. The mechanism: the single request choke point that already enforces the read-method allowlist also strips SocialSecurity / SSN / taxId on read, before anything is persisted, logged, or passed to an AI model. A transport-layer scrub, not downstream cleanup, and it fails safe: a response that cannot be parsed well enough to scrub gets dropped. It sits alongside the existing month/day-only birth-date rule (§3, §8), and it governs document ingestion too: Aspen's executed leases each carry an SSN field, so a lease-document or DD pipeline scrubs on the way in on the same terms. New rule in §2; §3, §4.1 (getMitsLeases) and §8 updated. (2) 🐾 REVERSAL: pets have an Entrata source, and the pet-gift engine is unblocked. This page said pet details had to come from our own form. Withdrawn. getLeases r1 is the source: 30 of 52 leases have pets, 34 pet records, each carrying name plus petType, breed, color, gender, age, weight, moveInDate, customerId and isAssistanceAnimal. Pets join to the resident on customerId, the same key the birthday path uses, and no new permission is needed. Also probed: getLeads r1 returns 126 pet records (prospect-side context, adds count, id, licenseCity); getMitsLeases gives only Count, PetType, Weight with no name, so it is not the pet source; and getPetTypes is not on the key and is not wanted, since petType comes inline. The pet-gift.html mockup already existed and now has real data behind it. (3) ⚠️ isAssistanceAnimal is a fair-housing filter, not a tact preference. Assistance and service animals are not pets. Filter those records out of the pet-gift program and out of any pet-themed marketing or segment, at ingest so a downstream query cannot reintroduce them, and fail closed when the flag is missing or unparseable. Rule 8 territory. One deliberate exception, and it runs the other way: getWorkOrders r1 carries petInfo, which should be surfaced to a technician being sent to a unit: that is access and safety, so no filtering applies there (§10). (4) Executed lease documents are closed for good. All 44 Aspen executed leases were text-extracted (7.4M characters): zero occurrences of "birth" or "date of birth", one SSN field and one "18 years" clause each. Lease documents record screening results, never DOB. So getLeaseDocuments would not have answered the birthday question even if granted, and it would have handed us exactly the data rule 12 makes us strip. Do not chase it. §4.2 and §14 item (6) updated. Also updated: design-map.html, where the pet capability moved from never in Entrata to buildable now (buildable 61 → 62, never 10 → 9, total still 94).
2026-08-05
five methods probed
The five permissioned-but-unprobed methods were probed. All five work, two long-standing positions on this page are reversed, and the key now has nothing left on it that nobody has called. All HTTP 200, all reads, issued serially from the VPS. No write was attempted and none is authorized. Constitution rule 2 does not move: every finding below is a read, and writes remain exactly sendLeads and updateLeads, banned. (1) Five methods verified, taking the key to 22 of 28 services verified against production. getWorkOrderPickLists returns the maintenance vocabulary property-scoped: Locations (with unitTypes), Priorities, StatusTypes, Categories, Problems, SubMaintenanceProblems, 5 WorkOrderTypes, 5 InspectionStatuses, and a 12-row CompanyEmployees roster (Liberty's staff, usable for assignment/routing). getMitsLeases returns 100 Tenant records against 52 LA_Lease records, so roommates and guarantors are finally visible, with Finances, GuarantorFor, Spouse, Reference, Residence and the Customer ID values other methods key on. getMarketingPreferencePickList returns 24 rows (recipient types LEAD/RESIDENT, channels email / phone / postal_mail / sms, 5 alert frequencies; parse defensively, some rows have no consentType). getMarketingPreferences returns per-channel optIn. getCalendarAvailability returns real tour configuration. §4.1 gains a row for each; the combined "permissioned but NOT yet probed" row is gone. (2) 🔄 REVERSAL: the tour-calendar warning was wrong and is withdrawn. This page said getCalendarAvailability was likely meaningless because Liberty books tours manually, and that book_tour should not be designed on it. The calendar holds real configuration: 30-minute appointments, 120-minute minimum lead time, agent-guided M-F 08:00–16:00 MST with no simultaneous cap, self-guided tours all 7 days including weekends, 06:00–18:00 MST, 1 at a time, plus per-date availableHours. Self-guided touring is already configured in Entrata, matching Alex's lock-box answer. The correct line is the booking is manual, the configuration is not: Entrata may not reflect appointments Liberty actually made and double-booking prevention stays ours, but the windows and parameters are real and designable. ⚠️ The query window maxes at 7 DAYS, not 60 (8+ returns 400/308). Also recorded: tour hours are not office hours (08:00–16:00 vs M-F 9:00–5:00). §4.1, §10, §13 Phase 3 and two §14 rows updated. (3) 🔄 REVERSAL: resident birthdays DO have an Entrata source. This page said no Entrata method carried a birth date and the Welcome Profile was the only path. Wrong. getLeases r1 and r2 return zero birth keys and getMitsLeases returns zero populated birth dates despite defining BirthDate, but getLeads r1 returns a populated birthDate. DOB is captured at the lead / application stage and never propagates to the lease or resident record. It is readable today on a method already on the key. Design path: read from getLeads (r1 requires propertyId + fromDate + toDate, else 400/310), join on customerId. Residual caveat: only covers residents whose lead record falls inside the queried window, so long-tenured residents need a historical sweep and the Welcome Profile stays as the fallback: coverage is unmeasured (new known-unknown row). (⚠️ Corrected and answered later the same day: this entry originally claimed "populated on 122 of 122 records," which was an artifact of Entrata omitting null fields. The sweep was run: 36% on the lead side, 65% of current residents. See the entry above.) ⚠️ PII: store month and day only, drop the year at ingest; it is all birthday recognition needs and it materially cuts exposure, and Entrata's Secure PII Delivery requirements apply. Also checked and recorded: executed lease documents are not reachable (getLeaseDocuments / getLeaseDocumentsList are in leases but not on the key), not needed now. §3, §8, §13 Phase 4, §14 updated. (4) ✅ The customerIds blocker on getMarketingPreferences is resolved, and the comms engines gain a consent gate. It works with recipientTypeId=2 (RESIDENT), and the IDs come from getMitsLeases (IDType: "Customer ID"), so the absent customers group is not required. That gives Flow D and Flow E a real per-channel opt-out check before anything is mailed or texted: build it as a gate that fails closed (unknown consent = no touch). It does not soften rule 7: consent is a channel permission, never a sentiment filter. The LEAD side (recipientTypeId=1) exists in the pick list and was not exercised. (5) Rate limits captured for four of the five, and error codes 308 / 310 added. §4.3 gains getMitsLeases (200/min, 4,500/hr, 20,000/day), getCalendarAvailability (120/min, 2,000/hr, 48,000/day), getMarketingPreferences (60/min, 1,000/hr, 10,000/day), and getMarketingPreferencePickList (10/min, 60/hr, 1,500/day, the tightest hourly bucket on the key; cache it daily, never call it per lead). getWorkOrders and getWorkOrderPickLists are now the only two methods on the key with no measured limit. (⚠️ Superseded later the same day: both were measured, and getWorkOrderPickLists turned out to hold the tightest daily bucket on the key at 1,000/day. See the entry above. §4.3 is complete.) Error vocabulary gains 400/308 (window wider than the method allows) and 400/310 (required param missing): both are parameter faults, not permission faults.
2026-08-03
second grant
Liberty's second permission grant landed. The top ask is closed, getWorkOrders is reading production data, and Alex answered both operating questions. Verified by a read-only re-probe from the VPS on 2026-08-03. (1) 28 services permissioned, up from 22. getAccessibleServices returned 200 with 28. The delta is six reads and zero writes, exactly the six asked for on 07-31: getWorkOrders + getWorkOrderPickLists (maintenance), getCalendarAvailability (properties), getMitsLeases (leases), getMarketingPreferences + getMarketingPreferencePickList (communications). The maintenance and communications groups are no longer absent from the key: both were whole-group grants, which is worth remembering the next time an ask looks too big to make. Alex Costa confirmed by email at 08:47 AM PT. (2) getWorkOrders is verified functional, not just permissioned. A read-only call (group maintenance, r1, propertyId 1311115) returned 200 with real production work orders: maintenanceRequestId 3435/3436, buildingName "3030", unitNumber "3030-201", status Completed, problem "Make Ready", maintenanceRequestFrom "Entrata PaaS", MDT timestamps, and parent/child structure via parentMaintenanceRequestId. The other five are permissioned but unprobed, so nothing is designed against them yet. (⚠️ Superseded 2026-08-05: all five were probed and all five work. See the entry above.) This unblocks make-ready progress for the turn engine (§7), the work-order-complete review trigger (§9), and the read half of the escalation loop (§10); it also makes the workorder.status.update webhook useful, since the MaintenanceRequestId it hands over now resolves (§4.5). §4.1 gains getWorkOrders, §4.2 loses it. (3) Writes are UNCHANGED at exactly two, and that fact got stronger. sendLeads and updateLeads only. sendWorkOrders and updateWorkOrders are still not on the key even though the maintenance group now is, so their absence is a deliberate per-method state rather than a side effect of a missing group. Constitution rule 2 does not move and nothing here dilutes it: Entrata is read-only against any live production property, Brent's 2026-07-31 decision to leave both writes enabled stands, there is no vendor-side guard and there never will be, and the code-side deny-by-default read-method allowlist at the single request choke point, failing safe, is the only control that will ever exist. The deleted 2026-07-30 retraction email stays deleted: do not recreate it. The new maintenance reads loosen nothing. (4) Alex answered both operating questions. Tours are booked manually: an agent takes the call or email and schedules it themselves, because each agent runs a portfolio and has to arrange drive time, or they hand out lock-box info for a self-guided tour. ⚠️ So getCalendarAvailability is permissioned but Liberty does not appear to run scheduling through Entrata's calendar: do not design book_tour on the assumption it returns meaningful slots until it is validated against real data. (⚠️ Superseded 2026-08-05: it was validated and it does return real configuration, including self-guided tour windows. The manual part is the booking, not the configuration. See the entry above.) Human escalation goes by direct email to Liberty's leasing / manager team, adrienne@libertyassetgroup.com today; they use Google / Breakroom chats but prefer email, and the team is being restructured so more addresses are coming (keep recipients in config). That settles the out-of-band leg of Flow F's escalation, which never had an Entrata dependency anyway. §10 and both §14 rows updated. (5) Two things Brent added on 2026-08-03, neither blocking. He wants to explore self-service tour scheduling later (Calendly-style, drive-time buffers on either side of a slot, an AI agent nudging leads into adjacent slots for routing efficiency) and was explicit that it is not critical. And he asked Alex a new question that is still unanswered: does each property have an application link so a prospect can apply any time? New §14 row.
2026-07-31
asks sent
The Liberty asks went out, and the old retraction draft was deleted. Nothing in this binder is waiting on an unsent email any more. (1) ✉️ SENT to Alex Costa (cc Taylor Grange), "Entrata API user for Liberty" thread. Asked for getWorkOrders + getWorkOrderPickLists (maintenance), getCalendarAvailability (properties), getMitsLeases (leases), and getMarketingPreferences + getMarketingPreferencePickList (communications). Two operating questions rode along: how do Liberty's agents book tours today, and if a prospect wants a person, what's the best way to get that in front of Liberty's team (Brent noted Entrata doesn't appear to drive follow-up to-dos, and suggested the property inbox or a Slack channel). All of it is in flight, awaiting his reply. §14's asks row and the two question rows are marked SENT 2026-07-31. (2) ❌ Deliberately NOT asked, and the reasons are now on the record so nobody re-adds them without thinking: getLeadEvents (the getLeads r1 eventDateFrom / eventDateTo / eventTypeIds filters cover the need), getExpiringLeases (getLeases carries lease end dates), getPropertyMedia and the testimonial methods (not urgent), and getCustomers, dropped from consideration entirely because its response schema has no birth-date field, so it would not serve the birthday-card use case even if unblocked. (3) The 2026-07-30 retraction draft was deleted. It was never sent and it no longer exists. Several pages described it as sitting unsent in Gmail drafts; that was stale and is corrected here, in entrata-api-context.md, and in foothold-design-scope.md. The ask it carried (have Liberty uncheck sendLeads and updateLeads) was formally declined by Brent on 2026-07-31, so there is no pending action and nobody should recreate that email. Constitution rule 2 does not move. Read-only against any live production property, undiluted. Both write permissions stay enabled permanently by choice, and the code-side deny-by-default read-method allowlist remains the only control that ever prevents a write.
2026-07-31
Brent's decisions
Two calls from Brent, recorded here so neither gets re-litigated. (1) The write-permission cleanup is DECLINED, not pending. The planned ask to Liberty ("uncheck sendLeads and updateLeads on the API user") will not be made. Brent is accepting the risk and leaving both write permissions enabled, on the reasoning that the permission being enabled is not what causes a write, our code calling a write method is, and it isn't worth a second round-trip with the PM. Constitution rule 2 does not change: still read-only against any live production property, no writes ever without his explicit written per-property sign-off naming the property and the methods. What changes is the mitigation posture, and it tightens the code rather than loosening it: there will be no vendor-side guard, permanently and by choice, so assume write capability is always present on this key and treat the deny-by-default read-method allowlist at the single request choke point as the only thing that ever prevents a write. Rule 2 extended to say so; §14's asks row and the web-service-permissions row updated. The ask is kept as a closed record rather than deleted, here and in reference/foothold-design-scope.md (asks #5) and reference/entrata-api-context.md (§1, §9). (2) Two questions moved from deferred to in-flight with Alex Costa at Liberty. The escalation question ("if a prospect wants a person, what's the best way to get that in front of your team?") had been filed ASK LATER, to be raised when we designed escalation; Brent asked it now. Sent with it, and new: how does Liberty book tours today, so the agent's scheduling design matches their real mechanic instead of guessing. Both are now §14 rows marked SENT 2026-07-31, awaiting Alex, and the deferred flags in foothold-design-scope.md §1.5 and its asks list are cleared. See the row above for the full sent list.
2026-07-31
webhooks + roster
Webhook research and the full permission-roster capture. Two corrections and one new section. (1) Webhooks are answered, and the answer is "keep polling." Entrata's dev-portal Webhooks page was found and vendored verbatim to reference/entrata-webhooks-2026-03-04.md (vendor updated_at 2026-03-04; the portal is a Gravitee SPA but its backing API is public and unauthenticated). Exactly 5 events (lease.completed, lease.partially.completed, lease.started, oa.application.events, workorder.status.update), no availability event, no pricing event, no documented lead-created event, so the 12-minute poll survives unchanged and stops being described as provisional. New §4.5: event catalog, SNS-shaped payload (Message is a JSON string; IDs and status only, never the record), RSA signature verification (the doc's prose and its own sample code disagree on the signed string, build to the sample), at-least-once delivery with a stable MessageId to dedupe on, undocumented retry/ordering/dead-letter behaviour (treat every event as a trigger to re-read), and the two-gate provisioning path (email apisupport@entrata.com plus a Liberty checkbox under Web Service Permission). Receiver is a Worker, so rule 1 is untouched. Cost is undocumented; "free" is unverified. Third-party integrator docs claiming Entrata has no webhooks are wrong and stale. New open question: does any event fire on lead / guest-card creation? (2) The permission roster was captured and the record is corrected. getAccessibleServices returned exactly 22 services, now written out in reference/entrata-method-index.md and entrata-api-context.md §2.1. sendWorkOrders and updateWorkOrders were NEVER permissioned. Earlier entries on this page said all four writes were enabled and that was wrong. Only sendLeads and updateLeads are, so the write cleanup ask to Liberty would have been two checkboxes, not four (and Brent declined to make it later the same day, see the rows above). The maintenance and customers groups are absent from the key in their entirety (as are 9 other groups), not switched off per method. ⚠️ Partially superseded 2026-08-03: the maintenance and communications groups landed in the second grant; customers is still absent, and the two maintenance writes were still withheld. getLeadEvents is not permissioned, mitigated by getLeads r1 accepting eventDateFrom / eventDateTo / eventTypeIds and returning events inline. getMitsLeases is the resident-birthday method, and getCustomers has no birth-date field at all, so unblocking it would not help. (⚠️ Superseded 2026-08-05: getMitsLeases' BirthDate field is empty in Liberty's live data. The working source turned out to be getLeads. The getCustomers half of the sentence still stands.) The 22-vs-16 accounting now closes: 16 verified-working reads and plumbing + 2 banned writes + 3 oauth (unused) + getAppAdditionalServices (OAuth-only, 400/107). §14's Liberty asks consolidated into one row. Constitution rule 2 is unchanged and nothing here weakens it.
2026-07-31
live probe
Entrata access VERIFIED LIVE by a read-only probe. 22 services permissioned (7 on 07-30), 16 returning production data. Org libertyassetgroup / 19530; exactly one property, Owyhee Park, PropertyID 1311115, 52 units, 4 buildings, 4 floor plans, office hours M-F 9–5 with no weekend coverage. Aspen and Summerset are not on the key. New §4.1 (what's readable), §4.2 (what's blocked), §4.3 (measured rate limits), §4.4 (scope of the key). Headline findings: getWorkOrders is blocked 1403, which strands the turn engine's make-ready signal and the review engine's work-order trigger (flagged in flows C, E, F; no replacement trigger decided); getCustomers, getExpiringLeases, getPropertyMedia also blocked; getBudgets/getBudgetActuals/getBankAccounts absent, so FlightDeck's budget comes from NPI's model; getAmenities and getSpecials return ZERO, so amenity and concession content cannot come from the PMS; getStatus is capped at 6/min and cannot be a heartbeat, while availability polling is effectively unconstrained (600/min); getWebsites leaks 98 Liberty properties, so a property-ID allowlist is recommended alongside the read-method allowlist. July 2026 = 109 leads, Zillow 87 / Website 11; getLeases with no filter is exactly the 52-lease rent roll, so loss-to-lease is computable today. Abandoned-app status IDs downgraded from blocked to "check the 62 LeadStatuses." §14 rewritten with the real open list plus a known-unknowns table. OpenAPI spec + a new per-method index + a narrative context doc vendored into reference/ and added to the doc registry. No writes were made and none are authorized; rule 2 is unchanged.
2026-07-31Constitution rule §2 broadened from "read-only against Owyhee Park" to read-only against any live production property, Owyhee Park today and Aspen the day it onboards, NPI-owned or not. Lifting it now takes Brent's written per-property sign-off naming the methods. Why: Liberty enabled write services on 2026-07-31 because the 2026-07-30 email asking them to skip the writes was drafted, never sent, and later deleted (deleted 2026-07-31; it does not exist and nothing is pending on it). (Corrected later the same day by the roster capture: two landed, sendLeads and updateLeads, not four. sendWorkOrders and updateWorkOrders were never granted. The rule change stands on its own either way.) The old rule's "belt-and-suspenders, Liberty left the writes off" line was false and is removed: the code-side read-method allowlist is the sole guard. Also: rule 4's Entrata logging obligation is now explicitly deferred, not active, while rule 2 holds (log to the outbox, replay later); write-bearing flows B/D/E/F carry a blocked-leg caution; rule cross-references renumbered; Entrata access moved BLOCKED → IN PROGRESS (permissions enabled, unverified, verify with getAccessibleServices); Phase 1 corrected, there is no test company, dev is read-only against Owyhee Park.
2026-07-30Liberty's client-level grant landed. Subdomain confirmed libertyassetgroup (org 19530) via getAccessibleClients; property granted is Owyhee Park Apartments. New Constitution rule §2: READ-ONLY, no write calls. Owyhee Park is a live production building, not the training company we asked for; writes wait for Aspen. Added error 1403 and the getAccessibleServices verification loop to the Entrata protocol; flagged the "defaults are fine" trap. Only remaining blocker is Liberty checking the web-service permissions.
2026-07-28v1. Drawn from the Overview, lead-comms-spec v3, entrata-integration, CONTEXT.md, ai-agents-spec, and the captured Entrata OpenAPI spec. Statuses as of today: Entrata key on VPS, grant blocked on Liberty, mock poller running.