alissa-tools-github-orcloop 0.3.1__tar.gz → 0.3.2__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.3.1/src/main/alissa_tools_github_orcloop.egg-info → alissa_tools_github_orcloop-0.3.2}/PKG-INFO +1 -1
  2. {alissa_tools_github_orcloop-0.3.1 → alissa_tools_github_orcloop-0.3.2}/src/main/alissa/tools/github/orcloop/__main__.py +108 -9
  3. {alissa_tools_github_orcloop-0.3.1 → alissa_tools_github_orcloop-0.3.2}/src/main/alissa/tools/github/orcloop/alissa_client.py +58 -0
  4. {alissa_tools_github_orcloop-0.3.1 → alissa_tools_github_orcloop-0.3.2}/src/main/alissa/tools/github/orcloop/config.py +141 -31
  5. {alissa_tools_github_orcloop-0.3.1 → alissa_tools_github_orcloop-0.3.2}/src/main/alissa/tools/github/orcloop/loop.py +24 -3
  6. alissa_tools_github_orcloop-0.3.2/src/main/alissa/tools/github/orcloop/version +1 -0
  7. {alissa_tools_github_orcloop-0.3.1 → alissa_tools_github_orcloop-0.3.2/src/main/alissa_tools_github_orcloop.egg-info}/PKG-INFO +1 -1
  8. alissa_tools_github_orcloop-0.3.1/src/main/alissa/tools/github/orcloop/version +0 -1
  9. {alissa_tools_github_orcloop-0.3.1 → alissa_tools_github_orcloop-0.3.2}/MANIFEST.in +0 -0
  10. {alissa_tools_github_orcloop-0.3.1 → alissa_tools_github_orcloop-0.3.2}/README.md +0 -0
  11. {alissa_tools_github_orcloop-0.3.1 → alissa_tools_github_orcloop-0.3.2}/requirements.txt +0 -0
  12. {alissa_tools_github_orcloop-0.3.1 → alissa_tools_github_orcloop-0.3.2}/setup.cfg +0 -0
  13. {alissa_tools_github_orcloop-0.3.1 → alissa_tools_github_orcloop-0.3.2}/setup.py +0 -0
  14. {alissa_tools_github_orcloop-0.3.1 → alissa_tools_github_orcloop-0.3.2}/src/main/alissa/tools/github/orcloop/__init__.py +0 -0
  15. {alissa_tools_github_orcloop-0.3.1 → alissa_tools_github_orcloop-0.3.2}/src/main/alissa/tools/github/orcloop/ghclient.py +0 -0
  16. {alissa_tools_github_orcloop-0.3.1 → alissa_tools_github_orcloop-0.3.2}/src/main/alissa/tools/github/orcloop/markers.py +0 -0
  17. {alissa_tools_github_orcloop-0.3.1 → alissa_tools_github_orcloop-0.3.2}/src/main/alissa/tools/github/orcloop/proc.py +0 -0
  18. {alissa_tools_github_orcloop-0.3.1 → alissa_tools_github_orcloop-0.3.2}/src/main/alissa/tools/github/orcloop/state.py +0 -0
  19. {alissa_tools_github_orcloop-0.3.1 → alissa_tools_github_orcloop-0.3.2}/src/main/alissa/tools/github/orcloop/version.py +0 -0
  20. {alissa_tools_github_orcloop-0.3.1 → alissa_tools_github_orcloop-0.3.2}/src/main/alissa_tools_github_orcloop.egg-info/SOURCES.txt +0 -0
  21. {alissa_tools_github_orcloop-0.3.1 → alissa_tools_github_orcloop-0.3.2}/src/main/alissa_tools_github_orcloop.egg-info/dependency_links.txt +0 -0
  22. {alissa_tools_github_orcloop-0.3.1 → alissa_tools_github_orcloop-0.3.2}/src/main/alissa_tools_github_orcloop.egg-info/entry_points.txt +0 -0
  23. {alissa_tools_github_orcloop-0.3.1 → alissa_tools_github_orcloop-0.3.2}/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.3.1
3
+ Version: 0.3.2
4
4
  Summary: ALISSA-TOOLS-GITHUB-ORCLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -2,7 +2,8 @@
2
2
 
3
3
  O1 wired the config surface and the argument parser; O3 wires the decision
4
4
  loop behind it; O6 adds the release-feed selector (`--release-feed bow` and its
5
- `--bow-prefix` / `--bow-owner` / `--scan-fallback-interval` knobs).
5
+ `--bow-prefix` / `--bow-owner` / `--scan-fallback-interval` knobs); O8 makes the
6
+ feed's authority resolve itself (see `resolve_feed_authority`).
6
7
 
7
8
  The CLI resolves and validates the full configuration
8
9
  (four-layer precedence, fail-closed `repos` guard), reports the effective
@@ -10,6 +11,12 @@ settings, then runs the orchestrator: `--once` for a single sweep (pair it
10
11
  with `--dry-run -v` to print every decision and its reasons without touching
11
12
  GitHub or the task graph), otherwise `run_forever`. Config errors still surface
12
13
  here, at startup, with a clean exit code.
14
+
15
+ Startup is the ONE place that may do I/O before the loop begins, and it does
16
+ exactly one call: under `release_feed: bow` with no explicit `bow_owners`, the
17
+ daemon asks Alissa who its token is and makes itself the feed authority. That
18
+ call failing is fatal — the daemon exits rather than starting up unable to tell
19
+ its own containers from a stranger's.
13
20
  """
14
21
 
15
22
  from __future__ import annotations
@@ -17,9 +24,10 @@ from __future__ import annotations
17
24
  import argparse
18
25
  import logging
19
26
  import sys
27
+ from dataclasses import replace
20
28
  from pathlib import Path
21
29
 
22
- from .alissa_client import AlissaError
30
+ from .alissa_client import AlissaClient, AlissaError
23
31
  from .config import (
24
32
  FEED_BOW,
25
33
  RELEASE_FEEDS,
@@ -119,10 +127,12 @@ def build_parser() -> argparse.ArgumentParser:
119
127
  metavar="ACTOR_ID",
120
128
  help="bow feed only: trust bodies of work OWNED by this Alissa actor "
121
129
  "as release feeds; repeatable. When given, REPLACES the config "
122
- "`bow_owners` list. Fail-closed like --repo: `--release-feed bow` with "
123
- "an empty owner list is a fatal startup error, because being SHARED a "
124
- "body of work is not a claim to drive this daemon. An actor id is the "
125
- "`ownerActorId` on the container's row in GET /v1/bodies-of-work.",
130
+ "`bow_owners` list. OPTIONAL left unset, the daemon resolves its own "
131
+ "token's actor at boot and trusts only its own containers; give this "
132
+ "flag only when the containers belong to someone else. Being SHARED a "
133
+ "body of work is never a claim to drive this daemon either way. Must be "
134
+ "an actor ID (the `ownerActorId` on the container's row in "
135
+ "GET /v1/bodies-of-work) — a username or display name is refused.",
126
136
  )
127
137
  over.add_argument(
128
138
  "--scan-fallback-interval",
@@ -197,6 +207,57 @@ def resolve_config(args: argparse.Namespace) -> Config:
197
207
  return Config.build(workspace_root, file_data, overrides_from(args))
198
208
 
199
209
 
210
+ def resolve_feed_authority(config: Config, alissa: AlissaClient) -> Config:
211
+ """Decide WHOSE bodies of work drive this daemon, and say so out loud.
212
+
213
+ Two answers, one of them free:
214
+
215
+ * `bow_owners` **set** — the operator named the authority explicitly. That
216
+ is the multi-operator case: the containers belong to an actor this token
217
+ is not. Nothing is resolved, nothing is called.
218
+ * `bow_owners` **unset** — the authority is **self**. The token already
219
+ carries an identity, so `GET /v1/ping` answers the question the operator
220
+ would otherwise have hand-copied out of the API, and the daemon trusts
221
+ exactly its own containers.
222
+
223
+ The whoami call is **fatal on failure**, deliberately, and this is the whole
224
+ fail-closed argument in one place. The alternative — carry on with an empty
225
+ authority — is technically safe (nothing would be trusted, so nothing would
226
+ release) but it is safe *silently*: the operator sees a daemon that runs and
227
+ releases nothing, which is exactly what a correctly configured daemon with
228
+ an empty queue looks like. Dying names the reason while someone is watching.
229
+ Note the property being protected is not "self is trusted" but "only a
230
+ KNOWN actor is trusted" — an unanswered whoami leaves that unknown, and no
231
+ default can fill it in.
232
+
233
+ Under `scan` this is a no-op: that feed never reads a Body of Work, so it
234
+ never needs an identity, and a daemon that only scans must not fail to boot
235
+ because an endpoint it does not use was unreachable.
236
+ """
237
+ if config.release_feed != FEED_BOW:
238
+ return config
239
+
240
+ if config.bow_owners:
241
+ log.info(
242
+ "bow feed authority: explicit (%s)", ", ".join(config.bow_owners)
243
+ )
244
+ return config
245
+
246
+ identity = alissa.whoami() # fatal on failure — see main()
247
+ log.info("bow feed authority: self (%s)", identity.actor_id)
248
+ if identity.display_name:
249
+ # Logged as context, never compared: a display name is renameable, which
250
+ # is why `config.bow_owners` refuses to hold one.
251
+ log.debug(
252
+ "resolved from this token's identity: %r", identity.display_name
253
+ )
254
+ # The id comes from the API, so it is the authority on its own shape and is
255
+ # NOT put through `config`'s actor-id check — that check exists to catch a
256
+ # human typing a username, and applying it here would let a future id format
257
+ # break a daemon that had been given no configuration to be wrong about.
258
+ return replace(config, bow_owners=(identity.actor_id,))
259
+
260
+
200
261
  def log_effective_config(config: Config) -> None:
201
262
  """The resolved settings, defaults and all. INFO carries the decisions an
202
263
  operator needs; -v (DEBUG) shows the full surface."""
@@ -241,14 +302,52 @@ def main(argv: "list[str] | None" = None) -> int:
241
302
  try:
242
303
  config = resolve_config(args)
243
304
  log.info("workspace: %s", config.workspace_root)
244
- log_effective_config(config)
245
- except (FileNotFoundError, ValueError) as exc:
305
+ # One client, shared with the orchestrator: the boot-time whoami and
306
+ # every later task-graph call must speak as the same actor, or the
307
+ # authority resolved here would not be the one doing the reading.
308
+ alissa = AlissaClient(base=config.alissa_endpoint)
309
+ except (FileNotFoundError, ValueError, AlissaError) as exc:
310
+ # `AlissaError` is here to keep main()'s exit contract whole, not because
311
+ # anything in this block raises one today (the client's constructor only
312
+ # reads ALISSA_BASE / ALISSA_API_TOKEN). Exit 2 means "never started" and
313
+ # every other way of never starting prints one line; a lone path that
314
+ # answers with a traceback and exit 1 — the code that means "started,
315
+ # then a pass failed" — would misreport itself the day the constructor
316
+ # grows any validation. Plain text, deliberately: the bow-specific advice
317
+ # belongs only to the call that can actually earn it, below.
246
318
  print(f"config error: {exc}", file=sys.stderr)
247
319
  return 2
248
320
  except KeyboardInterrupt:
249
321
  return 0
250
322
 
251
- orchestrator = Orchestrator(config)
323
+ # Deliberately its OWN try, holding exactly one call. The handler below
324
+ # names the bow feed, and `resolve_feed_authority` is the only thing here
325
+ # that can fail for that reason — under `scan` it returns before making any
326
+ # call at all. A wider block would let some unrelated Alissa failure be
327
+ # reported as an authority problem, pointing a scan-only operator at a knob
328
+ # that does nothing in their feed mode.
329
+ try:
330
+ config = resolve_feed_authority(config, alissa)
331
+ except AlissaError as exc:
332
+ # The bow feed could not learn who this token is. Fatal by design (see
333
+ # resolve_feed_authority): booting on an unknown identity would mean a
334
+ # daemon that looks healthy and silently releases nothing. Same exit
335
+ # code as a config error — it is the same class of failure, a daemon
336
+ # that never started, and the operator's fix is a token or an explicit
337
+ # bow_owners rather than anything the loop could retry.
338
+ print(
339
+ f"config error: could not resolve the bow feed authority — {exc}. "
340
+ f"Check ALISSA_API_TOKEN and the endpoint, or set bow_owners "
341
+ f"explicitly to name the actor(s) that own the feed bodies of work.",
342
+ file=sys.stderr,
343
+ )
344
+ return 2
345
+ except KeyboardInterrupt:
346
+ return 0
347
+
348
+ log_effective_config(config)
349
+
350
+ orchestrator = Orchestrator(config, alissa=alissa)
252
351
  try:
253
352
  if args.once:
254
353
  orchestrator.poll_once()
@@ -94,6 +94,18 @@ Both BOW endpoints require the daemon actor to be a **collaborator** on the
94
94
  Body of Work (otherwise 403 ``FORBIDDEN`` on attach/detach) -- an operator setup
95
95
  step, documented in the README. orcloop never attaches: attaching is the
96
96
  operator's trigger, detaching is orcloop's terminal release act.
97
+
98
+ WHO AM I (O8)
99
+ =============
100
+ ``GET /v1/ping`` -- ``{"pong", "timestamp", "userId", "actorId",
101
+ "displayName"}`` -- is the token's identity endpoint, and it is the ONLY one:
102
+ ``/v1/me``, ``/v1/whoami``, ``/v1/actors/me``, ``/v1/auth/me``, ``/v1/user(s)/me``,
103
+ ``/v1/identity``, ``/v1/actor``, ``/v1/session``, ``/v1/profile`` all answer 404
104
+ ``"The requested endpoint does not exist."``. Its OpenAPI summary states the
105
+ contract outright: *"Verify the token and return the acting identity."* That is
106
+ what lets ``bow_owners`` default to the daemon's own actor instead of making the
107
+ operator hand-copy an id -- see ``whoami`` and the boot resolution in
108
+ ``__main__.resolve_feed_authority``.
97
109
  """
98
110
 
99
111
  from __future__ import annotations
@@ -189,6 +201,21 @@ class BowMember:
189
201
  status: str
190
202
 
191
203
 
204
+ @dataclass(frozen=True)
205
+ class Identity:
206
+ """Who this token acts as, from ``GET /v1/ping``.
207
+
208
+ `actor_id` is the load-bearing field: it is the same opaque id that appears
209
+ as `ownerActorId` on a Body of Work, so it can be compared directly against
210
+ a container's owner. `user_id` and `display_name` are carried for logging
211
+ only -- a display name is renameable and must never reach an authority
212
+ check (see `config.bow_owners`)."""
213
+
214
+ actor_id: str
215
+ user_id: str = ""
216
+ display_name: str = ""
217
+
218
+
192
219
  @dataclass(frozen=True)
193
220
  class Criterion:
194
221
  id: str
@@ -317,6 +344,37 @@ class AlissaClient:
317
344
 
318
345
  # --- adapter surface ---------------------------------------------------
319
346
 
347
+ def whoami(self) -> Identity:
348
+ """The identity this token acts as (`GET /v1/ping`).
349
+
350
+ The `bow` feed's authority defaults to this actor, so the call is on the
351
+ BOOT path and its failure is fatal there -- never softened into a
352
+ warning. A daemon that could not learn who it is cannot tell its own
353
+ containers from a stranger's, and the only safe answers are "refuse to
354
+ start" or "trust nobody"; refusing is the loud one, so that is what
355
+ `__main__.resolve_feed_authority` does with what this raises.
356
+
357
+ A 2xx with no `actorId` is a contract violation rather than a missing
358
+ resource, so it surfaces as the base `AlissaError` -- the same way
359
+ `get_task` treats a response with no task object. `AlissaAuthError`
360
+ (a bad or unset token) and `AlissaTransient` (the endpoint was
361
+ unreachable) come through the shared taxonomy unchanged.
362
+ """
363
+ payload = self._request("GET", "/v1/ping")
364
+ row = payload if isinstance(payload, dict) else {}
365
+ actor_id = row.get("actorId")
366
+ if not isinstance(actor_id, str) or not actor_id.strip():
367
+ raise AlissaError(
368
+ 200,
369
+ f"GET /v1/ping returned no actorId (got {payload!r}) — the "
370
+ f"token's acting identity could not be determined",
371
+ )
372
+ return Identity(
373
+ actor_id=actor_id.strip(),
374
+ user_id=str(row.get("userId") or ""),
375
+ display_name=str(row.get("displayName") or ""),
376
+ )
377
+
320
378
  def list_marked_committed_tasks(self, marker: str = RELEASE_MARKER) -> list[MarkedTask]:
321
379
  """Committed tasks carrying the release `marker`, with descriptions.
322
380
 
@@ -50,15 +50,28 @@ RELEASE FEED (O6). `release_feed` selects where release candidates come from:
50
50
  O(total BOW membership). `scan_fallback_interval` keeps a low-frequency full
51
51
  trailer scan running underneath as a visibility safety net.
52
52
 
53
- The `bow` feed has a **second** fail-closed allowlist, `bow_owners`, and the two
54
- answer different questions. A container's title says which repo it targets, so
55
- `repos` bounds *where* a release lands. Nothing in a title says who created the
53
+ The `bow` feed has a **second** allowlist, `bow_owners`, and the two answer
54
+ different questions. A container's title says which repo it targets, so `repos`
55
+ bounds *where* a release lands. Nothing in a title says who created the
56
56
  container — and the daemon lists Bodies of Work it merely **collaborates** on,
57
57
  which any actor in the tenant can arrange without an acceptance step on this
58
58
  side. `bow_owners` names the actor id(s) whose containers may drive the daemon,
59
- so "whose work reaches an autonomous pipeline" is an operator decision rather
60
- than a consequence of being shared something. Empty is fatal under
61
- `release_feed: bow`, exactly as an empty `repos` is fatal always.
59
+ so "whose work reaches an autonomous pipeline" is a decided question rather
60
+ than a consequence of being shared something.
61
+
62
+ FEED AUTHORITY (O8). `bow_owners` is an OPTIONAL OVERRIDE, not required setup.
63
+ Left unset it resolves at boot to the **token's own actor** — the daemon asks
64
+ `GET /v1/ping` who it is and trusts only its own containers (see
65
+ `__main__.resolve_feed_authority`, which is also where a failed whoami is
66
+ fatal). Set it only for a multi-operator deployment, where the containers are
67
+ owned by someone other than the actor the daemon's token acts as. That the
68
+ default is self does not soften the gate: an unresolvable identity refuses to
69
+ boot rather than trusting everything, so the fail-closed property `bow_owners`
70
+ was introduced for is unchanged — only the hand-copying is gone.
71
+
72
+ Entries are actor **ids** and nothing else; a username or display name is
73
+ rejected here, naming it (see `_validate_actor_id` for why a name is both a
74
+ spoof surface and a silent one).
62
75
  """
63
76
 
64
77
  from __future__ import annotations
@@ -137,6 +150,21 @@ _REPO_NAME_RE = re.compile(
137
150
  r"/(?!\.+$)(?!.*\.[Gg][Ii][Tt]$)[A-Za-z0-9._-]+$"
138
151
  )
139
152
 
153
+ # What an Alissa actor id looks like: a 32-character opaque handle of lower-case
154
+ # letters and digits (`j5706fv7xe5jy1k5wdwzacab9s8axcd2`). Every id the API mints
155
+ # — actors, users, tasks, bodies of work — has this shape, and `bow_owners` is
156
+ # checked against it so a username or display name cannot be mistaken for one.
157
+ # The check is SHAPE only: it says "this could be an id", never "this actor
158
+ # exists" (nothing short of a lookup could, and a wrong-but-well-formed id
159
+ # already fails loudly as a feed nobody owns). If the API ever mints a different
160
+ # shape, this constant and the pattern below are the two lines to widen.
161
+ ACTOR_ID_LENGTH = 32
162
+ _ACTOR_ID_ALPHABET = frozenset("abcdefghijklmnopqrstuvwxyz0123456789")
163
+ # `\Z`, not `$`: `$` also matches just before a trailing newline, so
164
+ # `"<id>\n"` would pass. Callers strip, but a gate that only holds because its
165
+ # caller normalised is not a gate.
166
+ _ACTOR_ID_RE = re.compile(rf"^[a-z0-9]{{{ACTOR_ID_LENGTH}}}\Z")
167
+
140
168
  MIN_POLL_INTERVAL = 10 # the GitHub search API allows 30 req/min
141
169
 
142
170
  DEFAULT_ALISSA_ENDPOINT = "https://api.alissa.app"
@@ -257,6 +285,88 @@ def _validate_repo_entry(entry: str) -> None:
257
285
  )
258
286
 
259
287
 
288
+ def normalize_bow_owners(values: tuple[str, ...] | list[str]) -> tuple[str, ...]:
289
+ """Turn the raw `bow_owners` layer value into the validated authority list.
290
+
291
+ Entries are whitespace-stripped, empties dropped (so a trailing comma is
292
+ harmless, matching `repos`), duplicates collapsed — EXACTLY, with no
293
+ casefolding, because actor ids are opaque: two spellings that differ in case
294
+ are two different strings on the wire, and folding them would silently widen
295
+ the match. Every survivor must look like an actor id.
296
+
297
+ Emptiness is fine here and means "not overridden": the boot path resolves
298
+ the authority to this token's own actor (see the module docstring). There is
299
+ no fail-closed guard left in `build` for it — an empty list still admits
300
+ nothing (`trusts_feed_owner` returns False for every owner), so the property
301
+ is preserved by the comparison itself rather than by refusing to start.
302
+ """
303
+ out: list[str] = []
304
+ seen: set[str] = set()
305
+ for value in values:
306
+ entry = value.strip()
307
+ if not entry:
308
+ continue
309
+ _validate_actor_id(entry)
310
+ if entry not in seen:
311
+ seen.add(entry)
312
+ out.append(entry)
313
+ return tuple(out)
314
+
315
+
316
+ def _validate_actor_id(entry: str) -> None:
317
+ """Reject anything that is not an Alissa actor id, naming the entry.
318
+
319
+ Ownership is checked against a container's `ownerActorId`, which is opaque
320
+ and immutable. A username or display name is neither: it is operator-facing
321
+ text the owning account can CHANGE, so an allowlist written in those terms
322
+ grants authority to whoever holds the name when the check runs rather than
323
+ to the actor the operator meant — the reason ids are the only accepted
324
+ spelling.
325
+
326
+ Worse, getting it wrong would not even be loud. Nothing on the wire ever
327
+ equals a name, so a name in this list simply matches no container, and the
328
+ `bow` feed's symptom is an empty queue — indistinguishable from "no work
329
+ attached yet". Failing here, at boot, with the offending entry named, is the
330
+ only point where the mistake is visible.
331
+ """
332
+ if _ACTOR_ID_RE.match(entry):
333
+ return
334
+ lowered = entry.lower()
335
+ if _ACTOR_ID_RE.match(lowered):
336
+ # The one near-miss with a mechanical fix, so it gets its own message
337
+ # instead of being described. NOT lowered silently: ids are opaque, and
338
+ # quietly rewriting one makes a config stop meaning what it says.
339
+ hint = f" — actor ids are lower-case; did you mean {lowered!r}?"
340
+ else:
341
+ # LENGTH FIRST. Both problems are reported when both are present, but a
342
+ # short name is a length problem before it is an alphabet one: telling
343
+ # someone their 8-character username "contains 'ADHMORTZ'" buries the
344
+ # useful observation under a rendering of its own character set.
345
+ problems = []
346
+ if len(entry) != ACTOR_ID_LENGTH:
347
+ problems.append(
348
+ f"it is {len(entry)} characters, not {ACTOR_ID_LENGTH}"
349
+ )
350
+ offending = "".join(sorted(set(entry) - _ACTOR_ID_ALPHABET))
351
+ if offending:
352
+ problems.append(
353
+ f"it contains {offending!r}, which no actor id does"
354
+ )
355
+ hint = " — " + " and ".join(problems)
356
+ if offending:
357
+ hint += "; that looks like a username or a display name"
358
+ raise ValueError(
359
+ f"bow_owners entry {entry!r} is not an Alissa actor id{hint}. Use the "
360
+ f"opaque id — the `ownerActorId` on the container's row in "
361
+ f"GET /v1/bodies-of-work, or `actorId` from GET /v1/ping — never a "
362
+ f"username or display name: names are renameable, so an ownership "
363
+ f"check against one is a spoof surface, and since no id on the wire "
364
+ f"ever equals a name it would fail as a silently empty feed rather "
365
+ f"than an error. Leave bow_owners unset to trust this token's own "
366
+ f"actor, which needs no id at all."
367
+ )
368
+
369
+
260
370
  def _env_bool(env_name: str, raw: str) -> bool:
