fix(apply): guard app-owned collections + test nearest-group-wins (§11.6 review)

Two review findings:

- F2 (defense-in-depth): validate_bundle_for now rejects `collections` on an
  app node — the inverse of the existing group route/trigger guard. The CLI
  already prevents it (ManifestApp has no field + deny_unknown_fields), but a
  hand-rolled wire Bundle POSTed to /apps/{id}/apply would otherwise insert an
  inert app_id-owned group_collections row that no resolver reads.

- F1 (coverage): a journey for nearest-ancestor-group-wins, the CoW-shadowing
  guarantee the resolver's `ORDER BY depth LIMIT 1` provides. A parent and a
  child group both declare `catalog`; an app under the child writes, and a
  second app directly under the parent reads its (separate, empty) store —
  proving the deep write stayed in the nearest (child) store. Previously only
  the FakeResolver and flat sibling groups were exercised, so the ordering was
  untested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-30 07:15:37 +02:00
parent 0973344515
commit af525e71bd
2 changed files with 118 additions and 0 deletions

View File

@@ -576,6 +576,19 @@ impl ApplyService {
));
}
}
// §11.6: shared collections are owned by GROUPS. Reject them on an app
// node — the inverse of the group route/trigger guard above. The CLI
// already prevents this (`ManifestApp` has no `collections` field), so
// this is defense-in-depth against a hand-rolled wire `Bundle`; without
// it the reconcile would insert an inert `app_id`-owned marker row that
// no resolver ever reads.
if matches!(owner, ApplyOwner::App(_)) && !bundle.collections.is_empty() {
return Err(ApplyError::Invalid(
"an app manifest cannot declare shared collections — \
they are owned by a group"
.into(),
));
}
self.validate_bundle(bundle, inherited_endpoints)
}