diff --git a/openspec/changes/a-conversational-intake-that-files-for-the-citizen/design.md b/openspec/changes/a-conversational-intake-that-files-for-the-citizen/design.md new file mode 100644 index 00000000..a1fb03a2 --- /dev/null +++ b/openspec/changes/a-conversational-intake-that-files-for-the-citizen/design.md @@ -0,0 +1,89 @@ +# Design: a conversational intake that files for the citizen + +## D1. Why this cannot be `case-assistant-surface` + +`case-assistant-surface` is tool-free by construction, and its spec says the +tool-freeness is the feature: a chat box on a case must not be able to act on the +case. It enforces that with a `tools: ['__none__']` sentinel verified against +`ToolLoop::listAgentFunctions()`. + +Intake needs the opposite property. A citizen describing a broken streetlight needs a +melding to exist at the end of the conversation, or the conversation was theatre. + +Two ways to reconcile them. + +1. Give `case-assistant-surface` an optional tool set. This removes the guarantee for + every existing caller, and the guarantee is load-bearing. +2. A second surface, with exactly one narrowly declared tool. + +Option 2. The existing surface keeps its sentinel untouched, and intake's tool grant +is per owning app, per record type, and covers creation only. Nothing about intake can +read or change an existing record, because it is talking to somebody who does not have +one. + +## D2. Abstention is the requirement, not the classification + +Every chatbot classifies. The ones that fail in a gemeente fail by classifying +confidently and wrongly: a bezwaar filed as a melding loses a statutory term, and +nobody notices until the term has run. + +So the classification carries a confidence, and there is a threshold below which the +assistant says it does not know and hands over. The threshold is administered, because +the right value is a municipality's appetite, not ours. + +The measurable property: a wrong confident filing is worse than an abstention, so the +spec requires the abstention path to exist and to be reachable, and requires the +threshold to be readable by whoever set it. + +## D3. No path may dead-end + +The failure mode of a bad intake bot is not a wrong answer. It is a citizen who gives +up, and it leaves no trace at all: no case, no complaint, no measurement. An intake +that loses people is invisible in exactly the way that matters. + +So: every conversation ends in a filed request or a handover to a human, and the +transcript travels with the handover. "I could not help" is a handover, not an ending. + +This is stated as a requirement rather than as design guidance because it is the one +property nobody will notice is missing. + +## D4. One conversation, several channels + +Jira Service Management's documented agent runs in the portal, a widget, e-mail, Slack +and Teams. The interesting part is not the channel count, it is that a person moving +between them keeps their thread. + +A gemeente's version: somebody e-mails, gets an acknowledgement, then opens the portal +and continues. Making them repeat themselves is the most common complaint about +municipal digital service. + +So a conversation is keyed by the person and the subject, not by the channel, and a +channel adapter attaches to an existing conversation where one is open. Channel +adapters themselves belong to the apps that own the channels; hermiq holds the +conversation. + +## D5. Deterministic beats model, and the label says which + +dossiq's `SentimentService` scores Dutch contactmoment text for klacht, advocaat and +wethouder, deterministically. It is explainable, testable, stable across releases, and +a handler can be told exactly why it fired. + +A model sentiment score has none of those properties. It is still useful where nothing +deterministic exists, and it must never be presented as though it had them. + +So: use the deterministic signal where the owning app supplies one, record that it was +used, and where a model produced the score, label it as a model output on every +surface that shows it. A reader must be able to tell the two apart without asking. + +## D6. The external check is a tool, and its verdict is evidence + +RX Mission documents a plan review from inside the case (DigEplan). The sweep notes it +is "the one VTH-specific external check named anywhere in the corpus", which is a fair +warning that this is a narrow capability and not a category. + +Modelling it as a general "external review tool" rather than as a plan-review feature +keeps it honest: hermiq calls a tool the owning app declared, receives a verdict, and +records the verdict on the run with the reviewer's identity and time. + +hermiq forms no opinion about a building plan. It carries somebody else's opinion into +a conversation and writes down whose it was. diff --git a/openspec/changes/a-conversational-intake-that-files-for-the-citizen/proposal.md b/openspec/changes/a-conversational-intake-that-files-for-the-citizen/proposal.md new file mode 100644 index 00000000..c0b57e9e --- /dev/null +++ b/openspec/changes/a-conversational-intake-that-files-for-the-citizen/proposal.md @@ -0,0 +1,120 @@ +--- +kind: code +--- + +# Proposal: a-conversational-intake-that-files-for-the-citizen + +Round 4 discovery sweep, cluster 46 "AI, and what it is allowed to read" +(`procest/_round4/discovery/build-plan.md` in ConductionNL/market-intelligence, +2026-09-14). Owner hermiq on **decision D13**. Umbrella: +`competitor-parity-2026-09`. + +## Summary + +A citizen describes a problem in their own words on any channel, and the assistant +either answers it or files it correctly. hermiq runs the conversation and the +classification. The app that owns the record decides what is created. + +## Why + +The lane's clause on the lead candidate names the stake: **"the digitale balie every +gemeente is being sold, and the row that decides whether a case system or a separate +chatbot owns it"**. Every municipality in the Netherlands is currently being quoted +for a chatbot by somebody. The question this candidate settles is whether that +chatbot is part of the platform or a separate purchase that then needs integrating. + +hermiq already answers the inside-out half. `case-assistant-surface` gives a leaf app +a grounded, tool-free chat about the object on screen, and its tool-free property is +deliberate: "a leaf app embedding a chat box on a case does not want that box able to +act on the case". That is exactly right for a handler reading a case, and exactly +wrong for a citizen who has not got one yet. Intake has to be able to file. + +The sweep read dossiq `no` on it, with the note "AssistantController.php assists the +handler". The assistant we have helps the person behind the desk. This one stands in +front of it. + +## The candidates, with their lane citations + +| id | capability | relevance | driven passers | lane | +|---|---|---|---|---| +| C-intake-9 | A conversational intake answers or files on the citizen's behalf, on every channel. | should | none, jira-service-management documented | `intake.tsv:13` | +| C-communication-64 | The product tells the handler the writer is upset, from what they wrote. | should | none, jira-service-management documented | `communication.tsv:76` | +| C-integrations-1 | A building plan is checked against the rules by a plan-review application from inside the case. | could | none, rx-mission documented | `integrations.tsv:33` | + +**All three have no driven passer**, admitted under **decision D21** and labelled in +the spec. That is unusual and worth saying plainly: this is the least measured change +in hermiq's wave 3, and its requirements rest on vendor documentation. Jira Service +Management's virtual service agent is documented across a dozen pages, from intents +and the flow builder to the portal, widget, e-mail, Slack and Teams channels. +RX Mission's plan review is one documented page, `/modules/ Koppelingen`. + +**`C-communication-64` is the exception in a second way: dossiq already passes it, +and better.** `lib/Service/Kcc/SentimentService.php` scores Dutch contactmoment text +for klacht, advocaat and wethouder and returns an escalation level, +**deterministically rather than by model**. The sweep's own note reads "and dossiq has +the better shape already". So this change does not replace it. It requires that the +conversational intake **uses** the deterministic signal where one exists, and reaches +for a model only where none does. + +## What hermiq builds + +- **An intake conversation that can file.** A surface distinct from + `case-assistant-surface`: it holds a conversation with a person who has no record + yet, and may create one through a declared intake tool of the owning app. +- **A classification with a confidence and an abstention.** The assistant proposes + what kind of request this is. Below a threshold it says it does not know and hands + over, rather than filing a guess. +- **A handover that never dead-ends.** Every path reaches either a filed request or a + human, with what was said carried across. A citizen who gives up is the failure this + prevents. +- **The same conversation on every channel.** Portal, e-mail and chat reach one + intake, so a person who starts by e-mail and continues in the portal is not starting + again. +- **A deterministic signal preferred over a model one.** Where the owning app supplies + an escalation signal, the conversation uses it and records that it did. A model + sentiment is a fallback, and is labelled as a model output wherever it is shown. +- **An external check called from inside the conversation.** A declared external + review, such as a building plan check, is a tool the owning app declares. hermiq + calls it, carries its verdict into the conversation and records it on the run. + +## How dossiq consumes it + +1. dossiq declares an intake tool: what may be created, with which fields, by whom. + hermiq calls it. dossiq validates and creates. +2. dossiq declares its request catalogue, so the classification proposes from the + municipality's own list rather than from a model's idea of one. +3. dossiq's `SentimentService` stays the escalation signal on a contactmoment. hermiq + reads it and does not replace it. +4. A handover lands in dossiq's existing routing. hermiq creates no queue. + +## The existing specs this extends + +- `case-assistant-surface` is untouched and **stays tool-free**. Its + `tools: ['__none__']` sentinel is the guarantee a chat box on a case cannot act on + the case, and this change must not weaken it. Intake is a separate surface with a + separate, narrowly declared tool. +- `agent-guardrails`, whose prompt-injection filtering applies here as it does + everywhere. An intake surface takes text from the public, which makes it the most + exposed surface hermiq has. +- `ai-feature-governance`, which registers intake as its own feature with its own risk + category. An assistant that files on a citizen's behalf is not a minimal-risk + feature and the register must be able to say so. +- `run-audit-log`, which records the conversation and the filing as a run. + +## Size and dependencies + +**Size: L.** A conversational surface, a classification with abstention, a channel +join, a handover path and an external tool call. + +**Depends on:** `the-declared-tool-surface-and-the-prompt-library` for the tool +declaration shape, and `what-the-model-reads-and-what-is-kept` for the retention on +conversations that contain what a citizen typed. + +## What this change does not do + +- It does not decide what gets created. The owning app validates and creates. hermiq + proposes. +- It does not replace a working deterministic signal with a model. Where dossiq + already answers better, hermiq reads the answer. +- It does not perform a plan review. RX Mission's capability is an external + application; this change calls a declared tool and carries its verdict. diff --git a/openspec/changes/a-conversational-intake-that-files-for-the-citizen/specs/conversational-intake/spec.md b/openspec/changes/a-conversational-intake-that-files-for-the-citizen/specs/conversational-intake/spec.md new file mode 100644 index 00000000..ce1fb4dc --- /dev/null +++ b/openspec/changes/a-conversational-intake-that-files-for-the-citizen/specs/conversational-intake/spec.md @@ -0,0 +1,191 @@ +# conversational-intake + +## ADDED Requirements + +### Requirement: An intake conversation MUST be able to file, on its own surface + +The system MUST provide a conversational intake surface, distinct from the tool-free +case assistant surface, that holds a conversation with a person who has no record yet +and MAY create one through an intake tool the owning app declares. + +The intake tool grant MUST cover creation only, scoped to the record types the owning +app declares. The intake surface MUST NOT be able to read or change an existing +record. The tool-free case assistant surface MUST be unchanged, and its no-tool +sentinel MUST remain in force. + +Candidate C-intake-9 (`intake.tsv:13`), relevance `should`. **No driven passer.** The +evidence is jira-service-management's documented virtual service agent: intents, the +flow builder, AI answers, and the portal, widget, e-mail, Slack and Teams channels. +Admitted under decision D21 and labelled documented here. The lane's clause: the +digitale balie every gemeente is being sold, and the row that decides whether a case +system or a separate chatbot owns it. + +#### Scenario: A conversation ends in a filed request + +- **GIVEN** a citizen describing a broken streetlight +- **WHEN** the intake conversation completes +- **THEN** the owning app MUST have been asked to create a record of the declared + type, and MUST have validated it itself + +#### Scenario: Intake cannot touch an existing record + +- **GIVEN** an intake conversation and an existing case id +- **WHEN** any read or write of that case is attempted from the intake surface +- **THEN** it MUST be refused + +#### Scenario: The case assistant stays tool-free + +- **WHEN** the case assistant surface's tool list is resolved +- **THEN** it MUST still resolve to no tools + +### Requirement: A classification MUST carry a confidence and MUST be able to abstain + +The system MUST propose which kind of request a conversation is, drawn from the +catalogue the owning app declares, and MUST carry a confidence with the proposal. +Below an administered threshold the system MUST abstain and hand over to a human +rather than file. + +The threshold MUST be administered and readable by whoever set it. A wrong confident +filing loses more than an abstention: a bezwaar filed as a melding loses a statutory +term, and nobody notices until the term has run. + +#### Scenario: An uncertain intake does not guess + +- **GIVEN** a conversation whose classification confidence is below the threshold +- **WHEN** the conversation reaches its end +- **THEN** the system MUST hand over to a human and MUST NOT create a record + +#### Scenario: The catalogue is the municipality's + +- **WHEN** a classification is proposed +- **THEN** it MUST be drawn from the request catalogue the owning app declares, and + MUST NOT be invented + +#### Scenario: The threshold is readable + +- **WHEN** an administrator asks what the abstention threshold is +- **THEN** the system MUST answer with the value in force + +### Requirement: No conversation MUST dead-end + +The system MUST end every intake conversation in either a filed request or a handover +to a human, and MUST carry the transcript to the handover. A reply that cannot help +MUST be a handover, not an ending. + +A citizen who gives up leaves no case, no complaint and no measurement, so an intake +that loses people is invisible in exactly the way that matters. This is a requirement +rather than guidance for that reason. + +#### Scenario: An unhelpable conversation reaches a person + +- **GIVEN** a conversation the assistant cannot resolve or classify +- **WHEN** it ends +- **THEN** a handover MUST exist, carrying the transcript + +#### Scenario: Every terminal state is one of two + +- **WHEN** the terminal states of the intake conversation are enumerated +- **THEN** each MUST be a filed request or a handover + +### Requirement: One conversation MUST span the channels a person uses + +The system MUST key an intake conversation by the person and the subject rather than +by the channel, and MUST attach an incoming message on any channel to an open +conversation for that person and subject. + +Channel adapters belong to the apps that own the channels. hermiq MUST hold the +conversation and MUST NOT implement a channel transport of its own. + +#### Scenario: Starting by e-mail and continuing in the portal + +- **GIVEN** a citizen who started an intake by e-mail +- **WHEN** they open the portal and continue +- **THEN** the same conversation MUST continue, and they MUST NOT be asked to repeat + what they already said + +#### Scenario: hermiq transports nothing + +- **WHEN** hermiq is inspected for a channel transport +- **THEN** none MUST exist, and each channel MUST arrive through its owning app + +### Requirement: A deterministic escalation signal MUST be preferred over a model one + +Where the owning app supplies a deterministic escalation or sentiment signal, the +system MUST use it and MUST record that it did. A model-produced score MUST be used +only where no deterministic signal exists, and MUST be labelled as a model output on +every surface that shows it. + +Candidate C-communication-64 (`communication.tsv:76`), relevance `should`. **No driven +passer**; jira-service-management documented. The sweep read dossiq **`yes`** on it, +noting `lib/Service/Kcc/SentimentService.php` scores Dutch contactmoment text for +klacht, advocaat and wethouder and returns an escalation level, deterministically +rather than by model, and adding "dossiq has the better shape already". This +requirement exists to keep that shape, not to replace it. + +#### Scenario: The deterministic score wins + +- **GIVEN** an owning app supplying a deterministic escalation level for a message +- **WHEN** the intake evaluates that message +- **THEN** it MUST use that level, and the run MUST record that a deterministic signal + was used + +#### Scenario: A model score is labelled as one + +- **GIVEN** a message for which no deterministic signal exists +- **WHEN** a model produces a sentiment +- **THEN** every surface showing it MUST label it as a model output + +#### Scenario: A reader can tell them apart + +- **GIVEN** one message scored deterministically and one scored by a model +- **WHEN** both are shown to a handler +- **THEN** the handler MUST be able to tell which is which without asking + +### Requirement: An external review MUST be a declared tool whose verdict is recorded + +The system MUST let an owning app declare an external review as a tool, MUST call it +from within a conversation when the conversation asks for it, and MUST record the +verdict on the run with the reviewing party's identity and the time. + +hermiq MUST form no opinion of its own about the subject of the review. It carries +another party's verdict and records whose it was. + +Candidate C-integrations-1 (`integrations.tsv:33`), relevance `could`. **No driven +passer.** The evidence is rx-mission's documented `/modules/ Koppelingen`, admitted +under decision D21 and labelled documented here. The lane notes this is the one +VTH-specific external check named anywhere in the corpus, so the requirement is +written as a general declared review rather than as a plan-review feature. + +#### Scenario: A verdict travels into the conversation with its author + +- **GIVEN** an owning app declaring an external review tool +- **WHEN** a conversation invokes it +- **THEN** the verdict MUST be carried into the conversation, and the run MUST record + the reviewing party and the time + +#### Scenario: hermiq does not review + +- **WHEN** hermiq is inspected for logic that evaluates the subject of an external + review +- **THEN** none MUST exist + +### Requirement: Intake MUST be registered as an AI feature with its own risk category + +The system MUST register the conversational intake as its own `AiFeature`, so it +carries its own EU AI Act risk category, its own acknowledgement state and its own +provider binding. + +An assistant that files on a citizen's behalf is not a minimal-risk feature, and the +register must be able to say so independently of the assistant that helps a handler. + +#### Scenario: Intake is classified separately from the handler assistant + +- **WHEN** the AI feature register is read +- **THEN** the conversational intake MUST appear as its own feature with its own risk + category + +#### Scenario: The existing gate applies + +- **GIVEN** intake registered as high risk with no acknowledgement recorded +- **WHEN** enabling it is attempted +- **THEN** the existing acknowledgement gate MUST refuse it diff --git a/openspec/changes/a-conversational-intake-that-files-for-the-citizen/tasks.md b/openspec/changes/a-conversational-intake-that-files-for-the-citizen/tasks.md new file mode 100644 index 00000000..bf448c83 --- /dev/null +++ b/openspec/changes/a-conversational-intake-that-files-for-the-citizen/tasks.md @@ -0,0 +1,70 @@ +# Tasks: a-conversational-intake-that-files-for-the-citizen + +## Implementation Tasks + +### Task 1: The intake surface, separate from the case assistant +- **spec_ref**: `openspec/changes/a-conversational-intake-that-files-for-the-citizen/specs/conversational-intake/spec.md` +- **acceptance_criteria**: + - A distinct surface, with a create-only tool grant scoped to declared record types + - It cannot read or change an existing record + - The case assistant surface still resolves to no tools + +- [ ] Add the surface and its narrow grant +- [ ] Add a test asserting the case assistant sentinel is unchanged + +### Task 2: Classification with confidence and abstention +- **acceptance_criteria**: + - The proposal is drawn from the owning app's declared catalogue + - A confidence travels with it + - Below an administered threshold the system abstains and hands over + - The threshold is readable + +- [ ] Add the classification call and the catalogue read +- [ ] Add the threshold setting and the abstention path + +### Task 3: No dead ends +- **acceptance_criteria**: + - Every terminal state is a filed request or a handover + - The transcript travels with a handover + +- [ ] Enumerate terminal states and add the handover path +- [ ] Add a test over the terminal states + +### Task 4: One conversation across channels +- **acceptance_criteria**: + - A conversation is keyed by person and subject + - A message on another channel attaches to an open conversation + - hermiq implements no channel transport + +- [ ] Add the conversation key and the attach rule +- [ ] Confirm channels arrive through their owning apps + +### Task 5: Deterministic before model +- **acceptance_criteria**: + - A deterministic signal from the owning app is used and recorded as such + - A model score is used only in its absence and is labelled everywhere + - A reader can tell the two apart + +- [ ] Read the owning app's signal where declared +- [ ] Add the label to every surface showing a score + +### Task 6: The external review tool +- **acceptance_criteria**: + - The review is declared by the owning app + - The verdict is carried into the conversation + - The run records the reviewing party and the time + - hermiq evaluates nothing itself + +- [ ] Add the declared review call and its recording + +### Task 7: Register intake as its own AI feature +- **acceptance_criteria**: + - It appears in the register with its own risk category + - The existing acknowledgement gate applies to it + +- [ ] Add the feature registration + +### Task 8: Verification +- [ ] Unit tests for the abstention threshold, the terminal states and the + deterministic preference +- [ ] e2e coverage or a reason-bearing exclusion per scenario, per gate 19 diff --git a/openspec/changes/a-provider-and-a-place-per-ai-feature/design.md b/openspec/changes/a-provider-and-a-place-per-ai-feature/design.md new file mode 100644 index 00000000..dca31e6b --- /dev/null +++ b/openspec/changes/a-provider-and-a-place-per-ai-feature/design.md @@ -0,0 +1,80 @@ +# Design: a provider and a place per AI feature + +## D1. Two registers that do not meet + +Measured on HEAD: + +| register | grain | what it decides | +|---|---|---| +| `ModelPolicy` (`tenant-model-policy`) | per organisation | which `{provider, models[]}` pairs are permitted at all | +| `AiFeature` (`ai-feature-governance`) | per feature | risk category, and whether the feature is on | + +The question the sweep asks falls exactly between them: which provider does **this +feature** use. Today the answer is "whichever the policy defaults to", for every +feature at once. + +The join is one optional pair of fields on `AiFeature`. It is small because both +halves already exist, and it is worth writing down because the missing join is +invisible: an administrator looking at either screen sees a complete-looking picture. + +## D2. The feature narrows, and can never widen + +Two orders were possible. + +1. The feature binding overrides the policy. Then a feature can reach a provider the + organisation has forbidden, and the policy stops meaning anything. +2. The feature binding must be a subset of the policy. + +Option 2, and it is enforced at two moments: when the binding is written, and again on +every turn. The write-time check gives an administrator an immediate answer. The +run-time check is the one that matters, because a policy narrowed **after** a binding +was written must take effect without anybody revisiting the feature. + +`tenant-model-policy` already enforces the pair on every turn regardless of trigger. +This change adds the feature's own binding into that resolution rather than beside it, +so there is still one enforcement point. + +## D3. Residency is administered, never inferred + +The tempting implementation is to read the provider's endpoint hostname and guess: +`.eu` means Europe, a local address means on premise. + +It is wrong in both directions. A `.eu` domain resolving to a US region is ordinary, +and an on-premise reverse proxy in front of a hosted model looks local from inside. +A residency that is guessed is a residency an FG cannot rely on, and the whole value +of this field is that somebody can be held to it. + +So the label is typed by whoever configures the provider, alongside a free-text +location for the detail an enum cannot carry ("Frankfurt, AWS eu-central-1"). It is a +statement of fact by an administrator, and it is auditable as one. + +## D4. The refusal happens before the call + +A run that discovers a residency mismatch after sending the text has already sent the +text. Logging it afterwards records a breach rather than preventing one. + +So the check sits with the existing model-policy check, before the provider call. +Ordering matters: resolve feature binding, then narrow by policy, then check +residency, then call. Each step can refuse, and each refusal names which step refused. + +## D5. What the run record has to carry + +`run-audit-log` writes every run to openregister's tamper-evident chain. The question +"which model saw this case, and where" needs three things that are not on it today: +the feature, the provider and model actually used, and the residency in force **at +the time of the run**. + +The last one is the subtle one. A provider relabelled next year must not rewrite what +last year's runs say, so the label is copied onto the run rather than referenced. This +is the same reasoning humaniq's dated working pattern uses: the record of what was +true then survives what is true now. + +## D6. The split deployment is already possible, and that is the finding + +Decos Join sells "assistant in our cloud, case system on yours" as a deployment +option. In hermiq it is a configuration: an on-premise Nextcloud with a feature bound +to a hosted provider. + +So this change does not build it. It makes it **expressible and visible**, which is +what the matrix could not do. The residency label is the difference between an +architecture diagram and an answerable question, and a tender asks the question. diff --git a/openspec/changes/a-provider-and-a-place-per-ai-feature/proposal.md b/openspec/changes/a-provider-and-a-place-per-ai-feature/proposal.md new file mode 100644 index 00000000..5bcd208e --- /dev/null +++ b/openspec/changes/a-provider-and-a-place-per-ai-feature/proposal.md @@ -0,0 +1,104 @@ +--- +kind: code +--- + +# Proposal: a-provider-and-a-place-per-ai-feature + +Round 4 discovery sweep, cluster 46 "AI, and what it is allowed to read" +(`procest/_round4/discovery/build-plan.md` in ConductionNL/market-intelligence, +2026-09-14). Owner hermiq on **decision D13**. Umbrella: +`competitor-parity-2026-09`. + +## Summary + +An AI feature names the provider it runs on and the place that provider runs in. A +gemeente can put summarising on a local model and translation on a hosted one, and can +answer the question every functionaris gegevensbescherming asks first: which model saw +this case, and in which jurisdiction. + +## Why + +`C-integrations-38` is a **`must`**, and it is the sharpest of the three in cluster 46 +because it is procurement-blocking rather than pleasant. The clause the lane recorded: +"a gemeente must be able to say which model saw the case and in which jurisdiction". + +hermiq is close already, and the change is a join rather than a build. +`tenant-model-policy` constrains `{provider, models[]}` per organisation, with an +instance-admin fallback and run-time enforcement on every turn. +`ai-feature-governance` registers each `AiFeature` with an EU AI Act risk category and +a lifecycle gated on a DPO acknowledgement. + +Two registers, and nothing joins them. A tenant policy allowing both Ollama and OpenAI +allows **every** feature to use either. That is exactly the granularity Zammad has and +we do not: it carries an AI provider connection and switches features separately +(`config/routes/ai_provider_connection.rb`, `ai_agent.rb`, `ai_text_tool.rb`, +`ai_vector_index.rb`, admin areas `AI::Provider` and `AI::Assistance`, permissions +`admin.ai_provider` and `admin.ai_assistance_ticket_summary`). + +The second candidate is the deployment shape the same question implies. `Decos Join` +markets a split where the assistant runs in the vendor's cloud and the case system +stays on premise. The sweep's note is honest about it: "a deployment shape our matrix +cannot express". hermiq can express it cheaply, because a residency label on a +provider is one field and an answerable question. + +## The candidates, with their lane citations + +| id | capability | relevance | driven passers | lane | +|---|---|---|---|---| +| C-integrations-38 | The buyer chooses which AI provider is used and where it runs, per feature. | must | zammad | `integrations.tsv:34` | +| C-configuration-75 | The assistant runs in the vendor's cloud while the case system stays on premise. | could | none, decos-join documented | `configuration.tsv:111` | + +`C-configuration-75` has **no driven passer**. The evidence is Decos Join's +documented `/zaaksysteem/joni-plus` page, admitted under **decision D21** and labelled +documented in the spec below. A vendor page is an upper bound, never a measurement. + +## What hermiq builds + +- **A provider binding on the feature.** `AiFeature` gains an optional provider and + model. When set, runs of that feature use it. When unset, the feature falls back to + the organisation's `ModelPolicy` default, which is what happens today. +- **The binding is checked against the policy, not instead of it.** A feature cannot + name a provider the organisation's `ModelPolicy` forbids. The policy stays the + ceiling and the feature binding narrows within it. +- **A residency label on a provider.** Each configured provider declares where it + runs: `on-premise`, `eu` or `outside-eu`, plus a free-text location. The label is + administered, not inferred from a hostname, because a hostname is not a + jurisdiction. +- **A feature may require a residency.** An `AiFeature` may declare the residency it + demands. A run that would send data to a provider outside it is refused before the + call, not logged after it. +- **The answer, on the record.** Every run records the feature, the provider, the + model and the residency in force at the time. "Which model saw this case, and + where" becomes a read rather than an investigation. +- **A split deployment is a configuration, not a fork.** The on-premise instance + keeps its cases and points a feature at a provider labelled `eu` or `outside-eu`. + Nothing about the case system moves, and the residency label says what left. + +## How dossiq consumes it + +dossiq declares its AI features and nothing else. It does not choose a provider, does +not hold a residency and does not configure a model. When a run is refused on +residency, dossiq shows the refusal with the feature and the required residency +named, so a handler reads a reason rather than an error. + +## The existing specs this extends + +- `ai-feature-governance`, which owns the `AiFeature` register, the EU AI Act risk + category and the DPO acknowledgement gate. The gate is untouched: a feature that + names a provider is still disabled until the DPO signs for it. +- `tenant-model-policy`, which stays the ceiling. Its four requirements are unchanged, + and this change adds nothing that can widen a policy. +- `run-audit-log`, which gains three fields on what it already records. + +## Size and dependencies + +**Size: M.** Three fields, one narrowing check, one refusal and one recorded answer. +Both registers exist. + +**Depends on:** nothing in this umbrella. + +## What this change does not do + +It does not add a provider driver. The four supported chat drivers stay what +`tenant-model-policy` names. A residency label describes where an existing driver's +endpoint runs; it does not create a new one. diff --git a/openspec/changes/a-provider-and-a-place-per-ai-feature/specs/ai-feature-governance/spec.md b/openspec/changes/a-provider-and-a-place-per-ai-feature/specs/ai-feature-governance/spec.md new file mode 100644 index 00000000..96d37064 --- /dev/null +++ b/openspec/changes/a-provider-and-a-place-per-ai-feature/specs/ai-feature-governance/spec.md @@ -0,0 +1,161 @@ +# ai-feature-governance + +## ADDED Requirements + +### Requirement: An AI feature MAY bind its own provider and model + +The system MUST let an `AiFeature` carry an optional `provider` and `model`. When +both are set, runs of that feature MUST use them. When they are unset, the feature +MUST fall back to the organisation's effective `ModelPolicy` default, which is the +behaviour today. + +A binding MUST NOT be writable when the organisation's effective `ModelPolicy` does +not permit the pair, and the refusal MUST name the policy that forbids it. + +Candidate C-integrations-38 (`integrations.tsv:34`), relevance **`must`**, driven +passer zammad: AI provider and features at `config/routes/ai_provider_connection.rb`, +`ai_agent.rb`, `ai_text_tool.rb` and `ai_vector_index.rb`, admin areas `AI::Provider` +and `AI::Assistance`, permissions `admin.ai_provider` and +`admin.ai_assistance_ticket_summary`. + +#### Scenario: Two features, two providers + +- **GIVEN** an organisation whose `ModelPolicy` permits both `ollama` and `openai` +- **WHEN** one feature is bound to `ollama` and another to `openai` +- **THEN** each feature's runs MUST use its own provider + +#### Scenario: A binding outside the policy is refused at write time + +- **GIVEN** an organisation whose `ModelPolicy` permits only `ollama` +- **WHEN** a feature is bound to `openai` +- **THEN** the write MUST be refused, naming the policy + +#### Scenario: An unbound feature keeps today's behaviour + +- **GIVEN** a feature with no provider or model set +- **WHEN** it runs +- **THEN** the effective `ModelPolicy` default MUST be used + +### Requirement: The feature binding narrows the policy and is re-checked on every turn + +The system MUST resolve a run's `(provider, model)` pair as: the feature's binding +when set, otherwise the effective `ModelPolicy` default, and MUST then check the +resolved pair against the effective `ModelPolicy` on **every** turn, whatever +triggered the run. + +A policy narrowed after a binding was written MUST take effect without the feature +being revisited. A feature binding MUST NOT be able to widen what a policy permits. + +#### Scenario: Narrowing the policy disables a stale binding + +- **GIVEN** a feature bound to `openai` under a policy that permitted it +- **WHEN** the organisation's policy is narrowed to `ollama` only +- **THEN** the next run of that feature MUST be refused, naming the policy, with no + edit to the feature + +#### Scenario: One enforcement point, whatever the trigger + +- **GIVEN** a feature whose binding is outside the current policy +- **WHEN** it is run from a schedule tick, a manual run and an interactive turn +- **THEN** all three MUST be refused identically + +### Requirement: A configured provider MUST declare where it runs + +The system MUST let each configured provider carry a `residency` of `on-premise`, +`eu` or `outside-eu`, plus a free-text `location`. The residency MUST be administered +by whoever configures the provider and MUST NOT be inferred from an endpoint hostname +or address. + +A `.eu` hostname resolving outside the EU, and a local reverse proxy in front of a +hosted model, are both ordinary. A guessed residency is one no data protection officer +can rely on, and reliance is the whole purpose of the field. + +#### Scenario: Residency is a statement, not a guess + +- **WHEN** a provider is configured with an endpoint whose hostname suggests one + region +- **THEN** the system MUST NOT set a residency from that hostname, and MUST require + the administrator to state it + +#### Scenario: The detail an enum cannot carry is kept + +- **GIVEN** a provider declared `eu` +- **WHEN** its location is read +- **THEN** the free-text location the administrator typed MUST be returned with it + +### Requirement: A feature MAY require a residency, and a run outside it is refused before the call + +The system MUST let an `AiFeature` declare a `requiredResidency`. When it is set, a +run whose resolved provider carries a different residency MUST be refused **before** +any request reaches the provider. The refusal MUST name the feature, the required +residency and the provider's actual residency. + +The check MUST run in the same place as the model-policy check, in the order: resolve +the feature binding, narrow by policy, check residency, then call. Each step MUST name +itself when it refuses. + +A run refused after the text has been sent records a breach rather than preventing +one, which is why the ordering is specified rather than left to the implementation. + +#### Scenario: Case text never leaves for a forbidden region + +- **GIVEN** a feature requiring `on-premise` and a provider carrying `outside-eu` +- **WHEN** the feature runs +- **THEN** no request MUST reach the provider, and the refusal MUST name both + residencies + +#### Scenario: The refusing step names itself + +- **GIVEN** a run refused on residency and another refused on model policy +- **WHEN** each refusal is read +- **THEN** each MUST say which check refused it + +#### Scenario: No required residency refuses nothing + +- **GIVEN** a feature with no `requiredResidency` +- **WHEN** it runs against any permitted provider +- **THEN** no residency refusal MUST occur + +### Requirement: Every run MUST record the feature, the provider and the residency in force + +The system MUST record, on each run entry written to the audit trail, the AI feature +the run belongs to, the provider and model actually used, and the provider's residency +and location **as they stood at the time of the run**. + +The residency MUST be copied onto the run rather than referenced, so relabelling a +provider later MUST NOT change what earlier runs say. + +#### Scenario: Which model saw this case, and where, is a read + +- **GIVEN** a completed run +- **WHEN** its audit entry is read +- **THEN** it MUST carry the feature, the provider, the model, the residency and the + location + +#### Scenario: Relabelling a provider does not rewrite history + +- **GIVEN** runs recorded while a provider was labelled `eu` +- **WHEN** the provider is relabelled `outside-eu` +- **THEN** those earlier runs MUST still read `eu` + +### Requirement: A split deployment MUST be expressible as configuration + +An instance running on premise MUST be able to bind a feature to a provider whose +residency is `eu` or `outside-eu`, with no change to where its objects are stored. The +residency label MUST make visible exactly which features send data off the premises. + +Candidate C-configuration-75 (`configuration.tsv:111`), relevance `could`. **No +driven passer.** The evidence is decos-join's documented `/zaaksysteem/joni-plus` +page, admitted under decision D21 and labelled documented here. + +#### Scenario: The case system stays where it is + +- **GIVEN** an on-premise instance with one feature bound to a hosted provider +- **WHEN** that feature runs +- **THEN** only that feature's text MUST reach the hosted provider, and no object + storage MUST move + +#### Scenario: What leaves the building is readable in one place + +- **WHEN** an administrator reads the AI feature register +- **THEN** each feature MUST show the residency of the provider it will use diff --git a/openspec/changes/a-provider-and-a-place-per-ai-feature/tasks.md b/openspec/changes/a-provider-and-a-place-per-ai-feature/tasks.md new file mode 100644 index 00000000..40c88d45 --- /dev/null +++ b/openspec/changes/a-provider-and-a-place-per-ai-feature/tasks.md @@ -0,0 +1,52 @@ +# Tasks: a-provider-and-a-place-per-ai-feature + +## Implementation Tasks + +### Task 1: Bind a provider and model to an AI feature +- **spec_ref**: `openspec/changes/a-provider-and-a-place-per-ai-feature/specs/ai-feature-governance/spec.md` +- **acceptance_criteria**: + - `AiFeature` carries an optional `provider` and `model` + - An unbound feature resolves to the effective `ModelPolicy` default, as today + - A binding outside the effective policy is refused at write time, naming the policy + +- [ ] Add the fields to the `AiFeature` register fragment +- [ ] Add the write-time narrowing check + +### Task 2: Resolve and re-check on every turn +- **acceptance_criteria**: + - Resolution order is feature binding, then policy default + - The resolved pair is checked against the effective policy on every turn, whatever + the trigger + - A policy narrowed after the binding was written refuses the next run + +- [ ] Fold the binding into the existing model-policy resolution, not beside it + +### Task 3: Residency on a configured provider +- **acceptance_criteria**: + - `residency` is one of `on-premise`, `eu`, `outside-eu`, plus a free-text + `location` + - Nothing infers residency from a hostname or an address + +- [ ] Add the fields to the provider configuration +- [ ] Add the admin surface for them + +### Task 4: Refuse a run outside a required residency, before the call +- **acceptance_criteria**: + - Order is: resolve binding, narrow by policy, check residency, call + - No request reaches the provider when residency refuses + - Each refusal names the check that refused it + +- [ ] Add `requiredResidency` to `AiFeature` +- [ ] Add the check in the existing pre-call path + +### Task 5: Record the answer on the run +- **acceptance_criteria**: + - Each run entry carries feature, provider, model, residency and location + - Residency is copied, not referenced, so relabelling does not rewrite history + +- [ ] Extend what `run-audit-log` writes + +### Task 6: Verification +- [ ] Unit tests for the narrowing check, the stale-binding refusal and the + pre-call ordering +- [ ] e2e coverage or a reason-bearing exclusion per scenario, per gate 19 diff --git a/openspec/changes/competitor-parity-2026-09/.openspec.yaml b/openspec/changes/competitor-parity-2026-09/.openspec.yaml new file mode 100644 index 00000000..350f16df --- /dev/null +++ b/openspec/changes/competitor-parity-2026-09/.openspec.yaml @@ -0,0 +1,13 @@ +schema: conduction +created: 2026-09-14 + +# This change is an umbrella. It records where the five wave-3 changes beside it came +# from and which decision each rests on; it states no requirement of hermiq's own, +# which is why `specs/` is empty. An empty `specs/` is what `openspec validate` +# refuses without this flag ("Change must have at least one delta"), because the far +# commoner cause is an author who forgot to write the delta. +# +# Declaring it here rather than inventing a delta: a spec written only to satisfy a +# validator states a requirement nobody agreed to, and archiving would promote it into +# openspec/specs/ where it reads as binding. +skip_specs: true diff --git a/openspec/changes/competitor-parity-2026-09/proposal.md b/openspec/changes/competitor-parity-2026-09/proposal.md new file mode 100644 index 00000000..2b085363 --- /dev/null +++ b/openspec/changes/competitor-parity-2026-09/proposal.md @@ -0,0 +1,85 @@ +# Proposal: competitor-parity-2026-09 + +Round 4 discovery sweep of the dossiq competitor programme +(`procest/_round4/discovery/build-plan.md` in ConductionNL/market-intelligence, +written 2026-09-14). 36 systems read, 631 candidates kept, 70 capability clusters. +This is the hermiq umbrella for wave 3. + +## Summary + +**Decision D13 puts the assistant in hermiq.** dossiq declares which tools exist and +who may call them, anonymisation before a model reads belongs to filinq, and the run +record goes to openregister's audit trail. Cluster 46, "AI, and what it is allowed to +read", is hermiq's: nine candidates, three of them `must`, four driven passers. + +This umbrella carries no requirement of its own. It records where the five changes +beside it came from, so a reader who finds one finds the rest. + +## Why + +D13 rejected two alternatives explicitly. dossiq shipping its own assistant costs a +second assistant in the fleet. Switching each AI capability on separately is not an +alternative at all: the sweep found it as a `must` in its own right, so it becomes a +requirement on whichever app wins, and hermiq won. + +What hermiq already has matters as much as what it lacks, and each change below says +which. `tenant-model-policy` already constrains provider and model per organisation. +`ai-feature-governance` already gates a high-risk feature behind a DPO +acknowledgement. `run-audit-log` already records every run and every tool call on +openregister's tamper-evident chain. `agent-tool-governance` already holds per-agent +grants and the article 12 and 14 oversight surface. `woo-llm-anonymisation` already +runs a tool-free PII detection turn. + +Four of the nine candidates land on gaps inside those capabilities rather than on +empty ground, which is why three of the five changes are M rather than L. + +## The changes under it + +| change | candidates | size | +|---|---|---| +| `a-provider-and-a-place-per-ai-feature` | C-integrations-38, C-configuration-75 | M | +| `what-the-model-reads-and-what-is-kept` | C-access-and-privacy-3, C-access-and-privacy-53 | M | +| `the-declared-tool-surface-and-the-prompt-library` | C-integrations-19, C-configuration-44 | M | +| `a-conversational-intake-that-files-for-the-citizen` | C-intake-9, C-communication-64, C-integrations-1 | L | +| `identical-reports-collapse-into-one` | C-intake-30 | M | + +Cluster 46 holds the first nine. `C-intake-30` is not in it: the sweep puts it in +cluster 35, "intake routing, refusal and triage", owned by dossiq. Ruben's wave-3 +instruction assigns the judgement half to hermiq, and the change below draws that line +explicitly: hermiq decides whether two reports are the same thing, dossiq decides what +happens next. + +## The decisions these rest on + +- **D13.** hermiq owns the assistant. dossiq declares tools and rights. Anonymisation + before the model reads is filinq's. The run record goes to openregister's audit + trail. +- **D6.** Promotion is relevance-led and every `must` enters. Four of the ten + candidates here are `must`: an AI provider chosen per feature, anonymisation before + a model reads a document, every AI run recorded and retained, and identical reports + collapsed with a count. +- **D21.** A documented candidate is admitted and labelled. Five of the ten have **no + driven passer at all**, which is a higher share than any other wave-3 cluster. Each + requirement resting on one says so on its face. +- **D17.** The product serves a broad market including MKB, and a candidate rated + `not` is not disqualified. None of hermiq's ten is in the `not` bucket. + +## What is deliberately not here + +The sweep's candidate `C-search-38`, a query and command language in the +organisation's own language, sits in cluster 65, "search quality", owned by +openregister. Parked question Q9.15 revived with four passers (nextcloud-deck, +request-tracker, itop, youtrack) under decision D5, and its note is that YouTrack's +language exists in the system language while every other query language in the corpus +is English. The build plan places it with openregister's search and facet layer, not +with hermiq, so no change here claims it. + +## Affected projects + +- `hermiq`: five changes, listed above. +- `dossiq`: declares which tools exist and who may call them, places the assistant + surfaces, decides what to do with a collapsed report. Not changed here. +- `filinq`: owns the redaction that runs before a model reads a document. Not changed + here. +- `openregister`: owns the audit trail every run is written to, and the search and + command language. Not changed here. diff --git a/openspec/changes/competitor-parity-2026-09/tasks.md b/openspec/changes/competitor-parity-2026-09/tasks.md new file mode 100644 index 00000000..e08553a8 --- /dev/null +++ b/openspec/changes/competitor-parity-2026-09/tasks.md @@ -0,0 +1,21 @@ +# Tasks: competitor-parity-2026-09 + +## The umbrella + +- [x] Record the five wave-3 changes, their candidate ids and their sizes. +- [x] Record the decisions each one rests on (D13, D6, D21, D17). +- [x] Record what hermiq already ships against cluster 46, so no change rebuilds it. +- [x] Record why the command language (Q9.15, `C-search-38`) stays with openregister. + +## Per change, done inside that change + +- [ ] `a-provider-and-a-place-per-ai-feature` +- [ ] `what-the-model-reads-and-what-is-kept` +- [ ] `the-declared-tool-surface-and-the-prompt-library` +- [ ] `a-conversational-intake-that-files-for-the-citizen` +- [ ] `identical-reports-collapse-into-one` + +## Closing + +- [ ] Archive this umbrella only after the five changes under it are archived, so the + trail from a candidate id to a merged requirement stays readable. diff --git a/openspec/changes/identical-reports-collapse-into-one/design.md b/openspec/changes/identical-reports-collapse-into-one/design.md new file mode 100644 index 00000000..5c9f594e --- /dev/null +++ b/openspec/changes/identical-reports-collapse-into-one/design.md @@ -0,0 +1,98 @@ +# Design: identical reports collapse into one + +## D1. "Identical" is the wrong word, and the spec says the right one + +The candidate's row reads "identical reports collapsed into one". Taken literally it +is worthless: two people describing one power cut write different sentences, and a +byte comparison finds nothing. + +What a gemeente means is "about the same event". That is a judgement, it is +occasionally wrong, and the design has to survive being wrong rather than aim at never +being. + +Everything below follows from that: bands instead of a boolean, reversal without loss, +and reasons a human can read. + +## D2. Three bands, because the middle one is where the value is + +A boolean classifier forces every borderline case into one of two mistakes. + +- Called the same: a genuinely separate report is buried inside a group of two + hundred, and the person who filed it never hears back about their actual problem. +- Called different: two hundred and one items, which is the situation today. + +So three bands: + +| band | what happens | who sees it | +|---|---|---| +| above the upper threshold | joins the group | counted | +| between the thresholds | joins the group, flagged uncertain | listed beside the group for a human | +| below the lower threshold | stands alone | normal intake | + +The middle band is the candidate's own phrase, "with the near-duplicates beside it". +It is not hedging; it is where a human's attention should go, and it is the only band +that needs a screen. + +Both thresholds are administered, because the cost of each mistake is a +municipality's to weigh. + +## D3. Grouping is additive, so undoing it costs nothing + +The destructive implementation merges the reports, keeps one and discards the rest. +Undoing that means recovering discarded data, which is the kind of recovery that works +in a test and not in March. + +So a group is a set of references with a score. Every report still exists, still has +its own reporter, and still has its own acknowledgement. Pulling one out is deleting a +membership, and the count moves. + +This also makes the failure cheap. A wrong grouping costs a click, so the thresholds +can be set where they are useful rather than where they are safe. + +## D4. The acknowledgement is per person, always + +The sweep's own front page names row 17 of the loudest twenty-five as the first thing +to fix: an automatic acknowledgement on creation is Awb 4:3a, a statutory duty, and +dossiq returns zero hits for one. + +Grouping is exactly the feature that could quietly break that duty before it is built. +A handler seeing one item where two hundred people wrote will find it natural that one +confirmation went out. + +So it is written here, in the spec of the thing that would cause it: collapsing is a +view for the handler and never reduces the number of confirmations owed. Two hundred +people who wrote to the gemeente are owed two hundred confirmations. + +## D5. The deterministic key runs first, again + +For a melding a gemeente often already knows the answer: same address, same category, +within an hour. That is a key, not a judgement. It is explainable, stable and free. + +The rule is the one `a-conversational-intake-that-files-for-the-citizen` states: where +the owning app supplies a deterministic key, use it and record that you did. The model +is for what the key does not catch, which for a street-wide storing is most of it, +because people report from the address they are standing at rather than the one that +broke. + +Recording which decided a grouping is what makes an argument about it possible. + +## D6. The window is bounded, and the bound is the point + +Comparing a new report against every report ever filed is expensive and wrong. A +melding about a streetlight in March and one in October are not one event, however +similar the text. + +So the comparison runs over an open window whose length is administered per report +type. A storing lasts hours; a recurring nuisance complaint might reasonably group +over weeks. The window is part of the reason a group carries, so a reader can see what +was and was not considered. + +## D7. Why these reasons have to be readable + +A citizen told "your melding was merged into an existing one" will sometimes disagree, +and they will be right often enough that the answer cannot be "the model said so". + +A group therefore carries the terms that matched, the window in force, the score and +whether a deterministic key or the model decided. That is enough for a handler to +defend the grouping or to undo it, which are the only two useful outcomes of the +conversation. diff --git a/openspec/changes/identical-reports-collapse-into-one/proposal.md b/openspec/changes/identical-reports-collapse-into-one/proposal.md new file mode 100644 index 00000000..c0774f72 --- /dev/null +++ b/openspec/changes/identical-reports-collapse-into-one/proposal.md @@ -0,0 +1,113 @@ +--- +kind: code +--- + +# Proposal: identical-reports-collapse-into-one + +Round 4 discovery sweep, candidate `C-intake-30`, relevance **`must`**. The sweep +places it in cluster 35, "intake routing, refusal and triage", owned by dossiq +(`procest/_round4/discovery/build-plan.md` in ConductionNL/market-intelligence, +2026-09-14). Ruben's wave-3 instruction assigns the judgement half to hermiq under +**decision D13**. Umbrella: `competitor-parity-2026-09`. + +## Summary + +Two hundred meldingen about one street-wide power cut become one item with a count, +with the near-duplicates listed beside it. hermiq decides whether two reports describe +the same thing. dossiq decides what happens to them. + +## Why + +The lane's clause is the scene: **"two hundred meldingen about one straat-brede +storing, and dq 2.24 is one person filing twice, which is a different problem"**. That +sentence separates two things a reader will otherwise merge. + +| | ledger row 2.24 | this candidate | +|---|---|---| +| the situation | one person files the same thing twice | two hundred people file one event | +| the test | same reporter, same subject | different reporters, one underlying cause | +| the answer | refuse or link the second | collapse into one, with a count | +| where it lives | dossiq, cluster 35 | the judgement here, the act in dossiq | + +The sweep's own note says it: "2.24 detects a duplicate; this one collapses them and +counts." dossiq reads `no` on this candidate. + +**Why the judgement is hermiq's.** "Identical" is easy and useless: two people never +type the same words. The useful test is "the same underlying event", and that is a +similarity judgement over free text, which is what decision D13 puts in hermiq. dossiq +holding it would mean a second model consumer in the fleet, and the shape D13 rejected. + +**Why the act is dossiq's.** Whether a collapsed group becomes one case with two +hundred reporters, or two hundred cases linked to one parent, is a statutory question +about acknowledgement duties and archiving. hermiq has no business answering it. + +## The candidate, with its lane citation + +| id | capability | relevance | driven passers | lane | +|---|---|---|---|---| +| C-intake-30 | Identical incoming reports collapse into one item with a count, with the near-duplicates beside it. | must | none, jira-service-management documented | `intake.tsv:30` | + +**No driven passer.** The evidence is Jira Service Management's documented alert +grouping: what alerts are, how they are created, configuring alert grouping, grouping +with Rovo, related alert groups, alerts by signal or noise, regular expressions for +filtering, and viewing similar alerts with past responders. Admitted under **decision +D21** and labelled documented in the spec. A `must` resting on a vendor page is worth +noticing, and the spec says so on its face. + +## What hermiq builds + +- **A similarity question with a group answer.** Given a new report and an open window + of recent ones, hermiq answers which existing group it belongs to, or that it starts + a new one, with a score. +- **Three bands, not two.** Above an upper threshold it is the same event. Below a + lower one it is not. Between them it is a near-duplicate: attached to the group and + flagged as uncertain, so a human sees it beside the group rather than buried in it + or lost from it. +- **A group is a judgement, not a record of anything.** hermiq holds the grouping and + the score. It creates no case, merges nothing and deletes nothing. +- **Reversal without loss.** A report pulled out of a group returns to standing alone, + and the group's count moves. Nothing was destroyed to form a group, so nothing has + to be recovered to undo one. +- **The reasons are readable.** A group carries why its members were grouped: the + terms, the window and the score. "Why are these two hundred one thing" must be + answerable to a citizen who disagrees. +- **A deterministic signal first, again.** Where the owning app supplies a + deterministic key, such as the same address and the same category within an hour, + hermiq uses it and records that it did. The model is for what the key does not + catch. + +## How dossiq consumes it + +1. dossiq asks hermiq, per incoming report, which group it belongs to. +2. dossiq decides what a group means: one case with many reporters, or many cases + under a parent. That decision stays statutory and stays in dossiq. +3. dossiq's `SentimentService` and its own duplicate detection for row 2.24 are + untouched. One person filing twice is still dossiq's, and is a different test. +4. The acknowledgement duty under Awb 4:3a applies per request, whatever the grouping. + Collapsing reports for a handler's view must not collapse two hundred + acknowledgements into one, and the spec says so. + +## The existing specs this extends + +- `run-audit-log` records each similarity judgement as a run, so a grouping is + auditable like any other model output. +- `ai-feature-governance` registers the grouping as its own feature with its own risk + category. +- `agent-guardrails` applies: the input is text from the public. +- Nothing in `case-assistant-surface` changes. This is not a chat. + +## Size and dependencies + +**Size: M.** One similarity question, three bands, a group object, a reversal and a +reasons record. + +**Depends on:** `what-the-model-reads-and-what-is-kept` for the retention on the text +being compared, and `a-conversational-intake-that-files-for-the-citizen` for the +deterministic-before-model rule this change reuses. + +## What this change does not do + +- It does not merge, close or delete anything. Grouping is additive and reversible. +- It does not answer row 2.24. One person filing twice is a different test, in dossiq. +- It does not suppress an acknowledgement. Two hundred people who wrote to the + gemeente are owed two hundred confirmations of receipt. diff --git a/openspec/changes/identical-reports-collapse-into-one/specs/report-similarity/spec.md b/openspec/changes/identical-reports-collapse-into-one/specs/report-similarity/spec.md new file mode 100644 index 00000000..9726d6c9 --- /dev/null +++ b/openspec/changes/identical-reports-collapse-into-one/specs/report-similarity/spec.md @@ -0,0 +1,172 @@ +# report-similarity + +## ADDED Requirements + +### Requirement: hermiq MUST answer which group a report belongs to, and MUST NOT act on the answer + +The system MUST answer, for an incoming report and an open window of recent reports, +which existing group it belongs to or that it starts a new one, with a score. The +system MUST NOT create, merge, close or delete any record as a result. + +What a group means, and what is created for it, belongs to the app that owns the +reports. That decision is statutory and is not hermiq's. + +Candidate C-intake-30 (`intake.tsv:30`), relevance **`must`**. **No driven passer.** +The evidence is jira-service-management's documented alert grouping: configure alert +grouping, group alerts using Rovo, view related alert groups, view alerts by signal or +noise, view similar alerts and past responders. Admitted under decision D21 and +labelled documented here. + +The lane's clause: two hundred meldingen about one straat-brede storing. Ledger row +2.24 is one person filing twice, which is a different test and stays with the owning +app. + +#### Scenario: Two hundred reports of one power cut answer as one group + +- **GIVEN** two hundred reports describing one street-wide outage within the window +- **WHEN** each is evaluated +- **THEN** they MUST answer as one group with a count of two hundred + +#### Scenario: hermiq creates nothing + +- **WHEN** a group is formed +- **THEN** no case, no merge and no deletion MUST have occurred in any app as a direct + result + +### Requirement: A report MUST fall into one of three bands, not two + +The system MUST classify a report against a group in three bands: above an upper +threshold it joins the group, below a lower threshold it stands alone, and between +them it joins the group **flagged as uncertain** and MUST be listed beside the group +rather than counted silently into it. + +Both thresholds MUST be administered. The cost of each kind of mistake is the +municipality's to weigh. + +A boolean answer forces every borderline report into one of two failures: a genuinely +separate report buried inside a group of two hundred, or two hundred and one separate +items. The middle band is where a human's attention belongs. + +#### Scenario: A near-duplicate is visible, not buried + +- **GIVEN** a report scoring between the thresholds +- **WHEN** the group is read +- **THEN** the report MUST be attached and flagged uncertain, and MUST appear beside + the group for a human + +#### Scenario: A clearly separate report stands alone + +- **GIVEN** a report scoring below the lower threshold +- **WHEN** it is evaluated +- **THEN** it MUST start its own group + +#### Scenario: The thresholds are the administrator's + +- **WHEN** an administrator reads the grouping settings +- **THEN** both thresholds MUST be shown and MUST be editable + +### Requirement: Grouping MUST be additive and reversible without loss + +A group MUST be a set of references with a score. Every report in a group MUST remain +a complete record of its own, with its own reporter. Removing a report from a group +MUST leave it standing alone and MUST move the group's count. + +Nothing MUST be discarded to form a group, so nothing MUST need recovering to undo +one. + +#### Scenario: Pulling one report out costs nothing + +- **GIVEN** a group of two hundred +- **WHEN** one report is removed from it +- **THEN** that report MUST stand alone unchanged, and the count MUST read one hundred + and ninety-nine + +#### Scenario: No report is destroyed by grouping + +- **WHEN** the reports in a group are read +- **THEN** each MUST carry its own reporter and its own content, unmodified by the + grouping + +### Requirement: Grouping MUST NOT reduce the confirmations of receipt owed + +The system MUST NOT reduce, suppress or combine the acknowledgements owed to the +people who filed the reports in a group. A group is a view for the handler. + +Awb 4:3a owes every electronic request a confirmation of receipt. Two hundred people +who wrote to the gemeente are owed two hundred confirmations. A handler seeing one +item where two hundred people wrote will find it natural that one confirmation went +out, which is why this is stated in the spec of the feature that would cause it. + +#### Scenario: Two hundred reporters, two hundred confirmations + +- **GIVEN** two hundred reports collapsed into one group +- **WHEN** acknowledgement is evaluated +- **THEN** two hundred confirmations MUST be owed, one per report + +#### Scenario: Grouping carries no acknowledgement effect + +- **WHEN** the grouping answer is read +- **THEN** it MUST carry no instruction about acknowledgement, and the owning app's + duty MUST be unchanged by it + +### Requirement: A deterministic key MUST be preferred over a model judgement + +Where the owning app supplies a deterministic grouping key, such as the same location +and category within a stated period, the system MUST use it and MUST record that a +deterministic key decided. A model similarity MUST be used only for what the key does +not catch, and the group MUST record which decided each membership. + +#### Scenario: The key decides where it can + +- **GIVEN** an owning app supplying a deterministic key and two reports matching it +- **WHEN** they are evaluated +- **THEN** they MUST group on the key, and the membership MUST record that + +#### Scenario: The model covers what the key misses + +- **GIVEN** two reports of one outage filed from different addresses +- **WHEN** they are evaluated +- **THEN** the key MUST NOT match, the model MUST decide, and the membership MUST + record that the model decided + +### Requirement: The comparison window MUST be bounded and administered + +The system MUST compare an incoming report only against reports inside an open window +whose length is administered per report type. The window in force MUST be recorded on +the group. + +A report about a streetlight in March and one in October are not one event, however +alike the text. An unbounded comparison is both expensive and wrong. + +#### Scenario: An old report is out of scope + +- **GIVEN** a window of twenty-four hours and a similar report from last month +- **WHEN** a new report is evaluated +- **THEN** the old report MUST NOT be considered + +#### Scenario: The window is part of the record + +- **WHEN** a group is read +- **THEN** the window in force when it was formed MUST be readable on it + +### Requirement: A group MUST carry the reasons its members were grouped + +The system MUST record, per group, the terms that matched, the window in force, the +score per member, and whether a deterministic key or the model decided that member. +The reasons MUST be readable by a handler. + +A citizen told their melding was folded into an existing one will sometimes disagree, +and the answer cannot be that the model said so. A handler needs enough to defend the +grouping or to undo it, which are the only two useful outcomes of that conversation. + +#### Scenario: Why these are one thing is answerable + +- **GIVEN** a group of two hundred +- **WHEN** a handler asks why a given report is in it +- **THEN** the terms, the window, the score and the deciding method MUST be shown + +#### Scenario: Each judgement is on the audit trail + +- **WHEN** a similarity judgement is made +- **THEN** it MUST be recorded as a run on the audit trail, like any other model + output diff --git a/openspec/changes/identical-reports-collapse-into-one/tasks.md b/openspec/changes/identical-reports-collapse-into-one/tasks.md new file mode 100644 index 00000000..ead1bf6a --- /dev/null +++ b/openspec/changes/identical-reports-collapse-into-one/tasks.md @@ -0,0 +1,65 @@ +# Tasks: identical-reports-collapse-into-one + +## Implementation Tasks + +### Task 1: The similarity question +- **spec_ref**: `openspec/changes/identical-reports-collapse-into-one/specs/report-similarity/spec.md` +- **acceptance_criteria**: + - Given a report and an open window, the answer is a group or a new group, with a + score + - No record is created, merged, closed or deleted by hermiq + +- [ ] Add the similarity endpoint and its group answer + +### Task 2: Three bands +- **acceptance_criteria**: + - Above the upper threshold: joins, counted + - Between: joins, flagged uncertain, listed beside the group + - Below the lower threshold: stands alone + - Both thresholds are administered and readable + +- [ ] Add the bands and the thresholds +- [ ] Add the near-duplicate listing + +### Task 3: Additive and reversible +- **acceptance_criteria**: + - A group is a set of references plus scores + - Every report stays whole, with its own reporter + - Removing a membership restores a standalone report and moves the count + +- [ ] Add the group object and the membership removal + +### Task 4: Acknowledgements are untouched +- **acceptance_criteria**: + - The grouping answer carries no acknowledgement instruction + - Two hundred reports owe two hundred confirmations + +- [ ] Add a test asserting grouping changes no acknowledgement count + +### Task 5: Deterministic key first +- **acceptance_criteria**: + - The owning app's key is used where supplied, and recorded as the decider + - The model decides only what the key misses, and is recorded as the decider + +- [ ] Read the declared key +- [ ] Record the deciding method per membership + +### Task 6: The bounded window +- **acceptance_criteria**: + - The window is administered per report type + - The window in force is recorded on the group + +- [ ] Add the window setting and the recording + +### Task 7: Readable reasons +- **acceptance_criteria**: + - Terms, window, per-member score and deciding method are readable by a handler + - Each judgement is recorded as a run on the audit trail + +- [ ] Add the reasons record and its surface +- [ ] Write each judgement to the audit trail + +### Task 8: Verification +- [ ] Unit tests for the three bands, the reversal and the untouched acknowledgement + count +- [ ] e2e coverage or a reason-bearing exclusion per scenario, per gate 19 diff --git a/openspec/changes/the-declared-tool-surface-and-the-prompt-library/design.md b/openspec/changes/the-declared-tool-surface-and-the-prompt-library/design.md new file mode 100644 index 00000000..516287d8 --- /dev/null +++ b/openspec/changes/the-declared-tool-surface-and-the-prompt-library/design.md @@ -0,0 +1,88 @@ +# Design: the declared tool surface and the prompt library + +## D1. Two directions with one word + +| | inbound, today | outbound, this change | +|---|---|---| +| who acts | an agent inside hermiq | an agent outside Nextcloud | +| what it consumes | OpenRegister's derived MCP catalogue | tools an owning app declares as reachable | +| who authorises | OpenRegister RBAC at invoke | the owning app, as the calling principal | +| grants | `Agent.tools`, per agent | a registration, per client | +| spec | `agent-tool-governance` | this change, beside it | + +`agent-tool-governance` is explicit that hermiq is the sole agent consumer of the +catalogue and ships no tool code. The outbound direction is the mirror: hermiq is the +publisher and still ships no tool code. + +They share one rule, deliberately: default-deny on anything that writes. Writing that +rule twice would let the two drift, and a drift in that particular rule is a silent +write. + +## D2. The caller's rights, and nothing else + +The candidate's wording is exact: "under the caller's own rights". Two shapes were +possible. + +1. A registration holds rights. An agent is granted read on cases and acts with them. + It is simpler and it is a second permission model beside Nextcloud's, which is the + thing every one of these apps exists not to do. +2. A registration holds a **narrowing** of what the caller may already do. + +Option 2. The agent authenticates as a principal, and the owning app authorises the +call for that principal exactly as it would for a person. Nothing in hermiq raises +what anybody may do. + +The consequence worth stating: an administrator revoking a person's access revokes +their agent's too, on the next call, with nothing to update in hermiq. That property +is the reason for the shape. + +## D3. Two gates, and both must open + +Per call: + +1. Does this registration list this tool? Default-deny on writes. +2. May this principal perform this act on this object? The owning app decides. + +A caller who may write and an agent not granted the write tool cannot write. An agent +granted every tool and a caller who may not read still cannot read. Neither gate is +sufficient alone, and the spec says so, because a reviewer who sees one gate tends to +assume it is the gate. + +## D4. The output filter is a narrowing, not a transformation + +OpenProject carries `app/services/mcp_output_filters/`. The useful version is a field +allowlist per registration: an agent that may read a case does not necessarily receive +the whole case. + +It is deliberately not a transformation. A filter that rewrote values would put a +second copy of the data model in hermiq, and a field renamed in dossiq would silently +produce the wrong shape here. Narrowing is checkable; transforming is a second model. + +## D5. A prompt is an object because an administrator has to read it + +`AiPromptFactory` builds prompts in code. That is the right place for a prompt nobody +outside engineering will ever tune, and the wrong place for the twelve a gemeente +argues about. + +The lane's clause is the requirement: a gemeente that cannot read the prompt cannot +defend the output. "Defend" is the word that matters. When a citizen asks why the +assistant summarised their bezwaar the way it did, the answer is the prompt, and the +answer has to be retrievable by somebody who does not read PHP. + +So `AssistantPrompt` holds the exact text that will be sent, plus where it is offered +(`usageScope`), in what order, and whether it is on. An administrator reading the +object reads what the model was told. + +## D6. The kill switch is one act, recorded + +OpenProject has `enable_all` and `disable_all` beside `toggle`. The shape is worth +copying for one reason: an incident response that requires editing twelve rows is not +a response. + +Disable-all is one act, scoped or wholesale, recorded with the actor and the time. +Recording it matters as much as the speed: the first question after an incident is +when the assistant was switched off, and the answer should not be somebody's memory. + +Re-enabling is deliberately **not** symmetric. Disable-all switches off in bulk; +switching back on is per prompt, so a hurried re-enable cannot restore a prompt that +was disabled for a different reason weeks earlier. diff --git a/openspec/changes/the-declared-tool-surface-and-the-prompt-library/proposal.md b/openspec/changes/the-declared-tool-surface-and-the-prompt-library/proposal.md new file mode 100644 index 00000000..844c67a1 --- /dev/null +++ b/openspec/changes/the-declared-tool-surface-and-the-prompt-library/proposal.md @@ -0,0 +1,109 @@ +--- +kind: code +--- + +# Proposal: the-declared-tool-surface-and-the-prompt-library + +Round 4 discovery sweep, cluster 46 "AI, and what it is allowed to read" +(`procest/_round4/discovery/build-plan.md` in ConductionNL/market-intelligence, +2026-09-14). Owner hermiq on **decision D13**. Umbrella: +`competitor-parity-2026-09`. + +## Summary + +An agent outside Nextcloud reaches cases through a declared tool protocol, under the +**caller's** own rights, with a right per tool. And the prompts the assistant offers +on a case are an administered library, ordered, scoped, and switchable off in one act. + +## Why + +Decision D13 says dossiq declares the tools and the rights and hermiq owns the +assistant. Both candidates here are hermiq's half of that sentence. + +**The outside agent.** `C-integrations-19` is about the direction we do not have. +OpenProject mounts `API::Mcp` at `/mcp`, with an `MCPConfiguration` model, +`/admin/mcp_configurations`, and `app/services/mcp_output_filters/`. The sweep's note +separates it from the assistant question sharply: "Both expose the product to an agent +outside it, not an assistant inside it." hermiq's `agent-tool-governance` is about +agents **inside** hermiq consuming the OpenRegister catalogue. The lanes disagreed +about dossiq here: one read `partial` on `lib/Mcp/DossiqToolProvider.php` with +`DossiqCaseReader` and `DossiqCaseAuthorizer`, another read `yes` and noted "read +only". Read only is the gap: the candidate says "reads **and changes** cases". + +**The prompt library.** `C-configuration-44` has two driven passers. OpenProject +administers Text transform actions with `toggle`, `enable_all` and `disable_all`, and +an `AI::TextTransformAction` carrying a `usage_scope` and a prompt. The lane's clause +is the reason it is not cosmetic: "a gemeente that cannot read the prompt cannot +defend the output, and a kill switch is what an incident response needs". The sweep +read dossiq `partial` on `AiSettingsController.php` and `AiService.php`, which +configure a model, and on `lib/Service/Ai/AiPromptFactory.php`, with the note "no +administered prompt object with a type scope". + +## The candidates, with their lane citations + +| id | capability | relevance | driven passers | lane | +|---|---|---|---|---| +| C-integrations-19 | An outside AI agent reads and changes cases through a declared tool protocol, under the caller's own rights. | should | itop, openproject | `integrations.tsv:16` | +| C-configuration-44 | An administrator maintains the prompts the assistant offers on a case. | should | opencase, openproject | `configuration.tsv:43` | + +Both have two driven passers, so **decision D21**'s documented label is needed on +neither. + +## What hermiq builds + +- **A declared outbound tool surface.** The tools an outside agent may call are + declared by the app that owns the data, not by hermiq. hermiq publishes the surface + and holds no tool of its own, which is the posture ADR-063 and gate 27 already set + for the inbound catalogue. +- **Rights are the caller's, resolved per call.** An outside agent authenticates as a + principal, and every call is authorised as that principal by the owning app. A + registration that granted the agent standing rights of its own would be a second + permission model beside Nextcloud's. +- **A right per tool, on top of the caller's rights.** A registration names the tools + it may call, default-deny on anything that writes. A caller who may write and an + agent that was not granted the write tool still cannot write. Both must allow it. +- **An output filter per registration.** What leaves in a tool response can be + narrowed per registration, so an agent that may read a case does not necessarily + receive every field of it. +- **A prompt as an object.** An `AssistantPrompt` carries a label, the prompt text, a + usage scope naming where it is offered, an order and an enabled flag. An + administrator reads the exact text that will be sent. +- **A kill switch that is one act.** Disable all prompts, per scope or wholesale, in + one action, recorded with who did it and when. An incident response that requires + editing twelve rows is not a response. + +## How dossiq consumes it + +1. dossiq declares which of its tools are reachable by an outside agent, and which of + those write. hermiq publishes them and enforces the per-registration grant. +2. Every call still lands on dossiq's own authoriser under the calling principal, so + dossiq's existing per-object checks keep deciding. +3. dossiq ships the prompts it wants offered on a case as an initial library. An + administrator edits, reorders, scopes and disables them afterwards, and dossiq does + not hold the edited state. + +## The existing specs this extends + +- `agent-tool-governance`, which owns progressive disclosure, schema-scoped per-agent + grants with default-deny on writes, and the article 12 and 14 oversight surface. + This change adds the **outbound** direction beside the inbound one and reuses its + default-deny rule rather than inventing a second one. +- `ai-feature-admin-surface`, which gains the prompt library. +- `run-audit-log`, which records an outside agent's calls the way it records an + internal agent's. Unchanged in shape. +- `case-assistant-surface` stays tool-free by construction. Nothing here gives it a + tool, and its `tools: ['__none__']` sentinel is untouched. + +## Size and dependencies + +**Size: M.** One publication surface, one grant check reusing an existing rule, one +output filter, one schema and one switch. + +**Depends on:** nothing in this umbrella. + +## What this change does not do + +- It does not create tools. hermiq consumes a catalogue and never derives one + (ADR-063, gate 27), and that holds in the outbound direction too. +- It does not give an outside agent rights of its own. A registration can only + narrow what its caller may already do. diff --git a/openspec/changes/the-declared-tool-surface-and-the-prompt-library/specs/agent-tool-governance/spec.md b/openspec/changes/the-declared-tool-surface-and-the-prompt-library/specs/agent-tool-governance/spec.md new file mode 100644 index 00000000..6c42728f --- /dev/null +++ b/openspec/changes/the-declared-tool-surface-and-the-prompt-library/specs/agent-tool-governance/spec.md @@ -0,0 +1,110 @@ +# agent-tool-governance + +## ADDED Requirements + +### Requirement: An outside agent MUST reach declared tools through a registration + +The system MUST publish a tool surface an AI agent outside the instance can call. The +tools it offers MUST be declared by the app that owns the data. hermiq MUST publish +the surface and MUST ship no tool of its own, in the outbound direction as in the +inbound one (ADR-063, gate 27). + +An outside agent MUST hold a registration naming the tools it may call. The +registration MUST default-deny every tool that writes, reusing the default-deny rule +this capability already states for per-agent grants rather than stating a second one. + +Candidate C-integrations-19 (`integrations.tsv:16`), relevance `should`, driven +passers itop and openproject. OpenProject's evidence: `mount API::Mcp => "/mcp"`, +`app/models/mcp_configuration.rb`, `/admin/mcp_configurations`, +`app/services/mcp_output_filters/`, enterprise. The sweep's note: "Both expose the +product to an agent outside it, not an assistant inside it." + +#### Scenario: The owning app declares, hermiq publishes + +- **GIVEN** an app declaring two of its tools as reachable by an outside agent +- **WHEN** the surface is read +- **THEN** exactly those two MUST be offered, and hermiq MUST declare no tool of its + own + +#### Scenario: A write tool is denied unless granted + +- **GIVEN** a registration that names no tools explicitly +- **WHEN** it calls a tool that writes +- **THEN** the call MUST be refused + +### Requirement: Every outside call MUST be authorised as the calling principal + +The system MUST authenticate an outside agent as a principal and MUST have every call +authorised by the owning app for that principal, exactly as a call from a person would +be. A registration MUST NOT carry rights of its own and MUST NOT raise what its +principal may do. + +Revoking a person's access MUST therefore revoke their agent's on the next call, with +nothing to update in hermiq. + +#### Scenario: An agent cannot exceed its principal + +- **GIVEN** a registration granted a read tool, whose principal may not read a given + case +- **WHEN** it calls that tool on that case +- **THEN** the owning app MUST refuse it + +#### Scenario: Revocation reaches the agent without an edit + +- **GIVEN** a working agent registration +- **WHEN** its principal's access to a case is revoked +- **THEN** the next call on that case MUST be refused, and no hermiq object MUST have + been edited + +### Requirement: Both gates MUST open before a tool runs + +The system MUST check, per call and in this order: that the registration lists the +tool, and that the owning app authorises the act for the calling principal. Both MUST +pass. Neither MUST be treated as sufficient alone. + +#### Scenario: A permitted caller without the grant is refused + +- **GIVEN** a principal who may write a case and a registration not granted the write + tool +- **WHEN** the write is attempted +- **THEN** it MUST be refused by the grant check + +#### Scenario: A granted agent without the right is refused + +- **GIVEN** a registration granted every tool and a principal who may not read the + case +- **WHEN** a read is attempted +- **THEN** it MUST be refused by the owning app + +### Requirement: A registration MAY narrow what a tool response carries + +The system MUST let a registration declare an allowlist of fields a tool response may +carry. A field outside the allowlist MUST be absent from the response. The filter MUST +narrow only, and MUST NOT rename, reshape or compute a value. + +A filter that transformed values would hold a second copy of the owning app's data +model, and a field renamed in that app would then silently produce a wrong shape here. + +#### Scenario: A reading agent does not receive every field + +- **GIVEN** a registration allowing three fields of a case +- **WHEN** it reads a case carrying twelve +- **THEN** the response MUST carry those three and no others + +#### Scenario: Nothing is renamed on the way out + +- **WHEN** a filtered response is compared to the owning app's own field names +- **THEN** every field present MUST carry the owning app's name for it + +### Requirement: An outside agent's calls MUST be recorded like an internal agent's + +The system MUST record every call from an outside agent on the same audit trail it +records internal agent runs on, carrying the registration, the calling principal, the +tool and the outcome. The oversight surface this capability already specifies MUST +show them beside internal invocations. + +#### Scenario: One place to read who called what + +- **GIVEN** calls from an internal agent and from an outside registration +- **WHEN** the oversight surface is read +- **THEN** both MUST appear, each naming its caller diff --git a/openspec/changes/the-declared-tool-surface-and-the-prompt-library/specs/ai-feature-admin-surface/spec.md b/openspec/changes/the-declared-tool-surface-and-the-prompt-library/specs/ai-feature-admin-surface/spec.md new file mode 100644 index 00000000..940eb967 --- /dev/null +++ b/openspec/changes/the-declared-tool-surface-and-the-prompt-library/specs/ai-feature-admin-surface/spec.md @@ -0,0 +1,88 @@ +# ai-feature-admin-surface + +## ADDED Requirements + +### Requirement: The prompts the assistant offers MUST be administered objects + +The system MUST provide an `AssistantPrompt` carrying a label, the exact prompt text +that will be sent, a `usageScope` naming where it is offered, an order, and an +`enabled` flag. An administrator MUST be able to read the exact text, edit it, +reorder the library and scope a prompt without a release. + +A prompt built in code MUST NOT be offered on a case surface unless it exists as an +`AssistantPrompt`, so what an administrator reads is what the model is told. + +The lane's clause: a gemeente that cannot read the prompt cannot defend the output. +When a citizen asks why the assistant summarised their bezwaar as it did, the answer +is the prompt, and it must be retrievable by somebody who does not read code. + +Candidate C-configuration-44 (`configuration.tsv:43`), relevance `should`, driven +passers opencase and openproject. OpenProject's evidence: Administration, Text +transform actions, `resources :text_transform_actions` with `toggle`, `enable_all` and +`disable_all`, and `AI::TextTransformAction` carrying a `usage_scope` and a prompt. + +#### Scenario: The text that will be sent is the text on screen + +- **GIVEN** an administrator reading a prompt object +- **WHEN** the assistant runs that prompt +- **THEN** the text sent MUST be the text the object carries + +#### Scenario: Scope decides where a prompt appears + +- **GIVEN** a prompt scoped to one record type +- **WHEN** the assistant surface opens on a record of another type +- **THEN** the prompt MUST NOT be offered + +#### Scenario: Order is the administrator's + +- **GIVEN** a library reordered by an administrator +- **WHEN** the surface renders +- **THEN** the prompts MUST appear in that order, unsorted + +### Requirement: Disabling every prompt MUST be one recorded act + +The system MUST let an administrator disable every prompt, wholesale or within one +scope, in a single action. The act MUST be recorded with the actor and the time. + +Re-enabling MUST be per prompt. Disabling in bulk is an incident response; re-enabling +in bulk would restore a prompt that had been switched off weeks earlier for a +different reason. + +An incident response that requires editing twelve rows is not a response, and the +first question after an incident is when the assistant was switched off. That answer +must not be somebody's memory. + +#### Scenario: Everything stops in one act + +- **GIVEN** a library of twelve enabled prompts +- **WHEN** an administrator disables all +- **THEN** none MUST be offered on any surface, after one action + +#### Scenario: The switch-off is on the record + +- **WHEN** the audit is read after a disable-all +- **THEN** it MUST name the administrator and the time + +#### Scenario: Coming back is deliberate + +- **GIVEN** a library that was disabled wholesale +- **WHEN** an administrator re-enables +- **THEN** they MUST do it per prompt, and no bulk re-enable MUST exist + +### Requirement: A consuming app MAY ship an initial library and MUST NOT hold the edited state + +A consuming app MAY ship prompts as an initial library. Once an administrator has +edited, reordered, scoped or disabled one, that state MUST live in hermiq, and the +consuming app MUST NOT hold or restore it. + +#### Scenario: An edit survives the shipping app + +- **GIVEN** a prompt shipped by a consuming app and then edited by an administrator +- **WHEN** the consuming app is updated +- **THEN** the edited text MUST stand + +#### Scenario: A disabled prompt stays disabled + +- **GIVEN** a shipped prompt an administrator disabled +- **WHEN** the consuming app is updated +- **THEN** it MUST stay disabled diff --git a/openspec/changes/the-declared-tool-surface-and-the-prompt-library/tasks.md b/openspec/changes/the-declared-tool-surface-and-the-prompt-library/tasks.md new file mode 100644 index 00000000..0a5bf726 --- /dev/null +++ b/openspec/changes/the-declared-tool-surface-and-the-prompt-library/tasks.md @@ -0,0 +1,68 @@ +# Tasks: the-declared-tool-surface-and-the-prompt-library + +## Implementation Tasks + +### Task 1: Publish an outbound tool surface an owning app declares +- **spec_ref**: `openspec/changes/the-declared-tool-surface-and-the-prompt-library/specs/agent-tool-governance/spec.md` +- **acceptance_criteria**: + - The tools offered are declared by the owning app + - hermiq declares no tool of its own in either direction + - The surface is discoverable by an outside client + +- [ ] Add the publication surface +- [ ] Add the owning app's declaration path + +### Task 2: Registration, with default-deny on writes +- **acceptance_criteria**: + - A registration names the tools it may call + - Write tools are denied unless granted, reusing the existing rule rather than a + second one + +- [ ] Add the registration object and its admin surface +- [ ] Reuse the default-deny grant check + +### Task 3: Authorise as the calling principal +- **acceptance_criteria**: + - Every call is authorised by the owning app for the principal + - A registration carries no rights of its own + - Revoking a principal's access refuses the next call with no hermiq edit + +- [ ] Wire the principal through the call path +- [ ] Add a test proving a registration cannot exceed its principal + +### Task 4: Two gates +- **acceptance_criteria**: + - The grant check and the owning app's authorisation both run, in that order + - Neither passes a call alone + +- [ ] Add the ordered check and its refusal messages + +### Task 5: The output filter +- **acceptance_criteria**: + - A registration may allowlist response fields + - Nothing is renamed, reshaped or computed + +- [ ] Add the allowlist and apply it on the way out + +### Task 6: The prompt library +- **spec_ref**: `openspec/changes/the-declared-tool-surface-and-the-prompt-library/specs/ai-feature-admin-surface/spec.md` +- **acceptance_criteria**: + - `AssistantPrompt` carries label, text, `usageScope`, order and `enabled` + - The text sent is the text the object carries + - Order is not re-sorted for display + +- [ ] Add the schema and the admin pages +- [ ] Point the case assistant surface at the library + +### Task 7: The kill switch +- **acceptance_criteria**: + - Disable-all is one act, wholesale or per scope + - It is recorded with actor and time + - No bulk re-enable exists + +- [ ] Add the act and its audit entry + +### Task 8: Verification +- [ ] Unit tests for the two gates, the narrowing-only filter and the shipped-library + precedence +- [ ] e2e coverage or a reason-bearing exclusion per scenario, per gate 19 diff --git a/openspec/changes/what-the-model-reads-and-what-is-kept/design.md b/openspec/changes/what-the-model-reads-and-what-is-kept/design.md new file mode 100644 index 00000000..697d11cf --- /dev/null +++ b/openspec/changes/what-the-model-reads-and-what-is-kept/design.md @@ -0,0 +1,88 @@ +# Design: what the model reads and what is kept + +## D1. Detection is not redaction, and the distance matters + +`woo-llm-anonymisation` gives hermiq `POST /api/assistant/detect-pii`, returning +spans with categories and confidences from one tool-free LLM turn. It is genuinely +useful and it is genuinely not redaction. + +| | detect-pii | redaction | +|---|---|---| +| output | spans over the original text | a document with the spans gone | +| the model sees | the unredacted text, deliberately | nothing, it runs before | +| confidence | reported, and below 1 | not a property of the act | +| owner | hermiq | filinq, per D13 | + +The dangerous move is treating a detection as evidence of redaction. It would let +hermiq mark a run as safe on the strength of a probabilistic span list produced by +sending the model exactly the text it was supposed not to see. + +So the two are kept apart in the spec, explicitly, and a feature requiring redaction +is never satisfied by a detection. + +## D2. Why the refusal attaches to the document, not to the feature + +A feature that requires redaction still has to work. Summarising a handler's own note +involves no document, and refusing it would make the requirement unusable and +therefore unused. + +The requirement is conditional on what the run is handed: when a document reference is +passed, filinq's redaction outcome for that reference must come with it. Text that +nobody claims is a document is not gated, and the boundary is the reference rather +than the feature. + +## D3. Failing closed when filinq is absent + +Two options when the redaction client cannot be resolved. + +1. Fall back to hermiq's own detection and proceed. Tempting, because the endpoint + exists. It is the D1 failure with extra steps. +2. Refuse every run of every feature requiring redaction, and say why. + +Option 2. `ai-feature-governance` already establishes this posture: its DPO gate is +declarative on the transition and fails closed, on the argument that an imperative +check protects only the call site that remembers it. The same argument applies here. + +A gemeente that has switched `requiresRedaction` on has made a statement. Quietly +downgrading it because an app is missing turns their statement into a preference. + +## D4. A retention nobody set is forever, so there is always a default + +OpenProject's shape is a setting plus a cleanup job. Copying only the setting is the +common failure, and it is worse than having neither: the screen says ninety days and +the rows are still there in year three. + +So three parts, and all three are requirements: + +- an instance default retention an administrator sets, with a per-feature override; +- the retention **copied onto the run** when it is written, so changing the default + does not retroactively shorten or extend what was already promised; +- a job that removes expired payloads, and reports its last run and its count. + +The third is the one that can be checked. "Show me the last cleanup run" is a question +an auditor asks and an instance can answer. + +## D5. Deleting from a tamper-evident chain without breaking it + +`run-audit-log` writes onto openregister's `AuditTrail`, a hash and `previousHash` +chain. Deleting an entry breaks every hash after it, which destroys the property the +chain exists for. + +So retention removes the **payload**, not the entry. What stays: that a run happened, +when, for which feature, under which provider, and that its payload was deleted under +retention on a given date. What goes: the input text, the model output and anything +personal. + +This satisfies both duties at once. Article 30 wants to know that processing happened +and of what kind. Storage limitation wants the personal data gone. A tombstone carries +the first and drops the second, and the chain stays whole. + +## D6. The order of checks, again + +`a-provider-and-a-place-per-ai-feature` specifies: resolve binding, narrow by policy, +check residency, call. Redaction joins that sequence **before** the call and after +residency, and for the same reason: a check that runs after the request has left has +recorded a breach, not prevented one. + +Keeping both changes' checks in one ordered pre-call path means there is one place to +read to know what a run must pass, rather than two paths that drift. diff --git a/openspec/changes/what-the-model-reads-and-what-is-kept/proposal.md b/openspec/changes/what-the-model-reads-and-what-is-kept/proposal.md new file mode 100644 index 00000000..74c486ba --- /dev/null +++ b/openspec/changes/what-the-model-reads-and-what-is-kept/proposal.md @@ -0,0 +1,112 @@ +--- +kind: code +--- + +# Proposal: what-the-model-reads-and-what-is-kept + +Round 4 discovery sweep, cluster 46 "AI, and what it is allowed to read" +(`procest/_round4/discovery/build-plan.md` in ConductionNL/market-intelligence, +2026-09-14). Owner hermiq on **decision D13**. Umbrella: +`competitor-parity-2026-09`. + +## Summary + +Two `must` candidates, one question: what did the model see, and for how long do we +keep the record. hermiq requires a document to have been redacted before a feature +reads it, and gives every AI run a retention that somebody chose. + +## Why + +The lane's clause on the first candidate is the sentence to keep: **"no citizen data +reaches the model" is the question every FG asks about an assistant, and it is +answerable as a capability rather than as a policy.** A policy is a promise. A +capability is a refusal. + +Decision D13 assigns the redaction itself to filinq, which already has the client. So +hermiq's half is narrow and load-bearing: a feature may declare that it will not read +an unredacted document, and hermiq refuses when it has not been redacted. hermiq does +no redaction of its own. + +The second candidate is the other end of the same run. The lane's clause: "an AVG +verwerkingsregister has to say what personal data a model was shown, for how long, and +a retention nobody set is a retention of forever". OpenProject holds the passer: +`AI::TextTransformRun` with a status, an input and events, and +`ai_text_transform_run_retention_seconds` enforced by +`app/workers/ai/text_transform_runs/cleanup_job.rb`. A retention setting **and** a job +that acts on it. The setting on its own is decoration. + +hermiq already records. `run-audit-log` writes every run and every tool invocation to +openregister's tamper-evident `AuditTrail`, with organisation scoping and the GDPR +article 30 register. The sweep read dossiq `partial` on the candidate and noted +`lib/Controller/AiAuditExportController.php` exports an AI audit while "retention not +found". Same gap on this side: what is recorded is excellent, and nothing expires. + +## The candidates, with their lane citations + +| id | capability | relevance | driven passers | lane | +|---|---|---|---|---| +| C-access-and-privacy-3 | A document's personal data is removed before a model sees it. | must | none, decos-join documented | `access-and-privacy.tsv:42` | +| C-access-and-privacy-53 | Every AI run is recorded with its input and its outcome and deleted after a set period. | must | openproject | `access-and-privacy.tsv:56` | + +`C-access-and-privacy-3` has **no driven passer**: the evidence is Decos Join's +documented `/zaaksysteem/joni-plus` page, admitted under **decision D21** and +labelled documented in the spec. That a `must` this important rests on a vendor claim +is itself worth recording: nobody in the driven set was measured doing it. + +## What hermiq builds + +- **A redaction requirement on a feature.** An `AiFeature` may declare + `requiresRedaction`. A run of that feature that is handed a document reference must + be given filinq's redaction outcome for it, and is refused otherwise. +- **The refusal is on the document, not on the feature.** A feature requiring + redaction still runs on text nobody claims is a document. What it refuses is + reading a document that has not been through filinq. +- **filinq does the redaction, and hermiq never guesses.** When filinq is absent, + every feature requiring redaction is refused. hermiq must not fall back to its own + PII detection and call the result redaction. Detection finds spans; redaction + removes them, and they are not the same act. +- **A retention on every AI run.** A run entry carries the retention that applied when + it was written, resolved from an instance default that an administrator sets, with a + per-feature override. +- **A job that actually deletes.** A scheduled job removes run entries past their + retention. The instance reports when it last ran and how many entries it removed, + so a retention nobody enforces is visible rather than assumed. +- **What deletion means on a tamper-evident chain.** The run's payload is removed and + the chain entry stays, carrying that the record existed, when, for which feature, + and that it was deleted under retention. A chain with a hole is a broken chain. + +## How dossiq consumes it + +1. dossiq passes a document reference, and filinq's redaction outcome travels with it. + dossiq performs no redaction and asserts none. +2. A refused run reaches dossiq as a refusal naming the feature and the missing + redaction, so a handler reads why rather than a failure. +3. dossiq's own AI audit export keeps exporting. This change gives it an expiry it did + not have, and does not change its shape. + +## The existing specs this extends + +- `woo-llm-anonymisation`, which already runs a tool-free PII **detection** turn with + prompt-injection filtering active and no conversation persistence. This change + leaves all five of its requirements intact and adds the boundary beside it: + detection is not redaction, and a feature that requires redaction is not satisfied + by a detection. +- `run-audit-log`, which already records every run and tool invocation. It gains a + retention, an enforcing job and the deletion semantics for a hash chain. +- `ai-feature-governance`, whose `AiFeature` carries the two new declarations. Its DPO + acknowledgement gate is untouched. + +## Size and dependencies + +**Size: M.** Two declarations, one refusal, one retention resolution, one job and one +careful deletion. + +**Depends on:** filinq's redaction outcome being readable. Degrading without it is a +requirement below, not an accident. + +## What this change does not do + +- It does not redact. Decision D13 puts that in filinq, which already has the + redaction client. A second redactor in the fleet is a second thing to be wrong. +- It does not weaken the audit chain. Nothing here deletes a chain entry, and nothing + here rewrites a hash. diff --git a/openspec/changes/what-the-model-reads-and-what-is-kept/specs/run-audit-log/spec.md b/openspec/changes/what-the-model-reads-and-what-is-kept/specs/run-audit-log/spec.md new file mode 100644 index 00000000..9c676112 --- /dev/null +++ b/openspec/changes/what-the-model-reads-and-what-is-kept/specs/run-audit-log/spec.md @@ -0,0 +1,93 @@ +# run-audit-log + +## ADDED Requirements + +### Requirement: Every AI run MUST carry the retention that applied when it was written + +The system MUST resolve a retention period for every AI run from an instance default +an administrator sets, with an optional per-feature override. The resolved period MUST +be written onto the run entry, not referenced, so changing the default later MUST NOT +shorten or extend what an existing run was promised. + +The instance MUST have a default. A retention nobody set is a retention of forever. + +Candidate C-access-and-privacy-53 (`access-and-privacy.tsv:56`), relevance **`must`**, +driven passer openproject: `AI::TextTransformRun` with status, input and events, and +`ai_text_transform_run_retention_seconds` enforced by +`app/workers/ai/text_transform_runs/cleanup_job.rb`. + +#### Scenario: A run knows its own expiry + +- **GIVEN** an instance default of ninety days +- **WHEN** a run is recorded +- **THEN** its entry MUST carry ninety days as its retention + +#### Scenario: Changing the default does not move an old promise + +- **GIVEN** runs recorded under a ninety-day default +- **WHEN** the default is changed to thirty days +- **THEN** those runs MUST still carry ninety days, and new runs MUST carry thirty + +#### Scenario: A feature may keep less + +- **GIVEN** a feature overriding the retention to seven days +- **WHEN** it runs +- **THEN** the run entry MUST carry seven days + +### Requirement: A scheduled job MUST enforce retention, and MUST report that it did + +The system MUST run a scheduled job that removes the payload of every run entry past +its retention. The instance MUST report when the job last ran and how many entries it +acted on. + +A retention setting without an enforcing job is worse than neither: the screen says +ninety days and the data is still there in year three. The report is what makes the +enforcement checkable rather than assumed. + +#### Scenario: Expired runs lose their payload + +- **GIVEN** run entries past their recorded retention +- **WHEN** the job runs +- **THEN** their payloads MUST be removed + +#### Scenario: The last cleanup is an answerable question + +- **WHEN** an administrator asks when retention last ran +- **THEN** the instance MUST answer with a time and a count + +#### Scenario: A job that has never run is visible as such + +- **GIVEN** an instance where the job has not yet run +- **WHEN** the report is read +- **THEN** it MUST say so, rather than reading as a successful run of zero + +### Requirement: Retention MUST remove the payload and MUST NOT break the chain + +The system MUST NOT delete an audit chain entry to satisfy retention. It MUST remove +the entry's payload and leave a tombstone carrying that a run happened, when, for +which feature, under which provider, and that the payload was deleted under retention +on a stated date. + +Deleting an entry from a hash and `previousHash` chain invalidates every hash after +it, destroying the property the chain exists for. The tombstone keeps the article 30 +record of the processing while the personal data is gone, which is what storage +limitation asks for. + +#### Scenario: The chain still verifies after a cleanup + +- **GIVEN** an audit chain containing entries whose payloads have been removed +- **WHEN** the chain is verified +- **THEN** it MUST verify + +#### Scenario: The processing is still recorded after the data is gone + +- **GIVEN** a run whose payload has been removed under retention +- **WHEN** its entry is read +- **THEN** it MUST report that the run happened, when, for which feature and provider, + and that the payload was deleted under retention on a stated date + +#### Scenario: No personal data survives the tombstone + +- **GIVEN** the same entry +- **WHEN** it is read +- **THEN** the input text and the model output MUST be absent diff --git a/openspec/changes/what-the-model-reads-and-what-is-kept/specs/woo-llm-anonymisation/spec.md b/openspec/changes/what-the-model-reads-and-what-is-kept/specs/woo-llm-anonymisation/spec.md new file mode 100644 index 00000000..c1133f89 --- /dev/null +++ b/openspec/changes/what-the-model-reads-and-what-is-kept/specs/woo-llm-anonymisation/spec.md @@ -0,0 +1,108 @@ +# woo-llm-anonymisation + +## ADDED Requirements + +### Requirement: A feature MAY require that a document was redacted before it is read + +The system MUST let an `AiFeature` declare `requiresRedaction`. When it is set and a +run is handed a document reference, the run MUST be given filinq's redaction outcome +for that reference, and MUST be refused otherwise. The refusal MUST name the feature +and the document reference, and MUST happen before any request reaches the provider. + +The requirement MUST attach to the document reference, not to the whole run. A feature +requiring redaction MUST still run on text that carries no document reference, so the +declaration stays usable on a handler's own note. + +Candidate C-access-and-privacy-3 (`access-and-privacy.tsv:42`), relevance **`must`**. +**No driven passer.** The evidence is decos-join's documented `/zaaksysteem/joni-plus` +page, admitted under decision D21 and labelled documented here. The lane's clause: +"no citizen data reaches the model" is the question every functionaris +gegevensbescherming asks about an assistant, and it is answerable as a capability +rather than as a policy. + +#### Scenario: An unredacted document does not reach the model + +- **GIVEN** a feature declaring `requiresRedaction` and a document reference with no + redaction outcome +- **WHEN** the feature runs +- **THEN** no request MUST reach the provider, and the refusal MUST name the feature + and the reference + +#### Scenario: A note with no document still runs + +- **GIVEN** the same feature and a run carrying only typed text +- **WHEN** it runs +- **THEN** it MUST proceed + +#### Scenario: A redacted document proceeds + +- **GIVEN** the same feature and a document reference carrying filinq's redaction + outcome +- **WHEN** the feature runs +- **THEN** it MUST proceed, and the outcome MUST be recorded on the run + +### Requirement: hermiq MUST NOT redact, and MUST NOT treat detection as redaction + +The system MUST NOT perform redaction. Redaction belongs to filinq under decision D13. +A `detect-pii` result MUST NOT satisfy `requiresRedaction`, in any code path. + +Detection returns spans over the original text and requires the model to see that +text. Redaction removes the spans and runs before any model reads anything. Treating +the first as evidence of the second would mark a run safe on the strength of having +sent the model exactly what it was not supposed to see. + +#### Scenario: A detection result does not unlock a redaction-requiring feature + +- **GIVEN** a document with a completed `detect-pii` result and no filinq redaction +- **WHEN** a feature declaring `requiresRedaction` runs against it +- **THEN** the run MUST be refused + +#### Scenario: hermiq ships no redactor + +- **WHEN** hermiq's code is inspected for a path that removes personal data from a + document +- **THEN** none MUST exist, and the only redaction reference MUST be to filinq's + outcome + +### Requirement: A missing redaction client MUST fail closed + +When filinq's redaction outcome cannot be resolved, the system MUST refuse every run +of every feature declaring `requiresRedaction`, and MUST say that the redaction client +is unavailable. It MUST NOT substitute its own detection, and MUST NOT proceed +unredacted. + +This follows the posture `ai-feature-governance` already sets with its +acknowledgement gate: a declarative refusal protects every path, where an imperative +check protects only the call site that remembers it. + +#### Scenario: Without filinq, a redaction-requiring feature does not run + +- **GIVEN** an instance where filinq cannot be resolved +- **WHEN** a feature declaring `requiresRedaction` runs +- **THEN** it MUST be refused, naming the unavailable redaction client + +#### Scenario: Features that require nothing are unaffected + +- **GIVEN** the same instance +- **WHEN** a feature without `requiresRedaction` runs +- **THEN** it MUST proceed + +### Requirement: The redaction check MUST sit in the ordered pre-call path + +The system MUST check redaction in the same ordered pre-call path as the model policy +and the residency check: resolve the feature binding, narrow by policy, check +residency, check redaction, then call. Each step MUST name itself when it refuses. + +One ordered path means there is one place to read to know what a run must pass. + +#### Scenario: Nothing is sent before every check has passed + +- **GIVEN** a run that would fail the redaction check +- **WHEN** it is executed +- **THEN** no request MUST reach the provider + +#### Scenario: The refusing step is identifiable + +- **GIVEN** runs refused on policy, on residency and on redaction +- **WHEN** each refusal is read +- **THEN** each MUST name the step that refused it diff --git a/openspec/changes/what-the-model-reads-and-what-is-kept/tasks.md b/openspec/changes/what-the-model-reads-and-what-is-kept/tasks.md new file mode 100644 index 00000000..f8c98b0b --- /dev/null +++ b/openspec/changes/what-the-model-reads-and-what-is-kept/tasks.md @@ -0,0 +1,63 @@ +# Tasks: what-the-model-reads-and-what-is-kept + +## Implementation Tasks + +### Task 1: Declare a redaction requirement on a feature +- **spec_ref**: `openspec/changes/what-the-model-reads-and-what-is-kept/specs/woo-llm-anonymisation/spec.md` +- **acceptance_criteria**: + - `AiFeature` carries `requiresRedaction` + - The requirement attaches to a document reference, not to the whole run + - A run with no document reference still proceeds + +- [ ] Add the field to the `AiFeature` register fragment + +### Task 2: Refuse an unredacted document before the call +- **acceptance_criteria**: + - The check sits in the ordered pre-call path, after residency + - No request reaches the provider when it refuses + - The refusal names the feature and the document reference + +- [ ] Read filinq's redaction outcome for the reference +- [ ] Add the refusal to the pre-call path + +### Task 3: Fail closed, and never call detection redaction +- **acceptance_criteria**: + - A `detect-pii` result never satisfies `requiresRedaction`, in any path + - An unresolvable filinq refuses every redaction-requiring feature + - hermiq ships no redactor of its own + +- [ ] Add the closed-fail path and its message +- [ ] Confirm by inspection that no path removes personal data inside hermiq + +### Task 4: A retention on every run +- **spec_ref**: `openspec/changes/what-the-model-reads-and-what-is-kept/specs/run-audit-log/spec.md` +- **acceptance_criteria**: + - An instance default exists and is administered + - A per-feature override is possible + - The resolved period is copied onto the run entry + +- [ ] Add the setting and the override +- [ ] Write the resolved retention onto each run entry + +### Task 5: The job that enforces it +- **acceptance_criteria**: + - A scheduled job removes payloads past their retention + - The instance reports the last run and the count + - A job that has never run reads as never run, not as zero + +- [ ] Add the job +- [ ] Add the report to the admin surface + +### Task 6: Deletion that keeps the chain whole +- **acceptance_criteria**: + - No chain entry is deleted + - A tombstone carries run, time, feature, provider and the deletion date + - The input and the output are absent afterwards + - The chain verifies after a cleanup + +- [ ] Implement payload removal with a tombstone +- [ ] Add a chain verification test over cleaned entries + +### Task 7: Verification +- [ ] Unit tests for the closed fail, the copied retention and the tombstone +- [ ] e2e coverage or a reason-bearing exclusion per scenario, per gate 19