Skip to content

[Feature]: Objectives record claims, not measurements — no code anywhere compares a check-in to its target #206

Description

@unidoc-alip

Problem / motivation

An objective in this product carries everything needed to be measured. objectives has target_value, target_operator (gte|lte|eq|gt|lt), unit, and window_seconds — the schema comment on the last one reads "measurement interval (e.g., 2592000 for monthly)" (migrations/20260327000000_initial_schema.sql:958-961). checkins has value_numeric"actual measured value" — alongside success and occurred_at (:989-994). docs/auditor-guide.md:178 tells auditors that check-ins "represent periodic progress measurements against management objectives."

Nothing compares them. A repo-wide search for every occurrence of target_value/TargetValue, target_operator/TargetOperator and value_numeric/ValueNumeric across Go, Vue and SQL turns up persistence, DTO binding, changelog serialisation and display — and no comparison. There is no code path in which a target value and a measured value meet.

The consequence is not a missing number on a page. It is that an objective's status and a check-in's success are author-asserted:

  • objectives.status is one of draft, active, at_risk, paused, complete (db/objectives.go:14), validated only for enum membership (api_writepath_defaults.go:131-136). Whoever types complete makes it complete.
  • checkins.success is a *bool (db/checkins.go:34) with no relationship to value_numeric. Whoever types true makes it a pass.
  • GET /objectives/stats is a bare count(*) plus six count(*) FILTER clauses over status and archived_at (db/objectives.go:284-297, handler at api_objectives.go:302-309). It is a tally of those assertions. No progress, attainment, latest_value, trend or checkin_count field exists on GET /objectives, GET /objectives/:id or GET /objectives/:id/checkins.

So the module cannot distinguish an objective that is being met from one that is being missed while somebody keeps typing "pass". For a module whose purpose is demonstrating that management objectives are monitored, that is the whole job.

Reproduced live (admin, throwaway program and objective, both deleted afterwards):

POST /programs    {"key":"poc14","title":"…"}                                → 201  PROG-3
POST /objectives  {"program_id":3,"title":"… mean time to patch",
                   "status":"active","target_operator":"lte","target_value":14,
                   "unit":"days","window_seconds":2592000,"started_at":1785000000}
                                                                              → 201  POC14-1 (id 8)

GET /objectives/stats  →  {"total":7,"draft":0,"active":4,"at_risk":2,"paused":0,"complete":1,"archived":0}

POST /objectives/8/checkins {"value_numeric":45,"success":true,"message":"…"}  → 201
POST /objectives/8/checkins {"occurred_at":1893456000,"value_numeric":61,"success":true}
                                                                              → 201   (2030-01-01)
POST /objectives/8/checkins {"occurred_at":1577836800,"value_numeric":90}     → 201   (2020-01-01,
                                                                                      before started_at)

GET /objectives/stats  →  {"total":7,"draft":0,"active":4,"at_risk":2,"paused":0,"complete":1,"archived":0}
GET /objectives/8      →  byte-identical to the create response, updated_at unchanged
GET /objectives?program_id=3  →  same row, no derived field

{"value_numeric":45,"success":true} on a lte 14 days objective is accepted without complaint, and it is not a mistake the system can make on its own — it is the only way the system can be told anything. Three check-ins reporting 45, 61 and 90 days against a 14-day target leave the objective reading active, and every read endpoint byte-identical.

The declared measurement window is inert. window_seconds round-trips faithfully — bound at api_objectives.go:47 and :65, written at db/objectives.go:126,134 and :240,246, returned on every read, diffed into the changelog at :408-410 — and is read by nothing else in the tree. So "≤ 14 days, measured monthly" and "≤ 14 days, ever" are the same record, and a cumulative objective ("≥ 4 restore tests per year", currently at 3) is indistinguishable from a failing one. The period, when anyone bothers to record it, survives only inside the free-text unit string. grace_seconds is the same: schema comment "grace period before overdue" (:962), consulted by no query. (Its DEFAULT 3600 is also unreachable through the API — GraceSeconds is a non-pointer int on the create DTO at api_objectives.go:48, so omitting it sends 0. Invisible today only because nothing reads it.)

The one thing the module does derive is silence, not failure. OverdueObjectiveCheckins (db/overdue.go:241-293) compares MAX(checkins.occurred_at) against checkin_cycle months and flags objectives whose next check-in is due. It never touches success, value_numeric or target_value. So an objective whose every measurement misses, reported punctually, is invisible; an objective that is being met but not written up is chased. That signal is also narrower than it looks: GET /overdue calls GetOverdueSummary (api/server.go:3056-3062), which has no objective branch. Live, a second throwaway objective created for exactly this — POC14-2, status:"active", started_at 2020-01-01, checkin_cycle the default 12 months, zero check-ins, therefore years past due — produced no objectives key in the GET /overdue response at all, before or after a failing check-in was added to it:

POST /objectives {"program_id":3,"title":"…","status":"active",
                  "target_operator":"gte","target_value":99,"unit":"%",
                  "started_at":1577836800}                          → 201  POC14-2 (id 9)
GET  /overdue                                                       → no "objectives" key
POST /objectives/9/checkins {"occurred_at":1893456000,
                             "value_numeric":3,"success":false}     → 201
