# Sibyla channel-intake API and Apollo's `apollo-channel-intake` plugin — specification

> *Naming note (2026-09-02, see `naming-glossary.md`): "Sibyla" written here before 2026-09-02 means the FDR skill build or the module Apollo built from it, which is now **Argus**; the retired applications and database are **Sibyla Legacy**; "Apollo" means this platform, which became **Sibyla** in Phase 3; "Hermes" before 2026-09-02 means the Hermes Agent runtime or GOTT's instance of it, which is now the agent **Apollo** (Phase 4).*

*Revision 1, written 2026-09-02. Owner rulings that shape it: "o plugin do Hermes tem de ser
substituído pelo do Apollo" (2026-09-02); "contrato de rede pode ser alterado, revê e dá a tua
decisão"; "API deve viver no Apollo.Api" (2026-09-02). Status: for spec review before any code.*

> **Revision 2 (2026-09-02) — built.** The owner's word ("lets build the channel-intake API in
> src/Sibyla.Api") came with round 1's verdict REVISE on the table, so revision 2 is the 27
> adjudications of `apollo-channel-intake-api-spec-review.md` applied as written, and the code was
> built to them. The re-review by both reviewers that D-CI-10 requires before checkpoint A has
> **not** happened; it is recorded as pending in the review record. Where a section below still
> reads as revision 1, the following list is the authority:
>
> - **F1** no `worker_access`-style policy for `apollo_api`: grants only (`db/channel-intake-api-role.sql`, the migration); `ChannelIntakeFailClosedTests` asserts no policy names the role on `docint/jobque/jobsch/audlog/commst/ownmst`.
> - **F2** `UNIQUE (owner_id, id)` on `commst`; composite FKs from `chnbnd` and `chnreg`; the resolver re-checks the company under the owner GUC; a fail-closed test binds a foreign company and gets `23503`.
> - **F3** uniqueness and replay on `(owner, client_id, channel, external_message_id)` and `(owner, client_id, idempotency_key)`; the client id is in every lookup before any body.
> - **F4** `ChannelBindingResolver` runs on a raw `NpgsqlDataSource` connection inside one explicit transaction with `set_config(..., true)` first; the leak test opens a second transaction on the same connection and sees nothing.
> - **F5** `channel_binding_lookup` (permissive, `enabled AND client_id = NULLIF(GUC, '')`) plus `channel_binding_client_scope` (`AS RESTRICTIVE`) on `chnbnd`; `CHECK (length(client_id) > 0)`.
> - **F6** `sha256` required, integrity only: no registration-time verdict; the upload's streamed hash must equal it; duplicates flow through `IngestionService` as `PossibleDuplicate`, surfaced as candidate `completed` + `possibleDuplicateOfIntakeId`; candidate status `duplicate` does not exist.
> - **F7** recorded residual: DMARC enforcement belongs to the Apollo-owned mailbox watcher; trusted email never reaches the ERP without the human review that exists.
> - **F8** `audlog`: `INSERT, SELECT` only for `apollo_api`, `UPDATE/DELETE/TRUNCATE` revoked in the same block; the isolation test asserts the privilege set.
> - **F9** the daily quota (20 unverified-sender registrations per client) is counted in `chnreg` inside the registration transaction; the per-minute limits (60 registrations, 30 uploads) are the ASP.NET rate limiter partitioned by client id.
> - **F10** `IngestionService.AcceptUploadCoreAsync` participates in the caller's transaction; `AcceptUploadAsync` (the web path) wraps it unchanged; `ITenantContext.ActorSubject` (default `UserId ?? "system"`); `ApiTenantContext` with `UserId = null`, `AllCompanies = false`, `CompanyIds = {binding company}`, `ActorSubject = client_id`.
> - **F11** the code → status table is `FailureCodes.StatusOf`: 400 default; 401 `actor_invalid`; 403 `client_not_bound`, `scope_required`; 404 `not_found`, `candidate_not_found`; 409 `idempotency_conflict`, `routing_changed`, `registration_expired`; 411 `content_length_required`; 413 `file_too_large`; 429 `rate_limited`; 500 `internal`.
> - **F12** canonicalisation is JCS-shaped (`CanonicalJson`: sorted members, minimal escaping, non-ASCII verbatim), `receivedAt` truncated to whole UTC seconds, the idempotency key excluded; the golden vector is `tests/Sibyla.Tests.Api/golden/fingerprint.json`.
> - **F13** aggregation: any `awaitingUpload` → `awaitingUpload` (or `quarantined` for an unverified sender, whose registration is held from the start); else any `expired` → `expired`; else any `rejected` → `rejected`; else any `quarantined` → `quarantined`; else `completed`; `completedAt` stamped the first time nothing is awaited.
> - **F14** the reverse-proxied path option is struck; `api.sibyla.gottsolutions.net` stands (naming ruling N2: after Phase L).
> - **F15** `ReleaseQuarantineAsync(intakeId, companyId)` refuses to release a `sender_unverified` hold without a company; `Documents.razor` gains "Libertar para a empresa escolhida" and "Confiar neste remetente" (`ChannelBindingAdminService.TrustSenderOfAsync`).
> - **F16** scope `sibyla.channel-intake.write` has its own resource `apollo-channel-intake`, which is the token audience; a token whose `sub` differs from its client id is refused (`ChannelIntakeActor`).
> - **F17** a binding disabled or removed at upload time sends the bytes down the quarantine path; `routing_changed` only when the company changed under a registration that already completed a candidate.
> - **F18** `DatabaseTarget.ps1` (`ApiUser`), `setup-db.ps1` (role, `api_user` secret, connect grants, the role file), `PreviewParityTests` role list; the migration applied to Main and Preview.
> - **F19** the NOBYPASSRLS assertion runs from the API's own connection (`TestDb.OpenApi`) against `pg_roles`.
> - **F20** the plugin hashes the bytes it streams and compares with the registered value; no `expected_sha256` parameter; no `read_bytes()`.
> - **F21** the 64 KiB response cap and the four client-side codes are in the plugin; they are client-side and stay out of the server's closed set.
> - **F22** `X-Correlation-Id` is required on the three routes (`channel_intake_correlation_id_required`), validated before authentication, echoed on every response.
> - **F23** cutover step 5 (instructions and skills) precedes the toolset swap's gateway restart; the `UserChannels` authorization procedure is replaced by `seed-channel-bindings.ps1` and the trust action.
> - **F24** `ApiFactory` nulls `Authority`/`MetadataAddress` and supplies a `StaticConfigurationManager` with a test key; `Ingestion:StagingRoot` is a temp directory removed on dispose; the spoof test (G8) and the fingerprint vector exist; the limiter test is the quota test (the per-minute limiter is exercised by configuration, not by 61 requests).
> - **F25** `AddSibylaPlatformCore` (persistence, tenancy, ingestion, scanner) without Data Protection, harness, preview or CLI runner; `PreviewConnectionGuard` is called by the API host.
> - **F26** claim shapes cited from the legacy server (`client_id`/`azp`, `sub` ignored) and OpenIddict (`sub = client_id`); no token claim is logged. **Mint-and-inspect done at checkpoint B (2026-09-02)**: a real client-credentials token from `login.gottsolutions.net` carries `iss` with a trailing slash, `sub = client_id = sibyla-channel-intake`, `scope`, no `azp`; the host pins the issuer in both forms. Found and fixed the same way: the scope name `sibyla.channel-intake.write` **already existed** in the IdP from Sibyla Legacy with resource `sibyla-api`, so `RegisterIdpClients --channel-intake` now adds `apollo-channel-intake` to that scope's resources without removing the legacy one; tokens carry both audiences until cutover step 8 removes `sibyla-api` and revokes `sibyla-apolo`.
> - **F27** clean path is `Queued`; `Sibyla.Api` references Contracts and Infrastructure; the test project references only `Sibyla.Api`; `receivedAt` lower bound 30 days; the example `candidateKey` is in the charset; the secret is recorded in `idp-clients.json` as the operator's copy and the Hermes `.env` is the runtime copy.
>
> Deviations from revision 1 that the list implies and the code makes: the `apollo_api` role is created by `local/setup-db.ps1` (a database superuser step; the migrator cannot create roles), and the migration applies the role's policies only when it exists, otherwise `setup-db.ps1` applies `db/channel-intake-api-role.sql` afterwards; `chncan.duplicate_of_intake_id` is `possible_duplicate_of_intake_id`; the audience is `apollo-channel-intake`, not `apollo-api`.
> **Revision 3 (2026-09-04) — final review, D-CI-10.** Two independent read-only reviewers
> (spec conformance; operations and security) both returned ACCEPT on main `9aff2a1`; their
> findings, the fixes and the verdict lines are in the review record's "Final review" section and
> the evidence record `tests/Sibyla.Tests.Api/evidence/CUTOVER-260904.md`. Where an earlier section
> still reads as first built, the following is the authority: the API role is `sibyla_api` (the Phase
> D name of `apollo_api`, the same role renamed in place); the audience is `apollo-channel-intake`;
> candidate statuses are `awaitingUpload | completed | quarantined | rejected | expired`, with
> `possibleDuplicateOfIntakeId` on a completed candidate; uniqueness is `(owner, client, channel,
> externalMessageId)` and `(owner, client, idempotencyKey)`; the fingerprint excludes the idempotency
> key; the host registers `AddSibylaPlatformCore` behind `PreviewConnectionGuard`; the plugin is
> `apollo-channel-intake` 1.1.1 (`provides_hooks: [pre_gateway_dispatch]`: the trusted envelope with
> `external_sender_aliases` and `received_at_source`, any marker typed by a sender neutralised), with
> no `expected_sha256`; the API's `web.config` removes IIS WebDAV; a company-less intake is compared
> across the whole licence at intake, at assignment and at release (owner ruling 2026-09-04); the
> email watcher has 16 tests. The observation of 2026-09-04 and its fixes are in §10 step 7.


---

## 0. What this delivers

1. **`src/Sibyla.Api`** — a new ASP.NET Core host (the first Web API project in the solution) that
   exposes `/api/channel-intake/v1/*`, authenticated by JWT bearer tokens issued by
   GOTT.IdentityServer to a client-credentials client, and writes documents received through
   Hermes channels into the existing `docint` → `jobque` → `Sibyla.Worker.Documents` pipeline.
2. **Contract `sibyla.channel-intake.v1`** — a revised version of the legacy
   `sibyla.channel-intake.v1` (§2 records what changed and why).
3. **Apollo's plugin `apollo-channel-intake`** (a Hermes-runtime plugin) — the replacement for `sibyla-channel-intake`: three
   tools, same shape, new identity, posting to the Apollo API.
4. **Sender → tenant binding** — the verified registry the threat assessment requires before any
   non-golden channel opens (13 §B11.1, RB8, R-14, P2-04), which the legacy stack solved with
   `UserChannels` and Apollo does not have yet.
5. **The cutover order** that lets Sibyla Legacy's `Sibyla.Api` and its plugin be retired.

Out of scope: an admin screen for bindings (seeded by script in v1; the harness plan's `aiprofc`
becomes the admin surface later), replies to senders, the Microsoft Graph mailbox watcher itself
(it stays a Hermes cron, D-AI-10), document extraction (already built), and the deployment tooling
for the second IIS site (Slice 2 impact, §9).

```
Hermes channel (email/WhatsApp/Mattermost) ─▶ Hermes orquestrador + apollo-channel-intake plugin
        ─▶ POST /registrations ─▶ PUT .../content ─▶ docint + jobque ─▶ Sibyla.Worker.Documents
```

---

## 1. Findings — what exists (verified 2026-09-02)

### 1.1 The legacy contract, server side (GOTT.Sibyla, `src/Sibyla.Api/Intake/*`, `src/Sibyla.Infrastructure/Intake/ChannelIntakeService.cs`)

| Concern | Legacy behaviour |
|---|---|
| Routes | `POST /api/channel-intake/v1/registrations`, `GET /registrations/{id:guid}`, `PUT /registrations/{id}/candidates/{candidateId}/content` (raw body, `DisableAntiforgery`), all under policy `sibyla.channel-intake.write` |
| Auth | JWT bearer, audience `sibyla-api`, scope claim must contain `sibyla.channel-intake.write`; actor = `client_id` or `azp` claim, `sub` ignored; record ownership per API client (`IntakeItem.RegisteredBy`) |
| Sender → tenant | `(ChannelType, NormalizedIdentifier)` looked up in `UserChannels`, row must be enabled and its `UserProfile` active, else `403 channel_intake_sender_not_authorized`; no company or department chosen at intake; re-checked at upload (`channel_intake_routing_changed`) |
| Request | `contractVersion` exact `sibyla.channel-intake.v1`; `channel` ∈ email/whatsapp/mattermost; `externalMessageId` ≤512; `externalSenderIdentifier` ≤320 (email lower-cased with `@`; WhatsApp E.164 or byte-exact `\d{9,20}@lid`; Mattermost anything); `receivedAt` non-default; `trustedMetadata` JSON object, canonicalised, ≤16 KiB; 1..10 candidates with unique `candidateKey` ≤256, `fileName` = leaf ≤255, extension ∈ pdf/png/jpg/jpeg matching `contentType`, `sizeBytes` 1..25 MiB; unknown JSON members rejected |
| Headers | `Idempotency-Key` required ≤128; `X-Correlation-Id` ≤128 echoed |
| Idempotency | unique `(ChannelType, ExternalMessageId)` and `(RegisteredBy, IdempotencyKey)`; replay requires same key **and** same request fingerprint (SHA-256 of the canonical request) else `409 channel_intake_idempotency_conflict`; concurrent insert loses on the unique index and is re-resolved as a replay |
| Statuses | item `BytesPending → Completed | Rejected`; candidate `AwaitingUpload → Completed | Duplicate | Rejected`; content-hash duplicate stores nothing and returns 200 |
| Upload | `Content-Length` required (411), must equal declared size; content type must equal registered; magic bytes checked; SHA-256 computed while streaming; one DB transaction, orphaned objects deleted on failure |
| Errors | `{ "code": "channel_intake_*" }` with 400/403/404/409/411/413; 30 codes; audit actions `channel_intake.registered|completed|duplicate|rejected` |
| Known problems (Hermes skills, GOTT.Sibyla docs) | sender identity derived from a display name got rejected and the agent bypassed intake (fail-closed rule added); WhatsApp LID vs E.164 need separate rows; raw Graph ids fail the candidate-key charset; duplicate check at intake is global because company is null; mailbox triage ≠ intake |

### 1.2 The legacy client (`%LOCALAPPDATA%\hermes\plugins\sibyla-channel-intake\client.py`)

Fixed authority `https://login.gottsolutions.net`, fixed API origin, hard-coded client id
`sibyla-apolo` and scope `sibyla.channel-intake.write` (anything else is refused as
"not least-privilege"); refuses any path outside `/api/channel-intake/v1/`; token cache with 60 s
margin; retries 3× on 429/5xx/transport with `Retry-After`; one 401 re-auth; response bodies
capped at 64 KiB; upload only from approved Hermes media roots, regular file, no symlink,
extension ↔ MIME match, size = registered size, ≤100 MB. WhatsApp sender must match
`^(?:\+?\d{8,15}|\d{8,20}@(?:s\.whatsapp\.net|lid))$` locally. Tools
`sibyla_channel_intake_register|status|upload`; `plugin.yaml` declares `requires_env:
SIBYLA_CHANNEL_INTAKE_CLIENT_SECRET`. Enabled per platform via `platform_toolsets.*` in
`config.yaml` (currently listed under `plugins.disabled`).

Who calls it: the Hermes `orquestrador` profile (an LLM agent) — the Graph cron
`sibyla-email-graph-gateway` (script-only, paused) only triages the mailbox into
`Validar`/`Descartados`; it never calls the API. So **an LLM sits between the channel and the API**:
everything the plugin sends except the bearer token is agent-supplied.

### 1.3 Apollo today

| Concern | State | Where |
|---|---|---|
| HTTP API | none; Blazor Server only, no `[ApiController]`, no ProblemDetails, no JSON options, no `AddJwtBearer` | `src/Sibyla.Web/Program.cs` |
| Intake row | `docint` (`DocumentIntake`): `Channel` (max 30, hard-coded `"upload"`), filename, MIME, size, `Sha256`, `DuplicateOfId`, staging path, transfer/processing status, `QuarantineReason`, evidence, `UploadedByUserId`, `CompanyId` nullable (`_intake` area) — **no sender, message id, correlation or idempotency columns** | `Entities/DocumentIntake.cs`, `ApolloDbContext.cs:133-153` |
| Ingestion | `IngestionService.AcceptUploadAsync(stream, filename, contentType, companyId)`: extension gate → company access → stage to `{StagingRoot}/{owner}/{intake}/original{ext}` hashing inline → size gate (`MaxUploadBytes` 50 MiB) → `DocumentContentInspector` (magic bytes, forbidden containers, PDF active-content quarantine) → `IDocumentScanner` (fail-closed) → one transaction: content-hash duplicate lookup per `(owner, company)`, `docint`, `document-processing` + `storage-transfer` jobs, `audlog`, `jobsch` | `Infrastructure/Ingestion/*` |
| Idempotency | only on `jobque` keys derived from the new intake id — **no event-level key** (P2-01 open) | `IngestionService.cs:162,184` |
| Tenancy | `owner_id` on every table, EF filter + RLS `ENABLE+FORCE`, `tenant_isolation` on `app.owner_id` GUC, `worker_access` policy for role `apollo_worker` on `docint/jobque/jobsch`; `TenantResolver` maps IdP `sub` → `usrmst`; unknown subject fails closed | `Tenancy/*`, migration `IntakeAndQueue` |
| Sender registry | none (`aiprofc` planned in the harness plan, not built) | — |
| Audit | `audlog` append-only (trigger + revoked UPDATE/DELETE), `document.<verb>` action keys | `AuditLogAppendOnly` migration |
| IdP | clients `apollo-web`, `apollo-worker`; scope `apollo-api` (resource `apollo-api` → `aud`); no introspection, JWT validated locally; `RegisterIdpClients` upserts via OpenIddict managers against `gott_identity` | `local/tools/RegisterIdpClients/Program.cs` |
| Tests | xUnit 2.9.3 on the real local PostgreSQL via `local/secrets/apollo-db.json`; `WebApplicationFactory<Program>` in `Sibyla.Tests.Browser`; `RlsPolicyMetaTests` force RLS on **every** new table | `tests/*` |
| Deployment | Slice 1 plans one IIS site (`sibyla.gottsolutions.net`) and one service; no `api.apollo.*` host anywhere in the docs | `docs/apollo-deployment-prompt-260901.md` |

---

## 2. Contract review — `sibyla.channel-intake.v1` → `sibyla.channel-intake.v1`

The owner allowed the wire contract to change. Verdict: **keep the shape, fix the identity, add
three fields, tighten four rules, and rename nothing the runbooks depend on.** The two-step
register → exact-bytes upload → status protocol is proven end to end, the ops skills and the
agent instructions are written around it, and its failure classes are the right ones. What was
wrong with v1 was never the protocol; it was the trust model around the sender and the missing
early duplicate signal.

| # | Point | Legacy | Decision | Why |
|---|---|---|---|---|
| C1 | Protocol | register / upload / status | **Keep** | Proven; agent-shaped; exact bytes once; status for ambiguous timeouts |
| C2 | Contract id | `sibyla.channel-intake.v1` in body | **Rename** to `sibyla.channel-intake.v1`, still required in the body | Cheap guard that logs which contract an agent believes it speaks |
| C3 | Path | `/api/channel-intake/v1/…` | **Keep** | Version in the path; plugin path-prefix guard unchanged |
| C4 | Scope / client | `sibyla.channel-intake.write`, client `sibyla-apolo`, audience `sibyla-api` | **New** scope `sibyla.channel-intake.write` (resource `apollo-api`), new client `sibyla-channel-intake`, audience `apollo-api`; `apollo-web`/`apollo-worker` tokens carry no such scope and are refused | Dedicated least-privilege client, as the legacy deployment doc demanded; never the legacy secret |
| C5 | Actor | `client_id`/`azp`, `sub` ignored | **Keep** | Client-credentials tokens have `sub = client_id`; a human subject never registers |
| C6 | Tenant | sender → `UserChannels` → profile; no owner concept | **Change**: owner comes from the **client binding** (verified credential), company and processing eligibility from the **sender binding**; unknown sender → **quarantine in `_intake`**, not silent 403 (D-CI-2) | RB8: never bind a tenant from sender metadata; the credential is the only verified fact. P2-04 says unverified senders quarantine rather than vanish |
| C7 | Candidate hash | not sent; server hashes on upload | **Add** required `sha256` per candidate at registration, **integrity only** (revision 2, F6) | The upload's streamed hash must equal it; no verdict is given from a declared hash; Hermes already computes SHA-256 for its evidence |
| C8 | Size limit | 25 MiB server, 100 MB plugin | **Server = `Ingestion:MaxUploadBytes`** (50 MiB today), echoed as `maximumFileSizeBytes`; plugin hard cap 100 MB stays as a local sanity bound | One limit, owned by the ingestion options |
| C9 | Mattermost sender | anything | **Tighten** to the Mattermost user id shape `^[a-z0-9]{26}$` | Same fail-closed discipline as the other channels; display names never pass |
| C10 | WhatsApp sender | E.164 or byte-exact `\d{9,20}@lid` | **Keep**, plus the plugin keeps its local shape check | Documented incident; two aliases = two binding rows (D-CI-3) |
| C11 | Email sender | lower-cased, contains `@` | **Tighten**: one `@`, no whitespace/control chars, ≤320, local part ≤64 | Cheap, avoids garbage keys in the registry |
| C12 | `candidateKey` | ≤256, charset implicit | **Explicit** `^[A-Za-z0-9._:-]{1,256}$` | Raw Graph ids failed before; the rule is now in the contract and the plugin |
| C13 | `trustedMetadata` | opaque object ≤16 KiB, canonicalised, stored, never used for routing | **Keep** exactly that, and say so in the contract: it is *claims*, never *truth* | An LLM produces it; it must never influence owner, company or authorization |
| C14 | Replay | same key + same fingerprint → 200 `replayed`; else 409 | **Keep**; fingerprint now includes the hashes | Unchanged runbooks |
| C15 | Uniqueness | global `(channel, externalMessageId)` | **Per owner** `(owner, channel, externalMessageId)` and `(owner, client, idempotencyKey)` | Multi-tenant; RLS-scoped |
| C16 | Statuses | item `bytesPending`; candidate `awaitingUpload/completed/duplicate/rejected` | **Rename** item `bytesPending` → `awaitingUpload` (same word at both levels); **add** `quarantined` (both levels) and `expired` (both levels); **add** `intakeId` on a completed/quarantined candidate | Quarantine is a real outcome in Apollo; registrations must not live forever |
| C17 | Expiry | none | **Add**: `awaitingUpload` registrations expire 24 h after `registeredAt`; a PUT after expiry → `409 channel_intake_registration_expired` | Bounded state; lazy expiry on GET/PUT, no background job |
| C18 | Errors | `{code}` only | **RFC 9457 `application/problem+json`** carrying the same `code` vocabulary plus `correlationId` | Standard, still one stable code per failure; the plugin keeps reading `code` |
| C19 | Duplicate outcome | 200, stores nothing | **Keep** 200; candidate `completed` carrying `possibleDuplicateOfIntakeId` (F6 superseded the `duplicate` status) | Not an error for the agent |
| C20 | Content type on upload | must equal registered | **Keep**, parameters stripped | — |
| C21 | Rate limits | none | **Add** per-client limiter: 60 registrations/min, 30 uploads/min, 20 unverified-sender quarantines/day → 429 + `Retry-After` | The plugin already honours `Retry-After`; bounds abuse of the quarantine path |
| C22 | Health | none | **Add** `GET /health` (200, no body detail) and `GET /health/ready` (DB `SELECT 1`) unauthenticated | Deployment Slice 2 needs a probe |
| C23 | Tool names / env | `sibyla_channel_intake_*`, `SIBYLA_CHANNEL_INTAKE_*` | **Rename** to `apollo_channel_intake_*`, `APOLLO_CHANNEL_INTAKE_*` | New identity; the Hermes skills that mention the old names are updated at cutover (§10) |
| C24 | Schema | none | **Add** `GET /health/schema` (200, `{ "role": "<current_user>", "migrations": [applied ids] }`) unauthenticated; the API role gains `SELECT` on `__EFMigrationsHistory` (migration `SchemaHealthGrant`, `db/channel-intake-api-role.sql`) | Deployment Slice 2 `Precondition.Schema` compares the body with a release's `requiredMigrations` (tooling spec 5.5, sub-slice A2); disclosure accepted |

Not adopted, and why: multipart single-shot upload (loses the early duplicate answer and the
exact-bytes guarantee); a "cancel" endpoint (expiry covers it); a list endpoint (not an agent
need); per-candidate presigned uploads (one API, one auth path); dropping `contractVersion`
(guard is free).

---

## 3. Decisions

### D-CI-1 — A separate host, `src/Sibyla.Api`, on its own site

Owner ruling. `Sibyla.Api` is `Microsoft.NET.Sdk.Web`, `net10.0`, references
`Sibyla.Platform.Infrastructure` only, no Razor, no cookie, no OIDC, no antiforgery. Hostname
proposed: **`api.sibyla.gottsolutions.net`** (the name freed when Sibyla Legacy's API moves to `api.legacy.gottsolutions.net`, ruling N2; new DNS
record and certificate; see §9). Kestrel behind IIS like `Sibyla.Web`. No `Sibyla.Web` code is
referenced: tenant resolution for the API is a different mechanism (D-CI-2), so
`TenantResolutionService` stays where it is.

### D-CI-2 — Owner from the credential, company from the sender, unknown sender quarantined

A new table `chnbnd` (channel binding) holds two kinds of rows, both owned by an `owner_id`:

- **client binding**: `client_id` + `channel = NULL` + `sender = NULL` → "this IdP client may
  register for this owner". Exactly one owner per client (unique on `client_id`). Created by the
  operator when a Hermes instance is provisioned for a licence (D-AI-9: one profile per licence).
- **sender binding**: `client_id` + `channel` + `sender_normalized` (+ optional `company_id`) →
  "documents from this sender are trusted for this owner, and belong to this company or, when
  null, to the licence-wide `_intake` area". Unique on `(client_id, channel, sender_normalized)`.

Resolution on `POST /registrations`, in this order and fail-closed at every step:

1. Token valid (issuer pinned, audience `apollo-channel-intake`, scope contains
   `sibyla.channel-intake.write`) → `client_id`. Otherwise 401/403 before any DB access.
2. `SELECT set_config('app.client_id', $client, true)`; read the client binding through a
   dedicated RLS policy (`channel_binding_lookup`, §5) that exposes only rows whose `client_id`
   equals the GUC. No row → `403 channel_intake_client_not_bound`, nothing written, structured
   log with the client id.
3. `SELECT set_config('app.owner_id', $owner, true)`; from here every table is under
   `tenant_isolation` exactly as for the web app.
4. Sender binding lookup. Found and enabled → **trusted**: candidates are processed normally,
   `company_id` from the row. Not found or disabled → **unverified**: the registration is
   accepted with `status = quarantined`; uploads are still required (the bytes are the evidence),
   each candidate lands in `docint` with `ProcessingStatus = Quarantined`,
   `QuarantineReason = "sender_unverified"`, `CompanyId = null`, **no processing job** and **no
   transfer job**; release goes through the existing `ReleaseQuarantineAsync` after a human
   assigns the company. Daily quota per client (C21) bounds this path.
5. The sender binding is re-read on every PUT; if it changed owner-visible outcome (row
   disabled, company changed) → `409 channel_intake_routing_changed`, as in the legacy.

`trustedMetadata`, `externalSenderIdentifier`, `receivedAt` never set `owner_id`, `company_id`
or the trusted/unverified verdict beyond the lookup key. That is the RB8 invariant, and it is
tested (§8, G8 spoofing tests).

Why quarantine instead of the legacy 403: the owner is already verified by the credential, so
storing the bytes cannot cross a tenant; the Graph gateway's deny-by-default allow list and the
Hermes gateway's own user allow lists already stop most noise upstream; and an operator finding a
new supplier's invoice in `_intake` beats a document stranded in a Hermes cache that only a
runbook can recover.

### D-CI-3 — Aliases are separate rows

A WhatsApp sender known as `+3519…` and as `4084…@lid` needs two enabled sender bindings. The
plugin keeps the original identifier in `trustedMetadata.sender_original` when it retries with a
gateway-provided alias (existing Hermes guardrail). No alias inference on the server.

### D-CI-4 — Hashes in the contract

`sha256` (64 lowercase hex) is required per candidate at registration. Server behaviour:

- registration: if a `docint` row with the same `Sha256` exists for `(owner, company-of-binding)`
  → *(superseded by F6 in revision 2: no registration-time verdict; the hash is integrity only and duplicates surface after upload as `completed` + `possibleDuplicateOfIntakeId`)* the candidate is created as `duplicate` with `duplicateOfIntakeId`, no `uploadUrl`; the
  registration may complete without any upload.
- upload: the streamed hash must equal the declared one → else `400
  channel_intake_content_hash_mismatch`, staged bytes deleted, candidate stays `awaitingUpload`
  (the agent may retry with the right file).
- `sizeBytes` and `Content-Length` must still match (411/400 as before).

### D-CI-5 — Reuse `IngestionService`, extend it, do not fork it

`AcceptUploadAsync` gains an overload taking a `ChannelProvenance` record (channel, registration
id, candidate id, sender binding id or null, correlation id) and an `IngestionDisposition`
(`Process` | `Quarantine(reason)`). The staging, inspector, scanner, duplicate lookup, audit and
job creation stay in one place; `Channel` is written from the provenance instead of `"upload"`;
`UploadedByUserId` is null; audit action keys become `document.intake.<channel>` and
`document.intake.quarantine`. The web upload path is unchanged.

### D-CI-6 — Data model: two small tables beside `docint`, no new columns on `docint`

`chnreg` (registration) and `chncan` (candidate) carry the channel provenance; `chncan.intake_id`
points at `docint` (unique, nullable until upload). `docint` keeps its shape; §2.7's "sender
metadata, correlation + idempotency IDs" are one join away and stay out of the row the worker
touches. Both tables get `tenant_isolation`; `chnbnd` additionally gets `channel_binding_lookup`.

### D-CI-7 — A dedicated database role `apollo_api`

NOBYPASSRLS, like `apollo_app` and `apollo_worker`; grants limited to `chnbnd` (SELECT),
`chnreg`, `chncan`, `docint`, `jobque`, `jobsch`, `audlog` (INSERT/SELECT/UPDATE as needed),
`ownmst`, `commst` (SELECT). `setup-db.ps1` provisions it and writes `api_user` into the secrets
file; `RlsPolicyMetaTests` gains the assertion that `apollo_api` is NOBYPASSRLS and not superuser.

### D-CI-8 — Same IdP, one new scope and one new client, registered by the existing tool

`RegisterIdpClients` gains `--channel-intake`: upserts scope `sibyla.channel-intake.write`
(resource `apollo-api`) and client `sibyla-channel-intake` (confidential, client-credentials,
token endpoint only, permission for that single scope), writing
`apollo_channel_intake_client_id/secret` into `local/secrets/idp-clients.json`. The legacy secret
is never read, copied or reused.

### D-CI-9 — The plugin is versioned in this repo

`local/hermes/apollo-channel-intake/` holds `plugin.yaml`, `client.py`, `__init__.py`, and
`tests/test_client.py` (stdlib `unittest`, no network). `local/hermes/install-plugin.ps1` copies
the folder to `%LOCALAPPDATA%\hermes\plugins\apollo-channel-intake\` and prints the
`config.yaml` lines to add; it never edits `config.yaml` or `.env`.

### D-CI-10 — Review checkpoints

Same discipline as deployment Slice 1: spec review (this document) → checkpoint A (contract
records, data model, migration, bindings, RLS tests) → checkpoint B (API host, service, plugin,
end-to-end tests) → final review with the evidence record. Code is written only after the spec
review ends in ACCEPT.

---

## 4. The contract — `sibyla.channel-intake.v1`

All requests: `Authorization: Bearer <jwt>`, `Accept: application/json`,
`X-Correlation-Id` (1..128, `^[A-Za-z0-9._:-]+$`), echoed on every response.
Unknown JSON members → `400 channel_intake_request_invalid`. Times are UTC ISO-8601.

### 4.1 `POST /api/channel-intake/v1/registrations`

Headers: `Content-Type: application/json`, `Idempotency-Key` (1..128, same charset) required.

```json
{
  "contractVersion": "sibyla.channel-intake.v1",
  "channel": "email",
  "externalMessageId": "AAMkAGI2…",
  "externalSenderIdentifier": "fornecedor@exemplo.pt",
  "receivedAt": "2026-09-02T09:41:07Z",
  "trustedMetadata": { "gateway": "graph", "mailbox": "sibyla@gottsolutions.net", "attachmentIndex": 1 },
  "candidates": [
    { "candidateKey": "graph-3f9c…-att-1-8a1b…", "fileName": "FT 2026-123.pdf",
      "contentType": "application/pdf", "sizeBytes": 184233,
      "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" }
  ]
}
```

Rules: `channel` ∈ `email | whatsapp | mattermost`; `externalMessageId` 1..512, no control
chars; `externalSenderIdentifier` per C9–C11, normalised (email lower-cased; LID lower-cased);
`receivedAt` required, not in the future by more than 5 minutes; `trustedMetadata` object of
scalars only (string/number/bool, nesting depth 1), canonicalised, ≤16 KiB; candidates 1..10,
`candidateKey` per C12 and unique in the request; `fileName` 1..255 = its own leaf; extension ∈
`.pdf .png .jpg .jpeg` and `contentType` ∈ `application/pdf image/png image/jpeg` matching it;
`sizeBytes` 1..`maximumFileSizeBytes`; `sha256` 64 lowercase hex.

Responses:

- `201 Created`, `Location: /api/channel-intake/v1/registrations/{id}`, body §4.4 with
  `replayed: false`.
- `200 OK` with `replayed: true` when `(owner, client, Idempotency-Key)` or
  `(owner, channel, externalMessageId)` already exists **and** the request fingerprint matches.
- `409 channel_intake_idempotency_conflict` when either key exists with a different fingerprint.
- `403 channel_intake_client_not_bound` (no client binding), `401` (token), `429` (limiter).

Fingerprint: SHA-256 over the canonical JSON of `contractVersion, channel, externalMessageId,
externalSenderIdentifier (normalised), receivedAt (UTC), trustedMetadata (canonical),
candidates (sorted by candidateKey)`; the idempotency key is not part of it (F12).

### 4.2 `GET /api/channel-intake/v1/registrations/{id}`

`200` body §4.4 (`replayed: false`); `404 channel_intake_not_found` when the id does not exist
**or belongs to another client** (no existence leak); expiry is applied lazily before answering.

### 4.3 `PUT /api/channel-intake/v1/registrations/{id}/candidates/{candidateId}/content`

Headers: `Content-Type` = the registered type (parameters ignored), `Content-Length` required
and = `sizeBytes`; body = the exact original bytes (no multipart, no transfer re-encoding).

Server: stream to staging while hashing → hash must equal declared `sha256` → inspector →
scanner → one transaction (`docint`, jobs when trusted, `audlog`, `chncan.status`,
`chnreg.status`). Outcomes:

- `200` body §4.4; candidate `completed` (trusted) or `quarantined` (unverified sender, or an
  inspector/scanner quarantine verdict on a trusted sender: reason recorded), `intakeId` set.
- `200` with `replayed: true` when the candidate is already `completed | duplicate |
  quarantined` (bytes ignored after a cheap length check).
- `400 channel_intake_content_hash_mismatch | channel_intake_content_length_mismatch |
  channel_intake_content_type_mismatch | channel_intake_file_invalid` (inspector reject);
  `411 channel_intake_content_length_required`; `413 channel_intake_file_too_large`;
  `409 channel_intake_registration_expired | channel_intake_routing_changed`;
  `404 channel_intake_not_found | channel_intake_candidate_not_found`.
- A candidate previously `rejected` re-throws its stored code; staged bytes are deleted on any
  failure.

### 4.4 Registration body

```json
{
  "id": "6d2f…", "contractVersion": "sibyla.channel-intake.v1", "channel": "email",
  "externalMessageId": "AAMkAGI2…", "status": "awaitingUpload", "replayed": false,
  "senderVerified": true, "receivedAt": "…", "registeredAt": "…", "completedAt": null,
  "expiresAt": "…", "maximumFileSizeBytes": 52428800,
  "statusUrl": "/api/channel-intake/v1/registrations/6d2f…",
  "candidates": [
    { "id": "b1…", "candidateKey": "graph-…", "fileName": "FT 2026-123.pdf",
      "contentType": "application/pdf", "sizeBytes": 184233, "sha256": "9f86…",
      "status": "awaitingUpload", "failureCode": null, "intakeId": null,
      "possibleDuplicateOfIntakeId": null,
      "uploadUrl": "/api/channel-intake/v1/registrations/6d2f…/candidates/b1…/content" }
  ]
}
```

Statuses — registration: `awaitingUpload | completed | quarantined | rejected | expired`;
candidate: `awaitingUpload | completed | quarantined | rejected | expired` (F6: no `duplicate` status; a completed candidate may carry `possibleDuplicateOfIntakeId`, which is a flag for a person).
Aggregation after every upload: any `awaitingUpload` → `awaitingUpload`; else all `rejected` →
`rejected`; else any `quarantined` → `quarantined`; else `completed`. `senderVerified` tells the
agent which path it is on, so its reply to the sender can be neutral either way.

### 4.5 Errors

`application/problem+json`:

```json
{ "type": "about:blank", "title": "Bad Request", "status": 400,
  "code": "channel_intake_sender_invalid", "correlationId": "…" }
```

`code` is a closed set (Appendix A); no message text, no field values, no stack traces, no
internal ids. Unhandled exceptions → `500 channel_intake_internal` with the same shape.

---

## 5. Data model and security

```
chnbnd  channel binding      id, owner_id, client_id (≤100), channel (NULL | email|whatsapp|mattermost),
                             sender_normalized (NULL | ≤320), company_id (NULL → commst),
                             enabled, note (≤200), created_at, updated_at
        UNIQUE (client_id) WHERE channel IS NULL            -- one owner per client
        UNIQUE (client_id, channel, sender_normalized)      -- one row per alias
        CHECK ((channel IS NULL) = (sender_normalized IS NULL))
chnreg  registration         id, owner_id, client_id, channel, external_message_id (≤512),
                             sender_normalized, sender_verified, sender_binding_id (NULL),
                             company_id (NULL), received_at, registered_at, completed_at, expires_at,
                             status, idempotency_key (≤128), request_fingerprint (64),
                             correlation_id (≤128), trusted_metadata jsonb
        UNIQUE (owner_id, channel, external_message_id)
        UNIQUE (owner_id, client_id, idempotency_key)
chncan  candidate            id, owner_id, registration_id, candidate_key (≤256), file_name (≤255),
                             content_type (≤100), size_bytes, declared_sha256 (64), status,
                             failure_code (≤80), intake_id (NULL → docint, UNIQUE),
                             possible_duplicate_of_intake_id (NULL → docint), created_at, updated_at
        UNIQUE (registration_id, candidate_key)
```

RLS: all three tables `ENABLE + FORCE`, `tenant_isolation` on `app.owner_id` (the meta-tests
demand it). `chnbnd` additionally:

```sql
CREATE POLICY channel_binding_lookup ON chnbnd FOR SELECT TO apollo_api
  USING (enabled AND client_id = current_setting('app.client_id', true));
```

So before `app.owner_id` is set the API can see only its own caller's bindings, and nothing else
in the database. Migration `ChannelIntake` adds the tables, policies, the `apollo_api` grants
and the `worker_access`-style policies needed for `apollo_api` on `docint/jobque/jobsch/audlog`
(insert/update within the owner). `audlog` rows from the API use `ActorSubject = client_id`.

Transport and token: `RequireHttpsMetadata = true`, `ValidIssuer =
https://login.gottsolutions.net`, `ValidAudience = apollo-channel-intake`, `MapInboundClaims = false`,
clock skew 60 s, JWKS refreshed by the default handler; the discovery doc warns the IdP signs
with development certificates in production, so metadata validation is never disabled. Kestrel
`MaxRequestBodySize = MaxUploadBytes + 64 KiB`, IIS `maxAllowedContentLength` to the same value
(default 30 MB would truncate), request timeout 120 s on the PUT.

Logging: structured, one line per request with method, route template, status, `code`,
correlation id, client id, registration id, elapsed; never a sender identifier in clear (SHA-256
prefix), never metadata contents, never file names.

---

## 6. Project layout

```
src/Sibyla.Api/
  Sibyla.Api.csproj                      Sdk.Web, net10.0, refs Sibyla.Platform.Infrastructure, Sibyla.Platform.Contracts
  Program.cs                             config (ConnectionStrings:SibylaDb on sibyla_api (Phase D name; `apollo_api` before 2026-09-03); Identity:Authority/Audience),
                                         PreviewConnectionGuard + AddSibylaPlatformCore (no background services), JwtBearer, policy,
                                         rate limiter, problem details, MapChannelIntakeV1(), MapHealth(); `public partial class Program;`
  ChannelIntake/ChannelIntakeEndpoints.cs   MapGroup("/api/channel-intake/v1").RequireAuthorization(ChannelIntakePolicies.Write)
  ChannelIntake/ChannelIntakeActor.cs       client_id/azp → actor; scope check
  ChannelIntake/ProblemResults.cs           code → status mapping, problem+json writer
  appsettings.json                          non-secret only (Identity:Authority, Ingestion, Logging)
src/Sibyla.Platform.Contracts/ChannelIntake/ChannelIntakeContractsV1.cs   sealed records, enums, FailureCodes
src/Sibyla.Platform.Infrastructure/ChannelIntake/
  ChannelIntakeService.cs                register / get / upload (pure of HTTP)
  ChannelBindingResolver.cs              D-CI-2 steps 2–5
  ChannelIntakeValidators.cs             sender/channel/candidate rules (pure)
  CanonicalJson.cs                       canonicalisation + fingerprint
src/Sibyla.Platform.Domain/Entities/ChannelBinding.cs, ChannelRegistration.cs, ChannelCandidate.cs
src/Sibyla.Platform.Infrastructure/Migrations/<ts>_ChannelIntake.cs
local/hermes/apollo-channel-intake/{plugin.yaml, client.py, __init__.py, tests/test_client.py}
local/hermes/install-plugin.ps1
local/seed-channel-bindings.ps1          reads local/channel-bindings.json (client + senders), idempotent upsert via psql
local/run-api.ps1                        like run-web.ps1, https://localhost:7444
local/tools/RegisterIdpClients            --channel-intake
local/setup-db.ps1, local/tools/DatabaseTarget.ps1   role apollo_api / api_user
tests/Sibyla.Tests.Api/                  see §8
tests/Sibyla.Tests.TenantIsolation/ChannelIntakeFailClosedTests.cs
Sibyla.slnx                              + src/Sibyla.Api, + tests/Sibyla.Tests.Api
```

`Sibyla.Web` and `Sibyla.Worker.Documents` are not modified except for the shared
`IngestionService` overload (D-CI-5), which keeps the existing upload behaviour bit-for-bit
(covered by the existing `IntakeDuplicateControlTests`).

---

## 7. The plugin — `apollo-channel-intake`

`plugin.yaml`: `name: apollo-channel-intake`, `version: 1.1.1` (1.1.0 added `provides_hooks: [pre_gateway_dispatch]`, the trusted envelope of §10 step 7), `kind: standalone`,
`platforms: [windows]`, `provides_tools: apollo_channel_intake_register | status | upload`,
`requires_env: APOLLO_CHANNEL_INTAKE_CLIENT_SECRET`.

`client.py` keeps the legacy structure and guardrails, with these changes:

- env `APOLLO_CHANNEL_INTAKE_AUTHORITY` (default `https://login.gottsolutions.net`),
  `_API_BASE_URL` (default `https://api.sibyla.gottsolutions.net`), `_CLIENT_ID` (must be
  `sibyla-channel-intake`), `_CLIENT_SECRET`, `_SCOPE` (must be `sibyla.channel-intake.write`),
  `_UPLOAD_ROOTS`; `CONTRACT = "sibyla.channel-intake.v1"`.
- register: computes `sha256` of each candidate file (streamed, from the approved roots) and
  sends it; validates `candidateKey` charset locally and, when invalid, returns a local error
  telling the agent to hash the id instead of retrying blindly; Mattermost id shape check.
- upload: hashes the bytes it streams and compares them with the registered value (F20; there is no `expected_sha256` parameter); a mismatch is a local error, the wrong file.
- status: unchanged.
- responses pass through `problem+json` bodies as `error` with `code` at the top level, so the
  agent's existing failure-class handling (`channel_intake_sender_*`, transport, retry) keeps
  working.
- tool descriptions state the new outcome vocabulary (`quarantined`, `senderVerified: false`
  means "received, pending operator verification; reply neutrally").

Tests (`unittest`, no network, `urllib.request.urlopen` patched): config refusal for the wrong
client id/scope/origin; path guard; token cache and 401 re-auth; retry on 429 with
`Retry-After`; candidate normalisation and hashing; upload root/symlink/size/hash refusals;
payload shape golden file.

---

### 7.1 The email watcher `apollo-email-intake` (F7, built 2026-09-03)

`local/hermes/apollo-email-intake/apollo_email_intake.py`, installed by
`local/hermes/install-email-intake.ps1` as the Hermes cron script `apollo_email_intake.py`
(job `apollo-email-intake`, no-agent, every 2 minutes, local delivery; created **paused**, id
`25c3ac4615bf`, until the observation step says otherwise). It closes the gap the legacy Graph
watcher leaves: that one only triages Inbox -> Validar / Descartados; this one registers.

Per run, for every message in `Validar` with attachments within the lookback (14 days) up to
`max_messages` (10): reads the `Authentication-Results` header and **holds** the message for a
person when it explicitly failed (`dmarc=fail`, or `compauth=fail` without a DMARC pass) - the
F7 residual, enforced here and recorded as `trustedMetadata.authResults`; `dmarc=none`,
`bestguesspass`, a missing header proceed, Sibyla's sender registry and the human review being
the gates for what the document may do. Then selects up to ten `fileAttachment`s that are not
inline, `.pdf/.png/.jpg/.jpeg`, 1 byte..50 MiB; downloads the exact bytes into
`<HERMES_HOME>\cache\documents\email-intake\<key>\` (an approved upload root); registers once
through the **installed plugin's own client** (`register` computes the SHA-256, checks roots and
shapes; `external_message_id` = the RFC `internetMessageId`, stable across folder moves;
`candidateKey` = `graph-<sha256(rfc id)[:16]>-att-<n>-<sha256(attachment id)[:16]>`;
idempotency key = correlation id = `email-<sha256(rfc id)[:40]>`); uploads every
`awaitingUpload` candidate; then records the outcome in the ledger
`<HERMES_HOME>\state\apollo-email-intake.json` (keyed by the RFC id hash) and as an Outlook
category on the message (`Sibyla-Registada`, `Sibyla-Quarentena`, `Sibyla-Retida`), and deletes
the cached bytes. A rerun skips terminal entries without a call; a transient API failure or a
still-awaiting upload is retried next run; `registration_expired` re-registers, and the API re-opens the expired registration on that identical replay (round 2, C#3: candidates await again under a fresh lifetime, audit `channel_intake.reopened`); a permanent code
(`client_not_bound`, validation) holds the message with the code. The sender identity is the
authenticated `from` address, lower-cased; the watcher never replies, never reads document
contents, never touches the Inbox, never moves mail, and prints only redacted evidence.

Tests: `local/hermes/apollo-email-intake/tests/test_watcher.py` (16, `unittest`, no network:
trusted -> completed with category and ledger; rerun makes no call; DMARC fail held;
compauth rules; unverified -> quarantined with the upload still made; unsupported/inline
ignored; transient retried; permanent held once; dry run touches nothing; reports carry no
address; `max_messages`; safe leaf file names). Live: dry run against `sibyla@gottsolutions.net`
green (0 messages in 14 days; with a 120-day lookback one 2026-07-23 message, one PDF
candidate, `dmarc=none`). Graph requires the `$orderby` property to lead the `$filter`
(`InefficientFilter` otherwise) - fixed on the first live run.

Not in this piece: replying to the sender (the addendum leaves replies to the agent, and email
never got them); folding the legacy triage into this script (the triage cron stays as it is).

## 8. Tests and evidence

`tests/Sibyla.Tests.Api` (xUnit, same conventions as the other suites):

- **Contract tests** through `WebApplicationFactory<Program>` with `JwtBearerOptions` replaced
  in `ConfigureTestServices` by a test signing key and issuer; tokens minted per test with chosen
  `client_id`, `aud`, `scope`. Golden request/response pairs under `tests/…/golden/*.json`.
  Cases: 201 then 200 replayed; 409 on fingerprint change; every validation code once; 401/403
  for no token, wrong audience, `apollo-web`-shaped token, missing scope; 404 for another
  client's registration; PUT hash/length/type mismatches; 411; 413; expiry; routing changed;
  quarantine path end to end (docint row Quarantined, no jobs); trusted path end to end
  (docint Received, two jobs, audit rows); duplicates surface after upload as `possibleDuplicateOfIntakeId` on a completed candidate (F6); problem+json shape;
  health endpoints; rate limiter 429 with `Retry-After`.
- **Service tests** for `ChannelIntakeService` and validators without HTTP (fast, the bulk of the
  edge cases), including canonicalisation determinism and the concurrent-registration race
  (two parallel inserts, one wins, the other resolves as replay).
- **`Sibyla.Tests.TenantIsolation/ChannelIntakeFailClosedTests`**: `chnbnd` rows of client B
  invisible under `app.client_id = A`; nothing visible without the GUC; sender bound to owner 2
  never resolves for a client of owner 1; **G8 spoofing**: `trustedMetadata` and
  `externalSenderIdentifier` claiming another owner/company never change `owner_id`/`company_id`;
  `UPDATE chnreg SET owner_id = other` fails with `42501`; `apollo_api` is NOBYPASSRLS; the
  meta-tests pick up the three new tables automatically.
- **Plugin tests** run by `python -m unittest discover local/hermes/apollo-channel-intake/tests`.

Evidence record like Slice 1: `tests/Sibyla.Tests.Api/evidence/` with the red/green runs per
checkpoint and a fingerprint over contracts, migration, tests and plugin; `FINAL-review.md` with
the three review verdicts.

---

## 9. Deployment impact (Slice 2 input, not built here; reframed 2026-09-02 in the tooling spec's section 12 and the deployment prompt's superseded block)

- New IIS site `api.sibyla.gottsolutions.net` → new DNS record, new certificate binding, own
  app pool (No Managed Code), `maxAllowedContentLength`, environment
  `ConnectionStrings__SibylaDb` on `apollo_api`, `Identity__Authority`, `Identity__Audience`;
  no Data Protection ring needed (no cookies), no client secret on the server side.
- Slice 1's `IisTarget`/`SafeIisName`/`TlsTarget` families model one site; Slice 2 must plan the
  API site as a second `Iis.Apply`/`Tls.Apply` pair with its own readbacks and rollback, and add
  `GET /health` to the Health phase.
- The worker is unchanged. Staging root is shared with the web host (same `Ingestion:StagingRoot`,
  same ACL requirement for the API app-pool identity).

---

## 10. Cutover order (each step needs the owner's go; nothing here is executed by this spec)

0. `local/setup-db.ps1 -Target Main` as a database superuser (creates the `apollo_api` role and applies `db/channel-intake-api-role.sql`), then `local/migrate.ps1` on both databases. The migrator cannot create roles, so this is a person's step.
1. Phase L done (Sibyla Legacy on `legacy.*`); merge and deploy Sibyla with `Sibyla.Api`; `/health/ready` green on the new host.
2. `RegisterIdpClients --channel-intake` against `gott_identity`; record the client id; secret
   goes only into the Hermes `.env` as `APOLLO_CHANNEL_INTAKE_CLIENT_SECRET`.
3. `seed-channel-bindings.ps1` for tenant #1: the client binding and the sender rows the owner
   lists. **Owner ruling 2026-09-02: "use the same sender list as the legacy intake channel"** —
   the enabled `UserChannels` rows of active `UserProfiles` were read once from `gott_sibyla`
   (a migration read, not an integration), validated against C9–C11, written to the gitignored
   `local/channel-bindings.json` and seeded; no company mapping existed there, so every sender
   routes to the licence-wide `_intake` area until the operator assigns companies (the trust
   action or a re-seed with `companyCode`). **Done 2026-09-02**: 8 senders, 0 skipped, 0 failing.
4. `install-plugin.ps1`; add `apollo-channel-intake` to `plugins.enabled` and
   `apollo_channel_intake` to `platform_toolsets.{cli,whatsapp,buzz}` and `known_plugin_toolsets`;
   remove `sibyla-channel-intake` from `plugins.disabled`/entries; restart the gateway; `/reset`
   open sessions. Create the cron `apollo-email-gateway` (`hermes cron create`, the Graph watcher
   configured like the legacy `sibyla-email-graph-gateway`) and keep it paused until the observation step.
   **Done 2026-09-02 (owner's word), with three deviations recorded:** the legacy plugin stays
   listed under `plugins.disabled` rather than removed (an unlisted folder might load; its
   removal is step 8); the gateway was not started, because it was found down since 2026-09-01
   (`hermes gateway status`: process died without a clean shutdown; recovery is the scheduled
   task `Hermes_Gateway`), so the next start picks the configuration up; the cron wraps the same
   runner as the legacy one (`scripts/sibyla_email_gateway_runner.py`, which calls the Graph
   watcher under `C:\SibylaApps\Documental-agent` — legacy code, triage only, no API call), because
   the legacy cron no longer existed and an Apollo-owned watcher is F7's later work. `hermes
   plugins doctor apollo-channel-intake`: import and registration OK, three tools. The five
   `APOLLO_CHANNEL_INTAKE_*` keys are in the Hermes `.env`; the base URL is the production
   target `https://api.sibyla.gottsolutions.net`, which is Sibyla Legacy's API until Phase L and
   the deployment, so the tools answer with the API's refusals until then.
5. Update the Hermes instruction addendum and the skills that name the old tools
   (`sibyla-channel-intake-operations`, `hermes-messaging-access`, `microsoft-graph-email`,
   `requirements-aligned-development`, `hermes-plugin-development`): tool names, contract id,
   base URL, the `quarantined`/`senderVerified` vocabulary, and the hash requirement.
   **Done 2026-09-03** (agent session, owner's word): `.hermes.md` rewritten as
   `sibyla-apolo-document-intake/2.0` (backup of every touched file under
   `D:\Backups\SibylaDeployment\20260903-001541\hermes-step5-pre\`), `SOUL.md` note updated, the
   five skills updated (`sibyla-channel-intake-operations` SKILL + LID reference + token probe on
   `APOLLO_CHANNEL_INTAKE_*` with audience `apollo-channel-intake`; `hermes-messaging-access` SKILL +
   toolset reference; `microsoft-graph-email` SKILL + reprocessing reference;
   `requirements-aligned-development` failure-review reference; `hermes-plugin-development` needed
   nothing: it never named the old tools). Historical deploy notes under
   `devops/windows-iis-application-deployment/references/` keep the old names as records. Token
   probe green against production (`/health/ready` 200, scope exact, audience carries both
   `apollo-channel-intake` and the legacy `sibyla-api` until step 8). Gateway restarted (drained
   cleanly, new pid) so the addendum loads.
6. Unpause `sibyla-email-graph-gateway` (it still only triages; the orquestrador registers).
   **Done 2026-09-03 (owner's word), as `apollo-email-gateway`:** the cron created at step 4 was
   resumed; its first run failed in the legacy Graph watcher (`C:\SibylaApps\Documental-agent\scripts\graph_email_gateway.py`
   moved a message, then marked the old message id as read: Graph 404 `ErrorItemNotFound`); the
   watcher now marks the id the move returns (backup `graph_email_gateway.py.pre-step6.bak` in
   the Phase 0 folder); a hand run and the scheduled run report `ok`. `sibyla-email-graph-gateway`
   stays paused. Reminder: this cron is mailbox triage (Validar/Descartados); no emailed document
   is registered automatically until the Apollo-owned watcher (F7) exists.
7. Observe: one trusted email, one WhatsApp, one unverified sender → quarantined and released;
   replay shows zero duplicate work (P2-01).
   **Postponed 2026-09-03 by the owner.** Ready when wanted: the gateway has WhatsApp and Buzz
   connected, `apollo_channel_intake` is on the WhatsApp toolset, the five trusted WhatsApp
   numbers are on the gateway allow-list, addendum 2.0 is loaded. Protocol agreed: a trusted
   number sends one PDF (expect `completed`, job, extraction), the same file again (expect no
   second document), and for the unverified leg either the agent registers a test file with an
   unknown sender through the plugin tools (quarantine, released on the Uploads page) or a
   second phone is allow-listed at the gateway. The email leg is a manual reprocessing until F7.
   **Started 2026-09-04 (owner's word); first finding.** The first trusted WhatsApp PDF
   (11:09 UTC) was cached by the gateway and correctly refused by Apolo: the gateway's document
   note gives the agent only the file name and cached path, while the addendum requires the
   external message id, the sender identifier, the received timestamp and a candidate key from
   authenticated gateway metadata — so the agent failed closed and nothing reached the API.
   The gateway holds those values on the MessageEvent it dispatches (and its state store records
   the WhatsApp message id and the sender LID). Fix, in Apollo's own plugin: `apollo-channel-intake`
   1.1.0 registers the Hermes `pre_gateway_dispatch` hook and appends one line to the user turn of
   every WhatsApp/Mattermost message that carries media, `[apollo-channel-intake trusted gateway
   envelope: channel=…; external_message_id=…; external_sender_identifier=…; received_at=…;
   chat_id=…; candidates=N; candidate_i=<cached name>|<mime>|<message id>:<i>; …]`, values
   copied verbatim from the event (text-only messages untouched; a missing value is written as
   `unavailable`, never invented; the hook never raises). The skill step 2 and the addendum's
   intake step 1 now name that line as the envelope source. 48 plugin unit tests
   (`local/hermes/apollo-channel-intake/tests`). Pre-change copies under
   `D:\Backups\SibylaDeployment\20260904-111837\hermes-step7-pre\`.
   **Second observation (11:33 UTC), two findings.** (a) With the envelope the registration
   reached the API (`201`, registration `91272548…`) but as `quarantined`, `senderVerified:
   false`: the gateway identifies the sender by LID while the five trusted bindings are E.164
   numbers, and D-CI-3 keeps aliases as separate bindings. The gateway resolves the LID through
   its own `whatsapp/session/lid-mapping-*` files (`gateway.whatsapp_identity`), which the LID
   reference names as the one trusted source of the alias; the envelope now also carries
   `external_sender_aliases=+351…` (contract shapes only, the LID's own digits excluded, absent
   when no mapping exists) and the skill/addendum tell Apolo to register with the E.164 alias
   and keep the raw identifier in `trusted_metadata.sender_original`. (b) The upload answered
   **HTTP 405 from IIS itself** (`Allow: GET, HEAD, OPTIONS, TRACE`, HTML body →
   `channel_intake_non_json_response`): the host-wide WebDAV module handles PUT before ASP.NET
   Core sees it. Sibyla Legacy's API removed the module and its handler in its web.config; the
   platform API release carried the SDK default. Fix: `src/Sibyla.Api/web.config` (published with
   every release, verified by a temporary `dotnet publish`) removes `WebDAVModule` and the `WebDAV`
   handler; guard test `WebConfigTests`. A new API release through the hand path is needed before
   the upload leg can pass; the quarantined registration expires on 2026-09-05 unused.
   **API release `20260904-114642-d3e32c7` deployed 11:47 UTC on the owner's "Deploy now"**
   (run record §7e). **Third observation (11:50 UTC), leg 1 passed end to end in 21 s:**
   registration `d56cf09d…` under the E.164 alias, `senderVerified: true`, `completed`; document
   `0f3e1f94…` channel whatsapp, Processed (FT EINPT26/5769407, 2026-09-01, 1 990,83 EUR, 11
   fields), storage transfer to `Apollo/_intake/2026/…`, company inferred at 11:50:49 from the
   recipient tax id (`document.company.inferred`). **Replay (11:53 UTC), leg 2 found P2-01 not
   met:** the resend registered and uploaded (new message id, so a new registration by design)
   and a second document was created **and extracted** before being held — the per-(owner,
   company) lookup compared "same hash, company null" while the first copy had meanwhile received
   its inferred company; only the worker's post-inference re-check linked it (status
   PossibleDuplicate, `01f43f9b…`). One extraction and one storage transfer were spent.
   **Owner ruling 2026-09-04: an intake that carries no company is looked up across the whole
   licence (any company or none, quarantined copies excluded) and held before any processing;
   the human ruling is unchanged; assigning a held company-less resend to a company other than
   the original's releases it (link moved to a same-company copy if one exists, else the held
   processing is queued), assigning it to the original's company keeps the hold.** Also fixed on
   the way: ruling "not a duplicate" on a document the worker flagged after its own extraction
   no longer queues a second extraction under the same idempotency key — it is Processed.
   RED-first in `IntakeDuplicateControlTests` (four cases; the assignment re-check case retyped
   to the surviving contract), implementation in `IngestionService`. Ships with the next
   web/worker release; the API is unaffected.
   **Leg 3 (12:24 UTC, route A):** a synthetic one-page PDF registered through Apolo's own plugin
   client under an E.164 sender with no binding → `201`, `senderVerified: false`, `quarantined`;
   upload `200`; document `d13ea625…` Quarantined (`sender_unverified`), no job, nothing
   transferred; audit registered / quarantined / intake quarantine. Released by the owner on the
   Uploads page at 12:27:52 after naming a company: Queued, both jobs created, transfer succeeded,
   extraction failed the `sibyla.extract.v1` contract as expected for a non-invoice page (retries,
   then dead-letter). **Leg 4 (12:36–12:38 UTC):** an email from a trusted address with the AWS
   invoice attached reached Validar through `apollo-email-gateway` within a minute; the watcher's
   dry run listed one message (`dmarc=none dkim=none`, one candidate); the hand run registered it
   (`ba910339…`, channel email, `senderVerified: true`, `completed`), the document was extracted,
   transferred and then held as a possible duplicate of the morning's copy — the same post-
   extraction hold as leg 2, closed by the ruling above once the web/worker release ships.
   **Cron `apollo-email-intake` resumed 12:39 UTC on the owner's word** (first scheduled run
   12:41:32 UTC; first automatic run completed 12:42:03). **Step 7 complete.** The duplicate-control
   ruling reached production with release `20260904-124259-bb6a121` on all three hosts at 12:44 UTC
   (deployment run record §7f).
   **Unpause protocol for `apollo-email-intake` (round 2, C#5):** mail that reached `Validar`
   while the cron was paused is only seen inside the watcher's lookback (14 days by default) and
   is refused by the API when older than 30 days (`received_at_invalid` → held with
   `Sibyla-Retida`). So: first run by hand with `APOLLO_EMAIL_INTAKE_LOOKBACK_DAYS` set to the
   pause length (at most 30), `--dry-run` first, then for real; then `hermes cron resume`. A held
   or otherwise terminal message is retried with `apollo_email_intake.py --retry <key>` (the
   16-hex key printed in the report) followed by the next run.
8. **Done 2026-09-04 12:46–12:48 UTC (owner's word "Do all 3"; deployment run record §7g):**
   `Sibyla.Legacy.Api` site and pool stopped (only crawler traffic since 2026-09-03 22:51), the
   legacy plugin folder and its `config.yaml` entries removed with a gateway restart, the IdP
   client `sibyla-apolo` deleted and the legacy resource `sibyla-api` dropped from the intake scope
   (`RegisterIdpClients --retire-legacy-intake`, dry run first). Backups under
   `D:\Backups\SibylaDeployment\20260904-124632\hermes-step8-pre\`. The intake cutover is complete.
   Original text: Retire: stop Sibyla Legacy's `Sibyla.Api` site, delete the legacy plugin folder, revoke the `sibyla-apolo`
   client in the IdP (owner's call, retirement slice).

---

## 11. Open points for the owner

1. Hostname — decided: `api.sibyla.gottsolutions.net` as its own IIS site (live since 2026-09-03); no reverse proxy.
2. Unverified-sender policy: quarantine (D-CI-2, recommended) versus the legacy 403.
3. Initial sender list for tenant #1 (addresses, numbers, Mattermost ids, and the company each
   maps to, or none).
4. Whether `.tif/.tiff` should be accepted from channels (Apollo's upload allows them; the
   legacy contract did not; this spec keeps pdf/png/jpg only).

---

## Appendix A — failure codes (closed set)

`channel_intake_request_invalid`, `_contract_version_invalid`, `_channel_invalid`,
`_external_message_id_invalid`, `_sender_invalid`, `_received_at_invalid`,
`_trusted_metadata_invalid`, `_trusted_metadata_too_large`, `_candidate_count_invalid`,
`_candidate_key_invalid`, `_candidate_key_duplicate`, `_file_name_invalid`,
`_file_type_unsupported`, `_content_type_mismatch`, `_file_size_invalid`, `_file_too_large`,
`_sha256_invalid`, `_idempotency_key_required`, `_idempotency_key_invalid`, `_correlation_id_required`,
`_idempotency_conflict`, `_correlation_id_invalid`, `_client_not_bound`, `_actor_invalid`,
`_scope_required`, `_routing_changed`, `_registration_expired`, `_not_found`,
`_candidate_not_found`, `_content_length_required`, `_content_length_mismatch`,
`_content_hash_mismatch`, `_file_invalid`, `_rate_limited`, `_internal`.

Retired from the legacy set: `channel_intake_sender_not_authorized` (unverified senders are
quarantined, not refused), `_owner_required` (folded into `_not_found`), `_id_invalid` (route
constraint), `_invalid` (replaced by `_request_invalid`).

## Appendix B — traceability

Plan §2.7 (channel adapters, idempotency keys, sender metadata, `_intake`); discovery 09 §7.2 #3
and 15 §IDEM (idempotency row inside the registration transaction); 13 §B11.1/G8/RB8 and 14
R-14/P2-01/P2-03/P2-04 (sender → tenant binding, quarantine, zero duplicate work); harness plan
D-AI-9/D-AI-10 and the 2026-09-02 ruling on item 4; memory `apollo-intake-plugin`,
`legacy-sibyla-retirement`, `sibyla-no-document-carryover`.
