Skip to content

Commit a5f85b0

Browse files
LEANDERANTONYclaude
andcommitted
docs: Day 47 DEVLOG + ADR-027 (export entitlement) + governance refresh
Per docs/README.md governance: ports landed this session need a DEVLOG entry + any warranted ADR + index/count refresh. - docs/adr/ADR-027-tier-gated-export-entitlement.md (new): records the pricing-truth decision — Free = PDF + classic_ats, Pro/Business unlock DOCX + all themes, enforced server-side via the shared QuotaExceededError 429 path; /workspace/artifacts/export gained the missing auth dependency. Context / Decision / Consequences / Alternatives / References per the ADR template. - docs/adr/README.md: ADR-027 added to the Tiering+payments cluster; Current state note refreshed (date -> 2026-05-17, export entitlement + parent-domain consent cookie). - docs/DEVLOG.md: new "## Day 47" entry (latest-at-bottom convention) covering the consent cookie + instrumentation-client fix, the schema extra=ignore posture, the pricing audit + export gate, the 2 workspace UX ports, the hermetic test fix, and the Days 42-46 regression-safety re-verification. - README.md: "26 ADRs" -> "27 ADRs" + ADR-027 in the list (count drift is a governance update trigger). - docs/architecture.md: export step now notes the tier entitlement gate + ADR-027 ref. Project-specific framing only (no cross-project references in tracked docs). Commit-don't-push with the rest of the batch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2211b10 commit a5f85b0

5 files changed

Lines changed: 103 additions & 3 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ Each agent follows the same operating shape: deterministic baseline first, LLM-a
7777
- **61 Python test files** cover parsing, normalization, fitting, tailoring, orchestration, builders, exports, auth, quotas, persistence, the Lemon Squeezy webhook, voice transcription, artifact feedback, prompt-registry byte-identity, error handling, and the four ATS adapters.
7878
- **Quality runners** in `tests/quality/` produce evidence for each LLM-driven stage (parser, tailoring, review, resume gen, cover letter, assistant, JD parser, latency baseline). `backend/nightly_eval.py` wraps them into a single regression-checked batch — manual-only at pre-revenue stage by design, see [ADR-026](docs/adr/ADR-026-manual-only-nightly-eval-at-pre-revenue-stage.md).
7979
- **Every LLM prompt loads from a versioned JSON registry** (`prompts/<name>/v1.json`) — all 11 builders migrated off Python f-string concats, each guarded by a byte-identity test so a template can't silently drift from its original.
80-
- **26 ADRs** in `docs/adr/` record the architectural decisions, including the Streamlit-first → Next.js + FastAPI transition (ADR-012), DOCX-first export (ADR-015), conversational builder (ADR-016), state-aware assistant (ADR-017), three-layer retry stack (ADR-018), independent step navigation (ADR-019), tier resolution shim (ADR-020), atomic quota with refund (ADR-021), tier-aware model selection (ADR-022), Lemon Squeezy as Merchant of Record for v1 (ADR-023), the observability stack (ADR-024), the EU cookie consent banner (ADR-025), and manual-only nightly eval (ADR-026).
80+
- **27 ADRs** in `docs/adr/` record the architectural decisions, including the Streamlit-first → Next.js + FastAPI transition (ADR-012), DOCX-first export (ADR-015), conversational builder (ADR-016), state-aware assistant (ADR-017), three-layer retry stack (ADR-018), independent step navigation (ADR-019), tier resolution shim (ADR-020), atomic quota with refund (ADR-021), tier-aware model selection (ADR-022), Lemon Squeezy as Merchant of Record for v1 (ADR-023), the observability stack (ADR-024), the EU cookie consent banner (ADR-025), manual-only nightly eval (ADR-026), and the tier-gated export entitlement (ADR-027).
8181
- **Architecture details** live in [docs/architecture.md](docs/architecture.md); the day-2 operational runbook in [docs/deployment.md](docs/deployment.md).
8282

8383
## Deployment

docs/DEVLOG.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -836,3 +836,39 @@ Merged + deployed. Backend container restarted on the VPS; Vercel auto-rebuilt t
836836

837837
- [ADR-024: Observability stack — Sentry + PostHog with consent-gated analytics](docs/adr/ADR-024-observability-stack-sentry-and-posthog.md)
838838
- [ADR-025: EU cookie consent banner + GDPR-aligned analytics gating](docs/adr/ADR-025-eu-cookie-consent-banner-and-gdpr-analytics-gating.md)
839+
840+
## Day 47: Cross-Surface Hardening — Consent Cookie, Schema Posture, Pricing-Truth Export Gate, Workspace UX
841+
842+
A consolidated correctness pass over the Day-42→46 surface (tiering, schema-strict, observability/consent, prompt registry) plus a pricing-claims audit. Five commit clusters; nothing pushed yet (held behind an explicit deploy decision).
843+
844+
### Consent cookie now spans the apex + `app.` subdomain (`e9a3288`)
845+
846+
The cookie banner persisted consent in `localStorage`, which is origin-scoped — a user who accepted on the marketing apex was re-prompted on `app.job-application-copilot.xyz`. Switched the store to a first-party cookie scoped to `.job-application-copilot.xyz` (host-only on localhost / `*.vercel.app`), with a one-time legacy-`localStorage` migration read and `BroadcastChannel` cross-tab sync. Also fixed `instrumentation-client.ts`, which still read the now-never-written `localStorage` key at boot — left as-is it would have silently withheld Sentry Session Replay from every post-migration consenter (the boot read + the hot-add listener both went through the stale path). It now mirrors the cookie reader (cookie-first, legacy fallback, no React import). Public API unchanged; `tsc` + `eslint` clean.
847+
848+
### `_StrictBase` schema posture: `extra="forbid"``extra="ignore"` (`ef2994f`)
849+
850+
The shared LLM-output base rejected any benign extra key the model volunteered, turning the redundant client-side `model_validate` into an `AgentExecutionError` that routes to that agent's deterministic fallback (ADR-018). Honest scoping (verified against `src/openai_service.py`): the real fail-closed guard is OpenAI structured-outputs strict mode (`_build_response_format_schema``_enforce_strict_object_constraints` force-sets `additionalProperties:false` with `strict=True`, independent of this `ConfigDict`). `extra="ignore"` only relaxes the redundant re-validation; required-field / type / `Literal` enforcement is unchanged, so genuinely-malformed output (wrong type) still falls back. Scope held to the LLM-output base ONLY — `backend/auth_models.py`, `models.py`, `workspace_models.py` keep `extra="forbid"` (HTTP-boundary models where strict rejection is correct). Stale extra-key test updated (not reverted) + an over-permissiveness (wrong-type) guard + a model-level config-contract test added.
851+
852+
### Pricing-claims audit → tier-gated export entitlement + two copy fixes (`b82e772`)
853+
854+
Every landing pricing bullet was cross-referenced against `backend/tiers.py` + the gating code. All numeric caps were correctly wired; three claims were not: (1) Pro "Unlimited … saved jobs" — `saved_jobs` Pro is wired to 1000, not unlimited → reworded to "Unlimited job searches, 1,000 saved jobs"; (2) Free "PDF export, ATS theme" vs Pro "PDF + DOCX, all themes" — **no tier gate existed** on export format/theme and `/workspace/artifacts/export` had no auth at all → wired the gate so the copy is true (**ADR-027**); (3) Business "SSO, admin dashboard, shared shortlists" — zero wiring → reworded to "SSO & admin controls on request". 14 hermetic tests for the export-entitlement policy + raiser.
855+
856+
### Workspace UX: command-palette `:active` + locked-Premium upgrade CTA (`2211b10`)
857+
858+
A UX-parity audit of 12 workspace surfaces; 10 were already sound, 2 fixed: (a) the command palette set `data-active` only via `onMouseEnter`/Arrow keys, so a tapped row on touch got no press feedback — added `.b-cmd-item:active` mirroring the `[data-active]` treatment; (b) the Free-tier Premium toggle was HTML-`disabled` so its only upgrade hint was a hover `title` (dead on touch) — it now stays interactive when tier-locked and a tap surfaces the same `Notice` + `.b-notice-action` upgrade CTA the 429 path uses (`workspaceQuota.upgrade_url`), while `analysisLoading` / out-of-credits remain real disables.
859+
860+
### Test isolation: hermetic openai-unavailable test (`8e368c8`)
861+
862+
`test_run_structured_prompt_raises_when_openai_unavailable` asserted the no-credentials path but resolved the key via env / `openai_key.txt`, so it failed on any machine with a real key configured (pre-existing flake, unrelated to the schema change — verified by stashing). Now monkeypatches `load_openai_key → None` for a deterministic result regardless of local environment.
863+
864+
### Regression-safety re-verification (Days 42-46)
865+
866+
Re-audited the danger class "an LLM/IO gate that hard-fails the pipeline on drift": tier-aware model routing (defensive, free→standard / premium→gpt-5.5, everyone Free pre-cutover), atomic quota ordering (gate-before-work + best-effort refund), Lemon Squeezy webhook (HMAC `compare_digest`, unknown→200, env-gated), observability (no-op on empty DSN, pytest-skip, additive telemetry), per-agent fallback isolation (ADR-018 — a single agent's failure degrades only that agent), and the prompt registry — confirmed the registry tests assert **genuine full-string byte-identity** against the pre-migration concat (no golden-hash guard needed; `==` is stricter). All confirmed graceful-degrade; no regression from the Days 42-46 work.
867+
868+
### Deploy status
869+
870+
Local + unpushed. The schema + pricing/export-gate changes touch the workflow + entitlement surface, so the batch is held behind an explicit deploy decision rather than auto-shipping.
871+
872+
### ADRs added
873+
874+
- [ADR-027: Tier-gated export entitlement (Free = PDF + ATS theme)](docs/adr/ADR-027-tier-gated-export-entitlement.md)
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# ADR-027: Tier-Gated Export Entitlement (Free = PDF + ATS theme)
2+
3+
- Status: Accepted
4+
- Date: 2026-05-17
5+
6+
## Context
7+
8+
The landing pricing cards differentiate the tiers on export capability: Free is advertised as **"PDF export, ATS theme"** while Pro/Business advertise **"PDF + DOCX export, all themes"**. A pricing-claims audit (every bullet cross-referenced against `backend/tiers.py` + the gating code) found that differentiation was **not backed by any code**:
9+
10+
1. Every tier check in the codebase was on a metered counter (`tailored_applications`, `premium_applications`, `assistant_turns`, `saved_jobs`, …). **Nothing** gated `export_format` or `resume_theme`/`cover_letter_theme`.
11+
2. `POST /workspace/artifacts/export` (the main tailored-resume / cover-letter download) took **no auth dependency at all** — it operated purely on the `workspace_snapshot` in the request body, so there was no user identity to resolve a tier from even if a check had existed.
12+
13+
Net: a Free user could already download DOCX and use either theme. The Free/Pro export bullets were a fabricated paywall — exactly the class of unwired claim the audit exists to catch. Two ways to make the pricing copy honest: (a) reword the bullets to drop the differentiation, or (b) wire the gate so the existing copy becomes true. We chose (b) — DOCX + theme choice is a reasonable paid lever and the copy is already shipped.
14+
15+
## Decision
16+
17+
Enforce the export entitlement server-side as an **entitlement, not a metered counter**, reusing the existing quota 429 path so the frontend needs no new surface.
18+
19+
### Policy lives in `backend/tiers.py` (single source of truth)
20+
21+
`FREE_EXPORT_FORMAT = "pdf"` and `FREE_EXPORT_THEME = "classic_ats"`, plus `export_entitlement_block_reason(tier, *, export_format, themes) -> str | None`. Pro/Business return `None` (full entitlement). Free returns a short human label (`"DOCX export"` / `"Custom export themes"`) when the requested format/theme is outside the free allowance; format/theme comparison is whitespace- and case-insensitive (matching the request models' `_strip_theme` normalisation) and a blank/omitted value is never a violation (a caller that doesn't pass a theme must not be upsold). These two constants are kept in lockstep with the pricing copy — changing what Free gets is a pricing decision, recorded here, not a silent refactor.
22+
23+
### Enforcement reuses the `QuotaExceededError` 429 (not a bespoke error)
24+
25+
`backend/quota.py::enforce_export_entitlement(...)` raises `QuotaExceededError` with `counter="premium_export"`, `current=0`, `cap=0`, `reset_period="lifetime"`, `tier=tier`. This is the *same pattern* the `premium_applications` "Pro+ feature" rejection already uses (`_build_quota_exceeded_error`): an entitlement gate that flows through the canonical 429 handler so the frontend renders the uniform upgrade nudge (`.b-notice-action`). `counter="premium_export"` is a display-routing hint only — deliberately **not** a `TIER_CAPS` counter (there is no count to track); `reset_period="lifetime"` keeps the frontend from rendering a wrong "resets on the 1st" line.
26+
27+
### Both export routes gated; the main one gains auth
28+
29+
`enforce_export_entitlement` is called in both `POST /workspace/resume-builder/export` (already authed) and `POST /workspace/artifacts/export` (the missing `get_optional_auth_tokens` dependency was added). Tier resolution is **best-effort and never hard-fails the allowed path**: a soft `_resolve_export_tier` helper resolves anon / expired / invalid sessions to `"free"` (the most restrictive tier) rather than 401-ing, so the Free-allowed `pdf` + `classic_ats` export keeps working for everyone — including anonymous callers — exactly as before. Only the DOCX / non-`classic_ats` request on a non-paid tier is blocked. The gate runs before any session hydrate / export work so a blocked request has no side effects.
30+
31+
## Consequences
32+
33+
### Positive
34+
35+
- The shipped Free/Pro/Business export pricing bullets are now true by construction.
36+
- One source of truth (`backend/tiers.py`) for the export policy, lock-stepped with the pricing copy; a future "let Free have DOCX" change is a one-line, reviewed, pricing-visible edit.
37+
- Zero new frontend surface: the existing 429 → `Notice` + `.b-notice-action` "Upgrade" CTA handles the rejection uniformly with every other quota wall.
38+
- `/workspace/artifacts/export` now has a user identity, closing a gap where a metered/abuse decision could never be made on that route.
39+
40+
### Negative
41+
42+
- `/workspace/artifacts/export` is no longer anonymous at the FastAPI layer. In practice the whole workspace is already behind Google sign-in to reach the export UI, and the soft resolver defaults missing auth to Free (most restrictive) rather than rejecting, so no legitimate flow breaks — but the route's contract did change.
43+
- Reusing `QuotaExceededError` for an entitlement (no real `counter`/`cap`) is a slight semantic stretch; mitigated by following the established `premium_applications` precedent and the `reset_period="lifetime"` shaping.
44+
45+
### Neutral
46+
47+
- Preview (`/workspace/artifacts/preview`) is intentionally **not** gated — previewing a theme in-app is the upsell, only the *download* is the paid lever.
48+
- Frontend polish (pre-disabling the DOCX button + theme options for Free with an inline upgrade hint) is deliberately deferred; today a Free DOCX click returns the standard upgrade nudge, consistent with every other quota wall.
49+
50+
## Alternatives considered
51+
52+
- **Reword the pricing copy to drop the export differentiation.** Rejected: DOCX + theme choice is a legitimate paid lever and the differentiated copy was already shipped; making it true is the higher-value outcome.
53+
- **A dedicated `FeatureLockedError` + new 402/403 handler + new frontend handling.** Rejected as over-engineering: it would duplicate the upgrade-CTA UX that the `QuotaExceededError` 429 path already renders.
54+
- **Gate in the frontend only (hide DOCX/theme for Free).** Rejected: a frontend-only gate is not enforcement — the endpoint must be the source of truth; frontend pre-disabling is an optional later polish on top.
55+
56+
## References
57+
58+
- `backend/tiers.py``FREE_EXPORT_FORMAT`, `FREE_EXPORT_THEME`, `export_entitlement_block_reason`
59+
- `backend/quota.py``enforce_export_entitlement`, `_build_quota_exceeded_error` (the `premium_applications` precedent it mirrors)
60+
- `backend/routers/workspace.py``_resolve_export_tier`, both export routes
61+
- `tests/backend/test_export_entitlement.py` — 14 hermetic policy + raiser tests
62+
- ADR-021 (atomic quota with refund-on-failure) and ADR-020 (tier resolution shim) — the quota/tier infrastructure this builds on
63+
- ADR-015 (DOCX-first artifact export with theme palette) — the export surface now being entitlement-gated

docs/adr/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ The accepted set is grouped into four thematic clusters to make the current prod
3131
- [ADR-021: Atomic quota with refund-on-failure](ADR-021-atomic-quota-with-refund-on-failure.md)
3232
- [ADR-022: Tier-aware model selection via constructor injection](ADR-022-tier-aware-model-selection-via-constructor-injection.md)
3333
- [ADR-023: Lemon Squeezy as Merchant of Record for v1](ADR-023-lemon-squeezy-merchant-of-record-for-v1.md)
34+
- [ADR-027: Tier-gated export entitlement (Free = PDF + ATS theme)](ADR-027-tier-gated-export-entitlement.md)
3435

3536
### Observability + compliance
3637

@@ -51,7 +52,7 @@ The accepted set is grouped into four thematic clusters to make the current prod
5152

5253
## Current state note
5354

54-
As of 2026-05-16, the shipped product is a Next.js workspace deployed on Vercel backed by a FastAPI container on a Frankfurt VPS, with a Supabase EU project for Auth + persistence + the cached-jobs index. The agentic workflow runs Tailoring → Review → ResumeGen → CoverLetter on every analysis, with per-agent retry + fallback isolation. Tier enforcement is live across eight counters (Free / Pro / Business) with the Lemon Squeezy payment scaffold env-gated behind a "Coming soon" frontend fallback until the dashboard's final variant IDs land. The observability stack (Sentry `jobagent-backend` + `jobagent-frontend` + a shared PostHog free-tier project tagged with `product: "jobagent"`) is wired with a custom EU cookie consent banner gating PostHog + Sentry Session Replay behind explicit user opt-in. `backend/nightly_eval.py` exists and is tested but is **not** on the production cron at pre-revenue stage — re-enabling is a single crontab edit when revenue justifies the recurring LLM spend.
55+
As of 2026-05-17, the shipped product is a Next.js workspace deployed on Vercel backed by a FastAPI container on a Frankfurt VPS, with a Supabase EU project for Auth + persistence + the cached-jobs index. The agentic workflow runs Tailoring → Review → ResumeGen → CoverLetter on every analysis, with per-agent retry + fallback isolation. Tier enforcement is live across eight counters (Free / Pro / Business) with the Lemon Squeezy payment scaffold env-gated behind a "Coming soon" frontend fallback until the dashboard's final variant IDs land; export format/theme is additionally an entitlement gate (Free = PDF + `classic_ats`; Pro/Business unlock DOCX + all themes) enforced server-side via the same 429 upgrade path (ADR-027). The observability stack (Sentry `jobagent-backend` + `jobagent-frontend` + a shared PostHog free-tier project tagged with `product: "jobagent"`) is wired with a custom EU cookie consent banner gating PostHog + Sentry Session Replay behind explicit user opt-in; consent persists in a parent-domain cookie so it is honoured across the marketing apex and the `app.` subdomain. `backend/nightly_eval.py` exists and is tested but is **not** on the production cron at pre-revenue stage — re-enabling is a single crontab edit when revenue justifies the recurring LLM spend.
5556

5657
## Adding a new ADR
5758

docs/architecture.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ This is no longer a Streamlit runtime. The old Streamlit shell and related deplo
3939
8. The orchestrator runs `tailoring`, `review`, `resume_generation`, and `cover_letter`. The earlier `fit` and `strategy` stages were removed from the live workflow; the deterministic fit-scoring service in `src/services/fit_service.py` is still available as a building block for `tailoring` but is no longer a visible workflow stage.
4040
9. Builders assemble the tailored resume and cover letter.
4141
10. The workspace assistant answers grounded questions from the current workspace state.
42-
11. Export helpers produce DOCX and PDF files for the current document; both formats share the same theme palette (`classic_ats`, `professional_neutral`).
42+
11. Export helpers produce DOCX and PDF files for the current document; both formats share the same theme palette (`classic_ats`, `professional_neutral`). Format + theme are an entitlement gate: Free is limited to PDF + `classic_ats`, Pro/Business unlock DOCX + all themes, enforced server-side on both export routes via the shared 429 upgrade path (see [ADR-027](adr/ADR-027-tier-gated-export-entitlement.md)).
4343
12. For authenticated users, the latest workspace snapshot and saved jobs are persisted in Supabase.
4444

4545
## Main Modules

0 commit comments

Comments
 (0)