GET  /overdue                                                       → no "objectives" key

The objective branch runs only inside CreateOverdueReviewTasks (db/overdue.go:375-384), reachable via POST /overdue/tasks (server.go:665) and isms manager.

And because occurred_at is optional, defaults to now() (db/checkins.go:43-45) and is validated nowhere — handleCreateCheckin (api_objectives.go:633) contains no date logic — a future-dated check-in pushes nextDue = lastCheckin.AddDate(0, cycle, 0) (db/overdue.go:273-275) past the horizon and silences even that. The 2030-dated check-in above was accepted with a 201.

What the browser shows today. This is not an API-only gap; the UI presents the asserted values as though they were derived.

  • The Check-ins tab's only summary line is {{ passCount }} pass · {{ failCount }} fail · {{ checkins.length }} total (web/src/views/Objectives.vue:322), and both counts filter on the hand-typed boolean (:564-565).
  • Each check-in gets a green PASS or red FAIL badge straight from ci.success (:374-375), rendered inches from {{ ci.value_numeric }} (:376-377) with no relationship between them.
  • The "Last check-in" date is coloured emerald or red by latestCheckinIsPass / latestCheckinIsFail (:566-567, used at :298) — again the boolean.
  • The target renders in the list column (:130-131) and in the overview panel (:282-283); the measured value renders in the check-in row. They are never on screen as a comparison.
  • The Record check-in form places a Value number input next to a Result select offering Unspecified / Pass / Fail as a free choice (:332, :338-343).
  • Programs.vue has no objective rollup at all — a program shows its objectives, and nothing about how they are doing.

So a green PASS badge on a check-in that plainly misses its target is reachable in the browser today. The CLI does the same thing (cmd/isms/objective.go:297-303, :461-467 print PASS/FAIL from Success verbatim).

Proposed solution

The data model is already complete; what's missing is one comparison and the surfaces that show it.

1. Derive attainment. One helper — objective plus latest check-in (or the check-ins inside window_seconds) → met / missed / unknown, applying target_operator to target_value and value_numeric. unknown when either side is null, which must stay a first-class outcome: target_value is nullable and plenty of objectives are legitimately qualitative.

2. Surface it on reads. Add latest_value, attainment and checkin_count to GET /objectives and GET /objectives/:id, and an at_risk_computed / missing_target count to /objectives/stats alongside the existing status tallies. Additive fields; no existing consumer breaks.

3. Make success derived when it can be. If a check-in carries a value_numeric and the objective carries a target, compute pass/fail rather than accepting the author's word — or keep the field and return the computed verdict beside it so a disagreement is visible. Worth deciding explicitly whether an explicit success that contradicts the numbers should be rejected, overridden, or flagged; a qualitative check-in with no value_numeric still needs the manual boolean, so the field cannot simply be dropped.

4. Give window_seconds a job, or remove it. Either scope attainment to the declared window and expose it in the UI (it appears in no form today), or drop the column and stop implying a capability that isn't there. The same choice applies to grace_seconds. Whichever way, cumulative-per-period objectives need some honest representation — right now the period lives in a free-text unit string.

5. Validate occurred_at. Reject future dates, and reject or warn on dates before started_at. This is a one-line guard in handleCreateCheckin and it protects the overdue calculation from being silenced by a typo.

6. Show it. A target-vs-actual line on the objective overview, computed pass/fail on the check-in rows, and an attainment column on Programs.vue so a program can be read at a glance.

Steps 1, 2 and 5 are the small ones and deliver most of the value: once a read endpoint says "missed", the UI, the CLI and any MCP client inherit it.

Alternatives considered

Leave it manual and document it. Defensible if objectives are meant to be a narrative register rather than a KPI engine — but the schema already commits the other way, with a target, an operator, a unit, a measurement interval and a numeric measured value. Keeping it manual means deleting those columns, not keeping them inert. And it would mean the UI must stop rendering author-typed booleans as derived-looking PASS/FAIL badges.

Recompute objectives.status automatically (flip to at_risk when a check-in misses). Rejected as the first step: status is also a lifecycle field (draft, paused, complete) that a manager legitimately controls, and silently overwriting it would surprise people. A separate computed attainment field, shown next to the manual status, keeps both meanings intact — and if they disagree, that disagreement is itself worth seeing.

Handle it outside the product, by exporting check-ins and comparing them in a spreadsheet. That is what the module's users have to do today. It puts the one number an ISMS committee actually asks for outside the system of record, and it doesn't help the reader looking at the objective in the browser.

Adjacent, and left to its own thread: POST /objectives/:id/checkins requires admin or manager (api_objectives.go:634) even though objectives carry an owner (db/objectives.go:53), so the person accountable for an objective cannot record its measurement. That belongs with #203 rather than here, but whatever computes attainment will be fed by whoever can post check-ins.

Found during a multi-user manual test run on 2026-08-05 against a locally-built server (Postgres, file storage backend, iso27001 template scaffolded, accounts across admin/manager/contributor/reader), and re-verified on 2026-08-07. Every code reference above is from 86ccf59; every HTTP exchange was run live against throwaway entities created for the purpose and deleted afterwards.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions