Skip to content

standup/report count disabled conventions as completed work — listTerminalItemsSince ignores schema terminal_options #1049

Description

@thomasthinks

Summary

Disabling a convention is reported as completed work. pad project standup lists disabled conventions under completed, and pad project report's completed counter includes them.

This is two separable things, one mechanical and one semantic:

  1. listTerminalItemsSince() iterates models.DefaultTerminalStatuses unconditionally and ignores the collection's schema-declared terminal_options, even though models.TerminalValuesForDoneField() exists for exactly this.
  2. Even if it honoured the schema, the stock startup template declares terminal_options: ["disabled"] on Conventions — so terminal and completed work are the same thing for a collection whose items are rules, not work.

Steps to reproduce

pad workspace create "demo" --slug demo --template startup
pad --workspace demo item update CONVE-2 --status disabled
pad --workspace demo project standup --format json | jq '.completed'

Actual:

[ { "ref": "CONVE-2", "title": "Never push directly to main", "status": "disabled" } ]

Expected: [] — no work was completed; a rule was switched off.

pad project report --format json counts it too: on a fresh workspace where I disabled 4 seeded conventions and completed nothing, the current day's bucket reads {"created": 15, "completed": 4}.

Mechanism

internal/server/handlers_project_intel.go, listTerminalItemsSince() (v0.11.0):

for _, status := range models.DefaultTerminalStatuses {
	items, err := s.store.ListItems(workspaceID, models.ItemListParams{
		CollectionIDs: collIDs,
		Fields:        map[string]string{"status": status},

models.DefaultTerminalStatuses (internal/models/terminal.go) is the union list, which includes "disabled", "archived" and "deprecated". Its own doc comment scopes it narrowly:

DefaultTerminalStatuses is the fallback list used when a collection's done field has no terminal_options declared on its schema.

But the standup/changelog path uses it as the primary source, not a fallback. TerminalValuesForDoneField(schema, settings) is never consulted, so a collection that does declare terminal_options gets no say.

Verified: setting terminal_options: [] on the Conventions status field changes nothing — the disabled conventions are still reported as completed. That rules out schema configuration as a fix and localises it to this loop.

Same union list also means an archived Doc counts as completed work (terminal_options: ["archived"] on the stock Docs collection). I didn't test that one.

Why it matters

We onboard repos onto Pad default-deny: the four system-authored conventions the startup template seeds are agent-binding config, and a stock [MUST] can outrank the repo's own ratified docs. (In our first repo, CONVE-2 "Never push directly to main" contradicted three repo documents and sat active unnoticed for 19 days.) So each new workspace disables 3-4 conventions on creation and re-enables only what the repo's rules agree with.

The consequence is that every repo we add injects 3-4 phantom completions. In our cross-workspace rollup at 2 workspaces, 4 of the 5 "completed" rows were disabled conventions. It scales linearly with the fleet, and it makes throughput unusable as a signal — the number goes up when you turn rules off.

Suggested fix

For (1): have listTerminalItemsSince() resolve terminal values per collection via TerminalValuesForDoneField() rather than iterating the global union. Since it already loops per status and is best-effort, this looks like resolving the set per collID instead of once globally.

For (2) — the design question, and yours to make the call on: even a schema-correct terminal set conflates "this rule is off" with "this work is done". The cleanest separation is probably a per-collection flag for whether its items represent work (something like counts_as_work: false on Conventions/Playbooks/Docs), so standup, changelog and report throughput can skip non-work collections while ready/stale are unaffected. Happy to send a PR for (1) if you want it split that way.

Workarounds tried

  • Schema terminal_options: [] on Conventions — no effect, per above.
  • Using draft instead of disabled to park an unwanted convention — this does drop it out of completed, but a draft convention still appears in the default pad item list conventions output, whereas a disabled one is hidden. For default-deny that's a bad trade: the visibility is the whole point.
  • Client-side filtering — what we settled on. Our rollup drops any standup.completed entry whose status isn't done/completed. Note this only works because the standup payload carries per-item status; project report returns bare per-day counts, so its completed number can't be corrected downstream.

Environment

  • pad 0.9.2 (7687521, 2026-07-06), self-hosted daemon, SQLite, single user.
  • Source citations above are from tag v0.11.0 — the code path is unchanged there, but I have not run 0.11.0, so the runtime reproduction is on 0.9.2.

Minor, possibly by design

project report's completed counter is monotonic within a bucket: moving a task open → done → open leaves the count incremented. If the metric is meant to be "transitions into terminal during the window" that's defensible, but combined with the above it means a disabled convention is permanently baked into that day's throughput.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions