Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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/hours-to-humaniq/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-01
129 changes: 129 additions & 0 deletions openspec/changes/hours-to-humaniq/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# Change: hours-to-humaniq

## Why

ADR-107 decision 6 (`hydra/openspec/architecture/adr-107-money-and-effort-ownership.md`)
assigns booked hours to one app, and it is not this one:

> **Effort is recorded against the domain object and costed by hrmq.** Hours
> logged on a case are hrmq time entries carrying the case reference. Hours ×
> the composed hourly cost becomes a cost allocation dispatched to Shillinq,
> taakveld taken from the case's `caseType`. The domain app supplies **context
> and classification**; hrmq supplies **the wage base**; Shillinq supplies
> **the ledger-derived additions and the booking**.

Shillinq ships the opposite arrangement. `lib/Settings/register.d/uren-domain-subject-link.json`
adds `subjectApp` + `subjectId` to `UrenRegistratie` so a domain app can book
hours against a case here. Its own `_meta` cites the same ADR:

> Per hydra ADR-081, the domain app CLASSIFIES and Shillinq AGGREGATES.

Both readings cite ADR-081. That number was claimed by two documents until the
2026-08-26 renumbering, so the citation resolves to whichever the reader
assumed. Read against ADR-107 as it now stands, decision 6 is explicit about
where an hour lives, and this overlay contradicts it.

Note the ADR's status is **Proposed**, not Accepted. This change proceeds on it
anyway, for the reason in the next section: the arrangement it replaces does
not work.

## Investigation: nobody writes these fields, and nobody reads them

Before designing a migration, we checked what would have to migrate. The answer
is nothing.

**`UrenRegistratie.subjectApp` has no writer.** A fleet-wide grep across every
PHP, JS, Vue and JSON file in the workspace finds the field in exactly three
places: the schema overlay that declares it, a shillinq unit test asserting the
declaration, and one consumer.

**That consumer is a dossiq KPI, and it can only ever read zero.** Dossiq's
`CaseDetail` manifest carries a `case-kpis-hours` tile summing
`shillinq.UrenRegistratie.hours` filtered on `subjectApp: "dossiq"` and
`subjectId: @objectId`. Nothing writes a row matching that filter, so the tile
reports 0 hours on every case in every install. It has done so since it
shipped.

**Humaniq's side is unwired too.** `TimeEntry.domainObjectRef` and
`domainObjectType` are declared in `hr-cost-rate.json` and nothing in the fleet
writes or reads them either. The `x-notes` still give `procest:case` as the
example, an app id that was renamed to `dossiq`.

So the fleet holds two competing designs for case hours, and neither has ever
carried a record. This is the same failure ADR-107 itself documents about
procest's IV3 report: an aggregator reading a field almost nothing filled.

**Caveat.** We checked the source tree, not a production database. An operator
could have created rows by hand through the OpenRegister UI. Any install
running this change should count `UrenRegistratie` rows with a non-null
`subjectApp` before upgrading. The verification task below does that.

## What changes

`UrenRegistratie` stops being a place to book hours against a domain object.
Humaniq's `TimeEntry` becomes the only one, and it grows the case reference it
was already declared for.

Shillinq keeps everything ADR-107 decision 1 gives it. It remains the only
general ledger and the only statutory reporter. What it stops doing is holding
the hour.

### The design question this change must answer

`UrenRegistratie` carries nine fields `TimeEntry` has no equivalent for:

| Field | What it is for |
|---|---|
| `recognisedRate` | RateCard hourly rate snapshotted at booking time |
| `glTransactionId` | The GL transaction this hour posted to |
| `wbsoTagId`, `activityCodeId`, `tagSource`, `wbsoTaggedAt` | WBSO subsidy tagging |
| `projectAssignmentId`, `costProjectId` | Analytical dimensions |
| `utilizationPercent` | Derived utilization per REQ-CPA-109 |

These are ledger and subsidy concerns, and ADR-107 decision 1 keeps them here.
So the answer is not "copy them to humaniq". Two shapes are open, and
`design.md` decides between them:

1. **Shillinq derives a cost line per humaniq TimeEntry.** The hour lives in
humaniq. Shillinq holds a booking that references it and carries the rate,
the GL link and the WBSO tags. Matches decision 6 exactly. Costs a
cross-app read on every booking.
2. **Humaniq grows an opaque allocation payload.** `TimeEntry.allocationKey`
already exists as an opaque ledger dimension that humaniq refuses to
interpret. Extending that idea keeps the hour in one row. Risks smuggling
shillinq's model into humaniq under an opaque name, which is what
`allocationKey`'s own `x-notes` warn against.

### Scope

In scope, in this app:

- Retire `subjectApp` / `subjectId` from `UrenRegistratie`.
- Repoint the four specs that read hours: `invoice-from-time-and-expense`,
`time-expense-invoice-intake`, `wbso-uren-tagging-and-export`,
`zzp-urencriterium-tracker`.
- Repoint the six code consumers: `TimeIntakeService` (10 references),
`WBSOExportValidationGuard` (5), `FinancialDashboardService` (3),
`InvoiceGenerationService` (2), `UrencriteriumGuard` (2),
`SubjectCostAggregator` (1).
- Keep the ledger, the WBSO export and the urencriterium guard working. These
are statutory. A Dutch self-employed person loses a tax deduction if the
1225-hour count is wrong.

Out of scope, tracked elsewhere:

- The humaniq write path and the shared hours widget ship with the dossiq case
detail work.
- ADR-107's composed `hourlyCost` model is a separate programme.

## Risks

**The urencriterium and WBSO paths are statutory.** Both feed a tax position.
Neither may read a partial hour set during the migration. Tasks below keep the
old read path alive until the new one is proven against the same numbers.

**Humaniq becomes a hard dependency for hours.** Today shillinq books hours
alone. After this it cannot, and no fleet app declares an `<app>` dependency in
`info.xml`. An install without humaniq must degrade to a visible empty state,
never a silent zero. The dossiq KPI's current behaviour is exactly the failure
to avoid repeating.
70 changes: 70 additions & 0 deletions openspec/changes/hours-to-humaniq/specs/hours-to-humaniq/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Spec: hours-to-humaniq (delta)

## ADDED Requirements

### Requirement: REQ-H2H-001 · Hours on a domain object live in humaniq

An hour worked on a domain object (a dossiq case, any case or matter object)
MUST be recorded as a humaniq `TimeEntry` carrying `domainObjectRef` and
`domainObjectType`. Shillinq MUST NOT hold the hour. Per ADR-107 decision 6,
the domain app supplies context and classification, humaniq supplies the wage
base, and shillinq supplies the ledger-derived additions and the booking.

#### Scenario: `UrenRegistratie` no longer declares a domain subject

- **GIVEN** the shillinq register fragments
- **WHEN** `lib/Settings/register.d/uren-domain-subject-link.json` is looked up
- **THEN** the file does not exist
- **AND** no fragment declares `subjectApp` or `subjectId` on `UrenRegistratie`
- @e2e exclude verified by file-existence and a static grep over the register
fragments. A removed schema field has no browser surface of its own.

#### Scenario: A case shows hours booked in humaniq

- **GIVEN** a dossiq case with two humaniq `TimeEntry` records whose
`domainObjectType` is `dossiq:case` and whose `domainObjectRef` is the
case uuid
- **WHEN** a handler opens the case detail page
- **THEN** the hours tile shows the sum of those two entries

### Requirement: REQ-H2H-002 · An hours surface never reports zero for a missing app

Every surface that shows booked hours MUST distinguish "humaniq is absent" from
"no hours were booked". When humaniq is not installed or not reachable, the
surface MUST render a named empty state that says so. It MUST NOT render 0.

This requirement exists because the behaviour it forbids already shipped.
Dossiq's `case-kpis-hours` tile summed a shillinq field no code ever wrote, so
it reported 0 hours on every case in every install and looked correct doing it.

#### Scenario: Humaniq is not installed

- **GIVEN** an install with humaniq disabled
- **WHEN** a handler opens a case detail page
- **THEN** the hours tile names humaniq as unavailable
- **AND** the tile does not show a numeric total

#### Scenario: Humaniq is installed and the case has no hours

- **GIVEN** an install with humaniq enabled and a case with no `TimeEntry`
- **WHEN** a handler opens the case detail page
- **THEN** the hours tile shows 0

### Requirement: REQ-H2H-003 · Statutory hour counts are proven before cutover

The WBSO export guard and the urencriterium guard both feed a tax position. For
each, the humaniq-backed count MUST be proven equal to the `UrenRegistratie`
count over the same period and administration before the old read path is
removed.

A wrong urencriterium count costs a self-employed person a real deduction, so
"the tests pass" is not the bar. The two counts must be compared directly.

#### Scenario: Both guards agree across the cutover

- **GIVEN** an administration with hours booked over a full calendar year
- **WHEN** the urencriterium total is computed from `UrenRegistratie` and from
humaniq `TimeEntry` over the same year
- **THEN** the two totals are equal
- @e2e exclude a numeric equivalence check between two service read paths, with
no UI of its own. Covered by an integration test.
56 changes: 56 additions & 0 deletions openspec/changes/hours-to-humaniq/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Tasks: hours-to-humaniq

## 1. Decide the split

- [ ] 1.1 Write `design.md` choosing between the derived-cost-line shape and
the opaque-allocation shape described in `proposal.md`. Record why.
- [ ] 1.2 Confirm ADR-107 is promoted from Proposed to Accepted, or record
that this change proceeds on a Proposed ADR and why that is safe.

## 2. Prove there is nothing to migrate

- [ ] 2.1 Add an `occ` command that counts `UrenRegistratie` rows with a
non-null `subjectApp`. Report per administration.
- [ ] 2.2 Run it on the dev instance and on every reachable install. Record
the counts in `design.md`. A non-zero count reopens task 1.
- [ ] 2.3 Only when every count is zero: proceed. Otherwise write a data
migration first.

## 3. Move the hour

- [ ] 3.1 Humaniq writes case-scoped hours: `TimeEntry.domainObjectRef` +
`domainObjectType` are stamped on create from the logging app.
- [ ] 3.2 Correct humaniq's `x-notes` example from `procest:case` to
`dossiq:case`. The app id moved and nothing writes the field yet, so
this costs nothing now and is unrecoverable once hours exist.
- [ ] 3.3 Shillinq reads hours from humaniq for the ledger, in the shape
task 1.1 chose.

## 4. Repoint each consumer, statutory ones last

- [ ] 4.1 `SubjectCostAggregator` (1 reference). Smallest, and it proves the
read path.
- [ ] 4.2 `FinancialDashboardService` (3 references). Reporting only, no tax
position.
- [ ] 4.3 `InvoiceGenerationService` (2 references) and
`TimeIntakeService` (10). Invoicing.
- [ ] 4.4 `WBSOExportValidationGuard` (5 references). Subsidy. Run old and
new side by side over the same period and assert identical output
before cutting over.
- [ ] 4.5 `UrencriteriumGuard` (2 references). A wrong 1225-hour count costs
a real person a real deduction. Same side-by-side proof as 4.4.

## 5. Retire the dead fields

- [ ] 5.1 Delete `lib/Settings/register.d/uren-domain-subject-link.json`.
- [ ] 5.2 Delete `tests/Unit/Settings/UrenDomainSubjectLinkTest.php`.
- [ ] 5.3 Update the four affected specs: `invoice-from-time-and-expense`,
`time-expense-invoice-intake`, `wbso-uren-tagging-and-export`,
`zzp-urencriterium-tracker`.

## 6. Degrade honestly without humaniq

- [ ] 6.1 Every hours surface shows a named empty state when humaniq is
absent. Never a zero. The dossiq KPI reporting 0 hours on every case
is the bug this change exists to stop repeating.
- [ ] 6.2 Add a test that asserts the empty state, not the zero.
Loading