Skip to content

Commit a3c2179

Browse files
committed
[fix.infra.Ω.D8+] make RBFE execution scientifically and operationally fail closed
The FEP workflow previously let client-local state, stale scientific artifacts, and process-local submission timing stand in for durable identity and evidence. That made a visually plausible review insufficient proof of the exact receptor, ligand, mapping, campaign generation, or execution that would later run. Bind every scientific object to an owner-scoped campaign generation and exact content digest; require structured receptor, pose, mapping, charge, stereo, and chemistry witnesses before execution; invalidate descendants atomically when inputs change; and isolate Job/RunSet access by principal. Campaign preparation is now a durable, idempotent Job whose transactional dispatch outbox survives an API-process crash and rejects stale workers with fencing tokens. Migrations 039-047 provide the durable campaign, ownership, RunSet, idempotency, and dispatch state. Contract schemas and generated clients expose the same strict surface, while readiness remains fail closed until every required migration and executor capability is present. No physical FEP or GPU work was launched while establishing these guarantees.
1 parent 5ec2492 commit a3c2179

100 files changed

Lines changed: 22338 additions & 667 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎backend/artifacts_pg.py‎

Lines changed: 227 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,16 @@ def __init__(self, connect, *, base_path: str = '/v2/artifacts') -> None:
4848
self._connect = connect
4949
self.base_path = base_path
5050
self.counters = {'put': 0, 'put_deduplicated': 0, 'read': 0, 'miss': 0,
51-
'digest_mismatch': 0, 'unavailable': 0}
51+
'digest_mismatch': 0, 'unavailable': 0,
52+
'authorization_denied': 0,
53+
'authorization_schema_unavailable': 0}
5254

5355
# ── writes ────────────────────────────────────────────────────────────────
5456
def put(self, data: bytes, *, role: str, media_type: str | None = None,
5557
metadata: dict[str, Any] | None = None,
5658
method_version: str | None = None,
57-
method_id: str | None = None) -> A.Artifact:
59+
method_id: str | None = None,
60+
cursor=None) -> A.Artifact:
5861
"""Store bytes and name their role. Idempotent by (content, role).
5962
6063
The digest is computed HERE and passed as the primary key, and the database
@@ -74,7 +77,8 @@ def put(self, data: bytes, *, role: str, media_type: str | None = None,
7477
digest = A.sha256_hex(data)
7578
mt = media_type or A.MEDIA_TYPES.get(role, 'application/octet-stream')
7679
self.counters['put'] += 1
77-
with self._connect() as conn, conn.cursor() as cur:
80+
81+
def persist(cur) -> A.Artifact:
7882
cur.execute(
7983
'INSERT INTO app.blob (sha256, media_type, byte_len, bytes) '
8084
'VALUES (decode(%s, %s), %s, %s, %s) ON CONFLICT (sha256) DO NOTHING',
@@ -111,8 +115,17 @@ def put(self, data: bytes, *, role: str, media_type: str | None = None,
111115
metadata=metadata or {},
112116
method_version=method_version)
113117

118+
# A caller that already owns the authoritative transaction may lend its
119+
# cursor. This is not a convenience: campaign preparation has one CAS
120+
# commit barrier, and opening a private connection here used to make the
121+
# artifact survive when that later CAS rolled back.
122+
if cursor is not None:
123+
return persist(cursor)
124+
with self._connect() as conn, conn.cursor() as cur:
125+
return persist(cur)
126+
114127
def link_to_job(self, job_id: str, artifact_id: str, role: str,
115-
ordinal: int = 0) -> None:
128+
ordinal: int = 0, *, cursor=None) -> None:
116129
"""Record which invocation produced this artifact.
117130
118131
Separate from `put` because an artifact can exist before its job row does
@@ -122,12 +135,43 @@ def link_to_job(self, job_id: str, artifact_id: str, role: str,
122135
"""
123136
if psycopg is None:
124137
return
125-
with self._connect() as conn, conn.cursor() as cur:
138+
139+
def link(cur) -> None:
126140
cur.execute(
127141
'INSERT INTO app.job_artifact (job_id, artifact_id, role, ordinal) '
128142
'VALUES (%s, %s, %s, %s) ON CONFLICT DO NOTHING',
129143
(job_id, artifact_id, role, ordinal))
130144

145+
if cursor is not None:
146+
link(cursor)
147+
return
148+
with self._connect() as conn, conn.cursor() as cur:
149+
link(cur)
150+
151+
def link_to_campaign(self, campaign_id: str, artifact_id: str, role: str,
152+
ordinal: int = 0, *, cursor=None) -> None:
153+
"""Make campaign ownership explicit and independently queryable.
154+
155+
Artifact metadata is immutable first-writer context, not an ownership
156+
relation: content deduplication can reuse one artifact in several campaigns.
157+
The join table is therefore the authorization/provenance fact.
158+
"""
159+
if psycopg is None:
160+
return
161+
162+
def link(cur) -> None:
163+
cur.execute(
164+
'INSERT INTO app.rbfe_campaign_artifact '
165+
'(campaign_id, artifact_id, role, ordinal) '
166+
'VALUES (%s, %s, %s, %s) ON CONFLICT DO NOTHING',
167+
(campaign_id, artifact_id, role, ordinal))
168+
169+
if cursor is not None:
170+
link(cursor)
171+
return
172+
with self._connect() as conn, conn.cursor() as cur:
173+
link(cur)
174+
131175
# ── reads ─────────────────────────────────────────────────────────────────
132176
_SELECT = (
133177
'SELECT a.id, encode(a.blob_sha256, %s), a.role, a.media_type, '
@@ -144,6 +188,105 @@ def _row_to_artifact(self, row) -> A.Artifact:
144188
size_bytes=row[4], encoding=row[5], id=str(row[0]),
145189
metadata=dict(row[6] or {}), method_version=row[7])
146190

191+
@staticmethod
192+
def _principal(actor: dict[str, str] | None) -> tuple[str, str]:
193+
if not isinstance(actor, dict):
194+
raise failures.DiracInvalidParameters(
195+
'artifact access requires an authenticated actor')
196+
kind = str(actor.get('kind') or '')
197+
actor_id = str(actor.get('id') or '').strip()
198+
if kind not in {'human', 'agent', 'service'} or not actor_id:
199+
raise failures.DiracInvalidParameters(
200+
'artifact actor must be a human, agent, or service')
201+
return kind, actor_id
202+
203+
def _require_authorization_schema(self, cur) -> None:
204+
"""Refuse before compiling ACL SQL against a partially migrated schema.
205+
206+
PostgreSQL resolves every relation in a statement before evaluating its
207+
WHERE clause. Without this capability probe, a daemon started before 040
208+
or 045 turns an intentional deny into an undefined-table 500. A legacy
209+
private Artifact is never assigned an inferred owner or made public as a
210+
compatibility shortcut; it remains indistinguishable from absence.
211+
"""
212+
cur.execute(
213+
"SELECT to_regclass('app.rbfe_campaign') IS NOT NULL, "
214+
"to_regclass('app.rbfe_campaign_artifact') IS NOT NULL, "
215+
"EXISTS (SELECT 1 FROM pg_constraint "
216+
"WHERE conrelid=to_regclass('app.rbfe_campaign_artifact') "
217+
"AND contype='f' "
218+
"AND conname='rbfe_campaign_artifact_role_fk' "
219+
"AND pg_get_constraintdef(oid) LIKE "
220+
"'%%FOREIGN KEY (artifact_id, role)%%')")
221+
capability = tuple(cur.fetchone() or (False, False, False))
222+
if all(capability):
223+
return
224+
missing = []
225+
if not capability[0]:
226+
missing.append('040_rbfe_campaign_state.sql')
227+
if not all(capability[1:3]):
228+
missing.append('045_rbfe_campaign_artifact_ownership.sql')
229+
self.counters['authorization_schema_unavailable'] += 1
230+
raise failures.DiracFailure(
231+
'DB_UNAVAILABLE',
232+
'artifact authorization schema is incomplete; private artifacts '
233+
'remain fail-closed until explicit ownership can be verified',
234+
details={
235+
'required_migrations': missing,
236+
'legacy_unowned_policy': 'fail_closed',
237+
'owner_inference': False,
238+
'implicit_public': False,
239+
})
240+
241+
@staticmethod
242+
def _access_clause(alias: str = 'a') -> str:
243+
"""SQL ownership witness for an HTTP-visible Artifact.
244+
245+
A UUID or digest is an integrity capability, never an authorization
246+
capability. Legacy/unlinked rows therefore fail closed. Internal
247+
science paths continue to use ``head/read`` because their authorization
248+
happened when the server resolved the owning Job/Campaign reference.
249+
"""
250+
return f'''(
251+
coalesce({alias}.metadata->>'visibility','') = 'public'
252+
OR EXISTS (
253+
SELECT 1 FROM app.job_artifact ja
254+
JOIN app.job j ON j.id=ja.job_id
255+
WHERE ja.artifact_id={alias}.id
256+
AND j.actor_kind=%s AND j.actor_id=%s
257+
)
258+
OR EXISTS (
259+
SELECT 1 FROM app.rbfe_campaign c
260+
WHERE c.id::text={alias}.metadata->>'campaign_id'
261+
AND c.created_by_kind=%s AND c.created_by_id=%s
262+
)
263+
OR EXISTS (
264+
SELECT 1 FROM app.rbfe_campaign_artifact ca
265+
JOIN app.rbfe_campaign c ON c.id=ca.campaign_id
266+
WHERE ca.artifact_id={alias}.id
267+
AND c.created_by_kind=%s AND c.created_by_id=%s
268+
)
269+
OR EXISTS (
270+
SELECT 1
271+
FROM design.motif_scientific_object o
272+
JOIN app.rbfe_campaign c ON EXISTS (
273+
SELECT 1
274+
FROM jsonb_array_elements(
275+
CASE WHEN jsonb_typeof(c.state->'owned_object_refs')='array'
276+
THEN c.state->'owned_object_refs' ELSE '[]'::jsonb END
277+
) owned_ref
278+
WHERE owned_ref->>'id'=o.id::text
279+
)
280+
WHERE o.document_artifact_id={alias}.id
281+
AND c.created_by_kind=%s AND c.created_by_id=%s
282+
)
283+
)'''
284+
285+
@staticmethod
286+
def _access_params(kind: str, actor_id: str) -> tuple[str, ...]:
287+
return (kind, actor_id, kind, actor_id, kind, actor_id,
288+
kind, actor_id)
289+
147290
def head(self, address: str) -> A.Artifact:
148291
"""Metadata without bytes — the request a client makes to decide.
149292
@@ -184,6 +327,56 @@ def head(self, address: str) -> A.Artifact:
184327
'roles_at_digest': n}})
185328
return art
186329

330+
def head_authorized(self, address: str,
331+
actor: dict[str, str]) -> A.Artifact:
332+
"""Resolve metadata only when the authenticated actor owns its lineage.
333+
334+
Digest lookup is filtered before ordering; otherwise the newest artifact
335+
at shared bytes could belong to another actor and make an owned role look
336+
absent (or, worse, disclose which role was newest).
337+
"""
338+
if psycopg is None:
339+
raise failures.DiracFailure('DB_UNAVAILABLE', 'no database driver')
340+
kind, actor_id = self._principal(actor)
341+
by_digest = A.is_digest(address) or address.startswith('sha256:')
342+
digest = address[7:] if address.startswith('sha256:') else address
343+
access = self._access_clause('a')
344+
access_params = self._access_params(kind, actor_id)
345+
with self._connect() as conn, conn.cursor() as cur:
346+
self._require_authorization_schema(cur)
347+
if by_digest:
348+
cur.execute(
349+
self._SELECT
350+
+ ' WHERE a.blob_sha256=decode(%s,%s) AND ' + access
351+
+ ' ORDER BY a.created_at DESC LIMIT 1',
352+
("hex", digest, "hex", *access_params))
353+
else:
354+
cur.execute(
355+
self._SELECT + ' WHERE a.id=%s AND ' + access,
356+
("hex", address, *access_params))
357+
row = cur.fetchone()
358+
if row is None:
359+
self.counters['miss'] += 1
360+
self.counters['authorization_denied'] += 1
361+
# Deliberately indistinguishable from a nonexistent address.
362+
raise failures.DiracNotFound(
363+
f'no artifact at {address!r}',
364+
details={'address': address, 'looked_up_by':
365+
'digest' if by_digest else 'id'})
366+
art = self._row_to_artifact(row)
367+
if by_digest:
368+
cur.execute(
369+
'SELECT count(*) FROM app.artifact a '
370+
'WHERE a.blob_sha256=decode(%s,%s) AND ' + access,
371+
(digest, 'hex', *access_params))
372+
count = int(cur.fetchone()[0])
373+
if count > 1:
374+
art = A.Artifact(**{
375+
**art.__dict__,
376+
'metadata': {**art.metadata, 'roles_at_digest': count},
377+
})
378+
return art
379+
187380
def read(self, address: str) -> tuple[A.Artifact, bytes]:
188381
"""Bytes, verified against the digest they were stored under.
189382
@@ -213,6 +406,25 @@ def read(self, address: str) -> tuple[A.Artifact, bytes]:
213406
self.counters['read'] += 1
214407
return art, data
215408

409+
def read_authorized(self, address: str,
410+
actor: dict[str, str]) -> tuple[A.Artifact, bytes]:
411+
art = self.head_authorized(address, actor)
412+
with self._connect() as conn, conn.cursor() as cur:
413+
cur.execute('SELECT bytes FROM app.blob WHERE sha256 = decode(%s, %s)',
414+
(art.sha256, 'hex'))
415+
row = cur.fetchone()
416+
if row is None:
417+
self.counters['miss'] += 1
418+
raise failures.DiracNotFound(f'no artifact at {address!r}')
419+
data = bytes(row[0])
420+
try:
421+
A.verify_bytes(data, art.sha256)
422+
except failures.DiracFailure:
423+
self.counters['digest_mismatch'] += 1
424+
raise
425+
self.counters['read'] += 1
426+
return art, data
427+
216428
def read_range(self, address: str, range_header: str | None
217429
) -> tuple[A.Artifact, bytes, tuple[int, int] | None]:
218430
"""A byte range, sliced AFTER the whole object is verified.
@@ -229,3 +441,13 @@ def read_range(self, address: str, range_header: str | None
229441
return art, data, None
230442
lo, hi = rng
231443
return art, data[lo:hi + 1], rng
444+
445+
def read_range_authorized(
446+
self, address: str, range_header: str | None,
447+
actor: dict[str, str]) -> tuple[A.Artifact, bytes, tuple[int, int] | None]:
448+
art, data = self.read_authorized(address, actor)
449+
rng = A.parse_range(range_header, len(data))
450+
if rng is None:
451+
return art, data, None
452+
lo, hi = rng
453+
return art, data[lo:hi + 1], rng

‎backend/catalog.py‎

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,43 @@
4343
CONTRACTS = pathlib.Path(__file__).resolve().parent.parent / 'contracts'
4444

4545

46+
class _FrozenDict(dict):
47+
"""JSON object whose contract bytes cannot be changed after catalog load."""
48+
49+
def _immutable(self, *_args, **_kwargs):
50+
raise TypeError('Method descriptor is immutable')
51+
52+
__setitem__ = __delitem__ = clear = pop = popitem = setdefault = update = _immutable
53+
__ior__ = _immutable
54+
55+
56+
class _FrozenList(list):
57+
"""JSON array companion to :class:`_FrozenDict`."""
58+
59+
def _immutable(self, *_args, **_kwargs):
60+
raise TypeError('Method descriptor is immutable')
61+
62+
__setitem__ = __delitem__ = append = clear = extend = insert = pop = remove = _immutable
63+
__iadd__ = __imul__ = reverse = sort = _immutable
64+
65+
66+
def _freeze_json(value: Any) -> Any:
67+
if isinstance(value, dict):
68+
return _FrozenDict({key: _freeze_json(child)
69+
for key, child in value.items()})
70+
if isinstance(value, (list, tuple)):
71+
return _FrozenList(_freeze_json(child) for child in value)
72+
return value
73+
74+
75+
def _thaw_json(value: Any) -> Any:
76+
if isinstance(value, dict):
77+
return {key: _thaw_json(child) for key, child in value.items()}
78+
if isinstance(value, (list, tuple)):
79+
return [_thaw_json(child) for child in value]
80+
return value
81+
82+
4683
@dataclass(frozen=True)
4784
class ArtifactDeclaration:
4885
role: str
@@ -172,7 +209,17 @@ class MethodCatalog:
172209
"""
173210

174211
def __init__(self, specs: dict[str, MethodSpec]) -> None:
175-
self._specs = specs
212+
# MethodSpec is frozen, but a frozen dataclass containing a mutable dict
213+
# is not immutable. Normalize even injected test/plugin specs here so no
214+
# caller can rewrite execution routing after admission.
215+
self._specs = {
216+
mid: MethodSpec(
217+
method_id=spec.method_id, summary=spec.summary,
218+
descriptor=_freeze_json(spec.descriptor),
219+
handler_ref=spec.handler_ref, estimate_ref=spec.estimate_ref,
220+
artifacts=tuple(spec.artifacts), version=spec.version)
221+
for mid, spec in specs.items()
222+
}
176223

177224
# ── loading ───────────────────────────────────────────────────────────────
178225
@classmethod
@@ -226,7 +273,7 @@ def bind_versions(self, versions: dict[str, str]) -> MethodCatalog:
226273
for mid, spec in self._specs.items():
227274
out[mid] = MethodSpec(
228275
method_id=spec.method_id, summary=spec.summary,
229-
descriptor=spec.descriptor, handler_ref=spec.handler_ref,
276+
descriptor=_freeze_json(spec.descriptor), handler_ref=spec.handler_ref,
230277
estimate_ref=spec.estimate_ref, artifacts=spec.artifacts,
231278
version=versions.get(mid))
232279
return MethodCatalog(out)
@@ -337,14 +384,14 @@ def describe(self, method_id: str) -> dict:
337384
'version': spec.version,
338385
'summary': spec.summary,
339386
'description': spec.descriptor.get('description', ''),
340-
'input_schema': spec.input_schema,
341-
'output_schema': spec.output_schema,
342-
'refusals': spec.refusals,
387+
'input_schema': _thaw_json(spec.input_schema),
388+
'output_schema': _thaw_json(spec.output_schema),
389+
'refusals': _thaw_json(spec.refusals),
343390
'artifacts': [{'role': a.role, 'media_type': a.media_type,
344391
'required': a.required, 'typical_bytes': a.typical_bytes}
345392
for a in spec.artifacts],
346-
'execution': spec.execution,
347-
'exposure': spec.exposure,
393+
'execution': _thaw_json(spec.execution),
394+
'exposure': _thaw_json(spec.exposure),
348395
'executable': spec.is_executable,
349396
}
350397

@@ -368,7 +415,7 @@ def as_tool_list(self, *, surface: str = 'mcp') -> list[dict]:
368415
'method_id': spec.method_id,
369416
'description': (spec.summary + ' ' +
370417
spec.descriptor.get('description', '')).strip(),
371-
'input_schema': spec.input_schema,
418+
'input_schema': _thaw_json(spec.input_schema),
372419
'curated': allowed == 'curated',
373420
})
374421
return out

0 commit comments

Comments
 (0)