Landlord API — Slice 1: Foundation

Slice 1 makes a Better Auth API key a valid identity for the rental API, decides per-key what it may do, and freezes a public /api/v1/rental contract over the ten handlers that already exist. It adds no new business endpoints, no new tables, and keeps Bun authoritative for key identity while Go enforces a true hourly allowance in Redis.

View source markdown ↗ generated by claude-opus-5 · diagrams mermaid

Summary

Landlords can already be issued Better Auth API keys, but the rental API cannot read them. Slice 1 makes a key a valid identity, decides what each key may do, and freezes a public /api/v1/rental contract — without adding a single new business endpoint.

  • Slice 1 of 5 — Foundation
  • Runtimes Go API, Bun auth, rental portal
  • Surface 10 frozen v1 routes, key-only
  • New tables None — existing columns only
  • Review history 16 questions, 12 contradictions, all closed
  • Remaining gate Verification spike — gates shipping, not starting

The document was approved on 2026-09-19, put through a design review on 2026-09-20, and the last open product decision — rate-limit behaviour — was settled on 2026-09-21. Status is Approved, ready for implementation plan.

The five slices

The full ask spans five independent subsystems. The decomposition and the order were approved before this document was written.

OrderSliceDepends onWhy this position
1Foundation (this spec)Nothing works without key auth
2Idempotent sync1First real external writes need dedupe
3Media sync1, 2Photos attach to deduped properties
4Webhooks1Needs the key model; independent of 2–3
5CLI adapter1–3Thin client over a settled contract

Current state — existing code

Verified against the repository on 2026-09-19, re-verified and extended 2026-09-20. Every row below was read, not assumed.

Auth and keys

FactLocation
apiKey plugin enabled with enableSessionForAPIKeys: truebackend/bun/apps/monolith/src/config/di-container.ts:219
Bun gateway turns any x-api-key into a full session, replaying it against auth.handler as a synthetic get-sessionapi-gateway/middleware/auth.middleware.ts:54-75
apikey table already has permissions, expires_at, rate_limit_*, request_count, last_request, start, prefix, enabled, metadata, config_idpackages/auth-adapter/schema.ts:220
Go CLI creates a key via device flow, per device, best-effort revoking the previous onebackend/go/cmd/cli/cmd/auth/login.go:104, ADR-0010/0011/0012
Go auth reads only Authorization and the session cookieinternal/api/http/middleware/auth_middleware.go:25-90
Go falls back to a DB session read, then HTTP, and builds roles from users.role + first members rowbetter_auth_validator.go:221, :184
JWT revocation is a Redis marker compared against the token's iatbetter_auth_validator.go:163-175

Rental routing today

Existing rental route tree, module.go:407-476 . One shared auth middleware guards every protected branch
Existing rental route tree, module.go:407-476. One shared auth middleware guards every protected branch.
FactLocation
Owner routes gated on the landlord role, which is RequireJWTRole under another namerental/adapter/inbound/http/require_landlord.go
RequireJWTRole denies with {"error":"insufficient role","code":"forbidden"} and deliberately bypasses the global error handlermiddleware/jwt_role.go:19-41, pinned by TestRequireJWTRole_DenialBodyContract
A different RequirePermission already exists in the same Go package (org-scoped, single string)middleware/better_auth_rbac.go:75
Cross-landlord rows return 404, never 403, on purposerental/application/usecase/listing.go:102-115
Handlers render errors themselves; validation writes the body before returninglisting_handler.go:149 (renderListingError)
fields on a validation response is []string, not a list of objectsproperty_handler.go:329
Property routes at :37-50, listing routes at :34-39; the photo GET is registered only when the photos service is non-nilproperty_handler.go, listing_handler.go
Audit context carries IP, user agent, session, trace — and nothing elsemiddleware/audit_context.go:12-92

Audit storage — three tables, one usable

audit_trails

row history only
  • No actor column at all — id, event_id, table_name, operation, row_id, old_data, new_data, changed_at
  • Already triggered on rental_properties (migrator_rental.go:195-199)
  • Keeps its job; cannot carry attribution

audit_logs (Bun)

rejected
  • auth-adapter/schema.ts:170-192
  • org_id text NOT NULL REFERENCES organization(id)
  • Rental is orgless (ADR-0055), so the foreign key refuses the insert

audit_log (Go)

chosen
  • Created by GORM AutoMigrate — migrator.go:187 includes &leaveModels.AuditLogModel{} at :199
  • Model at leave/.../models.go:209-229
  • OrganizationID is varchar(36) not null default '' with no foreign key — an empty string is the schema's existing representation of an unscoped record

Write pattern to copy: digitalworker/adapter/outbound/persistence/postgresql/audit_logger.go:24-41.

verifyApiKey in the installed plugin

