fix(ai): meter synchronous generations
CI and Deploy / test (pull_request) Successful in 5m18s
CI and Deploy / deploy (pull_request) Has been skipped

This commit is contained in:
cesnimda
2026-08-15 20:25:10 +02:00
parent dbf28b97ce
commit 25a6da951e
9 changed files with 360 additions and 21 deletions
@@ -0,0 +1,191 @@
using System.Net;
using System.Text;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Http;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Moq;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class MeteredSummarizerServiceTests
{
[Fact]
public async Task Usage_limit_handler_returns_stable_429_problem()
{
var context = new DefaultHttpContext { TraceIdentifier = "trace-ai-limit" };
context.Response.Body = new MemoryStream();
Assert.True(await new AiUsageLimitExceptionHandler().TryHandleAsync(
context,
new AiUsageLimitException("monthly_ai_calls_exhausted", "Monthly AI limit reached."),
default));
Assert.Equal(StatusCodes.Status429TooManyRequests, context.Response.StatusCode);
context.Response.Body.Position = 0;
using var reader = new StreamReader(context.Response.Body);
var body = await reader.ReadToEndAsync();
Assert.Contains("monthly_ai_calls_exhausted", body, StringComparison.Ordinal);
Assert.Contains("trace-ai-limit", body, StringComparison.Ordinal);
}
[Fact]
public async Task Successful_synchronous_generation_is_admitted_and_finalized()
{
await using var fixture = await Fixture.CreateAsync("{\"summary\":\"measured result\"}");
var result = await fixture.Service.SummarizeAsync("measured input", 150, 30);
Assert.Equal("measured result", result);
var usage = Assert.Single(await fixture.Db.AiUsageRecords.AsNoTracking().ToListAsync());
Assert.Equal("synchronous", usage.SourceType);
Assert.Equal("synchronous.summary", usage.TaskType);
Assert.Equal("measured input".Length, usage.InputCharacterCount);
Assert.Equal("measured result".Length, usage.OutputCharacterCount);
Assert.Equal((usage.InputCharacterCount + usage.OutputCharacterCount + 3) / 4, usage.EstimatedTokenCount);
Assert.Equal(1, fixture.Handler.RequestCount);
}
[Fact]
public async Task Exhausted_limit_rejects_before_provider_call()
{
await using var fixture = await Fixture.CreateAsync("{\"summary\":\"must not run\"}");
fixture.Db.AiUsageRecords.Add(new AiUsageRecord
{
OwnerUserId = "owner",
SourceType = "synthetic",
SourceId = "monthly-limit",
TaskType = "synthetic",
CallCount = 250,
EstimatedTokenCount = 1,
CreatedAtUtc = DateTimeOffset.UtcNow,
});
await fixture.Db.SaveChangesAsync();
var error = await Assert.ThrowsAsync<AiUsageLimitException>(() => fixture.Service.SummarizeAsync("blocked"));
Assert.Equal("monthly_ai_calls_exhausted", error.Code);
Assert.Equal(0, fixture.Handler.RequestCount);
}
[Fact]
public async Task Existing_metered_scope_bypasses_the_synchronous_decorator()
{
await using var fixture = await Fixture.CreateAsync("{\"summary\":\"already metered\"}");
using (fixture.UsageScope.Suppress())
Assert.Equal("already metered", await fixture.Service.SummarizeAsync("workspace input"));
Assert.Empty(await fixture.Db.AiUsageRecords.AsNoTracking().ToListAsync());
Assert.Equal(1, fixture.Handler.RequestCount);
}
[Fact]
public async Task Durable_operation_scope_does_not_create_a_second_usage_record()
{
await using var fixture = await Fixture.CreateAsync("{\"summary\":\"operation result\"}");
var lease = new UserOperationLease(
Guid.NewGuid(), "owner", "lease", "strategy.snapshot", "local_only", "job", "1", 1, null);
using (fixture.OperationScope.Use(new AiOperationExecutionContext(lease, "local_only")))
Assert.Equal("operation result", await fixture.Service.SummarizeAsync("operation input"));
Assert.Empty(await fixture.Db.AiUsageRecords.AsNoTracking().ToListAsync());
Assert.Equal(1, fixture.Handler.RequestCount);
}
[Fact]
public async Task Free_owner_is_rejected_at_the_shared_provider_boundary()
{
await using var fixture = await Fixture.CreateAsync("{\"summary\":\"must not run\"}", Array.Empty<string>());
var error = await Assert.ThrowsAsync<AiUsageLimitException>(() => fixture.Service.SummarizeAsync("blocked"));
Assert.Equal("ai_not_available", error.Code);
Assert.Empty(await fixture.Db.AiUsageRecords.AsNoTracking().ToListAsync());
Assert.Equal(0, fixture.Handler.RequestCount);
}
private sealed class Fixture : IAsyncDisposable
{
private readonly SqliteConnection _connection;
public JobTrackerContext Db { get; }
public MeteredSummarizerService Service { get; }
public CountingHandler Handler { get; }
public AiUsageExecutionScope UsageScope { get; }
public AiOperationExecutionScope OperationScope { get; }
private Fixture(
SqliteConnection connection,
JobTrackerContext db,
MeteredSummarizerService service,
CountingHandler handler,
AiUsageExecutionScope usageScope,
AiOperationExecutionScope operationScope)
{
_connection = connection;
Db = db;
Service = service;
Handler = handler;
UsageScope = usageScope;
OperationScope = operationScope;
}
public static async Task<Fixture> CreateAsync(string responseJson, IReadOnlyList<string>? roles = null)
{
var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var currentUser = new Mock<ICurrentUserService>();
currentUser.SetupGet(service => service.UserId).Returns("owner");
var db = new JobTrackerContext(
new DbContextOptionsBuilder<JobTrackerContext>().UseSqlite(connection).Options,
currentUser.Object);
await db.Database.EnsureCreatedAsync();
var handler = new CountingHandler(responseJson);
var client = new HttpClient(handler) { BaseAddress = new Uri("http://ai.test") };
var factory = new Mock<IHttpClientFactory>();
factory.Setup(item => item.CreateClient("ai-service")).Returns(client);
var inner = new SummarizerService(factory.Object, new MemoryCache(new MemoryCacheOptions()));
var user = new ApplicationUser { Id = "owner", AiEnabled = true };
var users = TestHostFactory.CreateUserManager(user);
users.Setup(item => item.GetRolesAsync(user)).ReturnsAsync((roles ?? new[] { "Premium" }).ToList());
var usageScope = new AiUsageExecutionScope();
var operationScope = new AiOperationExecutionScope();
var service = new MeteredSummarizerService(
inner,
db,
users.Object,
new AiUsageMeter(db, TimeProvider.System),
operationScope,
usageScope);
return new Fixture(connection, db, service, handler, usageScope, operationScope);
}
public async ValueTask DisposeAsync()
{
await Db.DisposeAsync();
await _connection.DisposeAsync();
}
}
public sealed class CountingHandler(string responseJson) : HttpMessageHandler
{
public int RequestCount { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
RequestCount++;
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(responseJson, Encoding.UTF8, "application/json"),
});
}
}
}
@@ -20,14 +20,16 @@ public sealed class AiWorkspaceController : ControllerBase
private readonly IConfiguration _config;
private readonly JobTrackerApi.Data.JobTrackerContext? _db;
private readonly AiUsageMeter? _usage;
private readonly AiUsageExecutionScope? _usageScope;
public AiWorkspaceController(UserManager<ApplicationUser> users, IAiWorkspaceService workspace, IConfiguration config, JobTrackerApi.Data.JobTrackerContext? db = null, AiUsageMeter? usage = null)
public AiWorkspaceController(UserManager<ApplicationUser> users, IAiWorkspaceService workspace, IConfiguration config, JobTrackerApi.Data.JobTrackerContext? db = null, AiUsageMeter? usage = null, AiUsageExecutionScope? usageScope = null)
{
_users = users;
_workspace = workspace;
_config = config;
_db = db;
_usage = usage;
_usageScope = usageScope;
}
public sealed record GenerateRequest(string Module, string? Mode, string? ExtraContext);
@@ -71,6 +73,7 @@ public sealed class AiWorkspaceController : ControllerBase
try
{
using var metering = _usageScope?.Suppress();
var interaction = await _workspace.GenerateAsync(
user.Id, jobId, user.ProfileCvText ?? string.Empty, ResolveName(user),
new AiGenerateRequest(request.Module, request.Mode, request.ExtraContext), ResolveProvider(), ct);
+4 -1
View File
@@ -39,6 +39,7 @@ builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton(externalOrigin);
builder.Services.AddSingleton<AiPrivacyPolicy>();
builder.Services.AddSingleton<AiOperationExecutionScope>();
builder.Services.AddSingleton<AiUsageExecutionScope>();
builder.Services.AddTransient<AiPrivacyHeaderHandler>();
builder.Services.AddScoped<CurrentUserService>();
builder.Services.AddScoped<ICurrentUserService>(sp => sp.GetRequiredService<CurrentUserService>());
@@ -149,6 +150,7 @@ builder.Services.AddProblemDetails(options =>
options.CustomizeProblemDetails = context =>
context.ProblemDetails.Extensions["traceId"] = context.HttpContext.TraceIdentifier;
});
builder.Services.AddExceptionHandler<AiUsageLimitExceptionHandler>();
builder.Services.AddOpenApi();
var dataRoot = (builder.Configuration["Data:Root"] ?? "").Trim();
if (string.IsNullOrWhiteSpace(dataRoot))
@@ -203,7 +205,8 @@ builder.Services.AddHttpClient("ai-service", client =>
builder.Services.AddMemoryCache();
builder.Services.AddScoped<AnalyticsService>();
builder.Services.AddSingleton<ISummarizerService, SummarizerService>();
builder.Services.AddSingleton<SummarizerService>();
builder.Services.AddScoped<ISummarizerService, MeteredSummarizerService>();
builder.Services.AddSingleton<IJobCvMatchService, JobCvMatchService>();
builder.Services.AddSingleton<ICvAiClassifier, CvAiClassifier>();
builder.Services.AddSingleton<ICvAiNormalizer, CvAiNormalizer>();
@@ -0,0 +1,25 @@
namespace JobTrackerApi.Services;
public sealed class AiUsageExecutionScope
{
private readonly AsyncLocal<int> _suppressionDepth = new();
public bool IsSuppressed => _suppressionDepth.Value > 0;
public IDisposable Suppress()
{
_suppressionDepth.Value++;
return new Restore(this);
}
private sealed class Restore(AiUsageExecutionScope owner) : IDisposable
{
private AiUsageExecutionScope? _owner = owner;
public void Dispose()
{
var current = Interlocked.Exchange(ref _owner, null);
if (current is not null) current._suppressionDepth.Value--;
}
}
}
@@ -0,0 +1,26 @@
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
namespace JobTrackerApi.Services;
public sealed class AiUsageLimitExceptionHandler : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
{
if (exception is not AiUsageLimitException limit) return false;
httpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
await httpContext.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = StatusCodes.Status429TooManyRequests,
Title = "AI usage limit reached",
Detail = limit.Message,
Extensions =
{
["code"] = limit.Code,
["traceId"] = httpContext.TraceIdentifier,
},
}, cancellationToken);
return true;
}
}
@@ -0,0 +1,89 @@
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using Microsoft.AspNetCore.Identity;
namespace JobTrackerApi.Services;
// The shared synchronous provider boundary. Durable operations and the AI workspace reserve
// usage before reaching this layer, so their execution scopes explicitly bypass this decorator.
public sealed class MeteredSummarizerService(
SummarizerService inner,
JobTrackerContext db,
UserManager<ApplicationUser> users,
AiUsageMeter usage,
AiOperationExecutionScope operationScope,
AiUsageExecutionScope usageScope) : ISummarizerService
{
public Task<string?> SummarizeAsync(string text, int maxLength = 150, int minLength = 30)
=> MeterAsync("synchronous.summary", text, maxLength, ct => inner.SummarizeAsync(text, maxLength, minLength), CancellationToken.None);
public Task<string?> SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40)
=> MeterAsync("synchronous.rewrite", instruction + "\n\n" + text, maxLength,
ct => inner.SummarizeSectionAsync(instruction, text, maxLength, minLength), CancellationToken.None);
public Task<AiGenerationResult?> GenerateSectionWithMetadataAsync(
string instruction,
string text,
int maxLength = 180,
int minLength = 40,
CancellationToken cancellationToken = default)
=> MeterAsync("synchronous.generate", instruction + "\n\n" + text, maxLength,
ct => inner.GenerateSectionWithMetadataAsync(instruction, text, maxLength, minLength, ct), cancellationToken);
public Task<AiTextExtractionResult?> ExtractTextAsync(
Stream stream,
string fileName,
string? contentType = null,
CancellationToken cancellationToken = default)
=> inner.ExtractTextAsync(stream, fileName, contentType, cancellationToken);
public Task RunProbeAsync(CancellationToken cancellationToken = default)
=> inner.RunProbeAsync(cancellationToken);
public Task<AiServiceMetrics> GetMetricsAsync(CancellationToken cancellationToken = default)
=> inner.GetMetricsAsync(cancellationToken);
private async Task<T?> MeterAsync<T>(
string taskType,
string input,
int maximumOutputCharacters,
Func<CancellationToken, Task<T?>> generate,
CancellationToken cancellationToken)
where T : class
{
if (usageScope.IsSuppressed || operationScope.Current is not null || string.IsNullOrWhiteSpace(db.CurrentUserId))
return await generate(cancellationToken);
var ownerUserId = db.CurrentUserId;
var user = await users.FindByIdAsync(ownerUserId);
var entitlements = user is null ? AccountPlans.ForRoles(null) : AccountPlans.ForRoles(await users.GetRolesAsync(user));
if (user is null || !user.AiEnabled || !entitlements.Ai)
throw new AiUsageLimitException("ai_not_available", "AI features require an active Pro account with AI enabled.");
var boundedInputCharacters = Math.Min(input.Length, 20_000);
var boundedOutputCharacters = Math.Clamp(maximumOutputCharacters, 0, 4_096);
var estimatedTokens = Math.Max(1, (boundedInputCharacters + boundedOutputCharacters + 3) / 4);
var reservation = await usage.ReserveAsync(
ownerUserId,
entitlements,
"synchronous",
Guid.NewGuid().ToString("D"),
taskType,
boundedInputCharacters,
estimatedTokens,
cancellationToken);
var result = await generate(cancellationToken);
switch (result)
{
case string text:
await usage.FinalizeAsync(reservation.Record.Id, boundedInputCharacters, text.Length, cancellationToken);
break;
case AiGenerationResult generation:
await usage.FinalizeAsync(reservation.Record.Id, boundedInputCharacters, generation.Text.Length, cancellationToken);
break;
}
return result;
}
}
+1
View File
@@ -215,3 +215,4 @@ Output was reduced to filenames and commit counts. The token artifact appears un
| V-181 | AI usage meter/operation/workspace/export/deletion real-SQLite tests; EF model check; SQLite/MariaDB scripts; disposable SQLite backfill and fresh application startup; full backend | Repository root | Make Workspace and durable Strategy/CV usage owner-safe, idempotent and independent of deletable private history | PASS — focused 28/28 and backend 663/663; no pending model changes; both providers generate bounded additive DDL; SQLite backfills the synthetic legacy row exactly once; fresh runtime applies through `20260815175236_AddCrossFeatureAiUsage` and serves `/health` | Synthetic local rows only; no provider/model call, MariaDB server, production migration or worker activation. CV retains a conservative reservation and older synchronous AI paths are not yet universal | Main durable usage boundary implemented; remaining synchronous producers stay tracked under POL-001 |
| V-182 | Real ASP.NET Identity data-protection token integration on SQLite; focused auth tests; full backend | Repository root | Close SEC-005B expiry/replay/custom-username proof without SMTP or production | PASS — valid confirmation succeeds once, replay and zero-lifetime expiry return the same generic failure, a real change-email token preserves a custom username and cannot replay; focused 39/39 and backend 666/666 | Synthetic addresses and ephemeral local data-protection keys only; no email, browser, MariaDB or production call | SEC-005B local token-state gap closed |
| V-183 | Owner-filtered job-choice API test; correspondence Jest; frontend production build | Repository root / `job-tracker-ui` | Remove the email compose/thread-move selectors' false 100-job ceiling | PASS — backend search finds the oldest target among 130 owned rows and excludes another tenant; correspondence 20/20 proves debounced server search, compose selection and thread-move selection; TypeScript/production build passes | Synthetic rows/JSDOM only; no provider, email or production action | MAIL-001 exhaustive job selection gap closed |
| V-184 | Shared synchronous AI provider decorator, durable/workspace suppression scopes, quota exception handler and full backend | Repository root | Make numeric Free/Pro AI limits universal without double-counting already-reserved work | PASS — focused shared-provider/accounting suite 25/25 and backend 674/674; success finalizes measured characters, Free/exhausted requests stop before provider I/O, workspace/operation scopes create no second row, and quota failures return stable 429 details | Fake in-process provider and SQLite only; no model, Stripe, MariaDB or production call | POL-001 repository accounting gap closed; Stripe lifecycle and production smoke remain |
@@ -1,6 +1,6 @@
# POL-001 Free/Pro entitlement verification
Date: 2026-08-02
Date: 2026-08-15
Status: `IMPLEMENTED — NOT VERIFIED`. The server policy, worker rechecks, Free locked states and automated regressions pass. Real-browser, Stripe lifecycle, MariaDB and production checks remain unavailable.
@@ -11,26 +11,26 @@ Status: `IMPLEMENTED — NOT VERIFIED`. The server policy, worker rechecks, Free
- Pro and Admin use AI and Pro CV themes. The persisted Identity role remains `Premium`, and `Stripe:PricePremium` remains a compatibility key; neither is exposed as a public plan name.
- Current database roles are authoritative on every explicit HTTP AI action. A stale role claim cannot preserve access after downgrade.
- A locked explicit action returns HTTP 403 with `{ "code": "pro_required", "message": "This AI feature requires Pro." }`.
- Existing 250-call/1,000,000-token Pro ceilings remain because they are defined in the existing implementation roadmap. Free ceilings are zero. A content-free `AiUsageRecord` ledger is now authoritative for AI Workspace plus durable Strategy and CV work; legacy `AiInteraction` usage is backfilled. Older synchronous AI actions still need the same admission boundary before the numeric ceilings can be described as universal.
- Existing 250-call/1,000,000-token Pro ceilings remain because they are defined in the existing implementation roadmap. Free ceilings are zero. A content-free `AiUsageRecord` ledger is authoritative for AI Workspace, durable Strategy/CV work and every user-scoped generation through the shared synchronous provider boundary; legacy `AiInteraction` usage is backfilled. Health probes and non-generative text extraction are intentionally excluded.
## Entry-point inventory
| Capability | User entry / frontend | API or worker execution path | Admission and recheck | Usage accounting | Free behavior |
|---|---|---|---|---|---|
| AI Workspace modules | Job details → AI Workspace; `AiWorkspacePanel` | `POST /api/jobapplications/{jobId}/ai/generate``AiWorkspaceService``ISummarizerService` | `Pro` policy with live role lookup | Ledger reservation before generation; actual estimate finalized on success | Generate disabled; existing history/read/delete remain available |
| Candidate fit | Job details Candidate Fit and Strategy Snapshot | `GET .../{id}/candidate-fit` → attachment/correspondence context → multiple summarizer calls | `Pro` policy | No complete shared usage row | Deterministic `match-score` remains available; AI narrative locked |
| Candidate fit | Job details Candidate Fit and Strategy Snapshot | `GET .../{id}/candidate-fit` → attachment/correspondence context → multiple summarizer calls | `Pro` policy plus shared provider admission | One ledger row per provider generation | Deterministic `match-score` remains available; AI narrative locked |
| Focus plan | Job details Focus Plan and Strategy Snapshot | Durable `strategy.snapshot` operation → summarizer | `Pro` admission plus worker recheck | Atomic operation-ledger reservation; successful input/output estimate finalized | Locked; no synthetic fallback presented as generated |
| Interview brief | Job details Interview Prep | `GET .../{id}/interview-prep/brief` → summarizer | `Pro` policy | No complete shared usage row | Editable non-AI interview board remains available; generated brief locked |
| Tailored CV generation | Add Job option and job Tailored CV tab | `POST .../{id}/generate-tailored-cv-draft` → shared generation helpers → summarizer | `Pro` policy | No complete shared usage row | Job creation and manual tailored-draft editing remain available; no operation is started |
| Application package | Job workspace drafts | `POST .../{id}/generate-application-package` → attachment/email context → summarizer | `Pro` policy | No complete shared usage row | Existing/manual package drafts remain readable and editable |
| Follow-up draft | Job Follow-up tab | `GET .../{id}/followup-draft` → context → summarizer | `Pro` policy | No complete shared usage row | Manual correspondence data remains available; AI draft is locked |
| Job summary refresh | Job overview | `POST .../{id}/refresh-ai``SummarizeAsync` | `Pro` policy | No complete shared usage row | Existing summary/tags remain visible; refresh locked |
| Automatic job summary | Job create/detail | Core `POST /jobapplications` and `GET /{id}` optional summarizer calls | Live role condition inside core action | No complete shared usage row | Core request succeeds without calling AI |
| Interview brief | Job details Interview Prep | `GET .../{id}/interview-prep/brief` → summarizer | `Pro` policy plus shared provider admission | One ledger row per provider generation | Editable non-AI interview board remains available; generated brief locked |
| Tailored CV generation | Add Job option and job Tailored CV tab | `POST .../{id}/generate-tailored-cv-draft` → shared generation helpers → summarizer | `Pro` policy plus shared provider admission | One ledger row per provider generation | Job creation and manual tailored-draft editing remain available; no operation is started |
| Application package | Job workspace drafts | `POST .../{id}/generate-application-package` → attachment/email context → summarizer | `Pro` policy plus shared provider admission | One ledger row per provider generation | Existing/manual package drafts remain readable and editable |
| Follow-up draft | Job Follow-up tab | `GET .../{id}/followup-draft` → context → summarizer | `Pro` policy plus shared provider admission | One ledger row per provider generation | Manual correspondence data remains available; AI draft is locked |
| Job summary refresh | Job overview | `POST .../{id}/refresh-ai``SummarizeAsync` | `Pro` policy plus shared provider admission | One ledger row per provider generation | Existing summary/tags remain visible; refresh locked |
| Automatic job summary | Job create/detail | Core `POST /jobapplications` and `GET /{id}` optional summarizer calls | Live role condition plus shared provider admission | One ledger row when a provider generation runs | Core request succeeds without calling AI |
| CV import/parse | Career Profile upload/parse/reprocess | `/profile-cv/upload`, `/parse`, `/reprocess` → durable `cv.process` operation | `Pro` policy before admission; queued run rechecks live roles | Atomic conservative operation-ledger reservation; no raw CV content | Manual profile editing and previous review runs remain available |
| CV rebuild/improve/rewrite/PDF | Career Profile AI buttons | `/rebuild`, `/improve`, `/rewrite-section`, `/rewrite-preview`, `/export-pdf` | `Pro` policy; queued rebuild/improve recheck live roles | CV-run state only | AI controls locked; manual profile data remains available |
| CV Builder writing aid | CV Builder AI Tools | `POST /api/cv/ai/assist` → summarizer | `Pro` policy | No complete shared usage row | AI buttons disabled; CV editing/history remain available |
| CV rebuild/improve/rewrite/PDF | Career Profile AI buttons | `/rebuild`, `/improve`, `/rewrite-section`, `/rewrite-preview`, `/export-pdf` | `Pro` policy; queued work rechecks roles and synchronous generation uses shared admission | Durable operation reservation or synchronous provider ledger row | AI controls locked; manual profile data remains available |
| CV Builder writing aid | CV Builder AI Tools | `POST /api/cv/ai/assist` → summarizer | `Pro` policy plus shared provider admission | One ledger row per provider generation | AI buttons disabled; CV editing/history remain available |
| Pro CV themes | CV Builder Customize | `GET /api/cv/themes`; create/save validates selected theme | Live role lookup in theme catalog checks | Not applicable | Pro themes identified and unavailable; existing unchanged selection can still be saved |
| Job enrichment worker | No direct UI; disabled by default | `JobEnrichmentHostedService` per owner | Live role recheck immediately before summary; deterministic tag detection still runs for Free | No complete shared usage row | No model call; core tag enrichment remains possible |
| Job enrichment worker | No direct UI; disabled by default | `JobEnrichmentHostedService` per owner | Live role recheck plus shared provider admission immediately before summary; deterministic tag detection still runs for Free | One ledger row per provider generation | No model call; core tag enrichment remains possible |
| Admin AI probe | Admin system diagnostics | `/api/admin/system/ai/probe` | Admin role; Admin maps to Pro | Health metric only | Not a Free user path |
| Periodic service probe | No user entry | summarizer health probe | No private/user payload; operational health only | Health metric only | Not a user AI capability |
| Attachment storage | Add-job/files UI | `AttachmentsController` storage check | Central Free/Pro storage entitlement | Bytes stored | 250 MB Free; 5 GB Pro (existing defined capability) |
@@ -43,7 +43,8 @@ Status: `IMPLEMENTED — NOT VERIFIED`. The server policy, worker rechecks, Free
- `AccountPlansTests`: Free zero AI, Pro/Admin AI, and only `free`/`pro` external names.
- AI Workspace UI test: Free locked state, disabled generation and upgrade link.
- `AiUsageMeterTests`, operation integration, account export/deletion and SQLite compatibility tests cover idempotent reservation, limits, owner isolation, history-independent totals, Strategy finalization, CV conservative reservation and lifecycle handling.
- Full backend after the ledger migration: 663/663.
- `MeteredSummarizerServiceTests` prove synchronous success finalization, pre-provider quota rejection, Free-user rejection, workspace/operation double-count suppression and stable HTTP 429 problem details.
- Full backend after universal provider admission: 674/674.
- Full backend: 568/568.
- Full frontend: 47/47 suites, 157/157 tests.
- Production frontend build: pass.
@@ -55,7 +56,7 @@ Status: `IMPLEMENTED — NOT VERIFIED`. The server policy, worker rechecks, Free
- Stripe webhook transitions were code-inspected and existing status tests cover active/trialing vs expired states, but no real or mocked end-to-end checkout/webhook cycle ran in this package.
- MariaDB and production were not changed or tested.
- PRODUCT-001 removed landing-page prices, the third “Bring your own key” tier, Free AI allowance and “Unlimited AI” claims. Public capability copy now comes from one two-plan catalogue; commercial terms remain in configured Stripe Checkout.
- The durable ledger now spans AI Workspace, Strategy Snapshot and CV processing, and deleting user-visible AI history no longer erases usage. Candidate Fit, Interview Prep, application-package/follow-up drafting, CV Builder assistance and automatic summary paths remain synchronous and are not yet universally admitted through this ledger; the UI must therefore avoid claiming that the displayed numeric ceiling covers every AI path.
- The durable ledger spans AI Workspace, Strategy Snapshot, CV processing and all user-scoped calls through `ISummarizerService`. Failed or empty provider attempts retain their conservative reservation because they may still have consumed provider capacity; successful generations replace it with measured input/output. Health probes and extraction-only calls are not user generation usage.
## Rollback
+5 -5
View File
@@ -395,10 +395,10 @@ This queue records the highest-value work that can proceed without production cr
- **Required browser verification:** locked state/upgrade action/dismissal and Pro execution; mobile/theme/accessibility.
- **Required production verification:** configured Stripe/role mapping only when operator activation is approved.
- **Status:** `IMPLEMENTED — NOT VERIFIED`.
- **Blocker:** Stripe/MariaDB/production verification is unavailable. The main durable producers are accounted, but older synchronous AI actions still need ledger admission before numeric limits are universal.
- **Evidence:** `docs/verification/pol-001-free-pro-entitlements.md`; V-181; focused accounting/operation/lifecycle 28/28; full backend 663/663; existing frontend/browser entitlement evidence.
- **Blocker:** Stripe/MariaDB/production verification is unavailable. Repository entitlement and universal user-generation accounting are complete.
- **Evidence:** `docs/verification/pol-001-free-pro-entitlements.md`; V-181/V-184; focused shared-provider accounting 25/25; full backend 674/674; existing frontend/browser entitlement evidence.
- **Commit:** none.
- **Remaining work:** mocked Stripe expiry/downgrade lifecycle; move remaining synchronous AI actions through ledger admission; production role/config smoke. PRODUCT-001 has removed the former landing-page price/third-tier/unlimited claims.
- **Remaining work:** mocked Stripe checkout/webhook expiry/downgrade lifecycle and production role/config smoke. PRODUCT-001 has removed the former landing-page price/third-tier/unlimited claims.
### POL-002 — AI privacy, consent and external-fallback policy
@@ -416,7 +416,7 @@ This queue records the highest-value work that can proceed without production cr
- **Blocker:** browser localhost is denied; MariaDB/production/external-provider verification is unavailable. Task-specific payload minimization/accounting depend on AI-003/004.
- **Evidence:** `docs/verification/pol-002-ai-privacy.md`; focused backend 72/72 and final policy 28/28; sidecar 18/18; focused frontend 8/8; full backend 576/576; full frontend 47 suites/158 tests; production build; config and migration script checks.
- **Commit:** none.
- **Remaining work:** browser user/admin disclosure checks; MariaDB and production synthetic egress proof; AI-003/004 task-specific payload minimization, accounting and real producer verification. AI-001/002 now carry rechecked policy/task context and record bounded local-first provenance.
- **Remaining work:** browser user/admin disclosure checks; MariaDB and production synthetic egress proof; AI-003/004 task-specific payload minimization and real producer verification. AI-001/002 now carry rechecked policy/task context and record bounded local-first provenance; V-184 closes shared synchronous accounting.
### AI-001 — Durable AI queue, backpressure and operation APIs
@@ -452,7 +452,7 @@ This queue records the highest-value work that can proceed without production cr
- **Blocker:** browser and production checks, actual local-model selection and controlled provider fallback depend on administrator browser policy plus PROD-001/003 access/benchmarks. Repository behavior is not blocked.
- **Evidence:** `docs/verification/ai-002-provider-routing.md`; V-098V-100; focused backend 26/26, full backend 588/588, sidecar fake-transport 22/22, Compose/diff checks pass.
- **Commit:** none.
- **Remaining work:** keep Strategy/CV local-only until explicit external task approval; move older synchronous AI actions through central usage admission; MariaDB/selected-model/controlled-provider/production verification. Old provider/model configuration remains available for rollback.
- **Remaining work:** keep Strategy/CV local-only until explicit external task approval; MariaDB/selected-model/controlled-provider/production verification. Shared synchronous AI actions now use central usage admission (V-184). Old provider/model configuration remains available for rollback.
### PROD-001 — Read-only production AI inventory and rollout safety