From b404601b31bc2b07afbf1954c785e0c5ede4a19b Mon Sep 17 00:00:00 2001 From: Andy Eschbacher Date: Tue, 7 Jul 2026 07:34:14 -0400 Subject: [PATCH 1/2] refactor: hardening... --- internal/domain/fields.go | 85 ++++++++++++++----- internal/domain/schema_test.go | 19 +++++ internal/domain/validate.go | 6 +- ...first-class-entities-new-planning-nouns.md | 40 +++++++++ ...irst-class-entity-routine-audit-lineage.md | 3 +- ...ose-frontmatter-schema-policy-questions.md | 46 ++++++++++ ...eld-metadata-into-one-declarative-table.md | 51 +++++++++++ 7 files changed, 223 insertions(+), 27 deletions(-) create mode 100644 planning/epics/28-first-class-entities-new-planning-nouns.md create mode 100644 planning/tasks/6fkkz41cax80-adr-close-frontmatter-schema-policy-questions.md create mode 100644 planning/tasks/6fkm17nh3t6y-unify-task-field-metadata-into-one-declarative-table.md diff --git a/internal/domain/fields.go b/internal/domain/fields.go index 9ebaf76..905d550 100644 --- a/internal/domain/fields.go +++ b/internal/domain/fields.go @@ -1,22 +1,73 @@ package domain -// The canonical task-frontmatter field-type registry. This is the ONE place -// that knows which fields are ints, which are lists, and which exist at all — -// the core's `--set` coercion and the store's diagnose/fix paths all read it, -// so they can't drift apart (previously each kept its own copy, and `task set` -// wrote forms that the project's own `lint --fix` then rewrote). +// The canonical task-frontmatter field registry. taskFields (below) is the ONE +// place that declares which fields exist and their YAML storage type; the +// known-field set and the per-type maps (int/list/date) are all DERIVED from it, +// so they can no longer drift out of sync — previously knownTaskFields was a +// separate hand-list that had to be kept in lockstep with intFields/listFields/ +// dateFields by eye. The core's `--set` coercion, the store's diagnose/fix paths, +// lint, and `schema` all read the accessors below. // -// Keep in sync with the domain.Task yaml tags + the stamped date fields. +// Keep taskFields in sync with the domain.Task yaml tags; TestTaskFieldsMatchStruct +// pins that every modelled frontmatter tag (except the store-managed id) is here. -// intFields are frontmatter keys stored as YAML ints. -var intFields = map[string]bool{"tier": true, "autonomy_level": true} +// taskField declares one task frontmatter field and its YAML storage type +// ("string" | "int" | "list" | "date"). +type taskField struct { + Name string + Type string +} + +// taskFields is the single source of truth for task frontmatter fields — every +// field the tool reads or writes, with the type the writer produces. Order is +// cosmetic (all consumers are sets or sort). +var taskFields = []taskField{ + {"status", "string"}, + {"epic", "string"}, + {"description", "string"}, + {"effort", "string"}, + {"priority", "string"}, + {"tier", "int"}, + {"autonomy_level", "int"}, + {"tags", "list"}, + {"related_tasks", "list"}, + {"dependencies", "list"}, + {"blocks", "list"}, + {"blocked_by", "list"}, + {"audit_sources", "list"}, + {"projects", "list"}, + {"created", "date"}, + {"updated_at", "date"}, + {"started_at", "date"}, + {"revisit_at", "date"}, + {"completed_at", "date"}, + {"deprecated_at", "date"}, + {"deferred_at", "date"}, + {"audited", "date"}, +} -// listFields are frontmatter keys stored as YAML lists. -var listFields = map[string]bool{ - "tags": true, "related_tasks": true, "dependencies": true, - "blocks": true, "blocked_by": true, "audit_sources": true, "projects": true, +// taskFieldSet returns the set of task field names whose type == typ; typ == "" +// returns every known field. +func taskFieldSet(typ string) map[string]bool { + m := make(map[string]bool, len(taskFields)) + for _, f := range taskFields { + if typ == "" || f.Type == typ { + m[f.Name] = true + } + } + return m } +// The derived registries. Deriving them from taskFields means a new field is one +// table row, not four literals kept in lockstep. dateFields' other reader is +// ValidateField (validate.go) and FieldType (schema.go). +var ( + knownTaskFields = taskFieldSet("") + intFields = taskFieldSet("int") + listFields = taskFieldSet("list") + dateFields = taskFieldSet("date") +) + // IsIntField reports whether a frontmatter key is stored as a YAML int, and // IsListField whether it's stored as a YAML list. They are accessors over the // unexported registries so no sibling package can mutate the canonical type map @@ -25,16 +76,6 @@ var listFields = map[string]bool{ func IsIntField(f string) bool { return intFields[f] } func IsListField(f string) bool { return listFields[f] } -// knownTaskFields is every frontmatter key tskflwctl itself reads or writes. -var knownTaskFields = map[string]bool{ - "status": true, "epic": true, "description": true, "effort": true, - "tier": true, "priority": true, "autonomy_level": true, "tags": true, - "created": true, "updated_at": true, "started_at": true, "revisit_at": true, - "completed_at": true, "deprecated_at": true, "deferred_at": true, "audited": true, - "related_tasks": true, "dependencies": true, "blocks": true, - "blocked_by": true, "audit_sources": true, "projects": true, -} - // KnownTaskField reports whether a frontmatter key is one the tool knows. // `task set --set` rejects unknown keys unless forced — a typo'd field name // must not silently persist (decided 2026-06-12). diff --git a/internal/domain/schema_test.go b/internal/domain/schema_test.go index 86b9e69..25f8771 100644 --- a/internal/domain/schema_test.go +++ b/internal/domain/schema_test.go @@ -76,6 +76,25 @@ func TestAuditAuthoringFieldsMatchStruct(t *testing.T) { } } +// TestTaskFieldsMatchStruct is the no-drift guard fields.go asks for: every +// modelled task frontmatter field (a domain.Task yaml tag) except the store-managed +// `id` must be a known task field, so adding a struct field forces a taskFields +// registry row. The reverse deliberately does NOT hold — many known fields +// (completed_at, related_tasks, projects, …) are preserved but not modelled on the +// struct, so this only checks struct ⊆ registry. +func TestTaskFieldsMatchStruct(t *testing.T) { + rt := reflect.TypeOf(Task{}) + for i := range rt.NumField() { + name, _, _ := strings.Cut(rt.Field(i).Tag.Get("yaml"), ",") + if name == "" || name == "-" || name == "id" { + continue // derived fields and the store-managed id aren't settable known fields + } + if !KnownTaskField(name) { + t.Errorf("Task struct frontmatter field %q is not in the taskFields registry", name) + } + } +} + func TestFieldType(t *testing.T) { for name, want := range map[string]string{ "tier": "int", "autonomy_level": "int", diff --git a/internal/domain/validate.go b/internal/domain/validate.go index b9b0c69..e3fff25 100644 --- a/internal/domain/validate.go +++ b/internal/domain/validate.go @@ -83,10 +83,8 @@ func ValidateDate(value string) error { return nil } -var dateFields = map[string]bool{ - "created": true, "updated_at": true, "started_at": true, "revisit_at": true, - "completed_at": true, "deprecated_at": true, "deferred_at": true, "audited": true, -} +// dateFields (the YAML "date" typed keys) is derived from the taskFields registry +// in fields.go, alongside the other per-type sets. // IsRevisitDue reports whether a deferred task's revisit ("snooze until") date // has arrived: revisitAt is on or before now's calendar day. An empty or invalid diff --git a/planning/epics/28-first-class-entities-new-planning-nouns.md b/planning/epics/28-first-class-entities-new-planning-nouns.md new file mode 100644 index 0000000..1851ff6 --- /dev/null +++ b/planning/epics/28-first-class-entities-new-planning-nouns.md @@ -0,0 +1,40 @@ +--- +schema: 1 +status: active +description: 'Home for new first-class entities beyond task/epic/audit (routines, projects, ADRs): fields, lifecycle, storage, and CLI surface for each new noun — the ''which nouns exist'' axis.' +priority: low +tags: [] +created: "2026-07-07" +--- +# First-class entities — new planning nouns + +Home for adding new first-class entities BEYOND task/epic/audit — the "which +nouns exist" axis of the model. Distinct from epic 24's storage-model evolution +(stable-key layout / read-model / OCC), which is about HOW entities are stored; +this epic is about WHICH nouns exist and what they mean. + +## Charter + +Each new noun that earns first-class status is defined through the same machinery +the existing entities use: + +- its frontmatter fields + lifecycle (status/bucket vocabulary, if any), +- its on-disk layout (flat, id-led — per ADR-0003), +- its CLI surface (` new|list|show|…`) over `core.Service`, +- how it relates to existing entities (cross-references, rollups). + +## Candidate tenants + +- **routines** — a first-class entity tracking the routine↔audit lineage (audit + gets a `routine:` field; `audit list --routine`); complements Claude Code's + scheduler. Tenant #1 (the spike moved here from epic 24). +- **projects** — a grouping above/beside epics (TBD). +- **ADRs as tracked entities** — decisions as first-class, queryable records + rather than freeform docs (TBD). + +## Relationship to other epics + +- Epic 24 (data-model evolution) — provides the storage foundation (flat id-led + layout, OCC, projection) every new noun rides on. +- Epic 26 (frontmatter schema) — a new noun's fields should be declared through + the same field registry, once that lands. diff --git a/planning/tasks/6fjw1d2jm6e9-spike-routines-as-a-first-class-entity-routine-audit-lineage.md b/planning/tasks/6fjw1d2jm6e9-spike-routines-as-a-first-class-entity-routine-audit-lineage.md index a2f6ccc..aa0c791 100644 --- a/planning/tasks/6fjw1d2jm6e9-spike-routines-as-a-first-class-entity-routine-audit-lineage.md +++ b/planning/tasks/6fjw1d2jm6e9-spike-routines-as-a-first-class-entity-routine-audit-lineage.md @@ -2,7 +2,7 @@ schema: 1 id: 6fjw1d2jm6e9 status: ready-to-start -epic: 24-data-model-evolution-stable-key-storage-read-model-content-occ +epic: 28-first-class-entities-new-planning-nouns description: 'Explore routines as a first-class entity tracking the routine<->audit lineage (audit gets a routine: field; audit list --routine). Complements Claude Code''s scheduler; maybe its own epic.' effort: Unknown tier: 3 @@ -10,6 +10,7 @@ priority: low autonomy_level: 3 tags: [core, design] created: "2026-07-04" +updated_at: "2026-07-07" --- # Spike: routines as a first-class entity + routine↔audit lineage diff --git a/planning/tasks/6fkkz41cax80-adr-close-frontmatter-schema-policy-questions.md b/planning/tasks/6fkkz41cax80-adr-close-frontmatter-schema-policy-questions.md new file mode 100644 index 0000000..6ecfe58 --- /dev/null +++ b/planning/tasks/6fkkz41cax80-adr-close-frontmatter-schema-policy-questions.md @@ -0,0 +1,46 @@ +--- +schema: 1 +id: 6fkkz41cax80 +status: next-up +epic: 26-frontmatter-schema-declared-validation-contract +description: Design-first ADR closing epic 26's policy questions (strictness, unknown fields, schema location, severities, rollout) so one field registry drives lint, schema guidance, and the --json contract. +effort: Unknown +tier: 3 +priority: medium +autonomy_level: 3 +tags: [validation, schema, adr] +created: "2026-07-07" +--- +# ADR — declared frontmatter-schema contract: close the policy questions + +## Objective + +Epic 26 is design-first: before any validator is written, close the open policy +questions so a *single declared field registry* can generate lint rules, `schema +` authoring guidance, and a frontmatter JSON-schema from one source of +truth (replacing today's triplicated, drift-prone split). Output: an ADR (the +next number after 0003/0004) recording the decisions, plus this epic's open +questions resolved or explicitly deferred. + +## Acceptance criteria + +- [ ] ADR drafted covering the load-bearing questions: per-status strictness + matrix (Q1), unknown/custom-field policy (Q2), where the schema lives and + its relation to `schema --json-schema` (Q4), severity levels (Q6), and + backward-compat/rollout (Q8). +- [ ] Each of epic 26's 12 open questions is either decided or explicitly parked + with a reason. +- [ ] The "fail loud, don't over-fix" contract is preserved (non-goal: turning + `--fix` into a general repairer). +- [ ] The ADR-0003 carveout amendment (what makes a file an entity) is folded in. + +## Out of scope + +- Implementing the field registry / validator (a follow-on task once decided). +- A runtime/plugin schema language — start code-declared and fixed. + +## Related + +- Epic [26-frontmatter-schema-declared-validation-contract](../epics/26-frontmatter-schema-declared-validation-contract.md) — the 12 open questions live in its body. +- Prior art: `domain.LintTask`, `domain.MissingIDIssue`, `store.parseTask`'s loud + missing-frontmatter failure, `schema --json-schema`. diff --git a/planning/tasks/6fkm17nh3t6y-unify-task-field-metadata-into-one-declarative-table.md b/planning/tasks/6fkm17nh3t6y-unify-task-field-metadata-into-one-declarative-table.md new file mode 100644 index 0000000..2add6e9 --- /dev/null +++ b/planning/tasks/6fkm17nh3t6y-unify-task-field-metadata-into-one-declarative-table.md @@ -0,0 +1,51 @@ +--- +schema: 1 +id: 6fkm17nh3t6y +status: completed +epic: 26-frontmatter-schema-declared-validation-contract +description: Derive knownTaskFields + int/list/date maps from one declarative task-field table (not parallel hand-kept maps); add a Task-struct-tag sync test. Behavior-preserving groundwork for the field registry. +effort: Unknown +tier: 3 +priority: medium +autonomy_level: 3 +tags: [schema, validation, core] +created: "2026-07-07" +started_at: "2026-07-07" +updated_at: "2026-07-07" +completed_at: "2026-07-07" +--- +# Unify task field metadata into one declarative table + +First implementation slice of epic 26's Direction #1 ("one declarative field +registry as the single source of truth"). Behavior-preserving; needs none of the +open policy decisions, so it can land ahead of the design ADR. + +## Objective + +Today the task field metadata is split across parallel, hand-kept maps that must +stay in lockstep by eye: `knownTaskFields` (fields.go), `intFields`/`listFields` +(fields.go), and `dateFields` (validate.go). Add a field and you must remember to +touch every one. Collapse them into a single declarative `taskFields` table +(name → YAML type) that all four maps derive from, so they cannot drift. + +## Acceptance criteria + +- [ ] `knownTaskFields`, `intFields`, `listFields`, `dateFields` are all derived + from one `taskFields` table; no parallel hand-kept literals remain. +- [ ] Accessors (`KnownTaskField`, `IsIntField`, `IsListField`) and `FieldType` + are unchanged in behavior; existing tests stay green. +- [ ] New sync test: every `domain.Task` frontmatter yaml tag except `id` is a + known task field (closes the "keep in sync with the Task yaml tags" gap + that was previously eyeballed). + +## Out of scope + +- The open policy questions (per-status strictness, severities, rollout) — those + wait on the ADR. +- Folding LintTask's hand-written checks or the `--json` envelope schema into the + registry — later slices. + +## Related + +- Epic [26-frontmatter-schema-declared-validation-contract](../epics/26-frontmatter-schema-declared-validation-contract.md) +- Prior art: the `entity.go` descriptor registry, `schema.go` FieldType. From 2e4ae2df5c31fd60ca7f67e7d64baee2b4f50dbc Mon Sep 17 00:00:00 2001 From: Andy Eschbacher Date: Tue, 7 Jul 2026 20:34:16 -0400 Subject: [PATCH 2/2] chore: bump versions --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index acde498..0b1be68 100644 --- a/go.mod +++ b/go.mod @@ -62,7 +62,7 @@ require ( github.com/sahilm/fuzzy v0.1.1 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - github.com/yuin/goldmark v1.7.13 // indirect + github.com/yuin/goldmark v1.7.17 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect golang.org/x/net v0.56.0 // indirect diff --git a/go.sum b/go.sum index 0f41edf..608999b 100644 --- a/go.sum +++ b/go.sum @@ -137,8 +137,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= -github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark v1.7.17 h1:p36OVWwRb246iHxA/U4p8OPEpOTESm4n+g+8t0EE5uA= +github.com/yuin/goldmark v1.7.17/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=