inbound-library/inbound_library/registry.py
tegwick d05d6ed35b Build the inbound-library core: store, IMAP intake, registries, notices.
ILIB-WP-0002-T01..T06: append-only item store, read-only IMAP intake with
quarantine, file-backed registries, idempotent hub/pull/digest notices,
run-due with health and the systemd user timer. 14 tests pass. Registers
the intelligence topics and the intelligence-radar subscription.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1170398@bnt-lap001
Assistant-Session: 622cd705-cb31-4322-ba08-dd42e44fa583
2026-10-05 11:21:13 +02:00

118 lines
5.2 KiB
Python

"""File-backed registries: topics, sources and subscriptions.
Consumers change these with a commit. `validate` is the gate that check runs.
"""
import json
from pathlib import Path
CHANNELS = {'hub', 'pull', 'digest'}
CADENCES = {'daily', 'weekly', 'irregular'}
VISIBILITY = {'shared', 'restricted'}
def _load(path):
return json.loads(Path(path).read_text())
def load(root):
base = Path(root) / 'registry'
topics_file = base / 'topics.json'
topics = _load(topics_file) if topics_file.exists() else {'version': 0, 'topics': {}}
sources = {}
for path in sorted((base / 'sources').glob('*.json')):
source = _load(path)
sources[source.get('id', path.stem)] = {**source, '_file': path.name}
subscriptions = {}
for path in sorted((base / 'subscriptions').glob('*.json')):
subscription = _load(path)
subscriptions[subscription.get('repo', path.stem)] = {**subscription, '_file': path.name}
return {'topics': topics, 'sources': sources, 'subscriptions': subscriptions}
def validate(registry):
problems = []
known = set(registry['topics'].get('topics', {}))
rules = {}
for ident, source in registry['sources'].items():
where = f"sources/{source['_file']}"
if source['_file'] != f'{ident}.json':
problems.append(f'{where}: file name must be <id>.json')
for field in ('id', 'owner_repo', 'channel', 'topics', 'cadence', 'visibility'):
if field not in source:
problems.append(f'{where}: missing {field}')
if source.get('cadence') not in CADENCES:
problems.append(f'{where}: cadence must be one of {sorted(CADENCES)}')
if source.get('visibility') not in VISIBILITY:
problems.append(f'{where}: visibility must be shared or restricted')
for topic in source.get('topics', []):
if topic not in known:
problems.append(f'{where}: unknown topic {topic}')
if source.get('channel') == 'email':
match = source.get('match', {})
if not match.get('from'):
problems.append(f'{where}: email source needs match.from')
key = (match.get('from', '').lower(), match.get('subject', ''), match.get('list_id', ''))
if key in rules:
problems.append(f'{where}: duplicate match rule with {rules[key]}')
rules[key] = ident
elif source.get('channel') == 'url' and not source.get('url'):
problems.append(f'{where}: url source needs url')
for repo, subscription in registry['subscriptions'].items():
where = f"subscriptions/{subscription['_file']}"
if subscription['_file'] != f'{repo}.json':
problems.append(f'{where}: file name must be <repo>.json')
if not subscription.get('agent'):
problems.append(f'{where}: missing agent')
channels = set(subscription.get('channels', []))
if not channels or not channels <= CHANNELS:
problems.append(f'{where}: channels must be a non-empty subset of {sorted(CHANNELS)}')
if 'digest' in channels and subscription.get('cadence') not in ('daily', 'weekly'):
problems.append(f'{where}: digest channel needs cadence daily or weekly')
for topic in subscription.get('topics', []):
if topic not in known:
problems.append(f'{where}: unknown topic {topic}')
for ident in subscription.get('sources', []):
source = registry['sources'].get(ident)
if source is None:
problems.append(f'{where}: unknown source {ident}')
elif source.get('visibility') == 'restricted' and source.get('owner_repo') != repo:
problems.append(f'{where}: restricted source {ident} belongs to {source["owner_repo"]}')
return problems
def source_for_mail(registry, sender, subject='', list_id=''):
"""Return the email source whose match rule fits, or None (quarantine)."""
sender = (sender or '').lower()
for ident, source in registry['sources'].items():
if source.get('channel') != 'email':
continue
match = source.get('match', {})
if match.get('from', '').lower() != sender:
continue
if match.get('subject') and match['subject'].lower() not in (subject or '').lower():
continue
if match.get('list_id') and match['list_id'] != list_id:
continue
return ident
return None
def visible_sources(registry, repo):
"""Sources a repo may see: shared ones, plus restricted ones it owns."""
return {ident: source for ident, source in registry['sources'].items()
if source.get('visibility') == 'shared' or source.get('owner_repo') == repo}
def subscribers_for(registry, source_id):
"""Repos whose subscription matches a source by id or by topic."""
source = registry['sources'].get(source_id)
if source is None:
return []
matched = []
for repo, subscription in registry['subscriptions'].items():
if source_id not in visible_sources(registry, repo):
continue
if source_id in subscription.get('sources', []) or \
set(source.get('topics', [])) & set(subscription.get('topics', [])):
matched.append(repo)
return matched