Skip to content

feat(ai): :feature:ai scaffold — status, gating, quota and preview→confirm (#4) - #92

Merged
Adron merged 1 commit into
parity/queuefrom
issue/4-ai-scaffold
Sep 16, 2026
Merged

Adron merged 1 commit into
parity/queuefrom
issue/4-ai-scaffold

Conversation

@Adron

@Adron Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member

Closes #4. Part of epic #3. Foundation for #5, #6, #7, #8, #9.

What this is

A new self-contained :feature:ai module: the API layer, gating and preview→confirm contract that
the five AI feature issues will build on. No UI, no navigation, no menu entries — those ship
with the features themselves.

The contract came from the docs, not from guesses

The OpenAPI spec models all three AI routes as a bare {"type":"object"}, so it was useless for
shapes. The real reference is published at https://interlinedlist.com/help/api/ai-integration,
and every field, error code and limit here came from it.

I have since confirmed GET /api/ai/status against the live API with a real subscriber token,
and it matches exactly:

{ "subscriber": true, "providers": ["anthropic"],
  "defaultModels": { "anthropic": "claude-sonnet-5", "openai": "gpt-4.1-mini", "gemini": "gemini-2.0-flash" },
  "quota": { "usedToday": 0, "dailyLimit": 50, "remaining": 50 } }

So — answering the open question in the issue — /api/ai/status does expose quota
(usedToday/dailyLimit/remaining), and /suggest and /generate echo the same object.
Nothing was invented.

Design

  • AiFeature is the single feature discriminator, carrying all five documented wire values
    (writing_assist, powered_template, powered_document, message_series, article_series).
    Siblings reference a case rather than adding one.
  • AiGate (@Singleton) exposes StateFlow<AiAvailability> of
    Unknown / Unavailable / NotSubscribed / Available(quota). Only Available enables a control, so
    a free account never sees one. Empty providers hides AI for everyone; an absent providers
    field is deliberately not treated as the same claim; a subscription that cannot be confirmed hides
    AI rather than guessing. Gating reuses the app's existing model — /status's own subscriber
    flag first, falling back to getCurrentUser() → CustomerStatus.isSubscriber from :core:model,
    the same field :feature:profile gates on.
  • Preview→confirm is enforced by the type system, not by convention. generate() accepts only a
    ConfirmedPreview, whose constructor is module-private and reachable only via
    AiPreview.confirm(edited). So "nothing is written until the user approves" cannot be bypassed by
    a careless call site. AiPreviewSession + AiPreviewState give all five surfaces one state machine.
  • Error mapping keys off the API's code (falling back to status), keeping quota_exceeded,
    rate_limited (with Retry-After) and no_provider_configured distinct.

Verification

./gradlew :app:assembleDebug testDebugUnitTest → BUILD SUCCESSFUL, 744 tests repo-wide, 0
failures
(58 new). Repository tests run a real Retrofit/OkHttp stack against MockWebServer,
asserting actual request paths and bodies. The gating logic was mutation-checked — flipping
!subscriber to Available makes two tests fail, so the subscriber gate is genuinely covered.

Outside the module the only edits are one line in settings.gradle.kts and one
implementation(project(":feature:ai")) in app/build.gradle.kts (so the Hilt bindings join the
component). No libs.versions.toml change was needed.

Reviewer notes — deliberate calls worth a look

  1. AiError/AiResult are module-local rather than the shared AppError/ApiResult. The
    shared type drops the code field and so cannot tell the two different 429s apart, nor
    no_provider_configured from a generic conflict. Editing :core:common was out of scope. This
    is the one intentional duplication — say the word and it can be promoted instead.
  2. Compose is enabled in the build file but no Composable ships, to match the existing
    feature-module shape and so five parallel branches don't each have to edit it. No shared
    preview-sheet Composable was built: with no consumer yet and no emulator to test it, the state
    contract is the honest deliverable.
  3. Artifacts stay raw JsonObjects, round-tripped verbatim to /generate; each sibling issue
    decodes its own typed shape.
  4. AiGate.ensureResolved() has no mutex — two surfaces racing at startup cost one extra
    GET /api/ai/status.
  5. /suggest and /generate request/response shapes are from the published docs, not a
    captured live response. First real exercise is AI: writing assistant in the message composer (rewrite / tighten / expand / fix grammar / make a thread / suggest tags) #5.

…firm

Adds the self-contained :feature:ai module the AI surfaces (#5–#9) build on:
its own AiApi over GET /api/ai/status, POST /api/ai/suggest and
POST /api/ai/generate off the shared authed Retrofit, defensive DTOs, and a
repository exposing availability(), suggest() and generate().

Gating lives in one place: AiGate publishes an AiAvailability that is only
Available for a subscriber on a deployment with a provider configured, so no AI
control is ever drawn for a free account. The subscriber flag comes from
/api/ai/status, falling back to the same customerStatus the rest of the app
gates on when the body omits it; an unverifiable subscription hides AI rather
than guessing.

Preview→confirm is enforced by the type system: /generate only accepts a
ConfirmedPreview, whose constructor is module-private and is reachable only via
AiPreview.confirm(), and AiPreviewSession drives the shared state machine so the
rule is not re-implemented five times. Failures map off the API's `code` (not
just the status) so quota_exceeded, rate_limited and no_provider_configured stay
distinct, with the quota case surfacing as "Daily AI limit reached".

No UI, navigation entries or menu items — :app depends on the module only so its
Hilt bindings join the component.

Closes #4
@Adron
Adron merged commit 3876d6b into parity/queue Sep 16, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant