# Apollo — AI Projects / Development Harness (Administrator-only)

> *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).*

*Plan written 2026-08-31. Fast-prototyping mode: the goal is the shortest path that proves the
whole experience end to end — Project → Session → Chat → Harness → real work. Production
hardening is a named follow-up, not part of this build.*

---

## 0. What this delivers

An Administrator-only area inside the Apollo Blazor app that behaves like a coding-agent
application: a list of configured **AI Projects** in the left navigation, each project owning its
default harness, working folder, model and effort; sessions per project; a chat surface that
streams real harness output; real execution against the three CLIs installed on this box; and an
authenticated, ephemeral browser preview so the operator can validate changed UI without deploying
or replacing the application under development.

```
PROJECT ─▶ SESSION ─▶ CHAT ─▶ HARNESS ─▶ real analysis / development / operations
```

Not: *pick provider → pick filesystem → configure tools → configure environment → chat.*

---

## 1. Findings — what already exists (verified 2026-08-31)

### 1.1 GOTT.Apollo

| Concern | What is there | Files |
|---|---|---|
| Solution | .NET 10, modular monolith, `Sibyla.slnx`: Platform (Domain/Infrastructure/Contracts), Argus module (Sibyla until 2026-09-02), `Sibyla.Web`, `Sibyla.Worker.Documents`, `Sibyla.Sync` | `Sibyla.slnx` |
| Frontend | **Blazor Server**, `@rendermode InteractiveServer` per page; no SPA, no JS framework, no Web API layer — pages call scoped services directly | `src/Sibyla.Web/Components/Pages/*` |
| Auth | OIDC authorization-code + PKCE against GOTT.IdentityServer (`apollo-web` confidential client); cookie `sibyla.auth`; scopes `openid profile email roles offline_access apollo-api` | `src/Sibyla.Web/Program.cs` |
| Authorization | **Roles do not come from the token.** They are read from the Apollo DB (`usrrol`) at sign-in by `TenantResolver` and land on `TenantContextHolder.Resolved.Roles`. Two role keys exist today: `admin` and `superuser` — **both are renamed by D-AI-7** to match the IdP's vocabulary. The set is built with `StringComparer.OrdinalIgnoreCase`, so role comparisons are case-insensitive | `Infrastructure/Tenancy/TenantResolver.cs`, `ResolvedTenantContext.cs` |
| Authorization style today | **Scattered, and not actually enforced.** `Tenant.Resolved?.Roles.Contains("admin") is true` is repeated in `MainLayout.razor`, `Companies.razor`, `StorageAdmin.razor`, `ErpAdmin.razor`; `AddAuthorization()` is called with no policies at all. Worse: those pages carry only `@attribute [Authorize]` (authenticated), and the role check is a *rendered message* — the routes are reachable by any signed-in user (§4) | `Program.cs`, `Components/Pages/*.razor` |
| Tenancy | Every tenant table carries `owner_id`, an EF global query filter, and PostgreSQL RLS `ENABLE+FORCE` with the fail-closed `app.owner_id` transaction GUC set by `TenantSessionInterceptor`. Platform-operator tables use a separate `superuser_access` policy keyed on `app.superuser` | `TenantSessionInterceptor.cs`, migration `20260828171510_SuperuserOwnerAccess` |
| Database | PostgreSQL 18 (`gott_apollo`), EF Core + Npgsql, snake_case naming, 6-letter table codes (`ownmst`, `stgcfg`, `jobque`, `audlog`, `docint`, `erpldg`…), uuid keys, `jsonb` for payloads | `Persistence/SibylaDbContext.cs` |
| DI | One `AddSibylaPlatform(connectionString, keysPath)` extension; `AddDbContextFactory<SibylaDbContext>` **scoped** (layout and page render concurrently); admin services registered scoped | `Infrastructure/DependencyInjection.cs` |
| Background work | `jobque` table + `Sibyla.Worker.Documents` (`QueueWorker : BackgroundService`) claiming with `FOR UPDATE SKIP LOCKED`, lease + heartbeat, bounded retries, dead-letter, lanes (`claude`, `io`, `erp`) | `Sibyla.Worker.Documents/QueueWorker.cs` |
| **Existing CLI-agent integration** | `ClaudeDocumentProcessor` already shells out to the Claude CLI: resolves the real `claude.exe` (the npm `.cmd` shim mangles args), `-p` + `--output-format json` + `--add-dir` + `--allowedTools Read`, redirected stdout/stderr, linked-CTS timeout, `Kill(entireProcessTree: true)`, output treated as untrusted and contract-validated | `ClaudeDocumentProcessor.cs` — **this is the template to copy** |
| Real-time | None yet. Blazor Server circuits (SignalR) exist; no page polls or streams today | — |
| UI system | Hand-rolled CSS on prototype-derived tokens (`--sib-brand`, `sib-panel`, `sib-table-wrap`, `sib-chip`, `sib-page-head`, `sib-form`) | `wwwroot/css/tokens.css`, `app.css` |
| Navigation | Single `<nav class="sib-sidenav">` inside `MainLayout.razor`, switched by `CurrentModule` (route prefix) with an `Administration` group gated on `IsAdmin` | `MainLayout.razor` |
| Maintenance pattern | List panel + edit panel on one page, `@rendermode InteractiveServer`, `_busy` flag, service returns `(Entity?, string? Error)`, no ceremony | `StorageAdmin.razor` + `StorageAdminService.cs` |
| Auditing | `audlog` (`ActorSubject`, `Action`, `TargetType`, `TargetId`, `DetailJson`) | `Entities/AuditLogEntry.cs` |
| Localization | Static PT/EN dictionary `L.T("key")`; Share labels stay English (decision D11) | `Localization/L.cs` |
| Logging | Standard `ILogger<T>`; no structured sink configured yet | — |
| Existing Hermes/Gateway code | **None in the repo.** Hermes appears only in `docs/` as a planned *ingestion channel adapter* (P2-01). There is no Hermes client, no Gateway client, no AI/agent/session/chat concept to reuse | `docs/discovery/13`, `14`, `15` |

**Conclusion:** there is no existing chat/session/agent concept to extend. Everything below is new,
but it sits comfortably on the existing platform patterns and needs **no** change to tenancy,
ingestion, the queue, the worker, or Argus.

### 1.2 GOTT.IdentityServer

.NET 10, OpenIddict 7.5.0 + ASP.NET Core Identity + EF Core/PostgreSQL (`gott_identity`), running
in production on this box as `login.gottsolutions.net` (`C:\SibylaApps\IdentityServer`). Apollo is
registered data-only by `local/tools/RegisterIdpClients` (`apollo-web`, `apollo-worker`, scope
`apollo-api`).

**Roles that actually exist there** — read from the live `gott_identity` database 2026-08-31
(`AspNetRoles`), not from the seed code, which only creates `Administrator`; the rest were added
through the IdP's own Roles admin page:

```
Accounting · Administrator · Finance · Legal · Marketing · Organization · Sales · Support
```

**There is no `superuser` role at the IdP.** The two that matter here are **`Administrator`** (the
software-house / platform operator) and **`Organization`** (the administrator of a client
organization). The other six are functional roles Apollo does not consume yet.

**No Identity Server change is required.** Apollo already authenticates against it, both roles it
needs already exist there, and Apollo's *authorization* still resolves from its own `usrrol` table
rather than the token. What changes is on the Apollo side: its two role keys are renamed to match
this vocabulary, so the two systems stop using different words for the same thing (D-AI-7).

### 1.3 invoice-skill-build

`D:\fileStorage\repos\invoice-skill-build` — GitLab remote
`gottsolutions.dev/sibyla/invoice-skill-build`. **It is not a code repository**; it is the FDR
authority: a governed data + document tree.

- `SKILL.md` (256 KB) — the operating manual; deployed twin at `Specs/Skill/SKILL.md`, kept equal by `sync_skill_package.py`.
- `INDEX.md` — the authored folder map (which folders are load-bearing, what must not move).
- `Scripts/` — 678 files, the deployed Python pipeline (`run_pipeline.STEPS`, `entity_utils.py` owns shared rules).
- `Specs/` — 18 Engagement Rules, data schema, Change Log, Roadmap, policies.
- `Editor/Data/` — the canonical JSON data layer. `_sandbox_s12r02/` is the flat working sandbox the pipeline actually runs in (`cwd='.'`).
- `Control/` — generated control artefacts (`process_measures.json`, the process control workbook).
- **No `CLAUDE.md`, no `AGENTS.md`, no `.claude/`, no skills directory, no test runner.**

Implication for the Skill Build project: Claude started there gets *no* project instructions from
the repo. The plan therefore gives every project an optional **`SystemPromptAppend`** field, and
seeds Skill Build with a pointer to `SKILL.md` / `INDEX.md` / `Specs/Engagement Rules`. Cheap, and
it is exactly the missing piece. (Writing a `CLAUDE.md` into that repo is a separate decision and
is **not** part of this plan.)

### 1.4 The three harnesses — verified on this machine

All three CLIs are installed, authenticated and were probed live on 2026-08-31.

| | Hermes | Codex | Claude |
|---|---|---|---|
| Version | Hermes Agent v0.20.6 | codex-cli 0.151.0 | 2.1.251 (Claude Code) |
| Path | `C:\Users\Administrator\AppData\Local\hermes\hermes-agent\venv\Scripts\hermes` | `%APPDATA%\npm\codex` | `%APPDATA%\npm\claude` |
| Home / state | `%LOCALAPPDATA%\hermes` (`config.yaml`, SQLite session store) | `~/.codex` (`config.toml`, `sessions/**.jsonl`, `models_cache.json`) | `~/.claude` |
| Auth | OpenAI Codex subscription (`auth.json`) + OpenRouter key | ChatGPT/Codex subscription | Claude subscription (decision D12) |
| Default model | `gpt-5.6-sol` | `gpt-5.6-sol`, `model_reasoning_effort = "medium"` | account default |

**Non-interactive invocation (all three probed and confirmed):**

*Codex* — `codex exec --json [-C <dir>] [-m <model>] [-c model_reasoning_effort=<level>] -s danger-full-access "<prompt>"`

Emits JSONL on stdout, one object per line:

```json
{"type":"thread.started","thread_id":"01a0585d-9ded-77e3-8189-155abe7dae71"}
{"type":"turn.started"}
{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"OK"}}
{"type":"turn.completed","usage":{"input_tokens":16354,"cached_input_tokens":11008,"output_tokens":5,"reasoning_output_tokens":0}}
```

`thread_id` **is** the resumable session id: `codex exec resume <thread_id> --json "<next prompt>"`.
Other useful flags: `--add-dir <DIR>` (additional writable roots — this is how *Additional
Projects* is passed), `--skip-git-repo-check`, `-o/--output-last-message <FILE>`, `--output-schema`.
Note: with a non-TTY stdin Codex prints `Reading additional input from stdin...` — **redirect stdin
from an empty stream** when spawning.

*Claude* — `claude -p "<prompt>" --output-format stream-json --verbose [--model <alias|id>] [--effort low|medium|high|xhigh|max] [--add-dir <DIR>] --dangerously-skip-permissions`

Emits JSONL: a `{"type":"system","subtype":"init","session_id":"…","cwd":"…","tools":[…],"model":"…"}`
first, then `system/thinking_tokens`, `assistant` (message blocks incl. `thinking`, `text`,
`tool_use`), `user` (tool results), `rate_limit_event`, and a final `result` object carrying
`session_id`, `total_cost_usd`, `usage`, `duration_api_ms`. Resume: `claude -p --resume <session_id> …`,
or pre-assign with `--session-id <uuid>`. `--include-partial-messages` gives token-level deltas
(**not** needed for v1). Working directory = the process's cwd.

*Hermes* — `hermes chat -q "<prompt>" -Q [--in <dir>] [-m <model>] [--reasoning <level>] [-t <toolsets>]`

`-Q` (quiet, programmatic) prints exactly:

```
session_id: 20260831_150911_5b827f
OK
```

i.e. a `session_id:` line then the final response text. Resume: `hermes chat -q "…" -Q --resume <session_id>`
(or `--continue <name>` with `--create-if-missing`). **No streaming event protocol from the CLI** —
turn-level granularity only. `-z/--oneshot` is even quieter (final text only, *no* session id) — do
not use it, we need the id. Hermes also has native `hermes project` (named multi-folder workspaces)
and `hermes sessions list/export`; the richer path is `hermes serve` (JSON-RPC/WebSocket on 9119) —
**out of scope for v1**, on the roadmap.

**Model / effort capability facts:**

| Harness | Model flag | Effort flag | Effort values |
|---|---|---|---|
| Codex | `-m <slug>` | `-c model_reasoning_effort=<v>` | `low`, `medium`, `high` (per model; see `~/.codex/models_cache.json`) |
| Claude | `--model <alias\|id>` | `--effort <v>` | `low`, `medium`, `high`, `xhigh`, `max` |
| Hermes | `-m <provider/model>` | `--reasoning <v>` | `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, `ultra` |

Codex slugs currently cached: `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.5`, `gpt-5.4`,
`gpt-5.4-mini`, `gpt-5.3-codex-spark`.

---

## 2. The decisions that shape the build

**D-AI-1 — Authorization: gate on `superuser`, centralized as a policy.**
AI Projects run arbitrary code on the host with elevated flags and touch repositories that are not
tenant data. That is software-house capability, not organization administration — so the gate is the
platform-operator role **`superuser`** (D14), not `admin`. Implement it **once**, as a named
ASP.NET Core policy backed by `TenantContextHolder`,
and use it for the nav entry, every page, and every service entry point. No new IdP scope, no new
role, no `IsAdmin` copies.

**D-AI-2 — All five tables stay platform-level. The licence boundary is enforced on the database
connection Hermes is given, not on which sessions the operator can see.**

*(Owner ruling 2026-08-31: Codex repos and Claude are not scoped per licence; the agent Apollo (on Hermes)
session must be licence-specific. Clarified: "I'm logged in as administrator, but have selected
licence NP Group — when I ask a question to the Apollo Hermes agent he can only answer me with data
from that licence from the database.")*

The requirement is about **what the agent can read**, not about which transcripts are listed. Those
need very different mechanisms, and only one of them is worth building:

- **Hiding sessions from the operator is not a boundary.** The same administrator can switch licence
  in the topbar with one click and see anything they were "hidden" from. Scoping conversation
  *visibility* would cost a nullable `owner_id` on three tables, a hybrid RLS policy, hand-written
  EF filters and a cross-licence UI guard — to prevent nothing. Dropped.
- **Constraining what the agent's queries return is a real boundary**, and Apollo already owns the
  machinery for it: PostgreSQL RLS keyed on the `app.owner_id` GUC, with `NOBYPASSRLS` roles. It is
  the same fail-closed mechanism the whole platform rests on and the isolation suite already guards.

So:

- `aiproj`, `aiconv`, `aicnvp`, `aimesg`, `aiexec` are **all** platform-level — no tenant policy,
  one `superuser_access` policy on `current_setting('app.superuser') = 'on'`, exactly as migration
  `20260828171510` already does. Simple, uniform, one policy shape.
- `aiconv.owner_id` exists as a **recorded fact** — which licence the session was conducted under,
  for display and audit. It is not a visibility gate, so it needs no RLS of its own.
- **The enforcement**: when a run belongs to a `licence_scoped` project, Apollo hands the child
  process a database connection that is pinned to the selected licence — a SELECT-only,
  `NOBYPASSRLS` role plus `app.owner_id` set to that owner. Every query that connection can run
  returns that licence's rows and no others, because RLS says so. See §5.

`aiproj.licence_scoped` (seeded true for Apollo, false for the other three) is what decides whether
the scoped connection is handed over — an explicit flag, **not** inferred from `harness == HERMES`,
so a second operational project can exist later without a code change.

**D-AI-3 — One child process per *turn*, not one long-lived interactive process.**
All three CLIs support "send one prompt, resume by session id". No PTY, no stdin pumping, no
process kept warm between messages. A run is: spawn → stream JSONL/stdout → persist → exit. This
removes the hardest engineering (interactive terminal multiplexing) and loses nothing the prototype
needs.

**D-AI-4 — The runner is an in-process singleton in `Sibyla.Web`, not a new service and not `jobque`.**
A singleton outlives the Blazor circuit, so a browser disconnect cannot kill a run (§31). Going
through `jobque` + `Sibyla.Worker.Documents` would satisfy durability but forces every streamed
token through the database and adds a second process to the debug loop. Trade-off accepted and
recorded: an app-pool recycle / host restart **does** kill in-flight runs; on restart they are
marked `Interrupted` and the conversation is resumable via the harness's own session id. Moving the
runner to a dedicated worker is the first item on the hardening roadmap.

**D-AI-5 — Dangerous flags are harness constants, not project fields (§25).**
`--sandbox danger-full-access` and `--dangerously-skip-permissions` live in `CodexHarness` /
`ClaudeHarness` under a single `HarnessExecutionOptions.PrototypeMode` switch. They never appear in
Project Maintenance.

**D-AI-6 — Read-only against `gott_apollo` until cutover. Operational writes go through the FDR
JSON layer and the sync.**
*(Owner ruling 2026-08-31: "only read until the cut[over] from the invoice skill build; writing for
now must be through the invoice skill build `*.json` files and sync process.")*

This is not prototype timidity — it is the corpus architecture already recorded in
`docs/apollo-discovery-and-plan-260826.md` §3: the FDR remains the operating system of record until
go-live, and Apollo becomes it only at the final freeze-and-sync. So `apollo_ai_reader` is
`SELECT`-only by design, and there is no second write-capable role to build.

The three projects fall into place as one loop:

```
ask / analyse        Apollo project · Hermes  ─▶ gott_apollo   SELECT-only, licence-pinned (§5)
                                                    ▲
change the data      Skill Build · Claude     ─▶ Editor/Data/*.json in invoice-skill-build
                                                    │  governed by Specs/Engagement Rules
run the sync         Sibyla · Codex           ─▶ local\run-sync.ps1  ──────────┘
```

Three consequences the implementation must respect:

1. **No Apollo-side write path for operational data**, and no AI feature that implies one. A Hermes
   session reports and explains; it does not change the corpus. If a session concludes something
   must change, the change is made in the FDR JSON layer and arrives through the sync — **and is
   then propagated downstream inside that repo**: `Editor/Data/*.json` → `build_workbook.py` →
   `Invoice_Registry.xlsx` → `registry-data.json` → Supabase. Apollo reads only the JSON, so it
   cannot detect a half-done change; the Skill Build project's system prompt carries this rule
   (§13.6) because nothing else will catch it.
2. **The JSON layer is governed, not free.** `Specs/Engagement Rules` (18 of them), the Policy
   Change Validation Procedure, and `INDEX.md`'s "what must not move" bind any edit there. This is
   what `aiproj.system_prompt_append` is for on the Skill Build project (§1.3) — seed it to point at
   `SKILL.md`, `INDEX.md` and `Specs/Engagement Rules`, so a Claude session starts inside the rules
   rather than discovering them.
3. **The sync is the verification gate, and it is privileged.** It "reports and quarantines — never
   guesses, never deletes, never repairs declared exceptions" (README non-negotiable) and runs gates
   G0–G5/G8/G10, so a bad JSON edit surfaces as quarantine rather than silent corruption. But
   `run-sync.ps1` runs as **`apollo_migrator`, which is `BYPASSRLS`** — it is a whole-corpus,
   boundary-crossing operation. See §9.

**D-AI-7 — Add `SuperUser` at the IdP; keep Apollo's role concepts unchanged.**
*(Owner ruling 2026-08-31, second option, adopted: "podemos apagar Organization e acrescentar
SuperUser do lado do identityserver, mantendo assim os conceitos anteriores do lado da App.")*

**This is the safer of the two directions, and the evidence supports it.** Read from the live
`gott_identity` database 2026-08-31, role → users assigned:

```
Accounting 4 · Administrator 4 · Finance 4 · Legal 2 · Marketing 2 · Organization 0 · Sales 3 · Support 1
```

`Organization` has **zero** assignments and is referenced nowhere in the IdentityServer code — it
is safe to delete, and deleting it is worth doing so it stops looking like an available concept.
Every other role is genuinely in use by the wider estate (the `Finance-agent`, `Legal-agent`,
`Sales-agent`, `Marketing-agent` profiles under `C:\SibylaApps` are the likely consumers).

So:

| System | Role | Who it is |
|---|---|---|
| IdP | `Administrator` *(exists, 4 users)* | Gates IdentityServer's own admin pages — `[Authorize(Roles = "Administrator")]` on Users, Roles, Clients, Scopes, Branding |
| IdP | **`SuperUser`** *(add)* | The software-house / platform operator concept, named unambiguously |
| IdP | ~~`Organization`~~ *(delete)* | Unused |
| Apollo | `admin` *(unchanged)* | Administrator of a client organization |
| Apollo | `superuser` *(unchanged)* | Software-house / platform operator, D14 |

**Why this beats renaming Apollo's keys.** The first direction required `admin` to change meaning
(to `Organization`) while `Administrator` became the *higher* role — so every existing
`Roles.Contains("admin")` would have had to flip sense, which is precisely the rename that silently
inverts a privilege check. This direction moves nothing in Apollo. If the vocabularies are ever
aligned properly, the change becomes `admin` → `Administrator` and `superuser` → `SuperUser`: same
meanings, fuller words, no inversion.

It also leaves IdP `Administrator` doing the one job it already has. Overloading it with "Apollo
platform operator" would have given one word two jobs in the system whose whole purpose is to be
the single source of identity.

**Apollo needs no change for this.** Roles resolve from `usrrol`, not the token — the IdP addition
is vocabulary for later, not wiring. `ApolloRoles` keeps `admin` / `superuser`, `IsSuperuser` keeps
its name, and the `app.superuser` GUC and its `superuser_access` policies are untouched.

**Applied 2026-08-31.** IdP roles now:
`Accounting · Administrator · Finance · Legal · Marketing · Sales · Superuser · Support`
(`Superuser` created by the owner; `Organization` deleted — it had 0 user assignments and 0 role
claims, verified before the delete).

### D-AI-8 — One home per concept: `superuser` moves to the token, `admin` stays in `usrrol`

*(Owner question 2026-08-31: "porque temos isto definido dos 2 lados? não devia de ser só de 1?")*

**The duplication is real and is already producing wrong access.** Both stores, read 2026-08-31:

| User | IdentityServer | Apollo `usrrol` | |
|---|---|---|---|
| `admin@gottsolutions.net` | Administrator, **Superuser** | `admin`, `superuser` | ✅ agree |
| `luis.nascimento@` | Administrator, **Superuser**, +6 | `admin` | ❌ missing `superuser` |
| `miguel.teixeira@` | Administrator, **Superuser**, +5 | `admin` | ❌ missing `superuser` |
| `rachel.sa@` | Accounting, Finance, Sales | **`admin`** | ❌ holds it at neither IdP nor intent |
| `sibyla@` *(service)* | Administrator, Accounting, Finance | — | n/a |

Three of four disagree. Whichever store you read, someone's access is wrong — which is the concrete
answer to "shouldn't it be one side?".

**But it cannot collapse to one *store*.** It should collapse to one *home per concept*:

- **The IdP cannot express Apollo's roles.** `AspNetUserRoles` is a flat, global user→role map with
  no `owner_id`. Apollo is multi-tenant: a person can be administrator of NP Group and nothing in
  another organization, and `usrrol` also carries `module_key` (`argus:reviewer`). Encoding that
  into role names (`admin@np-group`) couples a shared IdP to Apollo's client list and stops scaling
  the day a second organization is onboarded.
- **Apollo should not own `superuser`.** It is a *global* fact about a person — they work for the
  software house — not a per-organization one. It is also the estate's business, not Apollo's: the
  IdP is shared with the other apps and agents.

So the rule is **one home per concept**, and the defect is that `superuser` currently has two:

| Concept | Home | Why |
|---|---|---|
| Identity, and global cross-app roles — `Superuser`, `Accounting`, `Finance`, … | **IdentityServer** | True everywhere, independent of any organization |
| `admin`, module roles (`argus:*`) | **Apollo `usrrol`** | Scoped per `owner_id` / `module_key` — the IdP structurally cannot hold this |

**The move is small, because the claim already arrives.** Verified in
`GOTT.IdentityServer/Services/TokenPrincipalFactory.cs`:
`case Claims.Role when principal.HasScope(Scopes.Roles): yield return Destinations.IdentityToken;`
— and Apollo already requests the `roles` scope and sets `RoleClaimType = "role"` with
`MapInboundClaims = false` (`Program.cs`). **Apollo receives these roles today and ignores them.**

1. In `TenantResolutionService.EnsureResolvedAsync`, which already holds the `ClaimsPrincipal`, read
   `principal.IsInRole("Superuser")` and pass it into `ResolvedTenantContext`.
2. `TenantContextHolder.IsSuperuser` returns that instead of testing the `usrrol` set;
   `TenantResolver` uses the same value for its owner-override guard. The `app.superuser` GUC and
   every `superuser_access` policy stay untouched — they read `IsSuperuser`, not its source.
3. Stop seeding `superuser` in `db/seed/tenant1.sql`, and delete the existing rows from `usrrol`.

Trade-off, stated: a token-sourced role is stale until the token refreshes, so granting or revoking
`Superuser` takes effect at next sign-in rather than immediately. For a role that changes about
never, that is the right trade — and it is the price of having one home instead of two.

Sequencing: this is *not* required for the AI area to work (D-AI-1 gates on `IsSuperuser`, whatever
feeds it). Do it in commit 1 if the drift is to be fixed now, or take it as its own change — but
take it before adding a third consumer of the role, not after.

**D-AI-9 — One Hermes profile per licence. It is a functional requirement, not hardening.**
*(Owner ruling 2026-08-31: "deveremos mesmo ter um agent por licença, até porque os números
whatsapp/buzz/emails têm de variar e ser configurados por licença.")*

This reframes what §5's licence work is *for*. The database pinning was about what the agent may
read; the profile is about **who the agent is when it acts**. A client's WhatsApp number, Buzz
account and mailbox are per-organization, and they live in Hermes' own per-profile configuration —
so a single shared profile cannot serve two organizations at all, regardless of isolation. The
second licence breaks it functionally, long before anyone worries about containment.

Verified 2026-08-31, and cheaper than it sounds:

- A profile is a genuinely separate instance: `%LOCALAPPDATA%\hermes\profiles\<name>\` carries its
  own `config.yaml`, `SOUL.md`, `home`, `cron`, `hooks`, caches and secrets. Five already run on
  this machine (`documental-agent`, `finance-agent`, `legal-agent`, `marketing-agent`,
  `sales-agent`).
- Hermes reads **`HERMES_PROFILE`** from the environment (`hermes_cli/config.py`,
  `agent/secret_scope.py`), so Apollo selects one by adding a single variable beside the `PG*` block
  it already sets — no alias wrappers, no command-line change, the same mechanism as the pinning.

**One small table**, platform-level like the rest (§3), `superuser_access` policy:

`aiprof` — `id`, `project_id` → `aiproj`, `owner_id`, `profile_name` (varchar 100), `enabled`,
`created_at`, `updated_at`. Unique `(project_id, owner_id)`.

**Fail closed, and do not fall back.** A licence-scoped run resolves `(project_id,
aiconv.owner_id)` → `profile_name`. If there is no row, or it is disabled, **refuse to start the
run** — "No Hermes profile is configured for \<licence\>." Falling back to the `default` profile
would send a client's message from another client's WhatsApp number, which is the single worst
failure this feature could produce. A missing profile must be a loud stop, never a silent default.

**Apollo records the profile; it does not provision it.** Creating a profile and configuring its
channels, credentials and gateway stays in Hermes (`hermes profile create`, then that profile's own
setup). Apollo stores only the name and passes it. That boundary keeps Apollo out of the business of
managing another product's secrets.

**What this does and does not close.** With profiles, memory, secrets, channels and gateway config
are separated per licence — which, together with the pinned database connection, is most of the way
to the owner's requirement. It still does not remove `terminal`, so a determined agent could read
another credential off the host; that needs the dedicated OS identity (§12 item 1). The honest
summary stays: **per-licence identity and per-licence data access, on a trusted host.**

Sequencing: this lands with the Hermes harness (§11 commit 8), not after it.

**Profile assignment (owner ruling 2026-08-31, reconfirmed 2026-09-01):** **NP Group uses `default`** — the profile that
already carries the operational configuration (`gpt-5.6-sol`, the `sibyla-channel-intake` plugin,
the live channel credentials). **Every other licence gets a clone**, configured with its own
WhatsApp number, Buzz and email accounts. So `aiprof` seeds one row — NP Group → `default` — and a
new licence is not usable through Hermes until its clone exists, which is precisely the fail-closed
behaviour above rather than an inconvenience to route around.

### D-AI-10 — Per-licence Hermes gateway configuration, in the Administrator options

*(Owner requirement 2026-08-31: per licence, name the agent (as "Apollo" is named in `default`), add
WhatsApp and/or Telegram and/or BUZZ and/or email, and be able to apply and validate/create the
Hermes gateways for that licence. For `default`, which already exists, the fields must be populated
from what is already configured.)*

This is D-AI-9 with a management surface: the profile stops being a name Apollo records and becomes
something Apollo creates, reads back, configures and tests — the same shape as the existing
`/storage` and `/erp` screens (config + credentials + **Testar ligação**), which is why it fits
without inventing a pattern.

#### What Hermes actually allows — verified 2026-08-31

| Operation | Command | Automatable |
|---|---|---|
| Create the licence's agent | `hermes profile create <name> --clone-from default --description "<agent>"` | ✅ fully non-interactive |
| **Read back existing config** | `hermes send --list --json` with `HERMES_PROFILE=<name>` | ✅ returns platforms + discovered channels as JSON |
| Read/write a setting | `hermes config get` / `set` / `unset` / `check` | ✅ |
| **Validate a channel** | `hermes send --to <target> --json "<test>"` | ✅ real delivery, **no LLM, no agent loop**, and no running gateway needed for bot-token platforms |
| Gateway lifecycle | `hermes gateway status` / `start` / `stop` / `install` | ✅ |
| Initial channel wizard | `hermes gateway setup` | ❌ **interactive, takes no arguments** |
| WhatsApp device pairing | QR / device link | ❌ inherently interactive |

`hermes send` is the find that makes this work: it is a delivery primitive that "reuses the gateway's
platform credentials — no LLM, no agent loop", with `--json` output. That is exactly Apollo's
existing **Testar ligação** button, applied to a messaging channel.

**Read-back proves out on `default` today.** `hermes send --list --json` returns:

```json
{"platforms": {"whatsapp": [{"id": "…@lid", "name": "Miguel Teixeira", "type": "dm"},
                            {"id": "…@lid", "name": "Luis Nascimento", "type": "dm"}],
               "buzz": []}}
```

So the NP Group row populates itself from the live profile rather than being retyped — which is the
owner's requirement, and also the only way the screen can be trusted, since a hand-entered mirror of
another system's config drifts the moment someone changes it in Hermes.

**Seeding NP Group (owner ruling 2026-09-01: `default` is already configured with Graph and
WhatsApp, and belongs to NP Group).** The seed is an *import*, never typed:

| Row | Read from |
|---|---|
| `aiprof` | profile `default`, agent name "Apollo" |
| `aiprofc` MESSAGING/WHATSAPP | `hermes send --list --json` → the discovered DMs |
| `aiprofc` EMAIL/MSGRAPH | `email_gateway.yaml` → `mailbox`, `folders`, the three allow-list tiers |
| `cron_job_name` | `sibyla-email-graph-gateway` |

Both channels start at their **real** status, not `NotValidated`: WhatsApp is configured and the
email watcher's `last_status` is `ok`, so the screen should say so on first render. The gateway
being `stopped` is surfaced separately (§ operational note) — configured and running are two
different facts, and collapsing them would make a stopped gateway look like a broken channel.

#### Two corrections to earlier assumptions in this plan

- **BUZZ is a native Hermes platform**, not a GOTT-side integration — it appears in `hermes send
  --list` as a first-class target alongside WhatsApp. Earlier text implying otherwise was wrong.
- **Email is not a `send` target; it is an *inbound intake* channel.** The `sibyla-channel-intake`
  plugin declares `channel: enum ["email", "whatsapp", "mattermost"]` and registers inbound messages
  and document candidates with the Sibyla Legacy API, authenticated by
  `SIBYLA_CHANNEL_INTAKE_CLIENT_SECRET`. So "various types of email" is configured as *sources that
  feed intake*, not as gateways Apollo can test with `hermes send`. It needs its own validation —
  and note the plugin currently posts to the Sibyla Legacy channel-intake endpoint, which under
  Apollo should become Apollo's own DOCINT ingestion (§1.1). Flagged, not silently assumed.

#### Email intake — Microsoft Graph, not IMAP (verified 2026-09-01)

*(Owner: "cada licença terá 1 email; neste momento temos sibyla@gottsolutions.net com email intake
de documentos para NP Group (default). Penso que esse email está com IMAP, certo?" — **it is not.**)*

The live mechanism is a Hermes **cron job**, not a gateway channel and not IMAP:

```
name    sibyla-email-graph-gateway        schedule  every 2 minutes
script  sibyla_email_gateway_runner.py    no_agent  true   ← script-only, no LLM
prompt  "Script-only Microsoft Graph email watcher for sibyla@gottsolutions.net. It routes
         unread Inbox messages to Validar or Descartados according to the configured
         deny-by-default policy. It does not send replies."
repeat.completed 22069                    last_status ok
```

Corroborating: the Hermes `.env` holds `MS_TENANT_ID` / `MS_CLIENT_ID` / `MS_CLIENT_SECRET` and
**no IMAP or SMTP keys at all**; `config.yaml` has no mail section; the bundled `email/himalaya`
skill (which *is* IMAP/SMTP) is **not installed and not configured** — its `configuration.md` is
generic example text (`imap.example.com`). The legacy `Sibyla.Worker/appsettings.json` also
references `graph.microsoft.com`. So the whole estate is on Graph.

**What that means for per-licence email.** One mailbox per licence (owner ruling) is provisioned as:
a Microsoft 365 mailbox, Graph app credentials in **that profile's** `.env`, and a **per-profile
cron job** running the same watcher script against that mailbox. Apollo records the mailbox address
as an `aiprofc` row (`platform = EMAIL`, `direction = Intake`) and drives it through
`hermes cron create` / `edit` / `run` / `runs` — all non-interactive.

**Validation for email is not `hermes send`.** It is: trigger the watcher once and read the durable
outcome — `hermes cron run <job>` (or `tick`), then `hermes cron runs <job>` for the recorded
attempt, plus `hermes cron status` to confirm the scheduler is alive. That keeps Graph credentials
inside Hermes, where D-AI-9 says they belong, rather than pulling them into Apollo to run a probe.

> **Clone hazard — the most dangerous detail in this whole feature.** `hermes profile create
> --clone` copies `config.yaml`, `.env`, `SOUL.md` and skills; `--clone-all` copies all state
> including cron. Either way, a licence cloned from `default` **inherits NP Group's Graph
> credentials, and with `--clone-all` its email watcher too** — so a new client's agent would poll
> `sibyla@gottsolutions.net`, double-processing NP Group's documents and exposing one client's mail
> to another. This is precisely the cross-licence leak D-AI-9 exists to prevent, arriving through
> the convenience of cloning.
>
> **Rule: after cloning, the email channel starts at `WaitingForCredentials` and Apollo refuses to
> mark it `Ok` while its mailbox target or Graph client id still equals the source profile's.**
> Provisioning is not complete until both differ. Assert this in the §10 tests — it is a one-line
> comparison guarding a failure nobody would notice until a client complained.

**Operational note, worth knowing before the demo:** every profile currently shows Gateway
`stopped`, and this job's `last_run_at` is 2026-08-25 with `next_run_at` in the past — so the cron
ticker is not running right now, even though the job is `enabled` and its last status was `ok`.
Email intake is therefore idle at the moment, not broken. `hermes cron status` and
`hermes gateway status` are the two checks the screen should surface.

#### Data model — one table plus its channels

Both platform-level (`superuser_access`), like the rest of §3. `aiprof` from D-AI-9 gains the agent
identity:

`aiprof` — `id`, `project_id` → `aiproj`, `owner_id`, `profile_name` (the Hermes profile),
**`agent_name`** (display name — "Apollo" for `default`), `description`, `enabled`,
`provisioning_state` (`Existing` | `Created` | `Failed`), `last_validated_at`, timestamps.
Unique `(project_id, owner_id)`.

`aiprofc` — `id`, `profile_id` → `aiprof`, **`company_id`** (nullable → `commst`), `channel`
(`EMAIL` | `MESSAGING`), **`provider`**, `direction` (`Send` | `Intake`), `target` (mailbox address,
number, chat id), `display_name`, `enabled`, `connection_status`, `last_validated_at`,
`cron_job_name` (email only), `settings_json` (the allow lists).

**`company_id` nullable is the "por licença e/ou empresa" requirement**: null = the whole licence,
set = that company only. Same shape as `erpcfg`, which is already per-company — no new pattern.

#### Providers — visible, with only the live ones enabled

*(Owner requirement 2026-09-01: email via Microsoft Graph, Gmail API or IMAP; messaging via
WhatsApp, Telegram or Teams, with an allow list.)*

This is exactly the D8/D9 house pattern already used for storage and ERP (`StorageProviders.All`
vs `.Live` — "non-live ones are visible but disabled in UI"). Reuse it verbatim rather than
inventing a second convention:

| Channel | Provider | State | Why |
|---|---|---|---|
| EMAIL | `MSGRAPH` | **live** | Running today — `graph_email_gateway.py`, 22k executions |
| EMAIL | `GMAIL` | visible, disabled | No runner exists; needs a Gmail API backend written |
| EMAIL | `IMAP` | visible, disabled | No runner exists. The bundled `email/himalaya` skill *is* IMAP/SMTP and could back it, but it is not installed or configured |
| MESSAGING | `WHATSAPP` | **live** | Native Hermes platform, configured in `default` |
| MESSAGING | `TELEGRAM` | **live** | Native Hermes platform (`hermes-telegram`) |
| MESSAGING | `TEAMS` | **live** | Native Hermes platform (`hermes-teams`) |
| MESSAGING | `BUZZ` | **live** | Native Hermes platform, appears in `send --list` |

**Be honest about the asymmetry.** The three messaging platforms are Hermes features — selecting one
is configuration. The three email providers are **not**: email intake is a custom script, so `GMAIL`
and `IMAP` are *engineering*, not a toggle. Modelling the field now costs nothing and keeps the
screen truthful; implementing them is a separate decision with a real cost. Do not let a dropdown
imply otherwise — that is precisely what the "visible but disabled" convention exists to prevent.

#### The allow list already has a proven shape — adopt it

`C:\SibylaApps\Documental-agent\config\email_gateway.example.yaml` is the live contract, and it
is better than anything worth inventing here:

```yaml
mailbox: "sibyla@gottsolutions.net"
folders: { descartados: "Descartados", tratados: "Tratados", validar: "Validar" }
allowed_sender_addresses: []           # deny-by-default
allowed_sender_domains: ["gottsolutions.net"]
validate_sender_domains: [...]         # important, but never auto-replied
discard_sender_domains: [] ; discard_subject_keywords: [...]
```

Three tiers, not one — *allow*, *validate-but-never-reply*, *discard* — and deny-by-default in
between. `aiprofc.settings_json` stores exactly these keys (jsonb, as `StorageConfig.SettingsJson`
and `ErpConfig.SettingsJson` already do), and messaging rows use the same `allowed_*` shape for
numbers and handles, honouring the `.hermes.md` rule "deny-by-default para números não autorizados".

There is also `config/group_companies.yaml` — a per-company registry with `deny_by_default: true`,
`no_inference: true`, matching on tax identifier / legal name / aliases, and a pending path for
unclassified documents. That is where the per-**company** routing already lives, and it is the thing
`aiprofc.company_id` should eventually replace rather than duplicate.

> **Where the config physically lives is the open seam.** Apollo owns the record; the runner reads a
> YAML inside `C:\SibylaApps\Documental-agent`, i.e. the tree being retired. For the prototype
> Apollo renders its record to that file on **Apply** and the runner is untouched. Repointing both
> the config location and the intake endpoint belongs with the cutover (§13.4) — moving them now
> means migrating a working pipeline twice.

`connection_status` reuses the existing `ConnectionStatus` enum (`NotValidated` / `Ok` / `Failed` /
**`WaitingForCredentials`**) — the fourth value already exists and is exactly right for "profile
created, WhatsApp not yet paired".

#### The screen, and the honest edge

Under the Administrator options, per licence: agent name, profile, the channel rows, and per row a
**Validar** button plus a **Ler do Hermes** (re-import) action; at the top, **Criar agente** for a
licence that has none.

The one thing Apollo cannot do is the first WhatsApp pairing — that is a QR/device link, and no flag
makes it non-interactive. So the flow is: Apollo creates and configures the profile, then shows
`WaitingForCredentials` with the exact command for the operator to run once
(`HERMES_PROFILE=<name> hermes gateway setup`), and **Validar** turns it green when the pairing
lands. Presenting that as a known, named step is better than a screen that pretends to finish the
job and leaves a channel silently dead.

**D-AI-11 — UI validation uses an ephemeral development preview, never a deployment.**

An agent changing a frontend must give the operator a real browser surface on which to validate
forms, navigation, responsive behaviour and error states before accepting the work. A successful
build or a screenshot is not enough. For projects with preview enabled, Apollo therefore creates a
Git worktree for the conversation and runs the harness in that worktree. The repository checkout
from which the controlling `Sibyla.Web` process was started is never the session workspace.

After a turn, the operator can explicitly **Start preview**. Apollo builds and starts the configured
application as an ephemeral child process bound only to `127.0.0.1` on an allocated port. An
authenticated same-origin Preview Gateway exposes it under `/_preview/<unguessable-token>/`,
including WebSocket forwarding for Blazor Server. **Start preview does not run `dotnet publish`, copy
files to `C:\SibylaApps`, change IIS, touch a Windows Service, or replace any deployed application.**

This distinction is load-bearing for the Apollo project itself:

```
Sibyla.Web controller (stable checkout, :7443)
    ├── Codex edits <WorkRoot>\worktrees\<conversation-id>
    └── PreviewManager starts that worktree on 127.0.0.1:<dynamic-port>
             └── /_preview/<token>/  ← operator validates the changed Apollo UI
```

The controller remains alive to own the chat, Codex process and preview even when the changed app
does not compile or crashes. A preview is a disposable validation runtime, not a release candidate
deployment. Stopping it kills the entire process tree; inactivity expires it; its stdout/stderr are
kept beside the conversation evidence. No Docker or Compose stack is introduced for previews.

Preview commands are Administrator-configured, typed project settings — never text supplied by the
model and never arbitrary arguments copied out of chat. Projects without a runnable UI simply leave
preview disabled. The first implementation supports one active preview per conversation and two on
the host, which matches the prototype's single-operator constraint.

The preview must not write to production data or invoke production integrations. Each project owns
an explicit Preview environment. For GOTT.Apollo this means a dedicated `gott_apollo_preview`
database and preview storage, with email, WhatsApp, ERP calls, document workers and other outbound
side effects disabled or stubbed. The shared preview database is acceptable while there is one
operator; database-per-conversation is a later isolation improvement. The database is provisioned,
migrated, seeded and populated from the FDR corpus by the existing local setup/sync toolchain with
an explicit Preview target (§8); preview never clones `gott_apollo` and never reads its credentials.

---

## 3. Data model

Five new platform tables, 6-letter codes, uuid keys, snake_case, `jsonb` payloads — the house style.

### `aiproj` — AI project configuration (§22, §23)

| Column | Type | Notes |
|---|---|---|
| `id` | uuid | |
| `key` | varchar(50) | stable slug: `apollo`, `sibyla`, `identity-server`, `skill-build`; unique |
| `name` | varchar(100) | display name |
| `description` | text | |
| `harness` | varchar(20) | `HERMES` \| `CODEX` \| `CLAUDE` (constants class, mirrors `StorageProviders`) |
| `default_model` | varchar(100) | null = harness default |
| `default_effort` | varchar(20) | null = harness default |
| `primary_folder` | varchar(500) | null for Hermes (context is the agent's own) |
| `context_label` | varchar(200) | what to show when there is no folder — e.g. "Gateway + Database" |
| `system_prompt_append` | text | optional; seeded for Skill Build (§1.3) |
| `licence_scoped` | bool | **true ⇒ conversations belong to a licence** (D-AI-2). Seeded: Apollo `true`, the other three `false` |
| `allow_additional_projects` | bool | |
| `allow_file_changes` | bool | prototype: **intent + audit only**, see §9 |
| `preview_enabled` | bool | true only for projects with a browser-runnable application |
| `preview_kind` | varchar(20) | null or an allowlisted runner key (`DOTNET` initially; `NPM` later) |
| `preview_target` | varchar(500) | project/package path relative to the worktree; never a free-form shell command |
| `preview_ready_path` | varchar(500) | default `/`; must return success before state becomes `Ready` |
| `preview_timeout_minutes` | int | idle expiry; default 60 |
| `preview_options_json` | jsonb | typed runner options and allowlisted environment keys; no secrets and no arbitrary arguments |
| `options_json` | jsonb | **typed, per-harness allowlist only** — e.g. Hermes `toolsets`/`profile`. Never an arbitrary CLI-argument passthrough: that would make D-AI-5's constants configurable again through the back door. For v1, read exactly the keys the harness declares and ignore the rest |
| `enabled` | bool | |
| `display_order` | int | |
| `created_at` / `updated_at` | timestamptz | |

### `aiconv` — Apollo conversation (§28)

`id`, `project_id` → `aiproj`, **`owner_id` (uuid, not null)**, **`owner_name` (varchar 200 —
snapshot for display)**, `created_by_subject` (varchar 100), `title` (varchar 200), `harness`,
`model`, `effort` (resolved at start, not re-read from the project later), `harness_session_id`
(varchar 200, nullable until the first run reports it), `state` (`Active` | `Archived`),
`workspace_path` (varchar 500, nullable for non-Git/non-development projects), `workspace_base_commit`
(varchar 64, nullable), `created_at`, `last_activity_at`.

`owner_id` is stamped at creation from `ITenantContext.OwnerId` — the licence selected in the
topbar at that moment — and is **immutable**: a session never moves between licences. It is
recorded for *every* project, not just licence-scoped ones (the operator is always standing in some
licence), which keeps the column non-nullable and the code branch-free. For a `licence_scoped`
project it is the licence the scoped database connection is pinned to (§5); for the others it is
simply provenance.

**It carries no RLS policy of its own** (D-AI-2) — it records, it does not gate.

Indexes: `(project_id, last_activity_at desc)`, `(created_by_subject, last_activity_at desc)`.

For a preview-enabled Git project, conversation creation resolves `HEAD`, creates
`<WorkRoot>\worktrees\<conversation-id>` with `git worktree add --detach`, stores both workspace
fields, and uses that path as the harness working directory for every turn. A dirty primary checkout
is not silently copied: the New Session screen names the base commit and warns that uncommitted
changes in the source checkout are not included. Archiving a conversation stops its active runtime,
marks it read-only and immediately removes its dedicated Git worktree. Durable execution evidence,
messages and audit records remain subject to the separate retention policy (§12).

### `aicnvp` — conversation ↔ additional project (§19, §20)

`id`, `conversation_id`, `project_id`, `role` (`Primary` | `Additional`), `folder` (snapshot of the
folder at selection time), `created_at`. Unique `(conversation_id, project_id)`.

### `aimesg` — conversation messages (§29)

`id`, `conversation_id`, `sequence` (int), `role` (`User` | `Assistant` | `System` | `Event`),
`content` (text), `content_json` (jsonb, nullable), `harness_item_id` (varchar 100, nullable),
`execution_id` (nullable → `aiexec`), `created_at`. Unique `(conversation_id, sequence)`.

No `owner_id`: the licence is a property of the conversation, and these rows are platform-scoped
like it (D-AI-2). The platform non-negotiable ("`owner_id` in every unique key") applies to
tenant-owned tables; this is not one, and `conversation_id` is a surrogate uuid, so there is no
cross-tenant key collision to prevent.

**What is persisted vs. transient** (§29 — do not store bulk low-value output):

| Event | Persist | Transient (stream only) |
|---|---|---|
| User prompt | ✅ `User` | |
| Final assistant text | ✅ `Assistant` | |
| Tool/command *summary* (name, target, exit code, truncated ≤2 KB) | ✅ `Event` | |
| Reasoning / thinking blocks | ❌ | ✅ |
| `thinking_tokens`, partial deltas, spinner noise | ❌ | ✅ |
| Full command stdout/stderr | ❌ (tail only, in `aiexec`) | ✅ |
| Errors, cancellations, timeouts | ✅ `System` | |

Raw JSONL for a run is written to disk as evidence (`<WorkRoot>\ai\<executionId>\events.jsonl`),
mirroring how `ClaudeDocumentProcessor` keeps CLI evidence next to the document. The DB stays small.

### `aiexec` — execution run (§30, §31)

`id`, `conversation_id`, `state` (`Created` | `Starting` | `Running` | `Completed` | `Failed` |
`Cancelled` | `TimedOut` | `Interrupted`), `harness`, `command_line` (redacted, varchar 2000),
**`scoped_owner_id` (uuid, nullable — the licence the run's database connection was pinned to, or
NULL when no scoped connection was handed over)**,
`working_directory`, `additional_dirs_json`, `harness_session_id`, `process_id`,
`started_at`, `finished_at`, `exit_code`, `error` (text), `usage_json` (jsonb — token counts / cost
from `turn.completed` / `result`), `events_path` (varchar 500).

### Migration

One EF migration, `AiProjects`. **One policy shape for all five tables** (D-AI-2):

```csharp
const string superuser = "current_setting('app.superuser', true) = 'on'";

foreach (var table in new[] { "aiproj", "aiconv", "aicnvp", "aimesg", "aiexec" })
{
    migrationBuilder.Sql($"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY;");
    migrationBuilder.Sql($"ALTER TABLE {table} FORCE ROW LEVEL SECURITY;");
    migrationBuilder.Sql($"""
        CREATE POLICY superuser_access ON {table}
        USING ({superuser}) WITH CHECK ({superuser});
        """);
}
```

This works with `TenantSessionInterceptor` unchanged — it already sets `app.superuser` from the
server-verified `superuser` role. No tenant-owned table is added, so the release-blocking isolation
suite stays green by construction.

**EF side.** None of the five entities implement `IOwnedEntity`, so
`ApplyOwnedEntityConventions` skips them and no tenant query filter is applied.
`AiConversation.OwnerId` is an ordinary non-nullable `Guid` column — deliberately *not* the
interface, because it records provenance rather than gating access. Do not "tidy" it onto
`IOwnedEntity` later; that would silently hide every session started under another licence.

### The scoped reader role (the actual licence boundary)

Same migration, or `local/setup-db.ps1` alongside the existing `apollo_app` / `apollo_worker` /
`apollo_migrator` roles — the latter is the better home, since that is where role passwords are
already generated into `local/secrets`:

```sql
CREATE ROLE apollo_ai_reader LOGIN PASSWORD '<generated>' NOBYPASSRLS;
GRANT CONNECT ON DATABASE gott_apollo TO apollo_ai_reader;
GRANT USAGE  ON SCHEMA public TO apollo_ai_reader;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO apollo_ai_reader;
-- FOR ROLE apollo_migrator is required, not optional: migrations create the tables, so the
-- default privileges must be attached to the creating role. This mirrors what setup-db.ps1
-- already does for apollo_app / apollo_worker.
ALTER DEFAULT PRIVILEGES FOR ROLE apollo_migrator IN SCHEMA public
    GRANT SELECT ON TABLES TO apollo_ai_reader;

-- Read-only is not enough on its own: never expose the credential store, even encrypted.
REVOKE ALL ON credst FROM apollo_ai_reader;
```

Three properties make this the real boundary:

1. **`NOBYPASSRLS`** — it is subject to `tenant_isolation`, which filters every tenant table by
   `owner_id = app.owner_id`. Unset GUC ⇒ zero rows, fail closed.
2. **`SELECT` only** — no `INSERT`/`UPDATE`/`DELETE` anywhere. A question cannot become a write.
3. **Not `apollo_worker`** — the cross-tenant `worker_access` policy is `TO apollo_worker`
   specifically, so this role never gets it. Do not grant `apollo_worker` to it, ever.

Then the four seed rows (§22) as idempotent `INSERT … ON CONFLICT (key) DO NOTHING` **inside the
migration**, so the system is "initialized with these records" and they are ordinary editable rows
afterwards — never hard-coded in application logic.

| key | name | harness | primary_folder | context_label | default_effort | licence_scoped | preview |
|---|---|---|---|---|---|---|---|
| `apollo` | Apollo | HERMES | *(null)* | Gateway + Database | `high` | **true** | disabled |
| `sibyla` | Sibyla | CODEX | `D:\fileStorage\repos\GOTT.Apollo` | — | `high` | false | **DOTNET** · `src/Sibyla.Web/Sibyla.Web.csproj` · ready `/` |
| `identity-server` | Identity Server | CODEX | `D:\fileStorage\repos\GOTT.IdentityServer` | — | `high` | false | disabled initially |
| `skill-build` | Skill Build | CLAUDE | `D:\fileStorage\repos\invoice-skill-build` | — | `high` | false | disabled |

The `sibyla` row is the development project for the GOTT.Apollo repository and is therefore the
seeded proof of D-AI-11. The `apollo` row remains the licence-scoped Hermes/Gateway data agent; it
has no source folder or runnable UI and must not expose a preview action.

---

## 4. Authorization — the centralized policy (§3)

New file `src/Sibyla.Platform.Infrastructure/Security/ApolloPolicies.cs` (Infrastructure rather than
Web, so the harness services can enforce it too):

```csharp
public static class ApolloRoles
{
    /// <summary>Software-house / platform operator (D14). IdP counterpart: SuperUser (D-AI-7).</summary>
    public const string Superuser = "superuser";

    /// <summary>Administrator of a client organization.</summary>
    public const string Admin = "admin";
}

public static class ApolloPolicies
{
    /// <summary>Administrator only. Gates all AI Projects surfaces (D-AI-1) and /licences.</summary>
    public const string PlatformAdministration = "platform-administration";

    /// <summary>Administrator OR Organization. Gates Companies, Storage and ERP connections.</summary>
    public const string OrganizationAdministration = "organization-administration";
}

/// <summary>Satisfied when the resolved user holds ANY of the listed roles.</summary>
public sealed class ApolloRoleRequirement(params string[] roleKeys) : IAuthorizationRequirement
{
    public IReadOnlyList<string> RoleKeys { get; } = roleKeys;
}

public sealed class ApolloRoleHandler(TenantContextHolder tenant)
    : AuthorizationHandler<ApolloRoleRequirement>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context, ApolloRoleRequirement requirement)
    {
        // Roles are resolved server-side from USRROL — never read from the token.
        // The resolved set is OrdinalIgnoreCase, so casing of role_key does not matter.
        var roles = tenant.Resolved?.Roles;
        if (roles is not null && requirement.RoleKeys.Any(roles.Contains))
        {
            context.Succeed(requirement);
        }
        return Task.CompletedTask;
    }
}
```

Registration in `Program.cs`, replacing the bare `AddAuthorization()`:

```csharp
builder.Services.AddScoped<IAuthorizationHandler, ApolloRoleHandler>();
builder.Services.AddAuthorizationBuilder()
    .AddPolicy(ApolloPolicies.PlatformAdministration, p => p.RequireAuthenticatedUser()
        .AddRequirements(new ApolloRoleRequirement(ApolloRoles.Superuser)))
    .AddPolicy(ApolloPolicies.OrganizationAdministration, p => p.RequireAuthenticatedUser()
        .AddRequirements(new ApolloRoleRequirement(
            ApolloRoles.Superuser, ApolloRoles.Admin)));
```

Because `TenantContextHolder` is scoped and populated by both `TenantResolutionMiddleware` (HTTP)
and `TenantResolutionCircuitHandler` (circuit), the handler works in both render paths — the same
mechanism the pages already rely on.

> **BLOCKER — fix the middleware order first, or every policy denies everyone.** `Program.cs` today
> runs `UseAuthentication()` → `UseAuthorization()` → `UseMiddleware<TenantResolutionMiddleware>()`.
> `UseAuthorization` evaluates the matched endpoint's policy metadata *at that point in the pipeline*,
> so the handler would read an unpopulated holder and fail closed on every request. Reorder to:
>
> ```csharp
> app.UseAuthentication();
> app.UseMiddleware<TenantResolutionMiddleware>();   // must precede authorization
> app.UseAuthorization();
> ```
>
> This has no effect today only because no endpoint policy currently depends on Apollo roles — §4.1
> is what makes it load-bearing.

Usage:

- pages: `@attribute [Authorize(Policy = ApolloPolicies.PlatformAdministration)]`
- navigation: `<AuthorizeView Policy="@ApolloPolicies.PlatformAdministration">`
- services: every public method on `AiProjectService` / `AiConversationService` / `HarnessRunner`
  re-checks the role and throws — **defence in depth; never trust the UI gate alone**, because
  these services start processes.

### 4.1 Access rules for the existing Apollo screens

*(Owner ruling 2026-08-31: "acesso a Empresas, Armazenamento, Ligações ERP deve ser restrito para
Role Administrador e Organization.")*

| Route | Screen | Policy | Roles |
|---|---|---|---|
| `/companies` | Empresas | `OrganizationAdministration` | `superuser`, `admin` |
| `/storage` | Armazenamento | `OrganizationAdministration` | `superuser`, `admin` |
| `/erp` | Ligações ERP | `OrganizationAdministration` | `superuser`, `admin` |
| `/licences` | Licenças / Organizações | `PlatformAdministration` | `superuser` |
| `/ai/**` | AI Projects (this plan) | `PlatformAdministration` | `superuser` |
| `/argus/**` (was `/sibyla/**`) | Argus module | *(unchanged)* | none — licence entitlement only |

**This closes a real gap, it is not a relabel.** Those four pages carry only
`@attribute [Authorize]` — authenticated, nothing more — and their role check is an `@if` that
renders *"Sem acesso — esta área requer o perfil admin."* The route is served, the component runs,
`OnInitializedAsync` is entered, and only the markup is withheld. Replace both halves: put the
policy on the route with `@attribute [Authorize(Policy = …)]` and delete the in-page `IsAdmin`
field and its message block. Unauthorized users then get the configured `AccessDeniedPath`
(`/no-access`, already set on the cookie handler in `Program.cs`) instead of a page that quietly
ran its initialiser.

Do the same for `Licences.razor`, which has the identical shape with `IsSuperuser`.

Because this is a behavioural change to shipped screens rather than the tidy-up §12 originally
called it, it belongs in the build order as its own commit (§11, commit 1) with the render smoke
suite extended to assert the denial — see §10.

### 4.2 Role *assignment* — the seed grants `admin` to everyone

*(Owner requirement 2026-08-31: "rachel.sa só deveria ter acesso ao Sibyla (Financial) e nada do
lado do Apollo — Companies, Armazenamento e Ligações ERP.")*

**This requirement is not about role names at all — it is about who holds which role.** Current
state of `usrrol` in `gott_apollo`, read 2026-08-31:

Resolved against the IdP 2026-08-31 — *(owner ruling: "a Rachel não tem Administrator no
identityserver logo perde")*, i.e. the IdP's `Administrator` assignments are the authority for who
should hold `admin` in Apollo:

| User | IdP `Administrator`? | Apollo `admin` today | Should be |
|---|---|---|---|
| `admin@gottsolutions.net` | yes | `admin`, `superuser` | keep |
| `luis.nascimento@gottsolutions.net` | yes | `admin` | **keep** |
| `miguel.teixeira@gottsolutions.net` | yes | `admin` | **keep** |
| `rachel.sa@gottsolutions.net` | **no** | `admin` | **revoke** — Argus only |
| `sibyla@gottsolutions.net` | yes | *(no `usrmst` row)* | stays out — service account |

The service account needs no exclusion rule: with no `usrmst` row, `TenantResolver` returns null for
its subject and `TenantContextHolder` stays unresolved, so both the EF filters and RLS return
nothing. It fails closed by construction — which is also true under D-AI-8, since a token-sourced
`Superuser` still has to land on a resolved tenant context to reach anything.

The cause is in `db/seed/tenant1.sql`, which grants `admin` to *every* user of owner #1:

```sql
INSERT INTO usrrol (...) SELECT gen_random_uuid(), u.owner_id, u.id, 'admin', NULL, now()
FROM usrmst u WHERE u.owner_id = 'a0000000-...-0001'   -- ← every user, not a chosen few
```

Narrow it to the accounts that should administer the organization, and revoke the rest:

```sql
DELETE FROM usrrol WHERE role_key = 'admin' AND module_key IS NULL
  AND user_id IN (SELECT id FROM usrmst WHERE email = 'rachel.sa@gottsolutions.net');
```

**No positive grant is needed to keep her in Argus.** Verified 2026-08-31: no Argus screen checks
a role — every one of them gates on `Tenant.Resolved.ActiveModules.Contains("argus")`, i.e. the
licence entitlement, and nothing else. So removing `admin` gives exactly the requested outcome:
Argus stays, Empresas/Armazenamento/Ligações ERP go away.

The two rulings interlock neatly: with §4.1 enforcing the three routes and §4.3 in place, rachel.sa
signing in at `/` has nothing in the Apollo menu and is taken straight to `/argus`.

> **Related gap, same class as §4.1.** The `/argus/admin/*` sub-views are gated *only* by the
> `IsAdmin` test in `MainLayout`'s nav — the pages themselves check nothing, so they stay reachable
> by URL for a user without the role. If those are meant to be organization-administration surfaces,
> they need `OrganizationAdministration` on the route too. Flagged, not assumed: say whether Argus
> admin belongs to `admin` or is module-level, and it goes in the same commit.

### 4.2.1 What D-AI-7 actually costs

Nothing in Apollo. Two statements at the IdP, outside this repo:

- create the `SuperUser` role (IdentityServer's own **Admin → Roles** page does this — it is
  `roleManager.CreateAsync(new IdentityRole(name))`, no code change);
- delete `Organization` (0 assignments, referenced nowhere).

Apollo's `usrrol` keys, `IsSuperuser`, `ITenantScopedDbContext.CurrentIsSuperuser`, the
`app.superuser` GUC and the `superuser_access` policies all stay exactly as they are.

### 4.3 No Apollo-menu access ⇒ open the first active module

*(Owner ruling 2026-08-31: "se o utilizador não tem acesso a nenhuma funcionalidade do menu
'apollo', deve automaticamente abrir o primeiro módulo ativo.")*

With 4.1 in place, a user holding neither `Administrator` nor `Organization` has **nothing** in the
Apollo platform context — the Administration group is their only content there, and the dashboard
becomes a dead end. Send them where they can actually work:

In `Home.razor` (`@page "/"`), after the tenant is resolved:

```csharp
// No Apollo-platform functionality for this user → open their first licensed module instead.
if (!HasAnyApolloMenuAccess && FirstActiveModule is { } key)
{
    Nav.NavigateTo(ModuleHome(key), replace: true);
}
```

- `HasAnyApolloMenuAccess` = holds `superuser` **or** `admin` (the exact set gated by
  4.1). Deriving it from the same policy names, rather than re-testing roles inline, is what stops
  this drifting apart from the nav — one source of truth for "is the Apollo menu empty".
- `FirstActiveModule` = the first entry of `SibylaModuleDescriptor.Catalogue` (order: `argus`,
  `medusa`, `calypso`) that is in `Tenant.Resolved.ActiveModules`. Catalogue order *is* the
  precedence — do not sort by display name.
- `replace: true` so Back does not bounce the user straight into the redirect again.
- **If there is no active module either**, do nothing and render the existing Home: licence status
  and the "no access" state. Redirecting to a presentation page (`/module/{key}`) would be worse —
  it implies something is available that is not.
- Only `/` redirects. Never add this to the layout, or a user with a direct link to a legitimate
  module page gets pulled away from it.

Two failure modes worth testing explicitly: an `admin`-only user must **not** be redirected (they
have Empresas/Armazenamento/ERP), and a module-only user with two active modules must land on the
catalogue-first one, not the most recently added.

Concretely, this is rachel.sa's path once §4.2 removes her `admin`: she signs in, `/` finds no
Apollo-menu access and one active module, and she lands on `/argus` — which is the whole intent of
the ruling.

---

## 5. Harness abstraction (§26, §27)

`src/Sibyla.Platform.Contracts/Ai/` — the seam. Deliberately *thin*; capability differences are
declared, not faked.

```csharp
public sealed record HarnessCapabilities(
    bool SupportsStreaming,       // Codex ✓ Claude ✓ Hermes ✗ (turn-level only)
    bool SupportsNativeResume,    // all three ✓
    bool SupportsModelSelection,  // all three ✓
    bool SupportsEffort,          // all three ✓
    bool SupportsAdditionalDirs,  // Codex ✓ (--add-dir), Claude ✓ (--add-dir), Hermes ✗ (context only)
    bool SupportsCancellation,    // all three ✓ (kill process tree)
    IReadOnlyList<string> Models,
    IReadOnlyList<string> EffortLevels);

public sealed record HarnessRunRequest(
    Guid ExecutionId,
    string Prompt,
    string? HarnessSessionId,       // null = start a new native session
    string? WorkingDirectory,
    IReadOnlyList<string> AdditionalDirectories,
    string? Model,
    string? Effort,
    string? SystemPromptAppend,
    IReadOnlyDictionary<string, string> Options);

public abstract record HarnessEvent(Guid ExecutionId, DateTimeOffset At)
{
    public sealed record SessionStarted(Guid Id, DateTimeOffset At, string HarnessSessionId) : HarnessEvent(Id, At);
    public sealed record Thinking(Guid Id, DateTimeOffset At, string Text) : HarnessEvent(Id, At);      // transient
    public sealed record ToolActivity(Guid Id, DateTimeOffset At, string Tool, string Summary) : HarnessEvent(Id, At);
    public sealed record AssistantText(Guid Id, DateTimeOffset At, string Text) : HarnessEvent(Id, At);
    public sealed record Usage(Guid Id, DateTimeOffset At, string Json) : HarnessEvent(Id, At);
    public sealed record Failed(Guid Id, DateTimeOffset At, string Message) : HarnessEvent(Id, At);
    public sealed record Finished(Guid Id, DateTimeOffset At, int ExitCode) : HarnessEvent(Id, At);
}

public interface IAiHarness
{
    string Key { get; }                       // HERMES | CODEX | CLAUDE
    HarnessCapabilities Capabilities { get; }
    IAsyncEnumerable<HarnessEvent> RunAsync(HarnessRunRequest request, CancellationToken ct);
}
```

`IAsyncEnumerable<HarnessEvent>` is the whole contract. `StartSession` / `ResumeSession` /
`SendMessage` collapse into one call because of D-AI-3 (`HarnessSessionId == null` ⇒ start).
`Cancel` is the `CancellationToken`. `GetStatus` belongs to Apollo's `aiexec` row, not the harness.
`ListModels` / `GetCapabilities` are `Capabilities`.

### Implementations (`Sibyla.Platform.Infrastructure/Ai/`)

All three share a `CliProcessRunner` base lifted from `ClaudeDocumentProcessor` — executable
resolution (the npm `.cmd` shim problem), `ProcessStartInfo` with `ArgumentList` (never a joined
string), redirected stdout/stderr, `UseShellExecute = false`, `CreateNoWindow = true`, **stdin
redirected and closed immediately**, linked-CTS timeout, `Kill(entireProcessTree: true)`.

Four details that are cheap now and painful later:

- **Drain stdout and stderr concurrently.** Reading one to completion before the other deadlocks
  when the unread pipe fills — the existing `ClaudeDocumentProcessor` already awaits both, keep that.
- **Distinguish cancellation from timeout.** A linked CTS makes them look identical at the catch
  site; check which token fired, so `aiexec.state` lands on `Cancelled` vs `TimedOut` (both states
  already exist in §3, and the difference is the first thing anyone asks of a failed run).
- **Aggregate assistant text into one final message.** Codex and Claude both emit text in several
  events; stream them all, but persist one `Assistant` message per turn rather than a dozen
  fragments.
- **`command_line` excludes the prompt entirely** — the prompt is already stored as a `User`
  message, and duplicating it into a second column doubles the redaction surface for nothing.
  Store the executable, the flags and the redacted environment keys.

**`CodexHarness`**

```
codex exec --json
    -C <primaryFolder>
    [--add-dir <additional>]…
    [-m <model>]
    [-c model_reasoning_effort=<effort>]
    -s danger-full-access          ← PrototypeMode constant, never a project field
    --skip-git-repo-check
    "<prompt>"
# resume: codex exec resume <threadId> --json … "<prompt>"
```

Parse JSONL: `thread.started` → `SessionStarted(thread_id)`; `item.completed` with
`item.type == "agent_message"` → `AssistantText`; other `item.completed` types (command execution,
file change, reasoning) → `ToolActivity` with a truncated summary; `turn.completed` → `Usage` then
`Finished`. Unknown `type` values are logged and ignored — the CLI adds events between versions and
the parser must not be brittle.

**`ClaudeHarness`**

```
claude -p "<prompt>"
    --output-format stream-json --verbose
    [--model <model>] [--effort <effort>]
    [--add-dir <additional>]…
    [--append-system-prompt "<systemPromptAppend>"]
    --dangerously-skip-permissions   ← PrototypeMode constant
# cwd = primaryFolder;  resume: add --resume <sessionId>
```

Parse JSONL: `system/init` → `SessionStarted(session_id)`; `assistant` message content blocks →
`thinking` ⇒ `Thinking`, `text` ⇒ `AssistantText`, `tool_use` ⇒ `ToolActivity`; `user` tool-result
blocks ⇒ `ToolActivity` completion; `result` ⇒ `Usage` + `Finished`. Ignore
`system/thinking_tokens` and `rate_limit_event` (log the latter at Warning when
`status != "allowed"` — the existing worker already learned that quota signals matter).

**`HermesHarness`**

```
hermes chat -q "<prompt>" -Q
    [--in <folder>]                  ← only when the project defines one
    [-m <model>] [--reasoning <effort>]
    [-t <toolsets from options_json>]
    [--resume <sessionId>]
```

**Hermes is kept as-is (brief §12).** No `--yolo`, no `--ignore-rules`, no new sandbox, no
re-created Gateway integration: Apollo invokes the operational agent exactly as an operator would
from a shell, and the agent's own config, toolsets, rules, approvals and Gateway / WhatsApp / Buzz /
Database tools apply unchanged. Output parsing is trivial: first `session_id:` line ⇒
`SessionStarted`; everything after ⇒ one `AssistantText`; exit ⇒ `Finished`. Declares
`SupportsStreaming = false`, so the UI shows a working indicator rather than a fake token stream —
honest, and one line of Razor.

> Hermes profile: the default profile is the operational one (`gpt-5.6-sol`, plugin
> `sibyla-channel-intake` enabled). If a different profile is wanted later, put the profile name in
> `options_json` and pass it through the profile wrapper — not needed for v1.

#### Licence scoping for Hermes — the pinned database connection (D-AI-2)

**How Hermes actually reaches a database, verified 2026-08-31:** it has *no* database toolset.
`hermes tools list` shows `web, browser, terminal, file, code_execution, vision, video, image_gen,
video_gen, x_search, tts, skills, todo, memory, session_search, clarify, delegation, cronjob,
computer_use` plus the `sibyla_channel_intake` plugin. "Database capability" means **Hermes shells
out to `psql`, or connects from Python via `code_execution`**.

That is what makes the answer simple. Both paths read libpq's standard `PG*` environment variables,
so Apollo controls the connection by controlling the child process's environment — no Hermes
configuration change, no new tool, no prompt instruction:

```
PGHOST      = localhost
PGPORT      = 5432
PGDATABASE  = gott_apollo
PGUSER      = apollo_ai_reader          ← SELECT-only, NOBYPASSRLS
PGPASSWORD  = <from local/secrets, never in the prompt or the transcript>
PGOPTIONS   = -c app.owner_id=a0000000-0000-0000-0000-000000000001   ← the selected licence
```

`PGOPTIONS` sets the GUC for the whole connection, so it applies to every statement whether or not
Hermes wraps anything in a transaction. A bare `psql -c "select ..."` or a bare
`psycopg.connect()` therefore lands on `apollo_ai_reader` with `app.owner_id` already pinned, and
`tenant_isolation` filters every tenant table to that licence. **Through this connection, a query
for another licence's rows returns nothing — enforced by PostgreSQL, not by instruction.**

Say what that is and is not, precisely, because the difference matters:

> **It is licence-pinned Apollo database access. It is not agent isolation.**

The claim that holds is *"the database connection Apollo gives Hermes cannot return another
licence's rows"* — provable, and proven by the §10 probes. The claim *"the agent can only answer
from that licence's data"* does **not** hold, because the agent also has `terminal`, a profile
memory shared across sessions, the legacy database until it is frozen, and any other credential
readable on the host. For the ordinary path — ask a question, get an answer from `gott_apollo` —
the pinning is exactly the requested behaviour. It is not a containment guarantee, and this plan
does not claim one anywhere.

Set these only when `aiproj.licence_scoped` is true, and take the owner from **`aiconv.owner_id`,
never from the currently selected licence**. An earlier draft said the opposite; it was wrong. If
the operator switches licence and continues an existing session, `ITenantContext.OwnerId` no longer
matches the conversation, and pinning to it would run the turn against the wrong client's data —
the exact failure this design exists to prevent. The selected licence only *authorizes* the
continuation:

```
conversation.owner_id == selected owner  →  run pinned to conversation.owner_id
otherwise                                →  reject
```

Validate that **server-side, in the same transaction that inserts `aiexec`** — the disabled composer
in §7.1 is a courtesy to the operator, not the control. Record the result in
`aiexec.scoped_owner_id`. Pass them via
`ProcessStartInfo.Environment`, never on the command line — `command_line` is persisted and
`PGPASSWORD` must never reach `aiexec` or the transcript.

**`gott_apollo` is the only database in scope** *(owner ruling 2026-08-31: the legacy database and
apps are being retired — finish, then freeze).* No `gott_sibyla` connection is configured, wrapped
or bridged; building one would be work with a scheduled demolition date. This also pulls the
operational agent onto the new database for free: within an Apollo AI session Hermes is *handed*
`gott_apollo`, so it answers from the current corpus rather than from whatever legacy source it
would otherwise reach for.

Transitional caveat, worth one sentence because it bears directly on the guarantee: until
`gott_sibyla` is actually frozen it still exists on this box, and Hermes holds `terminal` — so an
answer it sourced from the legacy database would not be licence-scoped, because that schema has no
RLS to scope it with. The context note below therefore names the connection to use, and the
acceptance test in §10 is written against `gott_apollo` data.

Add a one-line context note to the turn naming the organization, so the agent knows *why* its
queries are narrow and does not misreport an empty result as "no data exists":

> *Operating context: organization "NP Group". Use the database connection provided in your
> environment (`gott_apollo`); it is restricted to this organization's data and rows for other
> organizations are not visible to you. Do not query other databases on this host.*

**Limitations, stated plainly** — this is a real boundary, and it is not a total one:

- It binds the **database**. Hermes' other operational tools — Gateway, WhatsApp, email, Buzz — are
  configured inside Hermes per *profile* and are untouched by this. Scoping those is roadmap work.
- Hermes holds `terminal`, so a determined agent could read another connection string off the box
  (the `gott` password is in `C:\SibylaApps\IdentityServer\appsettings.Production.json`) and
  reconnect as a different role. This is the §9 trusted-host posture, unchanged: the credential
  Apollo hands over cannot cross licences, but the host is not a jail.
- Hermes' memory graph and skills are per profile and shared across sessions.

The production fix for all three is one Hermes **profile per licence** (`hermes profile create`),
which separates memory, secrets and gateway config on the Hermes side too; see §12.

### Model & effort catalogue (§27) — hybrid, cheapest reliable form

- **Effort levels: static per harness** in `HarnessCapabilities` (the table in §1.4). They are CLI
  enums; they do not need discovery.
- **Models: static defaults + one opportunistic read.** `CodexHarness` reads
  `~/.codex/models_cache.json` at startup (it already exists, carries `slug`,
  `supported_reasoning_levels` and `default_reasoning_level`) and falls back to a hard-coded list if
  absent. `ClaudeHarness` ships the aliases `fable | opus | sonnet | haiku`. `HermesHarness` ships
  the configured default plus free text.
- Project Maintenance offers **Default** plus the known list plus a free-text box. No runtime
  discovery calls, no background refresh, no cache table.

---

## 6. Execution architecture and lifecycle (§30, §31)

```
Browser (Blazor circuit)
    │  subscribe / unsubscribe — cheap, disposable
    ▼
HarnessRunRegistry   ── singleton in Sibyla.Web ──────────────┐
    │  one AiRun per execution:                               │
    │    Channel<HarnessEvent>  +  replay buffer  +  CTS      │
    ▼                                                         │
HarnessRunner (singleton)                                     │  survives circuit teardown
    │  resolves IAiHarness by key                             │
    │  writes aiexec / aimesg through a DI scope of its own   │
    ▼                                                         │
IAiHarness ─▶ child process (codex | claude | hermes) ────────┘
```

### The detached execution context (BLOCKER — without it nothing persists)

A background scope created off the root provider gets a **fresh, empty** `TenantContextHolder`, so
`TenantSessionInterceptor` emits `app.superuser = 'off'` and the five new tables' `superuser_access`
policy rejects every write. The runner must therefore carry a trusted snapshot taken *while the
request context is still alive*:

```csharp
// Captured on the request/circuit thread, before detaching. ResolvedTenantContext is already
// immutable and already server-verified — reuse it rather than inventing a parallel type.
sealed record AiRunContext(ResolvedTenantContext Tenant, Guid RunOwnerId);

// Inside every background persistence scope, before touching the DbContext:
scope.ServiceProvider.GetRequiredService<TenantContextHolder>().Set(ctx.Tenant);
```

Three rules:

- **Never capture the scoped holder itself** — capture the immutable `ResolvedTenantContext` it
  carries. Holding the holder across scopes is what turns a tenancy bug into a cross-tenant one.
- **`Tenant` is the operator's context** (it authorizes writing Apollo's own tables);
  **`RunOwnerId` is the conversation's licence** (it pins the harness's database connection). They
  can legitimately differ, and conflating them is the bug corrected below — keep them as two fields.
- The snapshot is a point-in-time copy: a role revoked mid-run is not noticed until the next run.
  Acceptable for runs bounded at 30 minutes; worth remembering before anyone lengthens that.

### Start

1. Page calls `AiConversationService.SendAsync(conversationId, prompt)` (policy re-checked).
2. Persist the `User` message and an `aiexec` row in `Created`; write `audlog` (`ai.execution.start`
   with project key, harness, folder, additional folders, model, effort, and the conversation's
   licence when it has one — **never the prompt body**).

   > Audit rows are always written with `audlog.owner_id = ITenantContext.OwnerId`, i.e. the licence
   > the operator was standing in — never `NULL`. This is both correct (it records where the action
   > was taken from) and necessary: `audlog`'s existing `tenant_isolation` policy rejects a
   > `NULL`-owner insert outright, so this plan needs no change to `audlog` at all. See §12 item 10.
3. `HarnessRunner.Start(...)` returns immediately with the `executionId`; the actual run is a
   detached `Task` owned by the singleton.
4. The page subscribes to the registry and renders.

### Stream

**One channel per subscriber, not one channel per run.** `System.Threading.Channels` readers
*compete* for items — two open tabs would each get half the events, and a reconnect can lose
anything emitted between reading the replay buffer and subscribing. The registry therefore holds,
per run: a **sequence-numbered** append-only replay buffer, and a set of subscribers each with its
own channel. Subscribing is `SnapshotAndSubscribe(afterSequence)` taken **under one lock**, so the
snapshot and the subscription cannot straddle a gap. Cap the buffer (default 2,000 events); past
the cap, drop the oldest and mark the run `truncated` in the UI — the full stream is on disk in
`events.jsonl`, and an unbounded buffer in a singleton is the memory leak this feature would
otherwise ship with.

The run task consumes `IAsyncEnumerable<HarnessEvent>`, and for each event: appends to the replay
buffer, fans out to every subscriber's channel, appends the raw line to `events.jsonl`, and persists
the event **only if** the §3 table says so. `SessionStarted` writes `harness_session_id` onto both `aiexec`
and `aiconv` (first run only).

### Complete / Fail / Timeout

Terminal event ⇒ `aiexec.state` + `finished_at` + `exit_code` + `usage_json`; the final assistant
text is persisted as an `Assistant` message; `aiconv.last_activity_at` bumped; `audlog`
`ai.execution.finish`. Timeout is a per-harness `HarnessExecutionOptions` value — start at
**30 minutes** for Codex/Claude (development turns are long), 10 for Hermes; both configurable.

### Cancel

`Cancel(executionId)` trips the CTS; the harness kills the process tree
(`Kill(entireProcessTree: true)` — already proven in `ClaudeDocumentProcessor`); state ⇒ `Cancelled`.
Only an explicit cancel, a timeout, a harness failure, or host shutdown stops a run.

### Browser disconnect / reconnect — the §31 requirement

- Disconnect only disposes a *subscriber*. The registry, the run task and the child process are
  untouched.
- On reconnect / refresh / navigating back to the conversation, the page:
  1. loads persisted messages from `aimesg`;
  2. asks the registry for a live run on this conversation;
  3. if found → renders the replay buffer, then subscribes to the live tail;
  4. if not found → reads `aiexec` for the last run's terminal state and shows it.
- Nothing produced while the browser was away is lost: the replay buffer holds it in memory, and
  the persisted messages hold the durable part.

### Host shutdown

An `IHostApplicationLifetime.ApplicationStopping` handler marks every `Running` execution
`Interrupted` and best-effort kills the children. On next start, a sweep marks any stale `Running`
row `Interrupted`. The conversation stays resumable via its `harness_session_id` — the native
session survived on disk in `~/.codex/sessions` / `~/.claude` / the Hermes SQLite store.

### Concurrency guard

One in-flight run per conversation — **enforced in the database, not by the disabled composer**.
Two circuit calls can both pass an in-memory check; a partial unique index cannot be raced:

```sql
CREATE UNIQUE INDEX ux_aiexec_one_active_per_conversation ON aiexec (conversation_id)
    WHERE state IN (0, 1, 2);   -- Created, Starting, Running
```

The insert that starts a run then fails with a unique violation instead of launching a second
process. Cheaper than the in-memory bookkeeping it replaces. A global cap
(`HarnessExecutionOptions.MaxConcurrentRuns`, default 3) protects the box and the subscriptions.

### Development preview lifecycle (D-AI-11)

`PreviewManager` and `PreviewRegistry` are singleton siblings of the harness runner, but preview
state is deliberately ephemeral. A preview record contains conversation id, workspace/base commit,
runner key, PID, loopback port, opaque access token, state (`Building` | `Starting` | `Ready` |
`Failed` | `Stopped` | `Expired`), last activity and log path. The durable evidence is the log plus
`audlog` entries (`ai.preview.start`, `ai.preview.ready`, `ai.preview.stop`, `ai.preview.fail`); no
sixth platform table is justified for disposable runtime state.

Start is explicit and server-authorized:

1. `AiPreviewService.StartAsync(conversationId)` re-checks `PlatformAdministration`, resolves the
   stored worktree and typed project preview profile, and refuses paths outside `WorkRoot`.
2. The manager reserves a free loopback port, creates a cryptographically random token, starts the
   allowlisted runner with `UseShellExecute = false`, redirected stdout/stderr and its Preview
   environment, then probes `preview_ready_path` until ready or timed out.
3. `/_preview/{token}/{**path}` requires the same policy, resolves only a live registry entry, and
   forwards HTTP and WebSockets to its loopback destination. The raw port is never returned to the
   browser. Unknown, expired and cross-conversation tokens fail closed.
4. Stop, expiry, controller shutdown or failed startup kills the entire process tree. A controller
   restart treats every prior preview as stopped; the operator can start a fresh one.

For .NET, invoke `dotnet run --no-launch-profile --project <validated-preview-target>` with
`ASPNETCORE_URLS=http://127.0.0.1:<port>`, `ASPNETCORE_ENVIRONMENT=Preview` and a generated
`PathBase=/_preview/<token>`. The Apollo preview adds `UsePathBase` before routing and renders its
`<base>` from the request base URI instead of hard-coding `/`. This keeps static assets, navigation,
SignalR and reconnect URLs inside the gateway path. The existing authentication cookie is usable
because controller and preview share origin, cookie scheme and an explicitly configured development
Data Protection key ring. In `Preview`, Apollo registers cookie validation but does not register or
challenge OIDC (and therefore needs no IdP client secret); the gateway performs the login challenge
before any request reaches the child. The child still applies its normal page/service authorization
and never trusts a proxy identity header.

The manager never uses `dotnet watch`: a stable snapshot is more useful for human validation than a
process that can rebuild underneath the operator while Codex is still editing. **Restart preview**
stops and starts explicitly against the latest worktree contents.

---

## 7. UI

### 7.1 Navigation (§8)

The AI area is a **peer of the module areas**, not an Argus sub-nav. Extend `MainLayout.razor`'s
existing `CurrentModule` switch with an `ai` branch on route prefix `/ai`, and add the entry point
inside the `Administration` group:

```razor
<AuthorizeView Policy="@ApolloPolicies.PlatformAdministration">
    <Authorized>
        <a href="/ai" class="@Active("/ai")">AI Projects</a>
    </Authorized>
</AuthorizeView>
```

Under `/ai`, the sidenav renders projects and their sessions — matching the brief's shape while
staying inside the existing `sib-sidenav` / `sib-nav-group` CSS:

```
AI Projects
▾ Apollo · NP Group    ← licence-scoped: the current licence is named in the header
    + New Session
    Gateway investigation
▾ Argus
    + New Session
    DOCLOG migration
…
──────────────
Project Maintenance    → /ai/projects
```

Sessions listed per project: the 8 most recent, then "All sessions →". Grouping (Today / Yesterday /
Earlier, §11) lives on the project page, where there is room for it.

**Licence display (D-AI-2).** Every session lists the licence it was conducted under; for a
`licence_scoped` project that is the load-bearing fact, so the group header names the *current*
licence and each session row shows its own `owner_name`. Sessions are **not** filtered by licence —
the operator sees all of them and can tell at a glance which licence each ran against:

```
▾ Apollo · now NP Group
    + New Session
    Gateway investigation      NP Group
    Payment reconciliation     Itoorer      ← visible, clearly another licence
```

Opening a session from another licence is allowed (it is a transcript, and the operator could switch
licence to read it anyway). What it must **not** do is silently let a new turn run against the wrong
data: the composer on a licence-scoped session whose `owner_id` differs from the current licence is
disabled, with *"This session ran under \<name\>. Switch licence to continue it."* The run is pinned
to the session's licence, never to whatever is selected at the moment — that is the whole point.

A run already in flight is unaffected by a licence switch; it is owned by the singleton runner and
keyed by execution id, and its connection was pinned when it started.

**Note for the implementer:** `MainLayout.OnInitializedAsync` currently does its own DB read with its
own `DbFactory` context inside its own transaction, because the layout and page render concurrently
in Blazor. Add the AI nav data the same way — that comment in the file is load-bearing.

### 7.2 Routes

| Route | Page | Purpose |
|---|---|---|
| `/ai` | `AiHome.razor` | project cards + recent sessions across all projects |
| `/ai/p/{key}` | `AiProject.razor` | one project: header (harness, folder, defaults), `+ New Session`, sessions grouped Today / Yesterday / Earlier (§11) |
| `/ai/p/{key}/new` | `AiNewSession.razor` | the small start form (§10) |
| `/ai/c/{id:guid}` | `AiChat.razor` | the chat surface |
| `/ai/projects` | `AiProjectAdmin.razor` | Project Maintenance (§22–§24) |

All five carry `@attribute [Authorize(Policy = ApolloPolicies.PlatformAdministration)]` and
`@rendermode InteractiveServer`.

### 7.3 New Session (§10)

Deliberately four controls, pre-filled from the project:

```
New Sibyla Session
  Harness   Codex                         (read-only — the project decides)
  Model     [ Default (gpt-5.6-sol) ▼ ]
  Effort    [ High ▼ ]
  Additional Projects  [ + Add ▼ ]        (only if allow_additional_projects)
  [ Start Session ]
```

For a `licence_scoped` project the form adds one read-only line above `Start Session`, because the
choice is consequential and permanent:

```
  Licence   NP Group      (from the topbar — this session can only read NP Group's data)
```

If the operator wants a different licence, they switch it in the topbar before starting. The
conversation's `owner_id` is stamped once and never changes, and every run it makes is pinned to it.

Title starts as "New session" and is replaced by the first ~60 characters of the first user prompt
(prototype-simple; no title-generation call).

### 7.4 Chat (`AiChat.razor`)

Three regions, all inside existing `sib-panel` styling:

- **Left**: the shared project/session sidenav from the layout.
- **Centre**: message list + composer. `User` messages right-aligned/tinted, `Assistant` rendered
  as Markdown, `Event` rows as compact monospace `sib-chip`-prefixed lines
  (`⚙ Bash · git status · exit 0`), `System` rows as warnings. A collapsed "thinking" strip while
  streaming. Composer disabled while `Running`, with a **Cancel** button.
- **Right — Session Context** (§20):

  ```
  SESSION CONTEXT
  Primary    ✓ Sibyla · D:\fileStorage\repos\GOTT.Apollo
  Additional ✓ Identity Server · D:\fileStorage\repos\GOTT.IdentityServer
  [ + Add Project ▼ ]        ← a select over enabled aiproj rows, never a free-text path (§20)
  ───
  Harness Codex · Model gpt-5.6-sol · Effort high
  Session 01a0585d-…  (native id, copyable)
  Status  Running · 2m14s          [ Cancel ]
  [ Archive session ]
  ```

  For a licence-scoped (Hermes) session the panel leads with the licence, since that is the most
  consequential fact about the conversation:

  ```
  SESSION CONTEXT
  Licence    NP Group
             Database access restricted to this licence
  Context    Gateway + Database
  ───
  Harness Hermes · Model gpt-5.6-sol · Effort high
  Session 20260831_150911_5b827f
  Status  Running · 0m41s          [ Cancel ]
  [ Archive session ]
  ```

  **Archive session** requires confirmation that the disposable conversation worktree will be
  permanently removed. It cancels and awaits a locally owned execution, refuses to proceed while
  another host owns an active execution, stops any active preview, marks the conversation read-only,
  removes the worktree through `git worktree remove --force`, and retains the transcript, execution
  evidence and audit trail. Repeating the action is safe and completes a previously failed workspace
  cleanup.

Markdown rendering: add **Markdig** (`Markdig.Markdown.ToHtml`) with
`UseAdvancedExtensions().DisableHtml()`, output via `MarkupString`. Harness output is untrusted
input — the same posture the worker already takes with Claude output. Do **not** hand-roll a parser,
and do **not** allow raw HTML through.

### 7.5 Project Maintenance (§24)

Straight copy of the `StorageAdmin.razor` pattern: a `sib-table-wrap` list panel (Project · Harness ·
Default Context · Enabled · Actions) above an edit panel bound to the §3 fields, `_busy` guard, and
an `AiProjectService.SaveAsync(...) → (AiProject?, string? Error)` returning validation messages:
harness must be a known key; primary folder must exist on disk (warn, don't block — a repo may be
cloned later); folder required for Codex/Claude, optional for Hermes; key immutable after creation.
Every save writes `audlog` (`ai.project.save`).

### 7.6 Localization

Add the handful of chrome keys to `L.cs` (`ai.projects`, `ai.new.session`, `ai.sessions`,
`ai.project.maintenance`, `ai.harness`, `ai.model`, `ai.effort`, `ai.context`, `ai.additional`).
Harness names, model ids and folder paths stay verbatim in both languages.

#### Three things D-AI-11 assumed — found in review, now fixed

*(Review 2026-09-01 against `src/Sibyla.Web/Program.cs`; **all three resolved the same day**, in
`local/run-preview.ps1`, `src/Sibyla.Web/appsettings.Preview.json` and `Program.cs`. The design
held; these were startup facts it had to carry, and each failed in a way that is easy to misread.)*

1. **The shared Data Protection key ring does not follow a worktree.** `keysPath` falls back to
   `ContentRootPath/../../local/secrets/dp-keys`, and `local/secrets/` is **gitignored** — so a
   `git worktree add` does not contain it. The preview would call `Directory.CreateDirectory` on an
   empty path, mint its own ring, and fail to decrypt the controller's `sibyla.auth` cookie; the
   operator would see a redirect loop, not an error. `PreviewManager` must pass
   `DataProtection:KeysPath` explicitly, pointing at the controller's ring.

   Worth keeping the two halves apart: the worktree **should not** contain `local/secrets`. That
   absence is a security gain this plan wanted anyway — it removes the `apollo_migrator`
   (`BYPASSRLS`) credentials from the folder a Codex session is rooted at, which §9 records as a
   real exposure and §12 item 4 as hardening. Pass the key ring by configuration; do not solve it by
   copying the secrets directory into the worktree.

2. **The preview must not require or initiate OIDC.** The gateway has already authenticated the
   operator, while controller and preview deliberately share the cookie scheme and Data Protection
   ring. `Program.cs` therefore registers cookie authentication but skips OIDC registration in the
   `Preview` environment. The child needs no IdP client secret and cannot initiate an independent
   login flow.

3. **`ASPNETCORE_ENVIRONMENT=Preview` turns off the developer exception page.** `IsDevelopment()`
   is false for any name but `Development`, so `UseExceptionHandler("/Error")` and `UseHsts()`
   engage. A preview whose whole purpose is validating a changed UI would then hide the exception
   detail behind a generic error page, and set HSTS on a loopback origin. Either keep the
   environment `Development` and select preview behaviour by configuration, or make the
   `IsDevelopment()` branch check for the preview environment too.

**How each was fixed.**

- **(1) lives in `local/run-preview.ps1`**, the single supported adapter this decision already called
  for. It receives the validated worktree path, port and path base, and supplies from the
  *controller* checkout what the worktree structurally cannot: `DataProtection__KeysPath` pointing at
  the shared ring. It also points the preview at a separate `gott_apollo_preview` database, so a
  validation run cannot touch production data. **(2) lives in `Program.cs`**: Preview registers only
  the shared cookie scheme and omits OIDC. `appsettings.Preview.json` is committed alongside them,
  holding safe defaults and **no credentials**.
- **(3) is a two-line change in `Program.cs`, and the obvious version of it is wrong.** Skipping the
  production branch for Preview is not enough: `WebApplication` only adds the developer exception
  page when `IsDevelopment()`, which "Preview" is not — so merely skipping `/Error` would leave a
  bare 500 with no detail, *worse* than what it replaced. Preview therefore calls
  `UseDeveloperExceptionPage()` explicitly. A render-smoke test boots the host in the Preview
  environment and asserts the pipeline still challenges an anonymous request, so the branch is
  covered rather than assumed.

None of this changed the decision — it is the difference between a preview that starts and one that
starts and is useful.

### 7.7 Development Preview (D-AI-11)

For a preview-enabled project, the chat shows a compact validation strip after a completed turn:

```
Changed UI available · workspace 7bd5f78
[ View changes ]  [ Run tests ]  [ Start preview ]
```

`View changes` may be the prototype's compact file/status summary; the advanced selective
diff/patch review UI remains out of scope. Preview transitions are visible without being chat
messages: `Building` → `Starting` → `Ready`, or `Failed` with a bounded log tail. When ready:

```
Preview ready · Sibyla.Web · Preview
[ Open preview ]  [ Restart ]  [ Stop ]
Expires after 60 minutes idle                         View logs
```

`Open preview` opens the authenticated gateway URL in a new tab by default, because forms,
responsive breakpoints and browser navigation need the full viewport. An optional right-panel
iframe can be added later. Returning to the conversation preserves preview state and invites the
operator to describe validation findings in the same composer, so Codex receives the human feedback
in the existing resumable session.

Project Maintenance exposes only the typed preview fields: enabled, runner, target, readiness path
and timeout. It validates that the target is relative, exists under the primary project folder and
matches the selected runner. There is no command textbox and no environment-variable editor.

---

## 8. Configuration

`appsettings.json` — one new section, no secrets (all three CLIs carry their own auth):

```json
"AiHarness": {
  "PrototypeMode": true,
  "MaxConcurrentRuns": 3,
  "WorkRoot": "D:\\ApolloData\\ai",
  "Codex":  { "ExecutablePath": "codex",  "TimeoutMinutes": 30 },
  "Claude": { "ExecutablePath": "claude", "TimeoutMinutes": 30 },
  "Hermes": { "ExecutablePath": "C:\\Users\\Administrator\\AppData\\Local\\hermes\\hermes-agent\\venv\\Scripts\\hermes.exe", "TimeoutMinutes": 10 }
},
"AiPreview": {
  "Enabled": true,
  "MaxConcurrentPreviews": 2,
  "DefaultIdleTimeoutMinutes": 60,
  "BindAddress": "127.0.0.1",
  "GatewayPath": "/_preview"
}
```

`PrototypeMode: false` must **refuse to start a Codex/Claude run** rather than silently drop the
dangerous flag — a half-configured harness that quietly changes its permission model is worse than
one that says no. Bind with `AddOptions<HarnessExecutionOptions>().BindConfiguration("AiHarness")`,
the pattern `WorkerOptions` already uses.

**Identity note:** the CLIs run as the Sibyla.Web process identity and read that identity's
`~/.codex`, `~/.claude` and `%LOCALAPPDATA%\hermes`. Under `local\run-web.ps1` that is
`Administrator` — everything just works. Under IIS it would be the app-pool identity, which has none
of those credentials. **The prototype runs from `run-web.ps1`, not IIS.** This is a real deployment
constraint, recorded here so it is not discovered at demo time.

`appsettings.Preview.json` is a committed safe-default file: production integrations disabled,
workers and scheduled jobs disabled, preview storage root, and no credentials. Secrets and the
`gott_apollo_preview` connection string are supplied through the existing `local/secrets` pattern.
`PreviewManager` invokes the controller checkout's allowlisted `local/run-preview.ps1`, which passes
the preview connection and stable development Data Protection key-ring path to `dotnet run`. The
launcher is resolved and validated beneath the trusted controller checkout; no script or executable
is loaded from the agent-editable worktree. No preview code path performs publish or deployment
operations.

### 8.1 Preview database provisioning and FDR synchronization

The current scripts are not preview-safe as written: `run-sync.ps1` hard-codes `gott_apollo` and
`apollo-db.json`; `seed-tenant1.ps1` does the same; and `setup-db.ps1` accepts a free database name
while reusing the main cluster roles. D-AI-11 therefore includes refactoring the **existing** local
toolchain around a closed target enum — not adding a second sync engine:

```powershell
.\local\setup-db.ps1    -Target Preview -AdminUser postgres -AdminPassword <pw>
.\local\migrate.ps1     -Target Preview
.\local\seed-tenant1.ps1 -Target Preview
.\local\run-sync.ps1    -Target Preview
```

`-Target` accepts only `Main` or `Preview` and defaults to `Main` to preserve today's commands. It
is resolved by one shared helper, `local/tools/DatabaseTarget.ps1`; individual scripts must not each
reimplement the mapping. The dot-sourced helper exports only `Get-ApolloDatabaseTarget`: it has no
script-level `param`, because that would bind over the caller's `Target` variable in PowerShell's
shared scope and could silently turn `Preview` back into the default `Main`.

| Target | Database | Secrets file | Migrator role | Web role |
|---|---|---|---|---|
| `Main` | `gott_apollo` | `local/secrets/apollo-db.json` | `apollo_migrator` | `apollo_app` |
| `Preview` | **`gott_apollo_preview`** | `local/secrets/apollo-preview-db.json` | **`apollo_preview_migrator`** | **`apollo_preview_app`** |

The Preview roles get freshly generated credentials and are not members of any Main role.
`apollo_preview_migrator` is `BYPASSRLS` because the same EF migrations and sync engine require it;
`apollo_preview_app` is `NOBYPASSRLS`. Preview has no worker role: document processing, queues and
scheduled/background integrations are disabled. Provisioning revokes database `CONNECT` from
`PUBLIC` and grants it only to the two Preview roles (plus the PostgreSQL administrator). The Main
`apollo_app`, `apollo_worker` and `apollo_migrator` roles receive no grant on the Preview database;
the Preview roles receive no grant on `gott_apollo`.

Because PostgreSQL grants database `CONNECT` to `PUBLIC` by default, the refactor applies the same
explicit-connect posture to Main as a prerequisite: revoke `CONNECT` from `PUBLIC` on `gott_apollo`,
then grant it to `apollo_migrator`, `apollo_app`, `apollo_worker` and `apollo_ai_reader` only. Without
this change, saying that Preview roles cannot connect to Main would be false even if they had no
table privileges. The isolation test records the effective `has_database_privilege`, not merely the
presence or absence of a direct GRANT.

`setup-db.ps1 -Target Preview` is idempotent: create the two roles if absent, create
`gott_apollo_preview` owned by the Preview migrator if absent, apply grants/default privileges, and
write only `apollo-preview-db.json`. It must never overwrite or merge `apollo-db.json`.

An installation may already have the earlier Preview model, where `gott_apollo_preview` and all its
objects are owned by `apollo_migrator`. Before migrations, setup adopts that database explicitly:
database and object ownership move to `apollo_preview_migrator`, legacy Main-role ACL/default grants
are removed, and `PUBLIC CONNECT` is revoked on both databases. `test-preview-principal.ps1` is the
read-only gate between setup and prepare: the dedicated migrator must own every existing public
table, be `BYPASSRLS`, reach Preview but not Main, while Main roles cannot reach Preview.

`migrate.ps1` currently invokes `dotnet ef database update` only from
`Sibyla.Platform.Infrastructure`; the Preview work must make the intended two-context behaviour
explicit for **both targets**. Run Platform first with `SibylaDbContext`, then run
`Apollo.Modules.Sibyla.Infrastructure` with `SibylaDbContext` and its separate
`__EFMigrationsHistory_Sibyla` table, failing immediately if either command fails.
`migrate.ps1 -Target Preview` therefore applies the exact same Platform and Argus migrations
against `gott_apollo_preview` as `apollo_preview_migrator`. `seed-tenant1.ps1 -Target Preview`
applies the same idempotent NP Group/licence/company/user seed needed by `Sibyla.Sync`; no
identities or tenant rows are copied by querying the Main database.

`run-sync.ps1 -Target Preview` runs the existing `Sibyla.Sync` project against the Preview
connection and the same canonical `invoice-skill-build\Editor\Data` source. Thus preview gets the
same governed corpus through the same gates, hashes, quarantine behaviour and transaction rollback
as Main. The sync remains delta-aware and idempotent: run it initially and again whenever the FDR
changes. A failed Preview sync changes nothing in Preview and never changes Main.

Add a convenience orchestrator for the complete, non-destructive preparation:

```powershell
.\local\prepare-preview.ps1 -AdminUser postgres -AdminPassword <pw>
# setup Preview → migrations Preview → tenant seed Preview → FDR sync Preview
```

It does **not** drop, truncate or recreate an existing database. A clean reset is destructive and
must remain a separate, explicitly confirmed administrator operation; neither `Start preview` nor
`prepare-preview.ps1` performs one. `Start preview` checks that the Preview database has the current
migrations and at least one successful sync record; otherwise it fails with the exact
`prepare-preview.ps1` command instead of falling back to Main. This global readiness probe uses the
dedicated Preview migrator so tenant RLS cannot hide successful `sync_run` records; its password is
removed from the environment before the child starts, and the Preview process receives only the
`apollo_preview_app` connection.

There are three layers of fail-closed target validation:

1. The PowerShell helper maps `Preview` to the fixed database, role and secrets filename and offers
   no `-Database` override in Preview mode.
2. `Sibyla.Sync` gains `Sync:Target`. Before opening its EF context it parses the Npgsql connection
   string and requires `Database=gott_apollo_preview` and
   `Username=apollo_preview_migrator` when the target is Preview; mismatch is a fatal error printed
   before any SQL. Main applies the inverse check and rejects Preview names/roles.
3. `Sibyla.Web` in environment `Preview` requires `ConnectionStrings:SibylaDb` to name exactly
   `gott_apollo_preview` and rejects `gott_apollo`, regardless of other configuration. The controller
   never passes its Main connection string to the preview child.

Do not add `-Target Both`: Main and Preview are separate transactions and pretending they are one
atomic sync would be misleading. Operators run the explicit Preview target when preparing or
refreshing UI validation data; a Preview failure must never change the exit status or outcome of a
Main synchronization already performed.

---

## 9. Security posture — Trusted Administrator / Trusted Host (§18, §21)

Stated plainly, once, and not worked around:

- Codex runs with `--sandbox danger-full-access`; Claude runs with `--dangerously-skip-permissions`.
- **The configured working folder is therefore not an operating-system security boundary.** Both
  agents can read and write anywhere the Sibyla.Web process identity can — which on this box is
  Administrator, i.e. the whole machine, including `gott_apollo`, `gott_identity`, the IIS sites and
  `C:\SibylaApps`.
- `Allow File Changes` and `Additional Projects` therefore establish **intent, context, UI state and
  audit trail** — not isolation. The plan does not claim otherwise anywhere in the UI. The Session
  Context panel says *"Prototype: context, not containment"* in small print.
- Explicit selection is still required for additional projects, because that interaction is the
  hook stronger authorization and isolation will attach to later (§21).
- The gate that *does* hold: only `superuser` reaches any of it, enforced by policy at the page and
  re-checked in every service that starts a process.
- Preview child processes bind to `127.0.0.1` only. The authenticated gateway is the sole browser
  entry point, and its opaque token is routing entropy, **not** authorization; every request still
  requires `PlatformAdministration`.
- Preview uses an allowlisted runner and target below the stored worktree. Neither the model nor the
  operator can submit a shell command, bind address, port or arbitrary environment variable through
  the UI. Gateway headers are not accepted as an identity source by the child application.
- Preview data is disposable and isolated from production. If a project's Preview environment,
  database and outbound-integration blocks are missing, `Start preview` fails closed.

**Two specific exposures found while planning — know about them, don't be surprised by them:**

- **`local/secrets/` is inside the Sibyla project's working folder.** `local/secrets/apollo-db.json`
  holds the `apollo_migrator` credentials in plaintext (gitignored, but present on disk), and a
  Codex session rooted at `D:\fileStorage\repos\GOTT.Apollo` can read it — as can any process
  running as that identity. `apollo_migrator` is **`BYPASSRLS`**. So the licence boundary of §5 is a
  boundary for the *Apollo* (the agent, on Hermes) project's connection; it is **not** a boundary the Sibyla or
  Identity Server projects sit behind, and it was never claimed to be — those are development
  projects on a trusted host (D-AI-2 scopes the operational agent, not the coding ones). Stated here
  so nobody later reads "licence-scoped" as applying to the whole AI area.
- **`local\run-sync.ps1` runs as `apollo_migrator` (`BYPASSRLS`)** over the whole corpus. It is the
  sanctioned write path (D-AI-6) and it is heavily gated (G0–G5/G8/G10, quarantine-not-guess), but
  it crosses every tenant boundary by design. Recommendation for the prototype: keep it an
  **explicit operator action** — asked for in the session, audited as `ai.sync.run` — rather than
  something a harness fires as a side effect of another task. Cheap to honour, and it keeps the one
  privileged operation in the loop deliberate.
- Every run is audited (`audlog`: actor subject, project, harness, folders, model, effort, exit
  state) and every run's raw event stream is kept on disk.

This is accepted for the prototype and must not block implementation. It is the first entry on the
hardening roadmap (§12).

---

## 10. Testing

Match the existing local-first, no-CI posture (decision D7 + the GitLab free-tier policy).

- **`Sibyla.Tests.TenantIsolation`** — extend the meta-test that enumerates tables so the five new
  ones are asserted to have RLS `ENABLED+FORCED`. This suite is release-blocking; it must stay green.
  Add one probe: a transaction without the platform-administrator GUC (`app.superuser` off) sees
  **zero** rows in every one
  of the five.

  Then the probes that matter — **`apollo_ai_reader` is the licence boundary** (D-AI-2), so it is
  what has to be proven, connecting *as that role*:
  1. With `app.owner_id` = owner A, a `SELECT` on each tenant table returns only owner A's rows.
  2. With `app.owner_id` = owner B, the same query returns only owner B's rows — and specifically
     **zero** of owner A's.
  3. With `app.owner_id` unset, every tenant table returns **zero** rows (fail closed).
  4. `INSERT`/`UPDATE`/`DELETE` are rejected on every table — the role is read-only.
  5. `SELECT` on `credst` is rejected outright.
  6. `apollo_ai_reader` is not a member of `apollo_worker`, so `worker_access` never applies to it.

  Probe 2 is the one that encodes the owner's requirement, and probes 3–6 are what stop it being
  quietly widened later. These belong in the release-blocking suite, not in a manual checklist.
- **`Sibyla.Tests.Browser`** — add the five routes to the render smoke set (forged auth cookie →
  tenant resolution → RLS), asserting a `superuser` principal renders and an `admin`-only principal
  is denied. Extend the same suite to §4.1: `/companies`, `/storage` and `/erp` must serve
  `Administrator` and `Organization` and **deny** a user holding neither — today they render for any
  authenticated user, so this assertion fails before the fix and passes after it. Add the §4.3
  redirect: a module-only user requesting `/` lands on the first active module.
- **`Sibyla.Tests.Platform`** — parser unit tests fed by **captured JSONL fixtures** from the three
  probes in §1.4 (they are already recorded there). Parsing must be tested without spawning a CLI.
- **Preview tests** — unit-test target/path validation, token lookup, timeout and process-tree
  cancellation. The browser suite starts a tiny fake HTTP/WebSocket preview child and proves: an
  unauthenticated request is challenged; `admin`-only is denied; `superuser` can load through the
  gateway; an unknown/expired token returns no child content; the destination remains loopback; and
  Stop makes the gateway route unavailable. A render test for Apollo under a non-root `PathBase`
  asserts CSS, navigation and the Blazor circuit all remain below `/_preview/<token>/`.
- **Preview database target tests** — exercise the shared PowerShell target resolver and the .NET
  connection guard without exposing passwords. Assert `Preview` resolves only to
  `gott_apollo_preview` / `apollo-preview-db.json` / `apollo_preview_*`; invalid target and any
  database/role mismatch fail before SQL; Main and Preview roles have no cross-database `CONNECT`;
  Preview has current Platform + Argus migrations, the tenant seed, and a successful `sync_run`.
  Run the existing sync acceptance once against Preview and assert its table counts/hash stamp match
  a Main run from the same FDR source, while connection/database identities differ.
- **Manual acceptance** (the prototype's real gate), in order:
  1. `/ai` lists four projects; a non-`superuser` sees no nav entry and is denied on the direct URL.
  2. New Sibyla session → "list the top-level folders in this repo" → real Codex output streams in.
  3. Refresh mid-run → output continues and backfills. Close the tab, reopen → same.
  4. Cancel a long run → process tree dies, state `Cancelled`.
  5. Second message in the same conversation → Codex resumes the same `thread_id` (verify against
     `~/.codex/sessions`).
  6. Skill Build session → Claude answers a question about `SKILL.md`.
  7. Apollo session → the agent answers, and `hermes sessions list` shows the session Apollo created.
  8. **Licence pinning — ordinary-path acceptance (D-AI-2).** This proves the pinning works; it
     does **not** prove containment, which the trusted-host posture explicitly does not offer.
     Select licence NP Group, start an
     Apollo (agent) session, and ask a question whose honest answer differs per licence ("how many
     companies are configured?", "list the most recent documents"). The answer covers NP Group only.
     Switch to another licence, start a new session, ask the same question → a different answer,
     with no NP Group rows in it. Then ask the second session directly for NP Group's data → it
     reports finding none, because the connection genuinely cannot see them.
  9. Identity Server session with Sibyla as an additional project → the model sees both trees.
  10. Project Maintenance: change Sibyla's default effort, start a new session, confirm it applies.
  11. On a machine without Preview prepared, **Start preview** fails closed and names
      `prepare-preview.ps1`; it never tries `gott_apollo`. Run the preparation command and verify it
      creates separate roles/secrets/database, applies both migration sets, seeds NP Group and
      records a successful FDR sync. Re-run it and confirm the whole operation is idempotent.
  12. Start a preview-enabled Sibyla/Codex session (the GOTT.Apollo repository). Confirm its
      workspace is a detached worktree and
      the controller still serves the chat from the stable checkout after Codex changes a Razor page.
  13. Click **Start preview**: build completes, `Open preview` loads CSS and establishes a Blazor
      circuit through the authenticated gateway, and no raw loopback port appears in browser HTML.
      Change a form in the preview and validate its inputs against `gott_apollo_preview` only.
  14. Give the observed UI correction in the same chat, let Codex update the worktree, click
      **Restart preview**, and see the correction. Stop it and verify the process tree dies, the URL
      stops serving, and no publish/IIS/`C:\SibylaApps` content changed.

---

## 11. Build order

Twelve commits, each independently runnable. Roughly the order a single developer would want.

**Implementation progress (2026-09-04): Claude harness delivered.** The build is now complete
through logical slice 8: `ClaudeHarness` uses the shared native `CliProcessRunner`, builds new and
resumed `claude -p --output-format stream-json --verbose` turns, keeps the user and appended-system
prompts out of `command_line`, parses init/assistant/thinking/tool-use/tool-result/result events,
persists token and cost usage, reports non-allowed rate-limit states, and is registered as the
`CLAUDE` `IAiHarness`. Fixture tests are 9/9; the full solution is 482/482; an authenticated live
no-tools probe against Claude Code 2.1.260 returned the expected streamed session, assistant, usage,
and success events. The §8 identity constraint is unchanged: this proves the local Administrator
prototype path; an IIS-hosted run still needs an authenticated execution identity.

**Implementation progress (2026-09-05): Hermes harness delivered.** `HermesHarness` now runs quiet
native and resumed turns, redacts the augmented prompt, aggregates Hermes' non-streaming stdout,
and captures the native `session_id` that the installed CLI writes to stderr. Every child receives
only the configured AI-reader credential, `PGOPTIONS` pinned to `aiconv.owner_id`, and the explicit
`HERMES_PROFILE`; `aiprof` fails closed and seeds NP Group → `default`. The migration is applied to
Main and Preview, the full solution is 519/519, and a live installed-Hermes smoke turn returned a
native session and the expected final answer. Project Maintenance is complete; logical slice 11
(per-licence gateway configuration/provisioning) remains. The §8 IIS execution-identity constraint
is unchanged.

**Production configuration (2026-09-05): the §8 identity constraint is closed for Hermes.** The
`Sibyla.Web` pool identity stays; the Hermes child gets `HERMES_HOME=D:\ApolloData\web-hermes`, a
home holding only the model block, `SOUL.md` and the owner's own fresh `openai-codex` device-code
login, plus traverse-only reach into the operator profile and read on the Hermes runtime. The
AI-reader credential enters through `web.json` (`AiHarness:Hermes`). Verified by a smoke turn with
the harness's exact environment. Codex and Claude under IIS still lack an execution identity;
per-licence profiles (slice 11) will be created under the web home's `profiles\`. Deployment run
record §7m.

| # | Commit | Contents |
|---|---|---|
| 0 | Pipeline order | Move `TenantResolutionMiddleware` above `UseAuthorization` in `Program.cs` (§4). One line; without it every policy below denies everyone. |
| 1 | Authorization + role assignment | `ApolloRoles`/`ApolloPolicies`, requirement, handler, `Program.cs` registration; §4.1 applied to `/companies`, `/storage`, `/erp`, `/licences` (route-level, in-page checks deleted); §4.2's narrowed `admin` grants in `tenant1.sql` + the revoke; §4.3's redirect in `Home.razor`. **This one changes behaviour on shipped screens and takes access away from real users** — extend the render smoke suite first so the denial is asserted, and confirm the §4.2 table with the owner before running the revoke. D-AI-7's two IdP statements are done separately, outside this repo. |
| 2 | Data model | Five entities, `SibylaDbContext` mappings, `AiProjects` migration (one `superuser_access` policy for all five) + the four seed rows, **the `apollo_ai_reader` role in `setup-db.ps1`**, and the §10 reader probes. `local\migrate.ps1` green, isolation suite green. |
| 3 | Harness seam | `IAiHarness`, `HarnessEvent`, `HarnessCapabilities`, `HarnessRunRequest`, `HarnessExecutionOptions`, `CliProcessRunner`. |
| 4 | **`FakeHarness` + runner/registry** | A harness that replays a scripted event list on a timer, then `HarnessRunner`, `HarnessRunRegistry`, the detached execution context, `aiexec` lifecycle, shutdown sweep, cancellation, audit. **Do the fake before the real CLI:** reconnect, cancellation, concurrency and event ordering are the hardest parts of this feature, and against a fake they are deterministic, instant and free. Every §6 behaviour gets a test here rather than a 30-second API round trip. |
| 5 | Codex harness | `CodexHarness` + JSONL parser + fixture tests, dropped into the machinery commit 4 already proved. |
| 6 | Chat UI | `/ai`, `/ai/p/{key}`, `/ai/p/{key}/new`, `/ai/c/{id}`, nav integration, Markdig, Session Context panel. **First end-to-end demo: Sibyla via Codex.** |
| 7 | **Development preview (D-AI-11)** | Worktree-at-conversation creation; typed DOTNET preview profile; `PreviewManager`/registry; loopback process and readiness probe; authenticated HTTP/WebSocket gateway; Start/Open/Restart/Stop UI and logs; Apollo `PathBase`; `appsettings.Preview.json`; `-Target Main\|Preview` across setup/migrate/seed/the existing sync; separate Preview roles/secrets; `prepare-preview.ps1`; three-layer connection guard; isolated and FDR-populated `gott_apollo_preview`; automated and manual acceptance. **No publish or IIS operation exists in this commit.** |
| 8 | Claude harness | `ClaudeHarness` + parser + fixtures; Skill Build works, `system_prompt_append` seeded. |
| 9 | Hermes harness | `HermesHarness` + **the pinned `PG*` environment (D-AI-2)** + **`aiprof` and `HERMES_PROFILE` per licence, fail-closed (D-AI-9)**; Apollo project works; non-streaming UI affordance; licence shown on the session, New Session and Session Context; composer guard when the session's licence ≠ the selected one. |
| 10 | Project Maintenance | `/ai/projects` CRUD + typed preview-profile validation + audit. |
| 11 | **Per-licence gateway config (D-AI-10)** | `aiprof` + `aiprofc`, the Administrator screen: create agent (`profile create --clone-from`), **read back** (`send --list --json`), configure, and **Validar** per channel (`send --to --json`). Seed NP Group → `default` by importing its live config, not by typing it. Email is a Graph cron watcher, validated via `cron run` + `cron runs`. **Ship the clone guard with it** — the inherited-credentials check is not a follow-up. |

Commits 0–7 are the end-to-end development proof, including human UI validation without deployment.
Commits 8–11 complete the remaining harness and gateway brief.

---

## 12. Deliberately out of scope (and the hardening roadmap)

**Retention and deletion of durable evidence — deliberately not built, and deliberately named.**
v1 does not delete conversation rows, projects, messages, executions, audit records or
`events.jsonl`: conversations and projects are archived (`state = Archived`) and the evidence is
kept indefinitely. Archiving does delete the conversation's disposable Git worktree after active
execution and preview processes have stopped; that filesystem cleanup does not delete evidence.
Cascade-deleting a conversation would destroy the execution evidence that justifies every audit row
pointing at it. Retention (how long evidence lives, what a real delete does to
`aiexec`/`aimesg`/`events.jsonl`) remains a decision to take before the corpus grows, not a default to
inherit — §12 roadmap.

**Not in the prototype:** real sandboxing or per-project filesystem confinement; approval/permission
prompts surfaced in the Apollo UI; a separate harness-execution service or container; `hermes serve`
JSON-RPC integration; token-level streaming (`--include-partial-messages`); multi-user concurrency
beyond one operator; conversation search; **advanced selective diff/patch approval UI** (the compact
changed-file summary and executable preview are in scope); cost budgeting and quota management;
generic agent-management configuration; any change to Hermes' own permissions, tools or Gateway;
any change to GOTT.IdentityServer; any change to the invoice-skill-build repository.

**Hardening roadmap, in priority order:**

1. **Isolation** — drop `danger-full-access` / `--dangerously-skip-permissions`; run Codex under
   `workspace-write` with `--add-dir` as the real boundary, and Claude with an explicit tool
   allowlist plus a permission-prompt channel into the Apollo UI. This is the item §18 defers.
2. **Process ownership** — move `HarnessRunner` out of `Sibyla.Web` into a dedicated worker (reusing
   the `jobque` lane pattern), so an app-pool recycle cannot interrupt a run and IIS hosting becomes
   possible.
3. **Run-as identity** — a dedicated service account with its own CLI credentials, so harness runs
   are not executed as the interactive Administrator.
4. **Secret hygiene** — redact tokens/keys from persisted event streams and `command_line`; retention
   policy for `events.jsonl`; and get the `apollo_migrator` password out of a plaintext file sitting
   inside a working folder an agent is rooted at (§9).
5. **Apollo-native write paths, at cutover** — until then D-AI-6 holds and there is nothing to
   build. At cutover the FDR stops being the system of record, and *that* is when a scoped
   write-capable role, an approval flow and reversibility need designing — as one piece of work,
   not as a grant added to `apollo_ai_reader`.
6. **Authorization depth** — per-project grants rather than one global `superuser` gate; separate
   read-only vs. change-allowed capability, enforced rather than declared.
7. ~~Extend licence scoping past the database~~ — **moved into the build as D-AI-9** (one profile
   per licence), because the per-licence WhatsApp/Buzz/email accounts make it functional rather than
   optional. What remains on this list is the half profiles do not solve: `terminal` access, which
   needs the dedicated OS identity in item 1.
8. `hermes serve` for real Hermes streaming and tool visibility.
9. ~~Consume roles from the token.~~ **Promoted to D-AI-8** — `superuser` gets one home (the token),
   `admin` and module roles keep theirs (`usrrol`). What remains for later: Apollo's `admin` and the
   IdP's `Administrator` still name different things, so if module or departmental roles are ever
   sourced from claims too, settle that naming first.
10. ~~`audlog` nullable-owner policy~~ — **fixed 2026-09-01**, migration `PlatformAuditAccess`. A
   platform-operator audit row is now writable and readable under a verified operator, by an added
   permissive policy rather than a widened tenant rule, so `tenant_isolation` stays exactly as
   written and tested. Five isolation probes, including the one that matters: the operator GUC
   grants NULL-owner rows only and does not unlock another tenant's audit trail.

---

## 13. Open questions for the owner

1. ~~Who keeps `admin`?~~ **Resolved 2026-08-31 — see §4.2.** The IdP's `Administrator`
   assignments decide it: `luis.nascimento` and `miguel.teixeira` keep `admin`, `rachel.sa` loses
   it. `sibyla@gottsolutions.net` is a **service account** (owner-confirmed) and stays out of
   Apollo's user model entirely.
2. ~~Do the `/argus/admin/*` sub-views belong to `admin`?~~ **Decided 2026-08-31: the two-way
   split.** `OrganizationAdministration` goes on `users` (User Access — it lists USRMST users, roles
   and grants, and is reachable by URL today since `SibylaAdmin.razor`'s only guard is
   `ActiveModules.Contains("argus")`). The other ten sub-views stay module-gated as they are.
   Whether Argus eventually needs its own roles — `usrrol` already supports them via `module_key`
   (`argus:*`) — is deferred until there is a reason.

3. ~~Is the Hermes licence requirement literal?~~ **Decided 2026-08-31: one Hermes profile per
   licence — see D-AI-9.** The deciding reason is functional, not security: WhatsApp numbers, Buzz
   and email accounts differ per client and are configured per licence, so a single shared profile
   physically cannot serve two organizations.

4. ~~Email intake per licence~~ **Resolved 2026-09-01 — see D-AI-10.** One mailbox per licence;
   the mechanism is a per-profile **Microsoft Graph** cron watcher (not IMAP, not a gateway
   channel). One thing still open, and it is a real decision rather than a detail: the watcher
   script registers into the Sibyla Legacy channel-intake API. Repoint it at Apollo's own DOCINT
   ingestion as part of this work, or leave it on the legacy path until the freeze? Repointing makes
   Apollo the intake owner sooner and removes a dependency on a system being retired; leaving it
   avoids touching a working pipeline mid-prototype. My recommendation: leave it, and repoint at the
   same time as the FDR cutover, so intake and system-of-record move together rather than in two
   separate migrations.
   **Owner ruling 2026-09-02: the Hermes plugin `sibyla-channel-intake` is replaced by Apollo's
   plugin.** Sibyla exposes its own channel-intake API (the v1 routes and payloads the legacy plugin
   already speaks, unless redesigned: `POST /api/channel-intake/v1/registrations`, `GET .../{id}`,
   `PUT .../{id}/candidates/{id}/content`; JWT bearer with a new client-credentials client from
   GOTT.IdentityServer, never the legacy secret), writing `DocumentIntake` rows with channel
   provenance; Apollo's plugin `apollo-channel-intake` (a Hermes-runtime plugin) with the same three tools posts to
   `api.sibyla.gottsolutions.net`. Order: Phase L done → Sibyla deployed and healthy → IdP client registered → plugin
   installed and enabled → cron re-pointed → observed → `Sibyla.Api` and the legacy plugin retired.
   This is a prerequisite of the Sibyla Legacy retirement, not something that waits for the FDR cutover
   (which no longer migrates documents — discovery plan D10 amendment of the same date).

5. **Hermes project working folder.** The Apollo project has no folder today (`context_label` =
   "Gateway + Database"), so Hermes runs in the Sibyla.Web process's cwd. If Hermes sessions should
   be anchored somewhere specific, name the folder and it becomes `--in <dir>`.
6. ~~Which database? Read-only or write?~~ **Resolved 2026-08-31 — see D-AI-6.** `gott_apollo` only
   (the legacy database and apps are being retired and frozen once Apollo is finished, so no bridge
   is built to them); read-only until the cutover from invoice-skill-build, with writes going
   through the FDR `*.json` layer and the sync. No second write-capable role is built.
7. ~~Skill Build project context~~ **Decided 2026-08-31.** Use `aiproj.system_prompt_append` for
   the prototype; move it to a `CLAUDE.md` when you are ready to make that a governed change.
   *(Owner ruling: "o repositório tem de responder a isso, não eu — assume o que está lá" — so the
   repo's own statement stands: `Editor/Data/*.json` is canonical and `Invoice_Registry.xlsx` is
   rendered from it. And: "alterando os `*.json` é necessário também atualizar o excel e o supabase
   para manter o Skill Build coerente.")*

   **The propagation chain, verified 2026-08-31** — it matches the D15 finding already recorded in
   `apollo-discovery-and-plan-260826.md` §1:

   ```
   Editor/Data/*.json                     ← canonical; the Apollo sync reads only this
     → Scripts/build_workbook.py          → Invoice_Registry.xlsx
       → Interface/sibyla-registry-web/data/registry-data.json
         → Interface/Supabase/import_registry_data.py   → Supabase sib_* tables
   ```

   (`Interface/Supabase/README.md`: "`sib_refresh_runs` records each import from
   `Invoice_Registry.xlsx`" — the mirror is workbook-derived, one-way, downstream.)

   **This is the clause an agent would otherwise skip.** Apollo's sync reads `Editor/Data/*.json`
   and nothing else, so a JSON change reaches Apollo whether or not the workbook and Supabase are
   refreshed — everything *looks* fine from Apollo's side while the Skill Build's own deliverable and
   web interface silently go stale. The instruction has to say so explicitly, because the feedback
   that would catch it is absent by design.

   > You are working in the Invoice Skill Build repository — the FDR corpus, and the system of
   > record for Argus's fiscal data until cutover. It is governed, not free-form.
   >
   > Read before changing anything:
   > • `SKILL.md` — the operating manual. 256 KB: read the relevant section, never load it whole.
   > • `INDEX.md` — what each folder is for, and which are load-bearing.
   > • `Specs/Engagement Rules/` — the binding rules. Most relevant here:
   >   *Data Update Governance Procedure*, *Code Change Governance Procedure*,
   >   *Document Archiving Policy*, *Policy Change Validation Procedure*.
   >
   > `Editor/Data/*.json` is the canonical data layer; `Invoice_Registry.xlsx` is rendered from it.
   > Before editing any JSON file by hand, check whether a `Scripts/build_*.py` step produces it —
   > if one does, change that script or the rule behind it and re-run the step, because the next run
   > overwrites a manual edit. Human judgements go to their designated durable source (review notes:
   > `user_observations.json`), never into a regenerated file.
   >
   > **After changing the data layer, propagate it or the repository becomes incoherent:**
   > re-render `Invoice_Registry.xlsx` (`Scripts/build_workbook.py`), refresh
   > `Interface/sibyla-registry-web/data/registry-data.json`, and re-import into Supabase
   > (`Interface/Supabase/import_registry_data.py`). Nothing downstream will warn you: the Apollo
   > sync reads the JSON only, so it will look correct while the workbook and the web interface are
   > stale.
   >
   > Also binding:
   > • Changing a rule, policy or procedure goes through the *Policy Change Validation Procedure*.
   >   Never amend a governance document as a side effect of another task.
   > • Never relocate the company archive folders, `Editor/Data/`, or the flat sandbox — 1,280
   >   `ArchivePath` values and 104 scripts point into them.
   > • Report and quarantine. Never guess a value, never delete evidence, never repair a declared
   >   exception.

8. **Sibyla project folder.** The brief maps Sibyla → `D:\fileStorage\repos\GOTT.Apollo`, and there
   is also a separate `D:\fileStorage\repos\GOTT.Sibyla` (plus several `GOTT.Sibyla-s2-*` worktrees).
   Taking the brief literally: `GOTT.Apollo`. Confirm the older tree is not wanted as a fifth project.
