alissa-tools-github-orcloop 0.2.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (23) hide show
  1. {alissa_tools_github_orcloop-0.2.0/src/main/alissa_tools_github_orcloop.egg-info → alissa_tools_github_orcloop-0.3.0}/PKG-INFO +1 -1
  2. {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.0}/src/main/alissa/tools/github/orcloop/__main__.py +61 -1
  3. {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.0}/src/main/alissa/tools/github/orcloop/alissa_client.py +165 -0
  4. {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.0}/src/main/alissa/tools/github/orcloop/config.py +132 -1
  5. {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.0}/src/main/alissa/tools/github/orcloop/loop.py +728 -38
  6. {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.0}/src/main/alissa/tools/github/orcloop/markers.py +49 -0
  7. {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.0}/src/main/alissa/tools/github/orcloop/state.py +35 -1
  8. alissa_tools_github_orcloop-0.3.0/src/main/alissa/tools/github/orcloop/version +1 -0
  9. {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.0/src/main/alissa_tools_github_orcloop.egg-info}/PKG-INFO +1 -1
  10. alissa_tools_github_orcloop-0.2.0/src/main/alissa/tools/github/orcloop/version +0 -1
  11. {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.0}/MANIFEST.in +0 -0
  12. {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.0}/README.md +0 -0
  13. {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.0}/requirements.txt +0 -0
  14. {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.0}/setup.cfg +0 -0
  15. {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.0}/setup.py +0 -0
  16. {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.0}/src/main/alissa/tools/github/orcloop/__init__.py +0 -0
  17. {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.0}/src/main/alissa/tools/github/orcloop/ghclient.py +0 -0
  18. {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.0}/src/main/alissa/tools/github/orcloop/proc.py +0 -0
  19. {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.0}/src/main/alissa/tools/github/orcloop/version.py +0 -0
  20. {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.0}/src/main/alissa_tools_github_orcloop.egg-info/SOURCES.txt +0 -0
  21. {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.0}/src/main/alissa_tools_github_orcloop.egg-info/dependency_links.txt +0 -0
  22. {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.0}/src/main/alissa_tools_github_orcloop.egg-info/entry_points.txt +0 -0
  23. {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.0}/src/main/alissa_tools_github_orcloop.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-orcloop
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-ORCLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -1,7 +1,10 @@
1
1
  """CLI entry point: alissa-orcloop (or python -m alissa.tools.github.orcloop).
2
2
 
3
3
  O1 wired the config surface and the argument parser; O3 wires the decision
4
- loop behind it. The CLI resolves and validates the full configuration
4
+ loop behind it; O6 adds the release-feed selector (`--release-feed bow` and its
5
+ `--bow-prefix` / `--bow-owner` / `--scan-fallback-interval` knobs).
6
+
7
+ The CLI resolves and validates the full configuration
5
8
  (four-layer precedence, fail-closed `repos` guard), reports the effective
6
9
  settings, then runs the orchestrator: `--once` for a single sweep (pair it
7
10
  with `--dry-run -v` to print every decision and its reasons without touching
@@ -18,6 +21,8 @@ from pathlib import Path
18
21
 
19
22
  from .alissa_client import AlissaError
20
23
  from .config import (
24
+ FEED_BOW,
25
+ RELEASE_FEEDS,
21
26
  Config,
22
27
  load_config_file,
23
28
  resolve_config_path,
@@ -91,6 +96,41 @@ def build_parser() -> argparse.ArgumentParser:
91
96
  over.add_argument("--scope-key", metavar="KEY", help="Autonomous-Scope trailer key")
92
97
  over.add_argument("--alissa-endpoint", metavar="URL")
93
98
  over.add_argument("--ledger-path", type=Path, metavar="PATH")
99
+ over.add_argument(
100
+ "--release-feed",
101
+ choices=RELEASE_FEEDS,
102
+ metavar="{" + ",".join(RELEASE_FEEDS) + "}",
103
+ help="where release candidates come from: 'scan' (the default) lists "
104
+ "every committed task and filters it on the release marker; 'bow' "
105
+ "reads the per-repo Bodies of Work named <bow-prefix><owner>/<repo>, "
106
+ "where attaching a task is the release trigger AND the repo declaration",
107
+ )
108
+ over.add_argument(
109
+ "--bow-prefix",
110
+ metavar="PREFIX",
111
+ help="title prefix that marks a Body of Work as a release feed "
112
+ "(default 'autodev: '); the rest of the title is the target owner/repo",
113
+ )
114
+ over.add_argument(
115
+ "--bow-owner",
116
+ dest="bow_owners",
117
+ action="append",
118
+ metavar="ACTOR_ID",
119
+ help="bow feed only: trust bodies of work OWNED by this Alissa actor "
120
+ "as release feeds; repeatable. When given, REPLACES the config "
121
+ "`bow_owners` list. Fail-closed like --repo: `--release-feed bow` with "
122
+ "an empty owner list is a fatal startup error, because being SHARED a "
123
+ "body of work is not a claim to drive this daemon. An actor id is the "
124
+ "`ownerActorId` on the container's row in GET /v1/bodies-of-work.",
125
+ )
126
+ over.add_argument(
127
+ "--scan-fallback-interval",
128
+ type=int,
129
+ metavar="SECONDS",
130
+ help="bow feed only: how often to ALSO run the full marker scan and "
131
+ "log every marked task the feed did not surface (default 3600; 0 "
132
+ "disables it). The sweep reports, it never releases.",
133
+ )
94
134
 
95
135
  close = over.add_mutually_exclusive_group()
96
136
  close.add_argument(
@@ -139,6 +179,10 @@ def overrides_from(args: argparse.Namespace) -> dict:
139
179
  "ledger_path": args.ledger_path,
140
180
  "close_loop_enabled": args.close_loop_enabled,
141
181
  "dry_run": args.dry_run,
182
+ "release_feed": args.release_feed,
183
+ "bow_prefix": args.bow_prefix,
184
+ "bow_owners": tuple(args.bow_owners) if args.bow_owners else None,
185
+ "scan_fallback_interval": args.scan_fallback_interval,
142
186
  }
143
187
 
144
188
 
@@ -161,6 +205,22 @@ def log_effective_config(config: Config) -> None:
161
205
  config.poll_interval, config.repo_parallelism, config.dry_run,
162
206
  config.close_loop_enabled,
163
207
  )
208
+ if config.release_feed == FEED_BOW:
209
+ log.info(
210
+ "release feed: bow — candidates are the tasks attached to the "
211
+ "%r bodies of work OWNED by %s; scan fallback sweep %s",
212
+ config.bow_prefix,
213
+ ", ".join(config.bow_owners),
214
+ f"every {config.scan_fallback_interval}s"
215
+ if config.scan_fallback_interval
216
+ else "DISABLED",
217
+ )
218
+ else:
219
+ log.info(
220
+ "release feed: scan — candidates are committed tasks carrying %r, "
221
+ "targeted by their %s trailer",
222
+ config.label, config.marker_key,
223
+ )
164
224
  log.debug("marker_key: %s", config.marker_key)
165
225
  log.debug("scope_key: %s", config.scope_key)
166
226
  log.debug("release_blocked_floor_days: %s", config.release_blocked_floor_days)
@@ -53,6 +53,47 @@ Three narrow, actionable buckets so the loop can react without string-matching:
53
53
 
54
54
  Anything else (a 400 validation error, say) surfaces as the base
55
55
  ``AlissaError`` -- a bug to fix, not a condition to retry blindly.
56
+
57
+ THE BODY-OF-WORK SURFACE (O6)
58
+ =============================
59
+ The `bow` release feed reads candidates out of per-repo Bodies of Work instead
60
+ of scanning every committed task. Three endpoints, all verified live against
61
+ ``api.alissa.app`` while implementing O6:
62
+
63
+ * ``GET /v1/bodies-of-work?includeShared=true`` -> ``{"bodiesOfWork": [...]}``.
64
+ **One call, no server-side title/status filter** (``?status=``/``?q=`` are
65
+ silently ignored), so the ``bow_prefix`` match runs here. The endpoint does,
66
+ however, have a SCOPE parameter, and it is not optional for a daemon: the
67
+ default list is what the actor **owns**, and ``includeShared=true`` adds the
68
+ ones it merely collaborates on. Measured live: 14 owned, 19 with the flag.
69
+ Feed containers are operator-created (there is no create endpoint) and the
70
+ daemon is a collaborator, so every one of them lives in that difference --
71
+ omitting the flag makes the ``bow`` feed discover nothing, silently. A
72
+ collaborator BOW is otherwise perfectly visible: ``GET
73
+ /v1/bodies-of-work/<id>`` returns it and membership reads work; it is only
74
+ absent from the DEFAULT listing. This is the whole discovery cost: the number
75
+ of Bodies of Work an org has is a small constant, not a function of task
76
+ count. The flag also widens the listing to containers ANY actor shared with
77
+ the daemon, so each row carries its ``ownerActorId`` and the loop admits only
78
+ the owners the operator allowlisted (``bow_owners``) -- see
79
+ ``list_bodies_of_work``.
80
+ * ``GET /v1/bodies-of-work/<id>`` -> ``{"bodyOfWork": {..., "tasks": [{"_id",
81
+ "status", "title"}]}}``. The membership list is EMBEDDED in the BOW detail;
82
+ there is no ``/bodies-of-work/<id>/tasks`` route, and ``GET /v1/tasks`` has no
83
+ ``bodyOfWorkId`` filter (an unknown query param is silently ignored, which
84
+ would have read as "every task is a member"). Member rows carry no
85
+ ``taskNumber``, so the loop resolves each committed member through
86
+ ``get_task`` -- which accepts the opaque ``_id`` as a ref.
87
+ * ``DELETE /v1/bodies-of-work/<id>/tasks/<ref>`` -> ``{"removed": bool}``. The
88
+ detach that terminates a successful release. **Idempotent**: detaching a task
89
+ that is not attached returns ``removed: false`` rather than an error, so a
90
+ crash-resumed detach is safe. The ``<ref>`` accepts both ``TASK-<n>`` and the
91
+ opaque id. A task id that does not exist at all is a 404 (``TASK_NOT_FOUND``).
92
+
93
+ Both BOW endpoints require the daemon actor to be a **collaborator** on the
94
+ Body of Work (otherwise 403 ``FORBIDDEN`` on attach/detach) -- an operator setup
95
+ step, documented in the README. orcloop never attaches: attaching is the
96
+ operator's trigger, detaching is orcloop's terminal release act.
56
97
  """
57
98
 
58
99
  from __future__ import annotations
@@ -116,6 +157,38 @@ class MarkedTask:
116
157
  description: str
117
158
 
118
159
 
160
+ @dataclass(frozen=True)
161
+ class BodyOfWork:
162
+ """A Body of Work as the LIST endpoint reports it. Only the fields the feed
163
+ needs: the id to read membership with, the title carrying the
164
+ `<prefix><owner>/<repo>` naming convention, the BOW's own status, and
165
+ `owner_id` -- the actor that OWNS the container (`ownerActorId`).
166
+
167
+ `owner_id` is the container's provenance and the only thing here that is
168
+ not operator-authored text: the listing includes containers this actor was
169
+ merely SHARED, so the title alone cannot say whether a feed is one the
170
+ operator set up (see `Config.bow_owners`). Empty when the payload omits it,
171
+ which the caller treats as untrusted rather than unknown."""
172
+
173
+ id: str
174
+ title: str
175
+ status: str = ""
176
+ owner_id: str = ""
177
+
178
+
179
+ @dataclass(frozen=True)
180
+ class BowMember:
181
+ """One task attached to a Body of Work, as the BOW DETAIL endpoint reports
182
+ it. Deliberately thin: the membership rows carry `_id`, `status` and
183
+ `title` and NOTHING else -- no taskNumber, no description. `status` is
184
+ enough to hold an uncommitted member without a detail round-trip; a
185
+ committed member is resolved through `get_task(id)`."""
186
+
187
+ id: str
188
+ title: str
189
+ status: str
190
+
191
+
119
192
  @dataclass(frozen=True)