261
371
  value = raw.strip().lower()
262
372
  if value in _ENV_TRUE:
@@ -352,13 +462,16 @@ class Config:
352
462
  bow_prefix: str = "autodev: "
353
463
 
354
464
  # WHOSE Bodies of Work may act as a release feed: the Alissa actor id(s)
355
- # allowed to OWN a feed container. Fail-closed like `repos` empty is
356
- # REJECTED at build when `release_feed` is `bow` (see build's guard). The
357
- # title tells the daemon which repo a container targets; it says nothing
358
- # about who created it, and the daemon lists containers it merely
359
- # collaborates on, which anyone in the tenant can arrange unilaterally.
360
- # So provenance is an operator decision, made here. Ids are opaque and
465
+ # allowed to OWN a feed container. The title tells the daemon which repo a
466
+ # container targets; it says nothing about who created it, and the daemon
467
+ # lists containers it merely collaborates on, which anyone in the tenant can
468
+ # arrange unilaterally. So provenance is decided here. Ids are opaque and
361
469
  # compared EXACTLY (unlike `repos`, which casefolds GitHub names).
470
+ #
471
+ # Empty means "not overridden", NOT "trust everyone": the boot path resolves
472
+ # it to this token's own actor and dies if it cannot (O8), and until it does
473
+ # an empty list matches no owner at all. An explicit list is the
474
+ # multi-operator case — containers owned by an actor this token is not.
362
475
  bow_owners: tuple[str, ...] = ()
363
476
 
364
477
  # The `bow`-feed safety net: every this-many seconds, ALSO run the full
@@ -400,6 +513,13 @@ class Config:
400
513
  payload does not state cannot be attributed, and an unattributable feed
401
514
  is exactly what `bow_owners` exists to refuse. Exact comparison — Alissa
402
515
  actor ids are opaque, so normalising them would only widen the match.
