Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
be9ac5c
docs(openspec): contracts-expiry-and-owner, an expiring status and a …
rubenvdlinde Sep 27, 2026
1862c6b
docs(openspec): contracts-expiry-and-owner, guard quick filters again…
rubenvdlinde Sep 27, 2026
cf9e35a
docs(openspec): contracts-expiry-and-owner, fix an escaped backtick
rubenvdlinde Sep 27, 2026
610e836
docs(openspec): contracts-expiry-and-owner, put appended schema parts…
rubenvdlinde Sep 27, 2026
3f68d6e
docs(openspec): contracts-licence-seats, licence metric and seats bou…
rubenvdlinde Sep 27, 2026
3e6b86d
docs(openspec): insight-exports-and-custom-reports, list exports, own…
rubenvdlinde Sep 27, 2026
24a9e69
docs(openspec): insight-supplier-facet, supplier as a fifth facet on …
rubenvdlinde Sep 27, 2026
a0139d8
docs(openspec): insight-knowledge-base, articles in Collectives linke…
rubenvdlinde Sep 27, 2026
c9eaac2
docs(openspec): operations-sync-status-and-progress, shared progress,…
rubenvdlinde Sep 27, 2026
aff90b5
docs(openspec): sharing-generated-api-docs, generated OpenAPI documen…
rubenvdlinde Sep 27, 2026
91d5745
docs(openspec): sharing-compliance-documents, compliance documents wi…
rubenvdlinde Sep 27, 2026
6126428
docs(openspec): operations-technology-components, organisation techno…
rubenvdlinde Sep 27, 2026
4095916
docs(openspec): sharing-itsm-exchange, service desk exchange through …
rubenvdlinde Sep 27, 2026
7e5efac
docs(openspec): sharing-itsm-exchange, seed data line follows the set…
rubenvdlinde Sep 27, 2026
2355fcc
docs(openspec): operations-record-reconciliation, OpenRegister dedup …
rubenvdlinde Sep 27, 2026
024fda5
docs(openspec): organisations-role-mapping-and-access-review, role gr…
rubenvdlinde Sep 27, 2026
e22da05
docs(openspec): operations-sync-status-and-progress, align the role g…
rubenvdlinde Sep 27, 2026
e0e235b
docs(openspec): security-baseline-classification, availability, integ…
rubenvdlinde Sep 27, 2026
413ee23
Merge remote-tracking branch 'origin/parity/openspec-pass-helper-b' i…
rubenvdlinde Sep 27, 2026
3cfd9b7
Merge remote-tracking branch 'origin/parity/openspec-pass-lead-b' int…
rubenvdlinde Sep 27, 2026
996d12f
chore(parity): OpenSpec-pass batch 3 matrix states and decisions
rubenvdlinde Sep 27, 2026
1bc4552
docs(openspec): guard the transfer and attestation endpoints on the m…
rubenvdlinde Sep 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions openspec/changes/contracts-expiry-and-owner/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-27
84 changes: 84 additions & 0 deletions openspec/changes/contracts-expiry-and-owner/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Design: contracts-expiry-and-owner

Read at development 49e65cb4.

## Where it fits

| Part | File and line | What changes |
|---|---|---|
| Schema | `lib/Settings/softwarecatalogus_register.json:3250` `catalogContract` | lifecycle block (`:3531`) renamed to the English states in the monolith; schema `version` (`:3270`, now 0.1.1) and register `info.version` (`:6`, now 2.5.0) move up |
| Fragment | new `lib/Settings/register.d/contracts-expiry-and-owner.json` (ADR-037) | `Expiring` appended to the `status` enum (`:3428`), the new property `responsibleUser`, and one recipient appended to the `contract-expiry` rule (`:3258`) |
| Service | `lib/Service/ContractStatusService.php` | `shouldExpire()` (`:77`) accepts Active and Expiring; new `shouldStartExpiring()` and `shouldReturnToActive()`; `expirePastContracts()` (`:114`) becomes one pass over Active and Expiring contracts |
| Job | `lib/BackgroundJob/ContractStatusJob.php:78` | unchanged call, it now logs three counts |
| Setting | `lib/Repair/InitializeSettings.php:103` `contract_expiry_window_days` | read by the service through `IAppConfig` |
| Page | `src/manifest.json:527` `Contracten` | columns (`:534`) gain `responsibleUser`; quick filters (`:542` to `:547`) split into Expiring and Expired |
| Page | `src/manifest.json:556` `ContractDetail` | no manifest change: the `ct-data` widget (`:566`) renders every schema property, `responsibleUser` included |
| Seed | `lib/Settings/stackiq_mock_register.json:7608` onward | demo contracts get an Expiring example and a `responsibleUser` |

No new controller, route or store. The pages keep reading OpenRegister directly (ADR-022).

## Decisions

### D0. What goes in a fragment and what stays in the monolith

`SettingsService::loadSettings()` deep-merges every `lib/Settings/register.d/*.json` into the register (`lib/Service/SettingsService.php:1653` to `:1680`). `deepMergeConfig()` (`:7338`) appends lists and only replaces the lists under `authorization` (`:7340`, `:7352`). An appended enum value and an appended recipient are what this change wants, so they go in the fragment. The lifecycle rename replaces values inside `final`, `from` and `to` lists, which an append cannot do, so that edit stays in the monolith together with the version bump.

### D1. Expiring is a stored status, set by the daily job

The archived change 2026-06-14-contract-administration (design decision 3) chose a query instead: "expiring soon" as a filter on `endDate`, never a status. That query was never built, and a query cannot be a status column value, a facet or a lifecycle state that another app reads. The matrix row asks for a status that moves on its own, so this change stores it.

Rejected: a derived value computed in the browser. It would show in one view and nowhere else, not in the API, the export or the notification filter.

### D2. The job re-evaluates Expiring every day, in both directions

`ContractStatusService` gets one pure decision per move, like `shouldExpire()` today:

| From | To | When |
|---|---|---|
| Active | Expiring | `endDate` is today or later and at most `contract_expiry_window_days` days away |
| Active or Expiring | Expired | `endDate` is before today |
| Expiring | Active | `endDate` is more than the window away, because someone extended the contract |

The job never touches In negotiation, never moves a contract out of Expired, and skips a contract without a parseable end date, as `shouldExpire()` does now (`:84` to `:97`). One query fetches Active and Expiring contracts with the same 5000 ceiling as `:137`.

Rejected: OpenRegister's automatic lifecycle transitions. They fire at the end of a write (`lib/Service/Lifecycle/AutoTransitionPass.php` in OpenRegister), not on a clock, so a date that passes without a save moves nothing.

### D3. The lifecycle block uses the English states

The block at `:3531` names Actief, Verlopen and In onderhandeling. The enum and every migrated row say Active, Expired and In negotiation. The register changelog 2.4.4 (`:7`) records the same bug on `organization` and why the schema version must move with it: OpenRegister's content check compares properties, required and authorization, never `configuration`, so a lifecycle-only edit never deploys. This change rewrites the block with `initial: In negotiation`, `final: [Expired]`, and the transitions `sign` (In negotiation to Active), `approach` (Active to Expiring), `extend` (Expiring to Active), `expire` (Active or Expiring to Expired) and `renegotiate` (Expired to In negotiation).

### D4. The responsible person is a Nextcloud user id

`responsibleUser` is a string with `referenceType: nextcloud-user`. The library's `CnFormDialog` renders that as a searchable Nextcloud user picker (`@conduction/nextcloud-vue` 2.57.1, `src/utils/schema.js:355` and `src/components/CnFormDialog/CnFormDialog.vue:217`), so no custom component is needed (ADR-012).

Rejected: reusing `contactPersonUser`. It is a nested name and email (`:3398`), written for suppliers and colleagues outside Nextcloud. OpenRegister resolves a `field` recipient only when the value is an existing Nextcloud uid (`lib/Service/Notification/NotificationRecipientResolver.php:187` in OpenRegister), so an email string would be dropped without a word.

Rejected: a group field for a team. The same resolver has no kind that reads a group id from a field. Named in the proposal's out of scope.

## Declarative versus imperative

- Notification: declarative. The recipient is one more entry, `{"kind": "field", "field": "responsibleUser"}`, in the existing `x-openregister-notifications` rule. Stackiq sends nothing itself (ADR-031).
- Lifecycle: declarative states and transitions in `x-openregister-lifecycle`, so a person can still move a contract by hand through OpenRegister's transition endpoint.
- The time-driven moves stay imperative in `ContractStatusJob`. OpenRegister has no clock-driven transition (D2), and the job already exists for the Active to Expired move.

## Seed data

`catalogContract` changes, so the demo descriptor gets matching rows. Values follow the English enum.

| Field | Contract 1 | Contract 2 | Contract 3 | Contract 4 |
|---|---|---|---|---|
| `@self.slug` | contract-contract-1-1 | contract-contract-2-2 | contract-contract-3-3 | contract-expiring-4 |
| `contractNumber` | CON-2025-001 | CON-2024-017 | CON-2026-003 | CON-2023-042 |
| `contractType` | SLA | Licence | Maintenance | Licence |
| `startDate` | 2025-01-01 | 2024-03-01 | 2026-02-01 | 2023-07-01 |
| `endDate` | 2027-12-31 | 2026-03-01 | empty | 60 days after the seed date |
| `status` | Active | Expired | In negotiation | Expiring |
| `responsibleUser` | admin | admin | empty | admin |

Contract 4 is new. The seed writer computes its end date from the import date, so the Expiring example stays inside the window. `admin` exists on every development and CI instance.

## Risks

- **A long window marks many contracts at once.** A window of 365 days on a large catalogue flips many rows in one night. The pass is bounded at 5000 rows and each save is logged, as today.
- **Existing Expired rows stay Expired.** The job never moves a contract out of Expired, so a contract that expired by mistake still needs a person to renegotiate it.
- **The rule may not fire yet.** Until `stackiq:ctr-expiry-alert` fixes the filter and the subject fields, the responsible user receives nothing. The scenarios that need a delivered notification are marked for the unit test that checks the declaration, not for a browser run.
56 changes: 56 additions & 0 deletions openspec/changes/contracts-expiry-and-owner/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
kind: code
depends_on: []
---

# Contracts show when they are expiring and name who is responsible

## Summary

A contract in stackiq jumps from Active straight to Expired on the day its end date passes. Nobody sees it coming, and the only person named on a contract is a free-text name and email that no warning can reach. This change adds an Expiring status that the daily contract job sets inside the notice window, and a responsible Nextcloud user on each contract, so the expiry warning has someone to go to.

## Why

This change covers two matrix rows.

- `stackiq:ctr-status`, "See each contract's status move from active to expiring to expired on its own." Stackiq rates itself partial: the daily job moves Active to Expired, but there is no expiring state in between. SAP LeanIX rates yes: "The contract fact sheet uses lifecycle phases to represent the current state of a contract: Plan, Phase In, Contract Start Date (active), Contract Notice Period, Contract End Date (expired)" (https://help.sap.com/docs/leanix/ea/contract-extension-to-meta-model). TOPdesk rates yes: "Status: Configurable drop-down showing the contract's lifecycle status, e.g. draft, active ... Reminder date" (https://docs.topdesk.com/en/creating-a-contract.html) and "The contract will terminate once the end date passes" (https://docs.topdesk.com/en/terminating-a-contract.html).
- `stackiq:ctr-contract-owner`, "Name the person or team responsible for a contract, so expiry warnings go to them." Demand: a GLPI feature request asks for contract assignees as notification recipients (https://github.com/glpi-project/roadmap/discussions/290). SAP LeanIX rates yes: automations "notify contract owners at key milestones" (https://help.sap.com/docs/leanix/ea/step-2-set-up-contract-lifecycle-automations). TOPdesk rates yes: "Operator The TOPdesk operator responsible for managing the contract ... Reminder date Date on which an operator should be reminded about the contract, e.g. ahead of expiry" (https://docs.topdesk.com/en/creating-a-contract.html) and "notify a manager that a contract will expire in a month" (https://docs.topdesk.com/en/events-that-trigger-actions.html).

Both rows are partial and built. This change builds the missing half of each: the expiring state between active and expired, and an expiry warning that reaches the named person.

## What stackiq has today

Read at development 49e65cb4.

- `lib/Service/ContractStatusService.php:77` `shouldExpire()` returns true only for status `Active` with a parseable `endDate` in the past. `:114` `expirePastContracts()` queries Active contracts (`:136`) and saves them as `Expired` (`:157`).
- `lib/BackgroundJob/ContractStatusJob.php:57` runs that pass once a day. It is registered in `appinfo/info.xml:99`.
- `lib/Settings/softwarecatalogus_register.json:3428` `catalogContract.status` has the enum Active, Expired, In negotiation. There is no expiring value.
- `lib/Settings/softwarecatalogus_register.json:3531` the schema's `x-openregister-lifecycle` still names the Dutch states Actief, Verlopen and In onderhandeling, while the enum and the stored rows are English. A lifecycle whose states match no row offers no transition.
- `lib/Settings/softwarecatalogus_register.json:3398` `contactPersonUser` is a nested object with a name and an email. It is not a Nextcloud user, so no notification recipient can resolve it.
- `lib/Settings/softwarecatalogus_register.json:3253` declares the `contract-expiry` notification. Its recipients (`:3258`) are the `software-catalog-admins` group and users with manage rights on the record. The person responsible is not among them.
- `lib/Repair/InitializeSettings.php:103` seeds `contract_expiry_window_days` with 90. No code reads it.
- `src/manifest.json:545` the Contracts page quick filter "Expiring / expired" filters `status` equal to `Expired` only, so it never shows a contract that is about to expire.

## What this change builds

- A fourth status value, `Expiring`, on `catalogContract.status`.
- The daily contract job moves an Active contract to Expiring when its end date falls inside the notice window, moves an Expiring contract to Expired once the end date has passed, and moves an Expiring contract back to Active when someone extends its end date past the window.
- The notice window comes from `contract_expiry_window_days`, which the job starts reading.
- The schema's lifecycle block names the English states the enum and the rows use, with Expiring added.
- The Contracts page gets separate Expiring and Expired quick filters.
- A `responsibleUser` property on `catalogContract`: a Nextcloud user picked in the contract form, shown on the contract detail page and as a column on the Contracts page.
- The `contract-expiry` notification rule gains a recipient that reads `responsibleUser`, so the responsible person gets the warning together with the administrators.

## Out of scope

- Making the `contract-expiry` rule fire. Its filter compares `status` with `Actief` and its subject uses the old Dutch field names. That is the pending row `stackiq:ctr-expiry-alert`, marked specified, and it is fixed there, not here. This change only adds a recipient to the rule.
- Dispatching scheduled notifications. OpenRegister owns the notification engine (ADR-031) and resolves the `field` recipient kind.
- A responsible team. OpenRegister resolves a `field` recipient only as a single user id (`NotificationRecipientResolver.php:187` in OpenRegister). A group held in a contract field needs a new recipient kind in OpenRegister first.
- Renewal chains, obligations and spend. Shillinq owns the contract lifecycle beyond the catalogue view (ADR-066), as the archived change 2026-06-14-contract-administration decided.
- An admin screen for the notice window. The setting is changed with `occ config:app:set` until a settings section asks for it.

## Risks

- A stored status can drift from the end date when someone edits the date. The job re-evaluates Expiring contracts every day, so the drift lasts at most one day.
- Adding an enum value and a property changes the schema. The schema version and the register version must both move up, or the import skips the change (register changelog 2.4.4, `lib/Settings/softwarecatalogus_register.json:7`).
- Contracts saved as Expiring by the job are visible to every reader of the contract. That is the intent, but a Nextcloud admin who filters on Active in a script sees fewer contracts than before.
Loading
Loading