ClaimSource line
The client cannot set rateLimit fields on createindex.mjs:725
metadata requires enableMetadata on the configplugin config schema
A per-key prefix is accepted on create, generated into the key and storedindex.mjs:575, :795, :807
Rate limiting resets only when now - lastRequest > timeWindow; every successful call updates lastRequestindex.mjs:1573-1631
remaining / lastRefillAt belong to a different quota mechanism, not to this limitersame
Config lookup is per-config; disabled configs are skipped; configId is checked strictlyindex.mjs:2105, :2129, :1638
A catch converts non-API database exceptions into INVALID_API_KEY, with no reason attachedindex.mjs:1808-1825

That last row is why the verification spike exists.

Decisions

Original decisions (2026-09-19)

#DecisionChosen
D1How far a key may push a listingFull — a key may also submit
D2Which API surface a key may reachEverything the landlord can do, each part switchable per key
D3Attribution recordedAudit actor, listing origin, declaration source, admin badge — all four
D4Duplicate preventionexternal_ref upsert, address fingerprint, cross-landlord flag, Idempotency-Key (Slice 2)
D5Photo syncFetch-by-URL, presigned upload, declarative gallery, content-hash dedupe (Slice 3)
D6Second surfaceOne capability layer, thin adapters
D7Extra scopeKey safety controls, webhooks, frozen contract, sandbox — all four
D8Ownership proof upload by keyYes, behind its own permission, off by default
D9Key validationGo asks Bun; Better Auth stays authoritative
D10Permission UIPlain-word bundles, fine-grained storage
D11Identity reuseVerify every request; revisit at Slice 3

Decisions added by the design review

D19 · Rate-limit semantics and owner

A true hourly allowance on fixed UTC clock-hour boundaries, enforced in Go against shared Redis. Bun keeps identity, revocation and the stored allowance. The plugin's own limiter is switched off for the landlord config only.

The product wants "1000 per hour" to mean exactly that — a boundary reset whether the caller was busy or idle.

Rejected: the plugin's idle-window limiter, where a caller in continuous traffic never resets. A rolling window was not chosen either — this is fixed buckets, not a sliding window. Direct apikey reads from Go were rejected as a violation of D9 and ADR-0006.

D12 · Where key auth is mounted

A separate APIKeyAuthMiddleware, mounted only on an explicit v1 route allowlist. AuthMiddleware is untouched.

The v1 permission gate cannot be forgotten on a future route, because keys never reach any route it is not mounted on.

Rejected: a branch inside AuthMiddleware — it would let a key reach every legacy rental route and every other module using that middleware, with no permission gate. A deny-by-default global guard was rejected as more moving parts.

D14 · Key isolation between CLI and landlord

A dedicated landlord configId in the same plugin, with enableSessionForAPIKeys: false for that config only. The CLI's default config keeps today's behaviour.

Config lookup is per-config and configId is checked strictly (index.mjs:2105, :1638), so the isolation is enforced by the plugin rather than by our own conventions.

Rejected: accepting that every key is a full Bun account; gating every Bun route; turning the flag off globally, which breaks grm auth login (ADR-0011).

D16 · v1 error shaping

An injected, route-selected renderer that reads domain sentinels before the handler commits output.

The stable codes do not exist on the wire today, and validation already wrote the body by the time a wrapper would see it.

Rejected: a response-shaping middleware. String-matching English messages or rewriting response bytes were rejected outright.

Remaining review decisions — D13, D15, D17, D18
#ChosenRejected
D13 · Credential precedence on v1 The key is the only identity. No cookie or Authorization fallback. A raw key presented as a Bearer token or cookie does not authenticate legacy Go routes either. Falling back to the session when the key is missing or invalid — it downgrades identity silently.
D15 · Where landlord keys are managed Rental portal, /landlord/api-keys, behind the existing LandlordRoute. The admin panel. ADR-0063 moved the landlord workspace out of panel; panel keeps only the admin listing review.
D17 · Audit destination Existing audit_log, organization_id = '', scoped by entity_type and owner. A new rental audit table; a fabricated organization UUID; dropping NOT NULL on a shared column.
D18 · Where the declaration marker lives rental_properties, beside the existing declaration fields. rental_listings has no audit trigger, so each re-stamp would destroy the prior value with no record.

D11 consequence — real cost per request

StepCost
Go → Bun verify1 HTTP round trip, bounded timeout, no automatic retry
Plugin bookkeeping1 write
User state + roles in Go1–2 reads
Redis limiter script1 round trip
Audit row, on writes only1 write

Architecture

The request flow

v1 request path. Each gate has its own distinct code, so an integrator can tell a bad key from a spent allowance from a missing scope
v1 request path. Each gate has its own distinct code, so an integrator can tell a bad key from a spent allowance from a missing scope.
  1. Key arrives

    Caller sends x-api-key: grm_live_… to Go at /api/v1/rental/….

  2. Verify against Bun

    APIKeyAuthMiddleware — mounted only on the v1 allowlist — asks Bun to verify, naming the landlord configId. Bun calls auth.api.verifyApiKey and returns who the key belongs to and what it may do.

  3. Read account state and roles

    Go reads from the database: the row exists, is not banned, is not deleted, plus users.role and the first members row through the existing buildRoles.

  4. Set the actor

    User, roles, key id, key name, permissions, mode, actor_type go on the request context.

  5. Charge the allowance

    Go charges the request against the key's hourly allowance in Redis. Over the allowance is 429 rate_limited.

  6. Scope, role, handler

    RequireKeyScope(resource, action) runs per route. Then the v1 landlord gate. Then the handler.

Handlers never learn a key was used. They read user_id as always. Rental is orgless (ADR-0055), so the key path must not require or invent an organization membership; buildRoles is reused only because it already tolerates that.

Ownership of each concern

ConcernOwnerWhy
Key exists, hash matchBetter Authplugin already does it
Enabled, expiredBetter Authplugin already does it
Allowance value per keyBetter Authstored in the existing rate_limit_max; Bun validates at creation
Rate-limit enforcement and countersGo, in Redis (new)D19. The plugin's limiter is off for the landlord config, so there is only one limiter.
Which userBetter Authapikey.reference_id
Account state: banned, deleted, activeGo, from DBnew. The Redis iat denylist cannot revoke a key — a key has no iat.
Roles (landlord, admin)Go, from DBGo already builds these
Permission check per routeGonew, small

Revocation, stated plainly

ActionKills a session?Kills a key?
Redis authInvalidBefore:<userID> markeryesno — keys carry no iat
Ban / delete / deactivate the accountyesyes, through the account-state read
enabled = false on the key rownoyes, that key only

There is no "log out everywhere" for keys. The account-state read is what makes a ban effective.

Failure modes

Go does not refuse to boot without Redis, because that would take the whole API down for a v1-only dependency. It emits an explicit configuration diagnostic at startup naming v1 as degraded. Real Redis is a release-readiness requirement for v1 in development, CI and deployment.

Session callers on legacy routes are unaffected only when their JWT verifies locally inside Go. That is the common case, not all cases: an opaque session token that falls through to validateViaService still reaches Bun. v1 is unaffected in a different sense — it does not serve session callers at all.

Route mounting

v1 mounts an explicit list of handler methods. It does not call the existing RegisterRoutes for properties, because that function also registers upload and proof routes that are out of scope for v1.

CaseResponse
Path not on the v1 allowlist404 route_not_found
Registered path, method not registered405 method_not_allowed

Neither runs key handling: no verify call, no scope check, no counter update. Both answer in the v1 envelope so an integrator gets a parseable response rather than a framework default.

Bun changes

Four endpoints, one config change, one guard.

Landlord key management — session-authenticated, own keys only

POST   /api/landlord/api-keys
GET    /api/landlord/api-keys
DELETE /api/landlord/api-keys/:id      (sets enabled = false; never a physical delete)

The server, not the client, fixes: the landlord configId, the prefix, the metadata.mode, the expanded permission map, and referenceId = the session user. It requires the global landlord role.

Internal verification — server-to-server

POST /api/internal/api-key/verify
  x-service-secret: <shared secret>
  { "key": "grm_live_...", "config_id": "<landlord config>" }

200 { "valid": true,
      "user_id": "usr_9",
      "key_id": "key_abc",
      "key_name": "propertyguru-sync",
      "permissions": { "listings": ["read","write"], "properties": ["read","write"] },
      "metadata": { "mode": "live" },
      "rate_limit": { "limit": 1000, "window_ms": 3600000 } }

401 { "valid": false, "code": "invalid_api_key" | "key_disabled" | "key_expired" }
429 { "valid": false, "code": "rate_limited", "retry_after": 42 }
503 { "valid": false, "code": "auth_unavailable" }

The endpoint names the landlord configId explicitly and checks it. Keys from the default CLI config are therefore invalid on v1validateApiKey (index.mjs:1638) checks configId strictly.

Config change

A second apiKey config for landlords, with enableSessionForAPIKeys: false, enableMetadata: true, and its rate limiter disabled, so only Go's limiter can reject a request. Nothing changes for the default CLI config.

Guard on the public plugin routes

/api/auth/api-key/* still exists and the CLI still needs create (ADR-0011).

Public routeGuard
createreject the landlord configId; reject a landlord prefix. Default CLI config only.
updatereject entirely for landlord-config keys — this is the mode and metadata mutation path
deletereject landlord-config keys outright, not merely keys owned by someone else. Only the dedicated management endpoint revokes a landlord key, and it soft-disables with enabled = false. Default-config keys: own keys only.
get / listown keys only; the key and its hash are redacted, start and prefix only

Go changes

  1. BetterAuthValidator.ValidateAPIKey(ctx, key) — the HTTP call above, beside the existing validateViaHTTP.
  2. APIKeyAuthMiddleware — new, mounted only on the v1 allowlist. AuthMiddleware is not modified.
  3. RequireKeyScope(resource, action) — new middleware. Named this way because RequirePermission is already taken in the same package (better_auth_rbac.go:75) with different semantics.
  4. A v1 landlord gate returning the v1 envelope. RequireJWTRole and its pinned denial contract stay exactly as they are.
  5. A v1 error renderer, injected per route group.
  6. An audit writer on port.Repos.
  7. The hourly limiter and its Lua script, plus startup validation of the Redis deployment constraint.

Permissions

Two actions: read, write. A delete is a write. The landlord ticks plain-language bundles; we store the expanded map. Bun owns the expansion table and is the only writer. Go consumes the expanded map as returned by the verify endpoint and never re-derives a bundle.

Landlord seesStored asDefaultSlice 1 state
Manage my listingsproperties:[read,write], listings:[read,write]onactive
Upload photosmedia:[read,write]onreads onlyGET /properties/:id/photos. Upload arrives in Slice 3.
Upload ownership documentsproofs:[read,write]offunavailable until Slice 3 — no endpoint of any kind
Handle enquiries & offersenquiries:[read,write]offno endpoint yet — disabled in the form
Money, agreements, tenantsagreements, tenancies, payments, handover, contacts, each [read,write]offno endpoint yet — disabled in the form

The full vocabulary is stored from Slice 1 so that keys issued now keep working unchanged as later slices open endpoints. The form shows every bundle; bundles with no reach are rendered disabled with the accurate reason — not silently removed, and not labelled "read only" when no read exists either.

The gate

v1 is key-only (D13). There are no session callers on it.

CallerResult
No key, or empty / invalid key401 invalid_api_key
Key holding the permissionpasses
Key without the permission403 insufficient_permission

A cookie or Authorization header on a v1 route is ignored. It cannot raise or lower the caller's authority. Legacy /api/rental/... is unchanged: session callers only, no key handling, no permission gate. The two surfaces do not share an identity path.

Rate limiting — settled 2026-09-21

The decision

A true hourly allowance, configurable per key, default 1000 per hour. The hour is a fixed UTC clock hour. It resets on the boundary whether the caller was busy or idle. There is no inactivity requirement. This is deliberately not a rolling or sliding window, and the document must not be read as promising one.

Why the plugin's limiter could not be used

index.mjs:1573-1631: the counter resets only when now - lastRequest > timeWindow, and every successful call updates lastRequest. A caller in continuous traffic therefore never resets — an idle-window limiter, not an hourly one. remaining and lastRefillAt belong to a separate quota mechanism and do not describe it.

The pre-review draft derived its headers from rate_limit_max, remaining and last_refill_at. That mapping was wrong at the source and is withdrawn. The plugin's limiter is switched off for the landlord config so two limiters can never both reject a request; the CLI config keeps it.

Where each part lives

Key identity, revocation, expiry
Bun / Better Auth (unchanged, D9)
Allowance value, validated at creation
Bun, stored in rate_limit_max
Window length
fixed at 3,600,000 ms; not per-key
Counting and rejection
Go, against shared Redis
Published headers
Go, from the limiter's own result

The counter

One Redis key per verified API key ID — never the raw key, never a hash of it. The value holds the bucket number and the count for that bucket.

  1. Read the clock from Redis

    The script reads the current time from Redis TIME. Redis is the single clock, so instances with skewed host clocks cannot split one hour into two buckets.

  2. Derive the bucket

    The UTC clock-hour bucket number comes from that time.

  3. Reset a stale bucket

    If the stored bucket number is older, the count resets to zero.

  4. Check before counting

    If the count has already reached the allowance, the script returns "rejected" and leaves the count unchanged.

  5. Otherwise increment

    Increment, and set the expiry to the next boundary.

  6. Return the verdict

    Admitted or rejected, the count, the allowance and the reset timestamp.

One declared key per call. No dynamic access to undeclared keys, so the script stays correct under Redis Cluster.

Headers

Always sent on a v1 response produced by the limiter, including the 429. Derived from the atomic result, never from plugin columns.

X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 847
X-RateLimit-Reset: 1789812000      (next UTC hour boundary, unix seconds)
Retry-After: 42                    (429 only; whole seconds to that boundary)

When authentication fails, or Redis is unavailable, no counters exist — so no counter headers are sent. Nothing is invented.

Known behaviour, stated rather than hidden

Two adjacent UTC clock-hour buckets. A caller spends the full allowance at the end of the first bucket and again at the start of the second, so up to twice the allowance lands in a short span across the boundary. Hour N Hour N+1 boundary 2x allowance reset reset
Boundary burst. A caller may spend a full allowance just before the reset and another just after, so up to twice the allowance can land in a short span. Inherent to fixed buckets, accepted, and documented in the OpenAPI description.

Redis deployment constraint

The counter is live state. If it is lost, allowances silently reset.

RequirementChecked
The limiter keyspace is noeviction — counters are never evicted under memory pressurestartup validation; refuse to serve v1 and log explicitly if not
Redis persistence is enabled, so a restart does not drop live bucketsstartup validation
Restart recovery behaves as specifiedintegration test

If the deployment cannot be validated, v1 answers 503 rate_limit_unavailable rather than counting on state it cannot trust.

Attribution and audit

D3 selected all four items. Slice 1 delivers the two that make D1 honest. The other two are product surface and stay in Slice 2, stated as such.

ItemSliceDetail
Audit records actor and key1one row per successful v1 write
Declaration records how it was signed1new columns on rental_properties
Listing stores its origin2source (panel | api | cli), key name
Admin review shows an API badge2moderation queue marks API-submitted listings

Declaration source

New nullable columns on rental_properties, beside the existing declaration fields: declaration_source, declaration_api_key_id, and the key name snapshot.

Nullable, because historical declarations have no source and none may be invented for them. Submit and any review-forcing edit or resubmission stamp these fields inside the same write, so a stamp and its row change cannot diverge.

This is an engineering attribution requirement. It is not a legal claim, and no legal opinion is relied on. ADR-0059 concerns agreement signatures, ADR-0057 listing slugs, ADR-0058 agreement documents — none of them governs the ownership declaration.

Key-write audit

Every successful v1 write records one row in the existing audit_log: actor user, actor_type, key id, key name, action, entity type, entity id, trace id, in details.

organization_id is written as '', which is the column's existing default and the schema's representation of an unscoped record (models.go:209-229). No organization row is created, referenced or implied. Rental audit queries scope by entity_type and owner, never by organization.

audit_trails keeps doing what it does — row snapshots with no actor. The two systems are distinct (ADR-0007) and neither replaces the other.

Key management

No new tables. Every column already exists on apikey.

The page lives in the rental portal at /landlord/api-keys, behind the existing LandlordRoute (frontend/solidstart/apps/rental/src/routes/landlord/LandlordRoute.tsx). Not the admin panel: ADR-0063 moved the landlord workspace out of panel, and a panel deploy must not be able to break a paying rental customer. It calls the Bun endpoints with the shared core auth client, and adds no Go CRUD.

Create form

FieldColumnDefaultSet by
Namenamelandlord
Permissionspermissionslistings + photoslandlord, from the bundle whitelist
Expiryexpires_at90 dayslandlord
Hourly allowancerate_limit_max1000landlord within bounds, server validates and writes
Windowrate_limit_time_windowfixed 3,600,000 msserver, not offered to the landlord
Modemetadata.modelivelandlord chooses, server writes
Prefixprefixgrm_live_ / grm_test_server, from the mode
Configconfig_idlandlord configserver

The allowance stays landlord-configurable, as the original create form intended. The installed plugin refuses client-set rateLimit fields (index.mjs:725), so the value travels as a request field to the Bun management endpoint, which validates it against bounds and writes it.

BoundValueNature
Default1000 per hourproduct default
Minimum1hard floor
Maximum10000provisional deployment ceiling, server-configurable

The maximum is a deployment setting, not a published tier. Nothing in the public contract promises it, and changing it is a configuration change rather than a contract change. The window is not configurable — it is one UTC clock hour, always.

Mode is immutable after issue. The prefix and metadata.mode must agree; the server validates that pair. A key whose mode is absent or malformed fails closed — it does not default to live. A key with empty or missing permissions is denied on every v1 route. No component writes the apikey table directly.

Reveal rule and list view

The full key is shown once, at creation. Never again. The list view shows start only, for example grm_live_8f3a…; get and list responses redact the key and its hash.

The list shows name, permissions, hourly allowance, last_request rendered as "last used", expiry, mode, and a revoke button. Revoke sets enabled = false. last_request stays the source for "last used" even though the plugin's limiter is off for this config — the plugin still stamps it. A regression test pins that, so an upgrade that changes it fails loudly instead of silently emptying the column. Nothing derives "last used" from Redis or from the audit log.

Test mode

Test keys carry the prefix grm_test_. A key's prefix cannot change after issue, so the prefix is reserved now rather than in Slice 4. Row marking (is_test on listings and properties) is Slice 2 work, so in Slice 1 a test key reads normally and is blocked on every write with 403 test_mode_unavailable.

The form labels the mode control accordingly: a test key is read-only until Slice 2. The control stays, because a sandbox was part of the approved D7 scope and removing it would be a product change. Slice 2 lifts the block and marks the rows — avoiding the trap where a test key silently writes real listings into the admin review queue.

The frozen public contract

Go currently has three error shapes: the global handler's {success, message, code} where code is HTTP status text; RequireJWTRole's {error, code} which deliberately bypasses that handler; and validation responses that add a fields list of strings. Repo-wide normalisation was attempted before and reverted. This design does not repeat that.

/api/rental/...      portal, sessions   unchanged forever
/api/v1/rental/...   public, keys       frozen contract

Same handlers underneath, different renderer. There is no forked route tree.

Error envelope

{
  "error": {
    "code": "validation_failed",
    "message": "validation failed",
    "fields": [ "monthly_rent" ]
  },
  "request_id": "trace_9f2a..."
}

fields appears only on validation failures, and it is a list of field names — the existing []string shape (property_handler.go:329). That is settled, not deferred. Per-field reasons are not published, because the validation layer does not produce typed reason data today. A non-validation error carries no fields key at all.

The renderer is injected and selected per route group, and runs before the handler commits output. It maps domain sentinels to codes; it never matches English message strings and never rewrites response bytes. It covers bind and validation failures, early auth failures, 404 and 405, and a sanitised 500 internal_error that leaks nothing. The trace id is created before authentication, so a request that fails at the key check still carries a request_id a landlord can quote. Legacy responses are preserved by regression goldens.

code is a stable snake_case string, never HTTP status text. Once published it cannot change.

StatusCodes
400validation_failed, declaration_not_accepted, no_ownership_proof
401invalid_api_key, key_expired, key_disabled
403insufficient_permission, insufficient_role, test_mode_unavailable, account_unavailable
404property_not_found, listing_not_found, route_not_found
405method_not_allowed
409listing_status_conflict, active_listing_exists
429rate_limited
500internal_error
503auth_unavailable, media_unavailable, rate_limit_unavailable

Success bodies

Rental entities are already consistently snake_case (property_id, monthly_rent, deposit_months) and are reused rather than duplicated into parallel public DTOs. The risk is that an internal field rename silently breaks an outsider's script. The repo's existing nric_masking_golden_test.go and contact_reveal_golden_test.go pin response JSON only; the v1 harness is table-driven and pins method, route, request body, query, status, response body and error codes, and the same table is asserted against the OpenAPI document.

Endpoints in v1 for Slice 1

GET    /api/v1/rental/properties
POST   /api/v1/rental/properties
GET    /api/v1/rental/properties/:id
PUT    /api/v1/rental/properties/:id
GET    /api/v1/rental/properties/:id/photos
GET    /api/v1/rental/listings/:id
POST   /api/v1/rental/listings
PUT    /api/v1/rental/listings/:id
POST   /api/v1/rental/listings/:id/submit
POST   /api/v1/rental/listings/:id/withdraw

All ten exist today (property_handler.go:37-50, listing_handler.go:34-39) and are mounted by explicit selection.

The OpenAPI document is hand-written at backend/go/openapi/rental-v1.yaml. The Bun generator is a stub and Go has none, so generating one is its own project. Slice 1's surface is small enough to write by hand, and the contract table is asserted against it, so the document cannot drift silently.

Internal endpoint isolation

fly.toml exposes one public http_service on port 3100. There is no evidence of any path-deny capability at that edge, so a path rule is not relied on.

LayerRule
ListenerThe internal handler is mounted on its own listener and port, not listed in the public service config. Reachable over private networking; localhost in development.
Public portReturns 404 for the internal route even when a valid secret is presented.
SecretMandatory. Constant-time compare. Boot refuses to start when it is unset or too short. No development default.
Go sideA separately configured internal base URL, not the public Bun URL.
LoggingThe raw key and the secret are never logged, at any level.

One secret is sufficient for Slice 1. Rotation is a coordinated restart and is documented as such; two-secret machinery is not built on speculation. Private connectivity between the two runtimes is a release acceptance check, not an assumption — it is not claimed to be provisioned already.

Testing

Full test matrix — 30 rows
WhatTest kindFails when
Bundle to permission mapunit, table-drivena bundle expands to the wrong resources
RequireKeyScopeunita key without the permission passes
v1 rejects session credentialsunita cookie, Bearer token, or raw key sent as Bearer authenticates a v1 route
Legacy rejects keysunitx-api-key gains any authority on /api/rental/...
Unlisted v1 pathunitit is handled at all, or answers with anything but 404 route_not_found in the v1 envelope
Unregistered method on a registered pathunitit answers with anything but 405 method_not_allowed in the v1 envelope
Neither runs key handlingunita verify call, scope check or counter update happens on a 404 or 405
ValidateAPIKey error mappingunitBun being down returns 401 instead of 503
Account stateintegrationa banned, deleted, missing or inactive owner's key still works, or is refused with anything but 403 account_unavailable
Cross-config rejectionintegrationa default CLI key authenticates on v1
Privilege escalationintegrationa CLI key mints a landlord key through the management endpoints
Public plugin guardintegrationupdate mutates a landlord key's mode or metadata; get/list reveal a key or hash
Key lifecycleintegrationa revoked or expired key still works
Test-key write blockintegrationa grm_test_ key writes a real row
Audit row on writeintegrationa successful v1 write leaves no audit_log row, or writes a non-empty organization_id
Audit rollbackintegrationa failed business write leaves an audit row behind, or a successful one leaves none
Declaration snapshotintegrationa re-stamp loses the previous declaration_source from audit_trails
Fresh-database migrationintegrationthe audit insert fails on a database built only from the migrator
Public contracttable-drivenany method, route, status, field or code changes
Legacy responsesregression goldenthe v1 renderer changes what the portal receives
OpenAPI agreementtable-driventhe document and the contract table disagree
Portal key UIrender testthe full key is shown twice, or appears in the list
Parallel callsintegrationmore than the allowance is served, or concurrent admitted requests are miscounted
Rejected requests consume nothingintegrationthe stored count rises past the allowance while a spent bucket is hammered, or X-RateLimit-Remaining on a 429 is not 0
Hourly reset under continuous trafficintegrationa caller who never goes idle is not reset at the UTC boundary
Cross-instance countingintegrationtwo Go instances count separately, or skewed host clocks split one hour into two buckets
Independent keysintegrationone key's traffic consumes another key's allowance
429 shapecontractthe 429 omits X-RateLimit-* or Retry-After, or Retry-After is not whole seconds to the next boundary
Redis outageintegrationv1 fails open, falls back in memory, 404s, crashes the process, or disturbs legacy routes — instead of 503 rate_limit_unavailable
Redis restart recoveryintegrationlive buckets are lost across a restart under the required configuration
Startup validationintegrationv1 serves traffic with an evicting or non-persistent limiter keyspace
Plugin limiter offregressionthe landlord config still rate-limits inside the plugin, or last_request stops advancing
Allowance boundsunita create request outside 1–10000 is accepted, or the window is taken from the client

Integration tests follow the existing recipe: pg-test on :55432, migrations run from a directory with no .env, because backend/go/.env points at UAT. The limiter tests additionally need a real Redis — cache/redis_client.go:20-47 tolerates an absent one, so a suite without Redis would pass vacuously.

Verification spike — acceptance prerequisite

The installed plugin has a catch converting non-API database exceptions into INVALID_API_KEY with no reason (index.mjs:1808-1825). Mapping that to 401 would make an integrator rotate a good key — the exact outcome the failure-mode rule exists to prevent.

Task 3a is a spike: build request-scoped instrumentation at the adapter level that observes the key lookup and the counter update for that request, so a genuine invalid key can be told apart from an infrastructure failure.

Injected faultExpected result
Lookup outage — the key read fails503 auth_unavailable
Successful read, failed update — the counter write fails503 auth_unavailable, never 401
Malformed or null response from the plugin503 auth_unavailable
Genuine unknown, disabled or expired key401 with the specific code

Tasks

Regenerated after the review. The earlier 14-task list assumed one Bun endpoint, a panel UI, a shaping middleware, and no audit writer.

All 30 tasks with their verification method
#TaskVerify
1Record pre-existing test baselinenumbers written down
2Bun: landlord apiKey config — separate configId, enableSessionForAPIKeys: false, enableMetadata: true; CLI config untouchedunit
3Bun: POST /api/internal/api-key/verify, secret-gated, config-checkedunit
3aSpike (gate): distinguish invalid key from infrastructure failureacceptance
4Bun: landlord key management — create, list, revoke; server-fixed prefix/mode/config/permissions; ownership and landlord role enforcedunit
5Bun: guard the public plugin routes; reject key-derived sessions; redact key and hashintegration
6Bun: internal listener on its own port; public port 404s the internal routeintegration
7Go: ValidateAPIKey, 503 when Bun is downunit
8Go: APIKeyAuthMiddleware on the v1 allowlist; no change to AuthMiddlewareunit
9Go: account-state and role read; orgless-safeintegration
10Go: audit context carries actor_type, api_key_id, api_key_nameunit
11Go: RequireKeyScopeunit
12Go: v1 error renderer — injected, sentinel-driven, pre-commit; legacy regression goldensgolden
13Go: mount the ten v1 routes by explicit selection; photo GET returns 503 media_unavailable when the service is absentintegration
14Go: v1 landlord gate with the v1 envelope; RequireJWTRole untouchedunit
15Go: test-key write blockintegration
16Go: audit writer on port.Repos, rebuilt on the tx handle; rollback tests both directionsintegration
17Go: declaration_source, declaration_api_key_id, key-name snapshot on rental_properties; stamped atomicallyintegration
18Go: fresh-database migration and audit-insert checkintegration
19Go: table-driven contract tests — method, route, body, query, status, codescontract
20Go: backend/go/openapi/rental-v1.yaml, asserted against task 19's tablecontract
21Portal: /landlord/api-keys — list, create, one-time reveal, revoke; disabled bundles with accurate labelsrender tests
22Full key lifecycle end to endintegration
23Bun: disable the plugin limiter for the landlord config only; regression-pin last_requestregression
24Bun: validate the allowance at creation — default 1000, min 1, configurable max (provisional 10000); window fixed server-sideunit
25Go: Lua hourly-bucket script — Redis TIME, single declared key, atomic check-increment, expiry at the boundaryunit
26Go: limiter middleware after verify and account state, before scope; 429 rate_limitedintegration
27Go: X-RateLimit-* and Retry-After from the atomic result; none on auth or Redis failurecontract
28Go: Redis unavailable → 503 rate_limit_unavailable, v1 only; startup diagnostic; no fallbackintegration
29Go: startup validation of the noeviction and persistence constraint; restart-recovery testintegration

Tasks 2–11 are the spine. Tasks 12, 19, 20 and 27 are the contract. Tasks 23–29 are the limiter. Task 21 is the only frontend work. Task 3a gates shipping, not starting: implementation proceeds, and the spike's outcome decides whether it can ship.

Out of scope for Slice 1

external_ref, address fingerprint, Idempotency-Key, cross-landlord duplicate flagging, photo sync of any kind, proof upload over the API, outbound webhooks, the CLI, test-row marking, listing origin, and the admin API badge. Each belongs to a later slice. Slice 1 exposes what already exists, safely.

Also excluded by decisionWhy
Redesigning CLI-key privilegesThe default config keeps enableSessionForAPIKeys: true, so a CLI key remains session-capable on Bun (ADR-0010/0011/0012). Recorded as a residual risk, not fixed here.
Repo-wide error normalisationAttempted before, reverted.
Unrelated schema cleanupThe audit column semantics use the schema as it stands.

Note the deliberate asymmetry: the media and proofs permissions ship in Slice 1 while their upload endpoints do not. Permissions are stored early so a key issued today needs no reissue when Slice 3 lands.

Open risks

RiskMitigation
A leaked key can submit listings in the landlord's name (D1)Expiry default 90 days, hourly allowance, instant revoke, account-state revocation, full audit attribution, and admin review still stands between submit and live
Per-request cost (D11)Accepted at current scale; the cost table states it; ponytail: comment names the ceiling; no verify cache, because revocation must be immediate
The limiter does not bound authentication costAccepted. Verification and the account-state read happen before counting, so a flooding key still costs one Bun call and the account reads per request even when it is 429'd. The limiter bounds downstream business work only. Revisit with the Slice 3 cache question.
Bun outage disables all key authDistinct 503 auth_unavailable, so integrators retry instead of rotating keys
Redis is now a hard dependency for v1503 rate_limit_unavailable, v1 only. Legacy routes unaffected, process does not crash, no route disappears. Startup diagnostic names the degradation.
Counter loss on restart or eviction silently resets allowancesNot detectable at runtime — an evicted key and a fresh bucket are both simply absent. Controlled as a deployment constraint: noeviction plus persistence, validated at startup, with a restart-recovery test.
Boundary burst — up to 2× the allowance across a clock-hour boundaryInherent to fixed buckets; accepted and documented in the OpenAPI description
A plugin database failure could masquerade as an invalid keyTask 3a spike, with a stop-and-revisit gate
Reusing entity structs as the public contractTable-driven contract test per resource; a rename fails the build
verifyApiKey internals change on plugin upgradeGo depends on our own Bun endpoint, not on plugin internals. Every plugin claim is tied to 1.6.11 and must be re-verified on upgrade.
CLI-config keys stay session-capable on BunOut of scope by decision; revisit if the CLI gains write reach

Follow-ups

  • Recommended ADR: the config and route trust boundary — one landlord configId with sessions disabled and its plugin limiter off, v1 key-only with the hourly allowance in Go, legacy session-only. One ADR covering the whole settled boundary, now that the rate decision is made.
  • Revisit the verify-cache question in Slice 3, together with bulk photo import. Any proposal must answer the revocation-delay objection.
  • Revisit the provisional 10000 ceiling once real usage exists. It is a deployment setting, so changing it is not a contract change.

The ownership-marker rationale stays with its source references. Not every implementation decision earns an ADR.