Files

28 KiB

Row 255 design: a bundled Pocket ID for users with no OIDC provider (Q203, Gitea #1760, Vikunja 458)

Launchpad, draft 1, 2026-10-25, on launchpad/row255 off refactor 78b69e1e6. Brief docs/plans/2026-10-21_bundled-pocket-id.md (blob 26f34400). Reviewer Webby. Sage checks it against Q203, O6, REQ-WEB-2 and REQ-INST-1. REQ-INST-2 (PRD 1.10) is post-v1 (mos-1204), and so is everything here.

This is a design only. Nothing here installs, downloads or starts Pocket ID. It changes nothing on .53, .54 or dragon-lin, which keeps Authentik.

Sources

  • Q203 and O6 as the brief quotes them. PRD docs/prd/mosaic-stack.md REQ-WEB-2 (:902), REQ-INST-1 (:1509), REQ-INST-2 (:1513) and REQ-INST-3 (:1528). Lead decision 44 items 5 and 6 (docs/plans/2026-09-26_lead-decisions.md:655-663).
  • The Q175 design agents/sage/work/q175-sso/DESIGN.md sections 4 and 10, and the code row 192 landed: packages/webui/src/sso-config.mjs (the console.sso shape), packages/webui/src/sso.mjs (the slot read, the discovery checks at :105-118, the client at :147-148, the callback at :133, the post-logout URI at :332).
  • Researcher's note agents/researcher/work/2026-10-04_vikunja-pocketid.md part 2 (v2.17.0).
  • A fresh read of upstream on 2026-10-25, from release metadata and source text only: no binary was fetched or run. The latest stable release is v2.19.1 (2026-10-22). Source paths below are under backend/internal/ at tag v2.19.1 in github.com/pocket-id/pocket-id, marked "P". Releases v2.18.0, v2.19.0 and v2.19.1 are not marked breaking.

What the Console already needs from any provider

This design keeps the Console's sign-in and start checks exactly as row 192 built them. Pocket ID becomes one console.sso.providers entry, the same shape any compliant OP uses (Q175 section 4, "Several providers"). The Console gets no new sign-in path, no password and no proxy-header identity (O6, REQ-WEB-2, REQ-INST-2). The facts that drive the rest of the design:

  • publicOrigin and every issuer must be https (sso-config.mjs httpsURL). A plain-http Pocket ID can't be a provider at all.
  • Each provider needs 1 to 64 subjects. A console.sso with no subject refuses to load. So the first person's subject has to exist before the Console starts with --sso (section 5).
  • The client authenticates with client_secret_basic (sso.mjs:148), so the client is confidential.
  • The slot is <configDir>/secrets/mosaic-stack/<slot>: a regular file, owned by the Console's uid, mode with no group or other bits, and outside the repo and every stack copy (readSlot).
  • config.json is user-authored. bootstrap.sh creates it and nothing overwrites it (canon invariant 2, Q175 section 4). This matters for sections 1 and 5.

1. The choice

The installer is REQ-INST-3's install command (Vikunja #432; today stack-upgrade.sh and the per-case install scripts). It takes one sign-in option per instance:

  • --oidc existing. The user points the Console at one or more OIDC providers they already have. Nothing is installed. The user writes console.sso themselves, as Q175 step 4 does today. The installer writes nothing into console.sso; it prints the entry shape and the two URIs to register (<publicOrigin>/auth/callback, <publicOrigin>/auth/signed-out).
  • --oidc bundled. The stack installs Pocket ID as an install component (section 2), and console.sso.providers holds exactly one entry, id: "pocket-id", whose issuer is the component's origin.
  • No option keeps today's behaviour: no console.sso, and the Console runs without --sso.

How the bundled choice is recorded. REQ-INST-1 lets a component be switched on "in the organization file or system config". It has to be the system config. The organization file can be written by agents through organizationWriter, which is the reason Q175 B1 put console.sso in config.json, and the same reason holds for a component that issues the Console's identities. So config.json gets one new optional key:

"components": {
  "pocket-id": {
    "release": "v2.19.1",
    "origin": "https://id.example.internal",
    "port": 1411
  }
}

The loader (scripts/mosaic-config.mjs) checks the shape: release is a vX.Y.Z tag, and the component script refuses one that differs from the pin file (section 2), origin is an https origin, port is 1024 to 65535. It also cross-checks: when components["pocket-id"] is present, console.sso must have exactly one provider, id pocket-id, issuer equal to origin. A config with console.sso providers and no components is the existing choice and loads as today.

M1, approved by Mos with three conditions (mos-1666 item 3). components is a new top-level config key. The loader refuses unknown keys today, so this is a config schema change. The conditions:

  1. Rollback trap. A copy older than P1 refuses a config that has the key, the same trap Q175 section 4 names for console.sso. P1 documents the order. On a rollback past P1, first take components out of config.json (restore the backup made before the key went in), then roll back the pointer. The STATE check after the rollback reads config.json and confirms the key is absent.
  2. Strict inside the key. The loader refuses any name under components other than pocket-id, and any field under pocket-id other than release, origin and port. Each refusal names the key path and quotes no value, as checkConsole does.
  3. test-config.sh cases:
    • a valid components loads;
    • a malformed one fails, naming the bad key path;
    • a config without components loads;
    • the cross-check refuses a console.sso whose provider doesn't match origin.

What each choice writes into console.sso is in section 5. For the bundled choice, Pocket ID is the only providers entry. The design keeps it that way because REQ-INST-2 says "the Console's one providers entry". A user who later adds an existing provider moves to --oidc existing and removes the component (section 2).

2. The component, against REQ-INST-1

Pinned. A Pocket ID release is a set of raw binaries plus checksums.txt (sha256). From v2.16.0 there is an SPDX SBOM per binary, and the release workflow attests checksums.txt through Sigstore (actions/attest in .github/workflows/release.yml). The repo holds one pin file, scripts/pocket-id-pins.sh, sourced the way scripts/case1-pins.sh is: the tag, and for each supported platform (linux amd64 and arm64 to start) the asset name and its sha256. The build row copies the sha256 from checksums.txt at the tag and records the gh attestation verify output for each asset as its evidence. The pin is checked by hand at build time, not trusted at install time. No Pocket ID source and no binary goes in the repo. The binary is not in any image either, since the container install comes after v1 (REQ-INST-3).

Recommended pin: v2.19.1, the current release. The build row checks it again, because Pocket ID ships a minor every one to four weeks. Upgrading is a pin file change in a reviewed commit.

Fetched and checked. scripts/component-pocket-id.sh fetch downloads the pinned asset for the host's platform over https into a new 0700 staging directory, computes its sha256, and compares it with the pin. On a mismatch it deletes the file and refuses with pocket-id-digest-mismatch, naming the expected value and the computed one. On a match it installs the binary read-only (0555) at <dataRoot>/components/pocket-id/bin/pocket-id-<tag>. The fetch is the only network step, and the user runs it at install. Tests never run it against the network (section 9).

Runs. A systemd user unit, mosaic-pocket-id.service (mosaic-pocket-id-NAME.service for a named instance, as row 190 names the bus and webui units), as the instance's user:

  • ExecStart=<dataRoot>/components/pocket-id/bin/pocket-id-<tag>.
  • WorkingDirectory=<dataRoot>/components/pocket-id/run, a directory the unit owns, so Pocket ID's habit of reading .env from the working directory reads nothing we didn't write. The build row checks there is no .env there at start.
  • Environment, all from the rendered unit:
    • APP_URL=<origin> and HOST=127.0.0.1 (P common/env_config.go: HOST defaults to 0.0.0.0).
    • PORT=<port>.
    • DB_CONNECTION_STRING and UPLOAD_PATH as absolute paths under <dataRoot>/components/pocket-id/data/ (P defaults are data/pocket-id.db and data/uploads, relative to the working directory).
    • ALLOW_USER_SIGNUPS=disabled and ALLOW_INSECURE_CALLBACK_URLS=false.
    • EMAIL_ONE_TIME_ACCESS_AS_UNAUTHENTICATED_ENABLED=false and ..._AS_ADMIN_ENABLED=false.
    • The OUTBOUND_ALLOWED_HOSTS_* allowlists set empty (added in 2.19.0).
    • No TRUST_PROXY unless section 6's proxy step sets it to the proxy's address.
  • ENCRYPTION_KEY (mandatory since v2.0, at least 16 bytes) never goes in the unit text. It lives in slot <configDir>/secrets/mosaic-stack/pocket-id-encryption-key, 0600. The component script generates it from /dev/urandom straight into the file and never prints it. The unit passes it with LoadCredential= and ENCRYPTION_KEY_FILE=%d/.... The build row confirms ENCRYPTION_KEY_FILE at the pinned tag; if it isn't there, it refuses rather than putting the key in Environment=.
  • The unit never carries STATIC_API_KEY. That key exists only during the enroll run (section 4).

The data directory holds the SQLite database, Pocket ID's signing key (stored encrypted with ENCRYPTION_KEY), and the passkey registrations. It is 0700. Agent sandboxes bind only an allowlist (packages/seat/src/sandbox.mjs), and <dataRoot>/components/ is not on it. The build row adds a sandbox test that pins that.

Health-checked. GET http://127.0.0.1:<port>/healthz returns 204 (P controller/healthz_controller.go). component-pocket-id.sh status reports the unit state, the binary tag against the pin, the binary's sha256 against the pin, and the health result. The Console's own /healthz already shows sso: { "pocket-id": ready | unreachable | refused } from its discovery retries, so an operator sees the provider from either side.

Removable. component-pocket-id.sh remove refuses while console.sso still names pocket-id, so the Console can't be left pointing at a provider that's gone. Once the user has taken the entry out (or moved to --oidc existing), the script stops and removes the unit, then moves the data directory to <dataRoot>/components/pocket-id.removed-<utc> with a receipt line in <dataRoot>/state/components.log (append-only). It never deletes the passkey database outright. Deleting the moved directory and the encryption key slot is a separate purge verb that names both paths and needs --yes. Removing the components key from config.json is the user's edit.

3. The Q175 start checks against the bundled instance

The Console checks are unchanged. Here is each one against Pocket ID, and the setting it needs:

Check (row 192) Pocket ID v2.19.1 Default that fails Fix
issuer is https (loader) issuer = APP_URL exactly (P controller/well_known_controller.go) APP_URL defaults to http://localhost:1411 APP_URL=<https origin>, section 6
discovery issuer equals config issuer equal when APP_URL has no trailing slash; Pocket ID refuses a path none config issuer = origin, no trailing slash; the loader cross-check compares them
id_token_signing_alg_values_supported overlaps algs advertises only the current key's alg. RS256 (RSA-2048) by default (P service/jwt_service.go) none algs: ["RS256"]
code_challenge_methods_supported has S256 advertises plain and S256 none none. The Console always sends S256 (sso.mjs:221)
JWKS has a usable key for algs one key at /.well-known/jwks.json none none. The build row's fake OP pins use/alg/kid as Pocket ID emits them
end_session_endpoint is https <APP_URL>/api/oidc/end-session http, if APP_URL is http same fix as row 1
redirect_uri is registered exact match first, then wildcard patterns; loopback callbacks match any port (P utils/callback_url_util.go) ALLOW_INSECURE_CALLBACK_URLS defaults to true register exactly <publicOrigin>/auth/callback, with no *; set ALLOW_INSECURE_CALLBACK_URLS=false
post-logout URI per-client logoutCallbackURLs, same matching none register exactly <publicOrigin>/auth/signed-out
ID token alg in algs, iss match as above none none

Two points the Console doesn't check, set on the client anyway:

  • pkceEnabled: true. Then Pocket ID refuses an authorize request that has no code_challenge. Pocket ID has no per-client S256-only setting (P oidc/provider.go turns on plain for the server). That doesn't relax anything: the Console sends S256 every time, and plain stays available only to other clients.
  • isPublic: false, which matches client_secret_basic.

Key rotation. pocket-id key-rotate --alg ... replaces the one signing key (P cmds/key_rotate.go). After a rotation to another alg, the Console's discovery check refuses on the next restart (no overlap with algs), and that is the intended fail-closed result. A rotation is a user step that updates algs in the same edit. The component script never rotates.

4. The client, and where its secret goes

The person running the install creates the client through component-pocket-id.sh enroll. No agent runs this verb. It refuses when any agent marker is in its environment: the MARKERS list in packages/bus/src/human.mjs (MOSAIC_RUN_ID, MOSAIC_BUS_CAP, CLAUDECODE and the rest) that the human boundary uses. A test pins the refusal.

Enroll needs admin rights before any person has a passkey. Pocket ID offers two ways to get them, and one of them is a hazard:

  • /setup and its backing POST /api/signup/setup are unauthenticated: whoever calls first becomes admin (P usersignup/service.go). Sandboxed agents share the host's network namespace (sandbox.mjs unshares user, pid and ipc, not net), so any agent could reach a loopback Pocket ID and take that first admin.
  • STATIC_API_KEY is a fixed admin key with no expiry (P apikey/service.go). It doesn't count as completing setup, so /setup stays open while it's set.

So enroll never lets the real unit serve before an admin person exists. It runs the pinned binary once in a private network namespace (unshare --user --net, or bwrap --unshare-net with the binary and data directory bound), where only enroll's own loopback can reach it:

  1. Generate a one-run static key into a 0600 temp file in a new 0700 directory, and start Pocket ID inside the namespace with STATIC_API_KEY_FILE pointing at it, plus the unit's environment.
  2. Wait for /healthz 204.
  3. Create the admin user, POST /api/users with isAdmin: true and the username and email given with --admin-user and --admin-email. Keep the returned id. That is the OIDC sub (P oidc/claims_service.go uses user.ID, a UUIDv4 set at creation, so it's stable).
  4. Create the Console client: client id mosaic-console-<instance>, callback and logout URLs exactly as in section 3, pkceEnabled: true, isPublic: false. Supply the secret instead of letting Pocket ID generate it. Enroll generates 48 random bytes, base64url, and writes them straight to slot <configDir>/secrets/mosaic-stack/console-sso-pocket-id (0600, O_CREAT|O_EXCL, not a symlink). It sends the same bytes in the create body over the namespace's loopback (dto/oidc_dto.go accepts a supplied secret of 16 or more characters). The value is never printed, logged or put in an argv, and a test pins that it appears in no output.
  5. Mint a one-time access token for the admin, POST /api/users/<id>/one-time-access-token.
  6. Stop the namespaced process, then shred and remove the static key file. The real unit has never had a static key, so no admin key survives enroll.
  7. Start the real unit and wait for /healthz 204.
  8. Print the sign-in link <origin>/lc/<token> to the user's terminal (section 5), and the console.sso entry (section 5).

The build row confirms each route and body against the pinned tag's source before it writes the client (the upstream read for this design didn't trace the create-client route). If any of steps 1 to 7 fails, enroll stops the process, removes the static key, leaves no slot file it created, and refuses with the step name.

The secret never enters the repo or an image. It sits in two places only: the slot file and Pocket ID's database (stored as a hash). The Console's readSlot reads it as it does any provider's.

5. The first person, and their subject in console.sso

Pocket ID is passkey-only for people (REQ-INST-2). The installing user enrolls like this:

  1. Enroll prints <origin>/lc/<token>. The token is valid for one hour and is a credential, so it goes only to the terminal of the person who ran enroll. It goes in no log and no file.
  2. The user opens it in a browser that can reach origin (section 6). Pocket ID signs them in and asks them to add a passkey. Without one they can't sign in again. If the hour passes, pocket-id one-time-access-token <username> (P cmds/one_time_access_token.go), run by the user against the data directory, prints a new link. component-pocket-id.sh relink wraps that call.
  3. Enroll already has the subject, the id from step 3 of section 4. It doesn't wait for a sign-in or read a subject-unknown log line.

How the subject gets into console.sso. config.json is user-authored and never auto-written. Two ways to respect that; I recommend the first:

  • R1 (recommended). Enroll prints the full entry and writes nothing:

    "console": { "sso": { "publicOrigin": "<publicOrigin>", "providers": [ {
      "id": "pocket-id", "issuer": "<origin>", "clientId": "mosaic-console-<instance>",
      "clientSecret": { "slot": "console-sso-pocket-id" }, "algs": ["RS256"],
      "subjects": [ { "sub": "<id>", "name": "<admin-user>" } ] } ] } },
    "components": { "pocket-id": { "release": "<tag>", "origin": "<origin>", "port": <port> } }
    

    The user pastes it into config.json, as with Q175 step 4. Rerunning scripts/bootstrap.sh (or --instance NAME) then validates it through the loader, including the section 1 cross-check, and writes nothing. Nothing changes in invariant 2.

  • R2 (rejected, Sage sage-4401: R1 chosen). enroll --write-config would write those two keys itself, with a backup of config.json first, and refuse if console or components were already present. That would be the first tool to modify an existing config.json, against canon invariant 2's "never overwritten" practice. Not built.

Whose subject. Enroll creates one person, the one running it, and only that subject goes in the entry. On Jason's instances, only his subject goes into console.sso (mos-1197). Further people are added with Pocket ID's admin UI (a user plus a one-time link), and their subjects go into console.sso by the user's edit, never by an agent. Pocket ID users who aren't in subjects can sign in to Pocket ID, but the Console refuses them subject-unknown, so REQ-WEB-2's "must map to an authorized human" holds.

6. Reach: origin, TLS and proxy

WebAuthn takes its RP id from APP_URL's host name, and its allowed origin is APP_URL (P webauthn/module.go). Browsers allow passkeys only on a secure origin. The Console also needs https for both publicOrigin and issuer. Pocket ID serves plain http, and I found no TLS listener setting in its environment config. So:

  • Pocket ID always binds 127.0.0.1:<port>. That is the default, and the component never binds anything else (HOST=127.0.0.1; the status verb refuses a unit whose HOST differs).
  • origin is an https origin served by a TLS front that the user already has for the Console. The Console needs one anyway, since publicOrigin is https. Pocket ID goes behind the same front under its own host name, for example id.<console host>. Pocket ID doesn't support a path prefix (APP_URL must have no path). A separate host name also keeps Pocket ID's cookies apart from the Console's __Host-mosaic_* cookies; the two share cookies if they sit on one host name on different ports.
  • The front reaches Pocket ID on loopback, so the front has to run on the same host. If it doesn't, Pocket ID would need a non-loopback bind, and that is a network.expose hard wait (REQ-WAIT-1), not a component option.
  • TRUST_PROXY is set to the front's address only, never true.
  • The Console fetches discovery and JWKS from issuer over https from the server side, so the Console's node has to trust the front's certificate. A public CA does that. A private CA needs NODE_EXTRA_CA_CERTS in the Console unit, which the build row adds as a render flag.

These steps are the user's, and on Jason's hosts they are Jason's, because each one changes who can reach the Console or Pocket ID, or who the host trusts:

  • J1: the DNS name for origin;
  • J2: the certificate for it, or a private CA and its trust in the browser and in NODE_EXTRA_CA_CERTS;
  • J3: the TLS front's route from origin to 127.0.0.1:<port> (a Traefik change on Jason's hosts);
  • J4: any listener off loopback.

The component checks and refuses; it never does them itself. With none of them done, Pocket ID runs on loopback with an https APP_URL that nothing serves yet. Enroll's step 2 health probe goes straight to 127.0.0.1, so enroll still works. The person can't add a passkey until J1 to J3 are done.

7. Agents

Unchanged. Agents keep their per-role service tokens (lead decision 44 item 6, REQ-INST-2). Pocket ID issues nothing an agent uses. The Console client is the only client enroll creates. No API, no client-credentials grant and no admin API key exist after enroll. Residuals, stated plainly:

  • Agents can reach 127.0.0.1:<port>, because the sandbox shares the host network. What they can reach there is the public sign-in pages, discovery and JWKS. Signup is disabled, email codes are off, /setup is closed because an admin exists, and there is no static key. A test of the rendered unit pins all four settings.
  • The data directory and the two slots are outside every sandbox bind (section 2).
  • A seat that ran as the same OS user outside a sandbox could read the data directory. The Console's existing slot has the same exposure; this design adds no new class of it.

8. Instances

One Pocket ID per named instance, never shared:

  • Each instance has one system config (row 190), and components and console.sso live in it. A shared Pocket ID would make one instance's config depend on a unit, a data directory and an encryption key that belong to another instance's config and data root.
  • Unit names, ports, data directory, slots and removal are all per instance (mosaic-pocket-id-NAME, <dataRoot of NAME>/components/pocket-id), so removing one instance's component can't break another's sign-in.
  • Each instance needs its own origin (section 6), so passkeys are per instance too. A person enrolls once per instance.
  • An instance that wants to use another instance's Pocket ID can still do it with --oidc existing. Then it is just an OIDC provider, with its own client registered by that instance's owner. Nothing special-cases it.

Two instances on one host each need their own port. The loader can't see other instances' configs, so component-pocket-id.sh refuses at fetch and enroll when the port is already bound on loopback.

9. Tests, stand-ins only

No test downloads or starts a real Pocket ID, and no test touches auth.diversecanvas.com, mosaic.woltje.com or 127.0.0.1:7332. The stand-ins:

  • Fake Pocket ID OP, a node server like row 192's fake OP, shaped from v2.19.1:

    • the discovery fields well_known_controller.go emits (issuer = origin, plain and S256, end_session_endpoint at /api/oidc/end-session, the three auth methods);
    • one RS256 JWK at /.well-known/jwks.json;
    • a token endpoint that takes only client_secret_basic;
    • /healthz 204.

    The Console starts against it under --sso with the section 5 entry, with no check changed. Negative cases:

    • an http issuer refuses at load;
    • an ES256-only JWKS against algs: ["RS256"] refuses at discovery;
    • an issuer with a trailing slash refuses on the cross-check.
  • Fake admin API on the same stand-in: POST /api/users, the client create, the one-time token, and a static-key check. Enroll runs against it with the binary replaced by a stub script that serves the stand-in. The tests pin:

    • the namespace argv;
    • the slot file's mode and O_EXCL;
    • that the secret appears in no stdout, stderr or argv;
    • the static key file gone after success and after a failure at each step;
    • the refusal under an agent marker in the environment.
  • Fetch: a local file served by a test https server (self-signed, trusted only by the test) with a matching pin, a mismatched pin and a truncated file.

  • Unit render: the environment table in section 2. HOST=127.0.0.1, no STATIC_API_KEY, no ENCRYPTION_KEY value in the text, and ALLOW_USER_SIGNUPS=disabled.

  • Loader: test-config.sh cases for the components key and the cross-check, and the copy-rollback note.

  • Sandbox: <dataRoot>/components/ is in no bind list.

10. Build rows, in landing order

All are post-v1 (REQ-INST-2). Each row has Webby or Sage's named reviewer and its own gate. None of them runs on .53, .54 or dragon-lin, and none installs Pocket ID on any host.

  1. P1, loader key. components["pocket-id"] in mosaic-config.mjs, strict inside the key, and the cross-check against console.sso (section 1, M1 conditions from mos-1666 item 3). P1 also writes the rollback order into docs/TOOLS.md beside Q175's: remove components (restore the backup) before the pointer goes back, and the post-rollback STATE check confirms the key is absent from config.json. Gate: test-config.sh green with the four M1 cases.
  2. P2, pin and fetch. scripts/pocket-id-pins.sh with the build-time sha256 and the attestation evidence, and component-pocket-id.sh fetch|verify. Gate: the fetch tests against the local stand-in; the evidence file shows gh attestation verify output for each pinned asset.
  3. P3, unit. The unit template, render|install|status|remove|purge, and the encryption key slot. Gate: the render and remove tests, and the sandbox bind test.
  4. P4, enroll. The namespaced bootstrap, the client, the subject, relink, and printing the entry (R1), (R2 rejected). Gate: the enroll tests in section 9, and a secret-leak grep over every test log.
  5. P5, Console against the fake Pocket ID. The section 9 fake-OP start tests, and the NODE_EXTRA_CA_CERTS render flag for the Console unit. Gate: the webui suite, with no change to sso.mjs checks (a diff of sso.mjs and sso-config.mjs against the base shows none).
  6. P6, installer option. --oidc existing|bundled on REQ-INST-3's install command, calling P2 to P4 in order, and the docs: docs/TOOLS.md, the host run sheet's J1 to J4 steps. Gate: the installer's own suite against the stubs; the docs list J1 to J4 as the user's steps.

Open items for review

  • M1 approved by Mos with three conditions (mos-1666 item 3), written into section 1 and P1.
  • M2 dropped: Sage chose R1 and rejected R2 (sage-4401).
  • Q1 (Webby): the private network namespace for enroll (section 4). The other option is to start the real unit and race /setup, which I think is unsafe while agents share the host network.
  • Q2 (Webby): one-providers-entry for the bundled choice (section 1). REQ-INST-2's wording reads that way. Mixing a bundled Pocket ID with existing providers would need Sage's ruling.
  • Not covered: Pocket ID for Vikunja or Gitea people sign-in (the Researcher note's part 3), and client-credentials between Mosaic services. Neither is in Q203.