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:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user