120
193
  class Criterion:
121
194
  id: str
@@ -296,6 +369,98 @@ class AlissaClient:
296
369
  )
297
370
  return out
298
371
 
372
+ # --- the Body-of-Work feed (O6) ----------------------------------------
373
+
374
+ def list_bodies_of_work(self) -> list[BodyOfWork]:
375
+ """Every Body of Work this actor owns OR collaborates on -- ONE call.
376
+
377
+ `includeShared=true` is load-bearing, not decoration: the endpoint
378
+ defaults to OWNED-only, and a feed container is created by the operator
379
+ in Studio (there is no create endpoint) with the daemon added as a
380
+ COLLABORATOR. Without the flag the feed's only discovery path returns
381
+ nothing and `release_feed: bow` is silently inert. There is still no
382
+ server-side title/status filter (see the module docstring), so the
383
+ caller narrows by prefix. Rows without an `_id` are dropped: a BOW that
384
+ cannot be addressed cannot be read for membership.
385
+
386
+ THE TRUST BOUNDARY THE FLAG MOVES. `includeShared=true` is necessary
387
+ (above) but it widens this listing to every container ANY actor in the
388
+ tenant has shared with the daemon -- unilaterally, with no acceptance
389
+ step on this side. So membership of this list is NOT a claim to be a
390
+ release feed: it means "visible", not "authoritative". The title-derived
391
+ gates downstream (`parse_bow_repo`, `Config.watches`) bound which repo a
392
+ release lands in, not whose task reaches it, and an allowlisted repo is
393
+ precisely where the daemon's writes are most trusted. Provenance is
394
+ therefore carried out of here as `BodyOfWork.owner_id` and checked
395
+ against the operator's `bow_owners` allowlist before a container is
396
+ treated as a feed. Collaboration makes a container READABLE (and is
397
+ still required, for detach); `bow_owners` makes it AUTHORITATIVE.
398
+ """
399
+ payload = self._request("GET", "/v1/bodies-of-work?includeShared=true")
400
+ rows = payload.get("bodiesOfWork") if isinstance(payload, dict) else None
401
+ if not isinstance(rows, list):
402
+ return []
403
+ out: list[BodyOfWork] = []
404
+ for row in rows:
405
+ if not isinstance(row, dict):
406
+ continue
407
+ bow_id = row.get("_id")
408
+ if not isinstance(bow_id, str) or not bow_id:
409
+ continue
410
+ out.append(
411
+ BodyOfWork(
412
+ id=bow_id,
413
+ title=row.get("title") or "",
414
+ status=row.get("status") or "",
415
+ owner_id=row.get("ownerActorId") or "",
416
+ )
417
+ )
418
+ return out
419
+
420
+ def body_of_work_tasks(self, bow_id: str) -> list[BowMember]:
421
+ """The tasks attached to one Body of Work, in the BOW's own order.
422
+
423
+ Membership is embedded in the BOW detail payload; there is no separate
424
+ listing route. Rows without an `_id` are dropped for the same reason as
425
+ above -- an unaddressable member cannot be resolved or detached.
426
+ """
427
+ payload = self._request(
428
+ "GET", f"/v1/bodies-of-work/{urllib.parse.quote(str(bow_id), safe='')}"
429
+ )
430
+ bow = payload.get("bodyOfWork") if isinstance(payload, dict) else None
431
+ if not isinstance(bow, dict):
432
+ raise AlissaError(200, f"no bodyOfWork object in response for {bow_id!r}")
433
+ out: list[BowMember] = []
434
+ for row in bow.get("tasks") or []:
435
+ if not isinstance(row, dict):
436
+ continue
437
+ task_id = row.get("_id")
438
+ if not isinstance(task_id, str) or not task_id:
439
+ continue
440
+ out.append(
441
+ BowMember(
442
+ id=task_id,
443
+ title=row.get("title") or "",
444
+ status=row.get("status") or "",
445
+ )
446
+ )
447
+ return out
448
+
449
+ def detach_task(self, bow_id: str, ref: str) -> bool:
450
+ """Detach a task from a Body of Work; True when this call removed it.
451
+
452
+ Idempotent by contract: detaching an already-detached task answers
453
+ `removed: false` with a 2xx, so the terminal step of a release can be
454
+ retried after a crash without a "was it already done?" probe. `ref`
455
+ accepts `TASK-<n>` or the opaque task id.
456
+ """
457
+ payload = self._request(
458
+ "DELETE",
459
+ f"/v1/bodies-of-work/{urllib.parse.quote(str(bow_id), safe='')}"
460
+ f"/tasks/{urllib.parse.quote(str(ref), safe='')}",
461
+ )
462
+ return bool(payload.get("removed")) if isinstance(payload, dict) else False
463
+
299
464
  def get_task(self, ref: str) -> Task:
300
465
  """Full detail for one task. `ref` is any reference form the API accepts
301
466
  (`TASK-<n>`, public id, url). Raises `AlissaNotFound` when the bound
@@ -24,6 +24,27 @@ closes the loop on merge, so it never runs against an unbounded repo set.
24
24
  The marker contract (`Autonomous-Dev` / `Autonomous-Scope` issue-body
25
25
  trailers) is parsed in `markers.py`; the keys that name those trailers live
26
26
  here (`marker_key`, `scope_key`) so an operator can rename them per-deployment.
27
+
28
+ RELEASE FEED (O6). `release_feed` selects where release candidates come from:
29
+
30
+ * `scan` (the DEFAULT, for backward compatibility) — list every committed task
31
+ and filter it on the release marker. Simple, but the read is O(all committed
32
+ tasks) every poll, which is the DB-load concern that motivated O6.
33
+ * `bow` — read them out of the per-repo **Bodies of Work** named
34
+ `<bow_prefix><owner>/<repo>` (default prefix `autodev: `). Attaching a task to
35
+ such a BOW is both the release trigger and the repo declaration; the read is
36
+ O(total BOW membership). `scan_fallback_interval` keeps a low-frequency full
37
+ trailer scan running underneath as a visibility safety net.
38
+
39
+ The `bow` feed has a **second** fail-closed allowlist, `bow_owners`, and the two
40
+ answer different questions. A container's title says which repo it targets, so
41
+ `repos` bounds *where* a release lands. Nothing in a title says who created the
42
+ container — and the daemon lists Bodies of Work it merely **collaborates** on,
43
+ which any actor in the tenant can arrange without an acceptance step on this
44
+ side. `bow_owners` names the actor id(s) whose containers may drive the daemon,
45
+ so "whose work reaches an autonomous pipeline" is an operator decision rather
46
+ than a consequence of being shared something. Empty is fatal under
47
+ `release_feed: bow`, exactly as an empty `repos` is fatal always.
27
48
  """
28
49
 
29
50
  from __future__ import annotations
@@ -55,13 +76,23 @@ CONFIG_KEYS = (
55
76
  "dry_run",
56
77
  "ledger_path",
57
78
  "close_loop_enabled",
79
+ "release_feed",
80
+ "bow_prefix",
81
+ "bow_owners",
82
+ "scan_fallback_interval",
58
83
  )
59
84
 
85
+ # `release_feed` values. `scan` is the O0-O5 behavior and stays the default so
86
+ # an existing deployment upgrades without a config change; `bow` is the O6 feed.
87
+ FEED_SCAN = "scan"
88
+ FEED_BOW = "bow"
89
+ RELEASE_FEEDS = (FEED_SCAN, FEED_BOW)
90
+
60
91
  # Keys carrying a boolean; env strings for these need real parsing (bool("false")
61
92
  # is True, which would silently invert the setting).
62
93
  _BOOL_KEYS = frozenset({"dry_run", "close_loop_enabled"})
63
94
  # Keys carrying a list of strings; an env value is comma-separated.
64
- _LIST_KEYS = frozenset({"repos"})
95
+ _LIST_KEYS = frozenset({"repos", "bow_owners"})
65
96
 
66
97
  MIN_POLL_INTERVAL = 10 # the GitHub search API allows 30 req/min
67
98
 
@@ -180,6 +211,33 @@ class Config:
180
211
 
181
212
  dry_run: bool = False
182
213
 
214
+ # Where release candidates come from: `scan` (all committed tasks, filtered
215
+ # on the marker) or `bow` (the per-repo Bodies of Work). Default `scan` —
216
+ # an existing deployment must not change behavior on upgrade.
217
+ release_feed: str = FEED_SCAN
218
+
219
+ # The naming convention that makes a Body of Work a release feed: its title
220
+ # is `<bow_prefix><owner>/<repo>`. The trailing space is part of the default
221
+ # (`autodev: fahera-mx/widgets`); prefix matching is case-insensitive.
222
+ bow_prefix: str = "autodev: "
223
+
224
+ # WHOSE Bodies of Work may act as a release feed: the Alissa actor id(s)
225
+ # allowed to OWN a feed container. Fail-closed like `repos` — empty is
226
+ # REJECTED at build when `release_feed` is `bow` (see build's guard). The
227
+ # title tells the daemon which repo a container targets; it says nothing
228
+ # about who created it, and the daemon lists containers it merely
229
+ # collaborates on, which anyone in the tenant can arrange unilaterally.
230
+ # So provenance is an operator decision, made here. Ids are opaque and
231
+ # compared EXACTLY (unlike `repos`, which casefolds GitHub names).
232
+ bow_owners: tuple[str, ...] = ()
233
+
234
+ # The `bow`-feed safety net: every this-many seconds, ALSO run the full
235
+ # marker scan and LOG every marked committed task the BOW feed did not
236
+ # surface, so a feed bug can never make work invisible. Purely level-based
237
+ # visibility — the sweep never releases (attachment is the only trigger).
238
+ # 0 disables it. Ignored entirely in `scan` mode (the scan IS the feed).
239
+ scan_fallback_interval: int = 3600
240
+
183
241
  # Derived at build time: `repos` casefolded for matching. GitHub names are
184
242
  # case-insensitive, but `repos` keeps the operator's casing for logs/repr.
185
243
  _repo_match: frozenset[str] = field(
@@ -205,6 +263,16 @@ class Config:
205
263
  GitHub owner/repo names."""
206
264
  return full_name.casefold() in self._repo_match
207
265
 
266
+ def trusts_feed_owner(self, actor_id: str | None) -> bool:
267
+ """Whether a Body of Work owned by this actor may act as a release feed.
268
+
269
+ Fail-closed on a MISSING owner too: a container whose provenance the
270
+ payload does not state cannot be attributed, and an unattributable feed
271
+ is exactly what `bow_owners` exists to refuse. Exact comparison — Alissa
272
+ actor ids are opaque, so normalising them would only widen the match.
273
+ """
274
+ return bool(actor_id) and actor_id in self.bow_owners
275
+
208
276
  @classmethod
209
277
  def build(
210
278
  cls,
@@ -280,6 +348,65 @@ class Config:
280
348
  if not value.strip():
281
349
  raise ValueError(f"{name} must be a non-empty string")
282
350
 
351
+ release_feed = str(raw.get("release_feed", cls.release_feed)).strip().lower()
352
+ if release_feed not in RELEASE_FEEDS:
353
+ raise ValueError(
354
+ f"release_feed must be one of {', '.join(RELEASE_FEEDS)}, got "
355
+ f"{raw.get('release_feed')!r}"
356
+ )
357
+
358
+ # NOT stripped: the convention's trailing space (`autodev: `) is load-
359
+ # bearing, so only an all-whitespace prefix is rejected. An empty prefix
360
+ # would make EVERY Body of Work a release feed.
361
+ bow_prefix = str(raw.get("bow_prefix", cls.bow_prefix))
362
+ if not bow_prefix.strip():
363
+ raise ValueError(
364
+ "bow_prefix must be a non-empty string — an empty prefix would "
365
+ "turn every Body of Work into a release feed"
366
+ )
367
+
368
+ bow_owners = _string_list(raw.get("bow_owners", cls.bow_owners), "bow_owners")
369
+ if release_feed == FEED_BOW and not bow_owners:
370
+ # Fail-closed, the `repos` guard's twin. `repos` bounds WHERE the
371
+ # orchestrator writes; this bounds WHOSE work drives it. The feed
372
+ # lists Bodies of Work the daemon merely collaborates on, and being
373
+ # made a collaborator takes no acceptance step on this side, so
374
+ # without an owner allowlist "shared with the daemon and titled for
375
+ # a watched repo" is enough to get a task rendered into an issue in
376
+ # that repo and handed to the develop pipeline. Only fatal for the
377
+ # `bow` feed: `scan` never reads a Body of Work at all.
378
+ raise ValueError(
379
+ "bow_owners is empty and release_feed is 'bow' — the release "
380
+ "feed is fail-closed and refuses to start without the actor "
381
+ "id(s) allowed to own a feed Body of Work (being shared one is "
382
+ "not a claim to drive this daemon). Set `bow_owners` in the "
383
+ "config file, pass one or more --bow-owner flags, or set "
384
+ "ALISSA_ORC_BOW_OWNERS. An actor id is the `ownerActorId` on "
385
+ "the container's row in GET /v1/bodies-of-work."
386
+ )
387
+
388
+ fallback = int(raw.get("scan_fallback_interval", cls.scan_fallback_interval))
389
+ if fallback < 0:
390
+ raise ValueError(
391
+ f"scan_fallback_interval must be >= 0 (0 disables the sweep), "
392
+ f"got {fallback}"
393
+ )
394
+ if 0 < fallback < MIN_POLL_INTERVAL:
395
+ # A floor on absurd values, nothing more. The sweep is driven from
396
+ # the poll loop, so it can never run more often than `poll_interval`
397
+ # anyway -- what this rejects is a value so small it reads as "off"
398
+ # to a human but is not (`1`), and it shares MIN_POLL_INTERVAL
399
+ # rather than inventing a second floor. The real contract -- that
400
+ # the sweep is LOW-frequency relative to the poll, so the `bow` feed
401
+ # keeps saving the full scan it exists to avoid -- is the operator's
402
+ # to set: any `scan_fallback_interval <= poll_interval` sweeps on
403
+ # every poll, which is legitimate on a slow poll and wasteful on a
404
+ # fast one, so it is documented (README) rather than enforced here.
405
+ raise ValueError(
406
+ f"scan_fallback_interval must be 0 (off) or >= {MIN_POLL_INTERVAL} "
407
+ f"seconds, got {fallback}"
408
+ )
409
+
283
410
  ledger_path = raw.get("ledger_path", cls.ledger_path)
284
411
  return cls(
285
412
  workspace_root=Path(workspace_root),
@@ -296,6 +423,10 @@ class Config:
296
423
  raw.get("close_loop_enabled", cls.close_loop_enabled)
297
424
  ),
298
425
  dry_run=bool(raw.get("dry_run", cls.dry_run)),
426
+ release_feed=release_feed,
427
+ bow_prefix=bow_prefix,
428
+ bow_owners=bow_owners,
429
+ scan_fallback_interval=fallback,
299
430
  )
300
431
 
301
432