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:
listTerminalItemsSince() iterates models.DefaultTerminalStatuses unconditionally and ignores the collection's schema-declared terminal_options, even though models.TerminalValuesForDoneField() exists for exactly this.
- 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.
Summary
Disabling a convention is reported as completed work.
pad project standuplists disabled conventions undercompleted, andpad project report's completed counter includes them.This is two separable things, one mechanical and one semantic:
listTerminalItemsSince()iteratesmodels.DefaultTerminalStatusesunconditionally and ignores the collection's schema-declaredterminal_options, even thoughmodels.TerminalValuesForDoneField()exists for exactly this.startuptemplate declaresterminal_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
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 jsoncounts 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):models.DefaultTerminalStatuses(internal/models/terminal.go) is the union list, which includes"disabled","archived"and"deprecated". Its own doc comment scopes it narrowly: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 declareterminal_optionsgets no say.Verified: setting
terminal_options: []on the Conventionsstatusfield 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 thestartuptemplate 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 viaTerminalValuesForDoneField()rather than iterating the global union. Since it already loops per status and is best-effort, this looks like resolving the set percollIDinstead 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: falseon Conventions/Playbooks/Docs), so standup, changelog and report throughput can skip non-work collections whileready/staleare unaffected. Happy to send a PR for (1) if you want it split that way.Workarounds tried
terminal_options: []on Conventions — no effect, per above.draftinstead ofdisabledto park an unwanted convention — this does drop it out ofcompleted, but adraftconvention still appears in the defaultpad item list conventionsoutput, whereas adisabledone is hidden. For default-deny that's a bad trade: the visibility is the whole point.standup.completedentry whose status isn'tdone/completed. Note this only works because the standup payload carries per-itemstatus;project reportreturns bare per-day counts, so its completed number can't be corrected downstream.Environment
0.9.2(7687521, 2026-07-06), self-hosted daemon, SQLite, single user.Minor, possibly by design
project report's completed counter is monotonic within a bucket: moving a taskopen → done → openleaves 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.