Project has experimental.typedRoutes enabled; passing template-literal
URLs through the Link href prop requires the wider Route type. Cast
at the Link boundary inside ListCard so callers can keep the simpler
string-typed href API.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three new <EntityCard> files using the shared <ListCard> shell, wired
into each list page's <DataTable> via cardRender.
- ReminderCard: Bell icon, related-entity subtitle (User/Anchor/
FileText icon by entity type), due-date meta with
past-due flag, accent bar (rose=past-due,
amber=pending, slate=snoozed, emerald=done).
Snooze/Complete/Edit/Delete in actions menu.
- AuditLogCard: Action icon (Plus/Pencil/Trash2/Eye), entity
title, "{verb} by {actor}" subtitle, timestamp
meta, optional changed-field chip line. Accent
bar by action (created=emerald, updated=blue,
deleted=rose). Immutable, no actions menu.
- UserCard: Initials avatar, displayName/email, role meta
(Shield icon), last-login distance, "Inactive"
pill when deactivated. Accent bar (violet=
super_admin, slate=inactive, none=active).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five new <EntityCard> files using the shared <ListCard> shell, wired
into each list page's <DataTable> via cardRender. Desktop view
(lg+) is unchanged.
- YachtCard: Ship icon, owner subtitle (User/Building2 icon by
ownerType), dimensions in meters preferred, hull #,
status pill. No accent bar (status is free-text).
- CompanyCard: Building2 icon, legalName subtitle, country (MapPin)
+ tax id (Hash) meta, member/yacht count line.
- BerthCard: Anchor icon, area subtitle (MapPin), dimensions
meta, status pill. Status-encoded accent bar
(emerald=available, amber=under_offer, slate=sold).
- InvoiceCard: FileText icon, client subtitle, due date (Calendar)
meta, prominent currency-formatted amount. Status
accent bar (emerald=paid, orange=overdue, ...).
- ExpenseCard: Receipt icon, category subtitle, expense date meta,
prominent amount, payment-status pill, "Possible
duplicate" pill when duplicateOf is set. Accent bar
by paymentStatus, overridden to amber when flagged
as duplicate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds optional cardRender prop to <DataTable> that switches the layout
to a vertical card list below lg: while keeping the same TanStack
table instance powering both views (pagination, sort, selection).
New shared shell:
- <ListCard> rounded card with optional left status accent bar,
whole-card link to detail page, top-right actions
slot, and tactile hover/active states.
- <ListCardAvatar> 40px brand-tinted circle (initials or domain icon).
- <ListCardMeta> inline icon + muted text segment.
- deriveInitials() shared helper that ignores numeric tokens (so
"Recovery Test 1777" -> "RT", not "R1").
Clients and interests pages now render mobile cards via cardRender
using this shell; desktop view (lg+) is unchanged. Interests cards
encode pipeline stage as a left-edge accent strip whose saturation
deepens with pipeline progression (open -> completed). Berths display
with an Anchor icon; null-berth interests fall back to a Compass +
"General interest" italic label. Hot leads get a discreet "Hot" pill.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plain h1 + p replaced with the mobile-aware PageHeader primitive so
the reports landing matches dashboard/settings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CardHeader/CardContent/CardFooter were uniformly p-6 (24px), which on
top of the mobile shell's 16px outer padding pushed form content 40px
inward — making cards feel content-shifted on a 393px viewport. Drops
to p-4 (16px) below sm and keeps p-6 from sm+ so desktop is unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Content cards/lists were rendering edge-to-edge on mobile because the
mobile shell's <main> had no horizontal padding (only safe-area top/
bottom). Adds px-4 to match the breathing room desktop gets from p-6.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task 24 audit run hit the 10-minute test.setTimeout ceiling after capturing
2 of 4 viewport passes (iphone-se complete, iphone-16 complete-ish, 16-pro
partial, pro-max not started). 4 viewports × ~45 routes × slowMo: 200 needs
more headroom than 600s gave. 30min is comfortable headroom; the per-test
project timeout is matched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
8 API route files were exporting handler functions directly from route.ts,
which Next.js 15 rejects with "$NAME is not a valid Route export field".
Per CLAUDE.md convention, service-tested handler functions live in sibling
handlers.ts files and route.ts only re-exports the GET/POST/etc. wrapped
in withAuth(withPermission(...)).
Discovered during the mobile-foundation Task 24 build validation; the route
files predate this branch but the build was never re-run on data-model.
Files:
- berth-reservations/[id], companies/autocomplete, companies/[id]/members
+ nested mid/set-primary, yachts/autocomplete, yachts/[id]/transfer,
yachts/[id]/ownership-history
- Integration tests updated to import from handlers.ts (companies,
memberships, reservations, yachts-detail)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move the four iPhone viewport descriptors (SE, 15/16, 16/17 Pro, Pro Max)
into tests/e2e/fixtures/devices.ts so the upcoming visual spec (Task 23)
can share the same anchors. The mobile-audit spec now spreads each
descriptor and adds a slug `name` plus a human `label` for the run header.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-execution baseline for the mobile foundation PR:
- Mobile audit harness (tests/e2e/audit/mobile.spec.ts + mobile-audit Playwright project) — visits every page at four anchor iPhone viewports (375/393/402/440), screenshots full-page to .audit/mobile/, generates index.md
- Design spec (docs/superpowers/specs/2026-04-29-mobile-optimization-design.md) — adaptive shell + responsive content; full active-iPhone-range coverage; foundation + per-page migration phases
- Implementation plan (docs/superpowers/plans/2026-04-29-mobile-foundation.md) — 24 TDD tasks for the foundation PR
- .gitignore: ignore /client-portal/ (legacy nested Nuxt repo) and /.audit/ (regenerable screenshots)
- Remove phantom client-portal gitlink (mode 160000 with no .gitmodules)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pass-6 findings — both MEDIUM cross-tenant FK injection.
- interests.service: createInterest/updateInterest/linkBerth accepted
clientId/berthId/yachtId from the request body without verifying the
referenced row belongs to the caller's port. getInterestById joins
clients/berths/yachtTags on these FKs without a port filter, so a
port-A caller could splice a foreign-port id and surface that
tenant's clientName, mooringNumber, or yacht ownership on read.
New assertInterestFksInPort helper guards all three surfaces.
- clients.service.createRelationship: accepted clientBId from the
body without a port check; the relationship list endpoint joins
clients without filtering by port, so the foreign client's name
+ email would render in the relationships tab. Now verifies
clientBId belongs to portId and rejects self-relationships.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1. HIGH — reminders.create/updateReminder accepted clientId/interestId/
berthId from the body and persisted them with no port check; getReminder
then hydrated the row via Drizzle relations (no port filter on the
join), so a port-A user with reminders:create could exfiltrate any
port-B client/interest/berth row by guessing its UUID. New
assertReminderFksInPort gates create + update.
2. HIGH — listRecommendations(interestId, _portId) discarded portId
entirely; the route GET /api/v1/interests/[id]/recommendations
forwarded the URL id straight through. A port-A user with
interests:view could read any other tenant's recommended berths
(mooring numbers, dimensions, status). Service now verifies the
interest belongs to portId and joins berths filtered by port.
3. HIGH — Berth waiting list. The PATCH route did not pre-check that
the berth belonged to ctx.portId — a port-A user with
manage_waiting_list could reorder a port-B berth's queue. Separately,
updateWaitingList accepted arbitrary entries[].clientId and inserted
them without verifying tenancy, polluting the table with foreign-port
FKs. Both gaps closed.
4. MEDIUM — setEntityTags (clients/companies/yachts/interests/berths)
accepted any tagId and inserted into the join table. The tags table
is per-port but the join only carries a single-column FK. The
downstream getById join `tags ON join.tag_id = tags.id` has no port
filter, so a foreign tag's name + color render in the requesting port.
Helper now batch-validates tagIds belong to portId before insert.
5. MEDIUM — /api/v1/custom-fields/[entityId] PUT had no withPermission
gate (any role, including viewer, could write) and didn't validate
that the URL entityId pointed at a port-scoped entity of the field
definition's entityType. Route now uses
withPermission('clients','view'/'edit',…); service validates the
entityId per resolved entityType (client/interest/berth/yacht/company)
against portId.
Test mocks updated to cover the new entity-port-scope check.
818 vitest tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three findings from a fourth-pass review:
1. MEDIUM — webhook URL SSRF. The validator only enforced HTTPS+URL
parse; it accepted private/loopback/link-local/.internal hosts. The
delivery worker fetched arbitrary URLs and persisted up to 1KB of
response body into webhook_deliveries.response_body, which is then
surfaced via the deliveries listing endpoint — a port admin could
register a webhook to an internal HTTPS endpoint, hit the test
endpoint to force immediate dispatch, and read the response back.
Validator now rejects RFC-1918/loopback/link-local/CGNAT/ULA IPs
(v4 + v6) and .internal/.local/.localhost/.lan/.intranet/.corp
suffixes; the worker re-resolves the hostname at dispatch time and
blocks before fetch (DNS rebinding defense). 21-case unit test
covers the matrix.
2. MEDIUM — POST /api/v1/email/accounts/[id]/sync had no owner check.
Any user with email:view could enqueue an inbox-sync job for any
accountId, which the worker would honour using the foreign user's
decrypted IMAP credentials and advance the account's lastSyncAt
(data-loss risk on the legitimate owner's next sync). Route now
asserts account.userId === ctx.userId before enqueueing, matching
the toggle/disconnect endpoints.
3. MEDIUM — addDocumentWatcher (and the wizard / upload watcher
inserts) didn't validate the watcher's userId belonged to the
document's port. notifyDocumentEvent then emitted a real-time
socket toast + email containing the document title to the foreign
user. New assertWatchersInPort helper verifies each candidate has
a userPortRoles row for the port (super-admin bypass).
818 vitest tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1. HIGH — Socket.IO accepted client-supplied `auth.portId` in the
handshake without verifying the user actually held a role in that
port, then unconditionally joined the socket to `port:${portId}`.
The `join:entity` handler also skipped authorization. This let any
authenticated CRM user receive realtime events from any other
tenant: invoice numbers + totals + client names, document signer
emails, registration events with full client name + berth, file
uploads, etc. Auth middleware now resolves the user's
userPortRoles (or isSuperAdmin) before honouring portId, and
join:entity verifies the entity's port matches a port the user
has access to. Pre-existing pre-branch issue but fixed here given
the explicit "all data is extremely sensitive" directive.
2. MEDIUM — listCrmInvites issued a global SELECT with no port
scope. The crm_user_invites table has no portId column (invites
mint global better-auth users, then port roles are assigned
later). The previous gating on per-port admin.manage_users let
any director enumerate every other tenant's pending invitee
emails + isSuperAdmin flags — a phishing target list and a
super-admin onboarding timing oracle. Restrict GET (list),
DELETE (revoke), and POST resend to ctx.isSuperAdmin.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1. HIGH — /api/v1/admin/ports/[id] PATCH+GET let any port-admin
(manage_settings) mutate any other tenant's port row by passing the
foreign id in the path. Now non-super-admins must target their own
ctx.portId; listPorts and createPort are super-admin only.
2. HIGH — Invoice create/update accepted arbitrary expenseIds and
linked them into invoice_expenses with no port check; the GET
response then re-emitted those foreign expense rows via the
linkedExpenses join. assertExpensesInPort now validates each id
belongs to the caller's portId before insert; getInvoiceById's
join filters by expenses.portId as defense-in-depth.
3. HIGH — Document creation paths (createDocument, createFromWizard,
createFromUpload) persisted user-supplied clientId/interestId/
companyId/yachtId/reservationId without verifying those FKs were
in-port. sendForSigning then loaded the foreign client/interest by
id alone and pushed their PII into the Documenso payload. New
assertSubjectFksInPort helper rejects out-of-port FKs at create
time; sendForSigning's interest+client lookups now also filter by
portId.
4. MEDIUM — calculateInterestScore read its redis cache before
verifying portId, and the cache key was interestId-only — a
foreign-port caller could observe a cached score breakdown.
Cache key now includes portId, and the port-scope DB lookup runs
before any cache.get.
5. MEDIUM — AI email-draft job results were retrievable by anyone who
could guess the BullMQ jobId (default sequential integers). Job
ids are now random UUIDs, requestEmailDraft validates interestId/
clientId belong to ctx.portId before enqueueing, the worker's
client lookup is port-scoped, and getEmailDraftResult requires
the caller to match the original requester's userId+portId before
returning the drafted subject/body.
The interest-scoring unit test that asserted "DB is bypassed on cache
hit" is updated to reflect the new (security-correct) ordering.
Two new regression test files cover the email-draft binding (5 tests).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three findings from the branch security review:
1. HIGH — Privilege escalation via super-admin invite. POST
/api/v1/admin/invitations was gated only by manage_users (held by the
port-scoped director role). The body schema accepted isSuperAdmin
from the request, createCrmInvite persisted it verbatim, and
consumeCrmInvite copied it into userProfiles.isSuperAdmin — granting
the new account cross-tenant access. Now the route rejects
isSuperAdmin=true unless ctx.isSuperAdmin, and createCrmInvite
requires invitedBy.isSuperAdmin as defense-in-depth.
2. HIGH — Receipt-image exfiltration via OCR settings. The route
/api/v1/admin/ocr-settings (and the sibling /test) were wrapped only
in withAuth — any port role including viewer could PUT a swapped
provider apiKey + flip aiEnabled, redirecting every subsequent
receipt scan to attacker infrastructure. Both are now wrapped in
withPermission('admin','manage_settings',…) matching the sibling
admin routes (ai-budget, settings).
3. MEDIUM — Cross-tenant alert IDOR. dismissAlert / acknowledgeAlert
issued UPDATE … WHERE id=? with no portId predicate. Any
authenticated user with a foreign alert UUID could mutate it. Both
service functions now require portId and add it to the WHERE; the
route handlers pass ctx.portId.
The dev-trigger-crm-invite script passes a synthetic super-admin caller
identity since it runs out-of-band.
The two public-form tests randomize their IP prefix per run so a fresh
test process doesn't collide with leftover redis sliding-window entries
from a prior run (publicForm limiter pexpires after 1h).
Two new regression test files cover the fixes (6 tests).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously /api/health did deep dependency probes (postgres + redis +
minio) and 503'd on any failure. That's readiness behavior, not
liveness — a transient Redis/MinIO blip would tell the orchestrator to
restart the pod when it should only be dropped from the load balancer.
Make /api/health a thin liveness check (returns 200 unconditionally if
the process is responding) and move the deep checks to a new
/api/ready endpoint with the canonical Kubernetes-style 200/503
contract. Docker-compose healthchecks keep pointing at /api/health,
which is now more conservative (no false-positive container restarts).
Documenso/SMTP are intentionally not probed in /api/ready: each tenant
configures its own credentials and a tenant misconfiguration shouldn't
deadline the entire shared CRM.
Also tighten the gdpr-bundle-builder casts: replace the scattered
`as unknown as Record<string, unknown>` double-casts with a small
`toJsonRow<T>()` helper that does the widen narrow→wide in one place
with one cast hop instead of two.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Article-15 bundles are JSON+HTML only (no receipts/contracts), so even
heavy clients land at <1 MB. Anything larger almost certainly indicates
an unbounded relation we forgot to cap. Fail the worker job before
uploading rather than push a runaway blob to MinIO + email the client a
download link of mystery size.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The same `interface AuditMeta { userId; portId; ipAddress; userAgent }`
was duplicated in 26 service files. Move the canonical definition into
`@/lib/audit` next to the related types and update every service to
import it. `ServiceAuditMeta` (the alias used in invoices.ts and
expenses.ts) collapses into the same name.
Tag CRUD across clients/companies/yachts/interests/berths followed an
identical wipe-then-rewrite recipe with two latent issues: the delete
and insert weren't wrapped in a transaction (a partial failure left
the entity with zero tags) and the audit-log payload shape diverged
(`newValue: { tagIds }` for clients/yachts/companies but
`metadata: { type: 'tags_updated', tagIds }` for interests/berths).
Extract `setEntityTags` in `entity-tags.helper.ts` that performs the
delete+insert inside a single transaction, normalizes the audit payload
to `newValue: { tagIds }`, and dispatches the per-entity socket event
through a switch so `ServerToClientEvents` typing stays intact.
The five `setXTags(...)` service functions now do parent-row tenant
verification and delegate the join-table work + side effects.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- maintenance worker now expires GDPR export bundles (db row + MinIO object)
on the gdpr_exports.expires_at boundary, plus 90-day retention sweep on
ai_usage_ledger; both jobs scheduled daily.
- portId scoping added to listClientRelationships and listClientExports
(defense-in-depth — parent-resource gates already prevent cross-tenant
reads, but service layer should enforce on its own).
- SELECT FOR UPDATE on parent client/company row inside add/update address
transactions to serialize concurrent isPrimary toggles.
- public /interests + /residential-inquiries endpoints swap their
in-memory ipHits maps for the redis sliding-window limiter via the
new rateLimiters.publicForm config (5/hr/IP), so the cap survives
restarts and is shared across worker processes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Surveys what it actually takes to ship the AI inbox-triage feature
gated on Google Workspace integration. Walks through three deployment
models with their real costs:
- Model A (Marketplace OAuth app): 4-6 months calendar, $15k-$75k for
the required CASA security assessment, recurring re-verification
- Model B (per-customer Internal OAuth app): ~5 weeks engineering, $0
Google-side, scoped to one workspace per customer
- Model C (forward-to-CRM mailbox): ~1 week, receive-only, no reply
drafts possible
Recommends Model B for the current customer profile, with B → A
promotion only if 3+ customers ask unprompted.
Documents what's already scaffolded (email_accounts/threads/messages
tables, syncInbox stub, BullMQ email queue, ai_usage_ledger, per-port
aiEnabled flag, withRateLimit('ai')) vs what's new (OAuth flow, Pub/
Sub push receiver, gws_user_tokens + email_triage tables, /inbox UI).
End-to-end flow, schema additions, AI cost interaction with the
Phase 3b token budgets, 5-phase build plan (G1-G5), and 5 open
decisions to resolve before scheduling the build. Explicitly out of
scope: M365, sentiment analysis, smart-drafts, cross-staff triage
queue.
No code changes — this is a design doc to drive a go/no-go decision.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two new runbooks under docs/runbooks/ plus the automation scripts the
backup runbook references. Both are written so an operator who has only
the off-site backup credentials and the runbook can recover the system
unaided.
Backup/restore (Phase 4a):
- docs/runbooks/backup-and-restore.md — covers what gets backed up
(Postgres / MinIO / .env+ENCRYPTION_KEY), schedule (hourly DB +
hourly MinIO mirror, 7-day hourly + 30-day daily retention),
cold-restore procedure with row-count verification, weekly drill
- scripts/backup/pg-backup.sh — pg_dump → gzip → optional GPG → mc
upload, fails loud
- scripts/backup/minio-mirror.sh — incremental mc mirror, no --remove
flag so accidental deletes on the live bucket can't cascade
- scripts/backup/restore.sh — interactive prod restore + --drill mode
that runs against a sandbox DB and diffs row counts
Email deliverability (Phase 4b):
- docs/runbooks/email-deliverability.md — what the CRM sends, DNS
records (SPF/DKIM/DMARC/MX), per-port override implications,
diagnosis flow ("didn't arrive" → 4-step checklist starting with
EMAIL_REDIRECT_TO), provider migration plan, realapi suite as the
end-to-end probe
Tests still 778/778 vitest, tsc/lint clean — these phases are docs +
shell scripts, no code changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a full GDPR Article 15 (right of access) workflow. Staff trigger
an export from the client detail; a BullMQ worker assembles every row
keyed to that client (profile, contacts, addresses, notes, tags,
yachts, company memberships, interests, reservations, invoices,
documents, last 500 audit events) into JSON + a self-contained HTML
report, ZIPs them, uploads to MinIO, and optionally emails the client
a 7-day signed download link.
- New table gdpr_exports tracks lifecycle (pending → building → ready
→ sent / failed) with a 30-day cleanup target
- Bundle builder (gdpr-bundle-builder.ts) — pure read-side, tenant-
scoped, with HTML escaping to block injection from rogue field values
- Worker hook in export queue dispatches on job name 'gdpr-export'
- New audit actions: 'request_gdpr_export', 'send_gdpr_export'
- API: POST/GET /api/v1/clients/:id/gdpr-export (admin-gated, exports
rate-limit, Article-15 audit on POST); GET /:exportId returns a
fresh signed URL
- UI: <GdprExportButton> dialog on client detail header — admin-only,
shows recent exports, supports email-to-client + override recipient,
polls every 5s while open
- Validation: refuses email-to-client when no primary email + no
override (rather than silently dropping the send)
Tests: 778/778 vitest (was 771) — +7 covering builder happy path,
HTML escaping, tenant isolation, empty client, request-flow validation,
and audit / queue interaction.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds three named rate limiters to the existing Redis sliding-window
catalog and a withRateLimit wrapper that composes inside withAuth.
Wires the OCR limiter into the receipt-scan endpoint so a runaway
client can't burn through the AI budget in a tight loop.
- ocr: 10/min/user
- ai: 60/min/user (reserved for future server-side AI surfaces)
- exports: 30/hour/user (reserved for GDPR bundle, PDF, CSV exports)
429 responses include X-RateLimit-* headers and a Retry-After hint.
Tests: 771/771 vitest (was 766) — +5 rate-limit tests covering catalog
shape, sliding window, cross-prefix isolation, cross-user isolation,
and resetAt timestamp.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a token-denominated guardrail in front of every server-side AI call
so a misconfigured port can't run up an unbounded bill. Soft caps surface
a banner; hard caps refuse new requests until the period rolls over.
Usage flows into a feature-typed ledger so future AI surfaces (summary,
embeddings, reply-draft) can drop in without schema changes.
- New table ai_usage_ledger (port, user, feature, provider, model,
input/output/total tokens, request id) with two indexes for rollup
- New service ai-budget.service.ts: getAiBudget/setAiBudget,
checkBudget (pre-flight gate), recordAiUsage, currentPeriodTokens,
periodBreakdown — all token-based, period boundaries in UTC
- runOcr now returns provider usage so the route can record the actual
spend instead of estimating
- Scan-receipt route gates on checkBudget before invoking AI; returns
source: manual / reason: budget-exceeded when blocked, surfaces
softCapWarning on the success path
- Admin UI: new AiBudgetCard on the OCR settings page — shows current
spend, per-feature breakdown, soft/hard cap inputs, period selector
- Permission: admin.manage_settings on both routes
Tests: 766/766 vitest (was 756) — +10 budget tests covering enforce/
disabled/cap-exceed/estimate-exceed/soft-warn/period boundaries/
cross-port isolation/silent ledger failure.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>