@nanobpm/nano-workforce 0.51.0 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,123 @@
1
+ # ADR 0003 — Epic base-branch admission: explicit, auto-created, and guarded
2
+
3
+ Status: **Proposed.**
4
+ Date: 2026-08-13.
5
+
6
+ > **Scope note.** A **nano-workforce-local** ADR — it governs how *this app* admits an epic for
7
+ > execution. Platform-wide ADRs live in `Magikcraft/nano-bpm/docs/adr` (referenced by number + repo).
8
+ > Continues nano-workforce's series after ADR 0001 (ADR 0002 is planned but not yet written; see below).
9
+
10
+ Relates to:
11
+ nano-workforce **ADR 0001** (cross-repo epics + integration branches — this ADR hardens *how* an epic's
12
+ integration branch is chosen, created, and protected),
13
+ nano-workforce **ADR 0002** *(planned — not yet written in this repo; escalations as user tasks)*: the
14
+ confirm / shared-base gates are human decisions that *could* later surface as user tasks; see Open questions,
15
+ nano-bpm **ADR 0058** (the OpenAPI endpoint surface — `startPlanFanout` is a spec operation whose request
16
+ schema this ADR changes),
17
+ migration `019_plan_base_branch.sql` (the `plans.base_branch` column, whose example is `epic/agent-protocol`),
18
+ and in this repo: `operations/startPlanFanout.ts` (the launch operation), `app/plan.ts`
19
+ (`startPlan`, `normalizeBaseBranch`, `renderBaseBranchBrief`, `PLAN_TERMINAL_STATUSES`), `app/baseGuard.ts`
20
+ + `app/github.ts` (`baseBranchLanded`, `fetchDefaultBranch`), and `resources/processes/plan-fanout.bpmn`.
21
+
22
+ ## Context
23
+
24
+ An epic is launched through the `startPlanFanout` operation, which calls `startPlan(data, engine, parsed,
25
+ baseBranch)`. The `base_branch` (migration 019) tells every fanned-out task agent to branch off it and open
26
+ its PR against it (`renderBaseBranchBrief`), landing the whole epic on a long-lived integration branch that
27
+ reaches the default branch — and any merge-to-default side effect such as auto-publishing — only when the
28
+ integration branch is deliberately merged.
29
+
30
+ Four gaps make this a footgun surface:
31
+
32
+ 1. **Implicit default.** `normalizeBaseBranch` maps a blank/absent value to `null`, silently meaning "target
33
+ the repository default branch." An operator who *meant* to name an integration branch but omitted it
34
+ lands every task straight onto the default branch (e.g. `main`) with **no integration buffer** — and
35
+ any merge-to-default side effect fires per task.
36
+ 2. **No branch creation.** Nothing creates the integration branch. `baseGuard`/`baseBranchLanded` only
37
+ *read* (`gh pr list`, `fetchPrBase`, `fetchDefaultBranch`). So if the named branch doesn't exist, the
38
+ **first task's** `git fetch origin <branch>` / `gh pr create --base <branch>` fails — a late, per-task
39
+ failure instead of a clean admission error.
40
+ 3. **No typo guard.** A mistyped branch name is indistinguishable from an intended new one; without
41
+ creation it fails late, and *with* naive creation it would silently spawn a wrong-rooted branch.
42
+ 4. **No collision guard.** Two in-flight epics can target the **same** integration branch, interleaving
43
+ commits and poisoning each other's base — with no warning.
44
+
45
+ The pieces to fix this already exist: `fetchDefaultBranch` (default-branch identity), `PLAN_TERMINAL_STATUSES`
46
+ (`done|failed|abandoned` → the complement is "active"), a strict `isPlausibleBranchName` allowlist, and the
47
+ `019` example convention `epic/*`.
48
+
49
+ ## Decision
50
+
51
+ **Every epic launch must state its base branch explicitly, and `startPlanFanout` admits it through one
52
+ fail-fast gate** — `admitPlan(...)` — run before any task fans out, backed by a durable `ensure-base-branch`
53
+ head step in `plan-fanout.bpmn`. The gate has four ordered rules:
54
+
55
+ ### 1. Required + explicit — no implicit default
56
+
57
+ `baseBranch` becomes a **required** field of `StartPlanFanoutRequest`. `normalizeBaseBranch` **rejects** a
58
+ blank/absent value (`MissingBaseBranchError` → HTTP 400) instead of returning `null`. "Land on the default
59
+ branch" is now a **conscious, named, confirmed** choice (rule 3), never a silent fallback. The
60
+ `base_branch == null ? default : brief` fork in the launch/prompt path is removed; `renderBaseBranchBrief`
61
+ is always rendered. (`plans.base_branch` stays nullable in the DB **only** to grandfather pre-migration
62
+ rows; new launches always set it.)
63
+
64
+ ### 2. Create-if-missing, idempotently — with an `epic/*` guard
65
+
66
+ `ensureBaseBranch(repo, branch, token)`:
67
+ - **Exists** → no-op (never reset — a reset would nuke in-flight task PRs stacked on it; a stacked epic's
68
+ base already exists and is left alone).
69
+ - **Missing and matches `epic/*`** → create `refs/heads/<branch>` off the **default branch HEAD**.
70
+ - **Missing and *not* `epic/*`** → **reject** (`BaseBranchMustExistError`): a non-`epic/*` branch must
71
+ already exist, so a typo can't silently spawn a wrong-rooted branch. (`epic/*` is the `019` convention.)
72
+
73
+ Runs at admission (fail fast) **and** as a head `ensure-base-branch` service task in `plan-fanout.bpmn` so
74
+ it is durable + retriable even on a re-plan.
75
+
76
+ ### 3. Confirm-default — naming the default branch is deliberate
77
+
78
+ If the explicit target **equals the repository default** (via `fetchDefaultBranch`), admission requires an
79
+ explicit `confirmDefaultBase: true`, else **reject** (`DefaultBaseNotConfirmedError` → 400) with a message
80
+ spelling out the consequence ("every task lands directly on `<default>` with no integration branch; any
81
+ merge-to-default side effect fires per task"). This is the single guardrail on the one dangerous explicit
82
+ value.
83
+
84
+ ### 4. Shared-base guard — one integration branch, one epic
85
+
86
+ If another plan whose `status ∉ PLAN_TERMINAL_STATUSES` (i.e. **active**) targets the **same repo + same
87
+ base branch**, admission **rejects** (`SharedBaseError` → 409) unless `allowSharedBase: true`. **Exempt: the
88
+ default branch** — many epics target the default concurrently and don't collide (each task PR is
89
+ independent). The guard fires only for a **shared custom integration branch**, the genuinely dangerous case.
90
+
91
+ ### Recommended defaults (confirm before build)
92
+
93
+ - **Hard-require flags**, not soft warnings, for rules 3 and 4: a warning in a headless submit flow is
94
+ ignorable; a required `confirmDefaultBase` / `allowSharedBase` is a "warn you can't skip."
95
+ - **`epic/*` auto-create guard** (rule 2): auto-create only `epic/*`; any other non-existent name is a
96
+ 400. Matches the `019` convention.
97
+
98
+ ## Consequences
99
+
100
+ - **A footgun class disappears:** no silent land-on-main, no first-task-fails-on-missing-branch, no typo'd
101
+ wrong-rooted branch, no two-epics-one-branch interleave — all become clean admission errors.
102
+ - **Breaking API + launch-path change.** `StartPlanFanoutRequest.baseBranch` is now required; any
103
+ fire-and-forget caller/CLI/epic template that omitted it will (intentionally) 400. The OpenAPI spec +
104
+ generated types (`nano-generated/api-io.d.ts`, controller) regenerate; the submit surface + docs update.
105
+ - **Back-compat:** pre-migration / in-flight `base_branch = null` plans are **grandfathered** (the column
106
+ stays nullable; the required-ness is enforced at *admission* of new launches, not by a DB `NOT NULL`).
107
+ - **New write permission exercised:** ref creation (already held by the token that pushes task branches).
108
+ - **Ties into ADR 0001/0002:** this is the admission half of the integration-branch story (0001), and its
109
+ human gates could later be modeled as user tasks (planned ADR 0002) rather than flags — see Open questions.
110
+
111
+ ## Open questions
112
+
113
+ - **Gates as user tasks (planned ADR 0002)?** Should `confirmDefaultBase` / shared-base become an inbox **user
114
+ task** ("Epic X wants to target `main` / share `epic/y` — approve?") instead of a submit-time flag? For
115
+ *pre-fan-out* admission a synchronous flag is simpler and fail-fast; a user task fits only if we want a
116
+ human in the launch loop. Deferred.
117
+ - **Auto-create root.** Always off default HEAD — correct for a fresh integration branch. Should a stacked
118
+ epic be able to declare `stackOn: <branch>` so its base is auto-created off *another* epic's branch
119
+ rather than default? (Today: that base must pre-exist.)
120
+ - **Convention scope.** Is `epic/*` the only auto-createable prefix, or should the app config own the
121
+ allowlist?
122
+ - **Grandfathered nulls.** Leave historical `null` rows as-is, or backfill them to the (then-current)
123
+ default branch for a uniform read model?
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.51.0",
3
+ "version": "0.53.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",