516
+
517
+ Fail-closed on an EMPTY authority as well, which is what makes the O8
518
+ default safe to resolve outside this class: before boot fills the list
519
+ in (or when something skipped that step), every owner is untrusted and
520
+ nothing is a feed. The failure mode of a missing authority is therefore
521
+ an inert daemon, never an open one — boot's job is only to make that
522
+ state loud instead of quiet.
403
523
  """
404
524
  return bool(actor_id) and actor_id in self.bow_owners
405
525
 
@@ -501,25 +621,15 @@ class Config:
501
621
  "turn every Body of Work into a release feed"
502
622
  )
503
623
 
504
- bow_owners = _string_list(raw.get("bow_owners", cls.bow_owners), "bow_owners")
505
- if release_feed == FEED_BOW and not bow_owners:
506
- # Fail-closed, the `repos` guard's twin. `repos` bounds WHERE the
507
- # orchestrator writes; this bounds WHOSE work drives it. The feed
508
- # lists Bodies of Work the daemon merely collaborates on, and being
509
- # made a collaborator takes no acceptance step on this side, so
510
- # without an owner allowlist "shared with the daemon and titled for
511
- # a watched repo" is enough to get a task rendered into an issue in
512
- # that repo and handed to the develop pipeline. Only fatal for the
513
- # `bow` feed: `scan` never reads a Body of Work at all.
514
- raise ValueError(
515
- "bow_owners is empty and release_feed is 'bow' — the release "
516
- "feed is fail-closed and refuses to start without the actor "
517
- "id(s) allowed to own a feed Body of Work (being shared one is "
518
- "not a claim to drive this daemon). Set `bow_owners` in the "
519
- "config file, pass one or more --bow-owner flags, or set "
520
- "ALISSA_ORC_BOW_OWNERS. An actor id is the `ownerActorId` on "
521
- "the container's row in GET /v1/bodies-of-work."
522
- )
624
+ # Shape-validated in EVERY feed mode, not just `bow`. The value is inert
625
+ # under `scan`, but a malformed id that boots quietly today is a daemon
626
+ # that refuses to start on the unrelated day the operator flips the feed
627
+ # over and this is the layer that knows the entry came from a human.
628
+ # Empty stays legal: the boot path resolves the authority to this
629
+ # token's own actor (O8), so `bow_owners` is an override, not setup.
630
+ bow_owners = normalize_bow_owners(
631
+ _string_list(raw.get("bow_owners", cls.bow_owners), "bow_owners")
632
+ )
523
633
 
524
634
  fallback = int(raw.get("scan_fallback_interval", cls.scan_fallback_interval))
525
635
  if fallback < 0:
@@ -67,9 +67,10 @@ titled `autodev: <owner>/<repo>` (`bow_prefix`).
67
67
  trailer logs a warning and the BOW wins (never a silent retarget).
68
68
  `Autonomous-Scope` is unchanged: still read from the task body, still the
69
69
  intra-repo parallel-release glob list.
70
- * **Only an ALLOWLISTED OWNER's Body of Work is a feed** (`bow_owners`, empty
71
- is fatal under this feed). The listing includes containers merely SHARED with
72
- the daemon, and sharing is unilateral -- so a feed-shaped title is a target
70
+ * **Only an ALLOWLISTED OWNER's Body of Work is a feed** (`bow_owners`, which
71
+ defaults at boot to the daemon's OWN actor and is fatal if that cannot be
72
+ resolved -- O8). The listing includes containers merely SHARED with the
73
+ daemon, and sharing is unilateral -- so a feed-shaped title is a target
73
74
  declaration, never a provenance claim. `bow_prefix` and `repos` bound which
74
75
  repo a release lands in; they say nothing about whose task gets there, and an
75
76
  allowlisted repo is where the daemon's writes are most trusted. A prefix-
@@ -609,6 +610,26 @@ class Orchestrator:
609
610
  surfaced: set[int] = set()
610
611
  unresolved: set[str] = set()
611
612
 
613
+ if not self.config.bow_owners:
614
+ # Not reachable through the CLI: boot resolves the authority (the
615
+ # token's own actor, or the operator's explicit list) and refuses to
616
+ # start if it cannot. Kept because it is the feed's central property
617
+ # and it must hold for an Orchestrator built any other way -- a
618
+ # test, an embedder, a future entry point. `trusts_feed_owner`
619
+ # already refuses every owner on an empty list, so the release
620
+ # behavior here is unchanged; what this adds is the LOUDNESS boot
621
+ # would have provided. The pass is INCOMPLETE, so the fallback sweep
622
+ # does not go on to report every marked task as "the feed did not
623
+ # surface this" -- the feed did not run.
624
+ log.error(
625
+ "release_feed is 'bow' but no feed authority is set — no body "
626
+ "of work can be authoritative, so nothing is released this "
627
+ "pass. Startup normally resolves this to the token's own actor; "
628
+ "set bow_owners explicitly if the feed containers are owned by "
629
+ "another actor."
630
+ )
631
+ return FeedPass(results, surfaced, unresolved, complete=False)
632
+
612
633
  try:
613
634
  bows = self.alissa.list_bodies_of_work()
614
635
  except AlissaError as exc:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-orcloop
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: ALISSA-TOOLS-GITHUB-ORCLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera