feat(interceptors): hook set_if (CAS) + pic interceptors ls (§9.4 M12)

Closes the §9.4 track:
- set_if (compare-and-swap), per-app and shared, now runs the same (kv, set)
  before/after interceptor as set — otherwise CAS was a silent bypass of a set
  policy. The before-hook can transform the new value; the after-hook sees the
  swapped bool.
- read-only pic interceptors ls --app|--group: app shows the RESOLVED chain
  (every marker guarding its writes, nearest-owner-wins), group its own markers.
  New apply_service::interceptor_report + InterceptorInfo, /apps|groups/{id}/
  interceptors routes (AppRead/GroupScriptsRead), client + cmd mirroring
  extension-points.

CLAUDE.md updated: §9.4 service interceptors are now COMPLETE (M1-M12).
Pinned by a journey: set_if of a guarded key is denied while a free key swaps,
and interceptors ls lists the kv/set guard (12 interceptor journeys green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-16 20:44:42 +02:00
parent ef16d48a8c
commit 0dd03af0c6
9 changed files with 289 additions and 14 deletions

View File

@@ -18,8 +18,9 @@ use serde_json::json;
use crate::app_repo::AppRepository;
use crate::apply_service::{
ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, CollectionInfo,
ExtensionPointInfo, NodeKind, OwnershipClaim, PlanResult, ProjectDecl, RouteTemplateInfo,
StructureMode, SuppressionInfo, TreeBundle, TreePlanResult, TriggerTemplateInfo,
ExtensionPointInfo, InterceptorInfo, NodeKind, OwnershipClaim, PlanResult, ProjectDecl,
RouteTemplateInfo, StructureMode, SuppressionInfo, TreeBundle, TreePlanResult,
TriggerTemplateInfo,
};
use crate::authz::{require, AuthzDenied, Capability};
use crate::group_repo::GroupRepository;
@@ -46,9 +47,45 @@ pub fn apply_router(service: ApplyService) -> Router {
.route("/groups/{id}/routes", get(group_routes_handler))
.route("/apps/{id}/suppressions", get(app_suppressions_handler))
.route("/groups/{id}/suppressions", get(group_suppressions_handler))
.route("/apps/{id}/interceptors", get(app_interceptors_handler))
.route("/groups/{id}/interceptors", get(group_interceptors_handler))
.with_state(service)
}
/// Read-only §9.4 interceptor report for an app: the RESOLVED chain view (every
/// marker guarding its writes, nearest-owner-wins). Viewer-tier `AppRead`. Backs
/// `pic interceptors ls --app`.
async fn app_interceptors_handler(
State(svc): State<ApplyService>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<InterceptorInfo>>, ApplyError> {
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id))
.await
.map_err(map_authz)?;
let report = svc.interceptor_report(ApplyOwner::App(app_id)).await?;
Ok(Json(report))
}
/// Read-only §9.4 interceptor report for a group: its OWN declared markers.
async fn group_interceptors_handler(
State(svc): State<ApplyService>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<InterceptorInfo>>, ApplyError> {
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
require(
svc.authz.as_ref(),
&principal,
Capability::GroupScriptsRead(group_id),
)
.await
.map_err(map_authz)?;
let report = svc.interceptor_report(ApplyOwner::Group(group_id)).await?;
Ok(Json(report))
}
/// Read-only §11 tail suppression report for an app: the inherited templates it
/// declines (`target_kind`, `reference`). Viewer-tier `AppRead`. Backs
/// `pic suppress ls --app`.

View File

@@ -854,6 +854,19 @@ pub struct CollectionInfo {
pub kind: String,
}
/// One row of the read-only §9.4 interceptor report (`pic interceptors ls`).
/// For an app it is the RESOLVED chain view (markers visible on its ancestor
/// chain, nearest-owner-wins); for a group it is the group's OWN declarations.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InterceptorInfo {
pub service: String,
pub op: String,
pub phase: String,
pub script: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_ms: Option<i32>,
}
/// One row of the read-only §11 tail suppression report (`pic suppress ls
/// --app`). `target_kind` is `trigger` | `route`; `reference` is the handler
/// script name (trigger) or path (route) the app declines.
@@ -3671,6 +3684,32 @@ impl ApplyService {
/// `inherited default` / `null` = unset). For a **group**: its own declared
/// names (no single app to resolve a provider against). Backs the read-only
/// `extension-points ls` and the `pull` round-trip.
/// §9.4 read-only interceptor report. For an app: the RESOLVED chain view
/// (every marker visible on its ancestor chain, nearest-owner-wins) — what
/// actually guards its writes. For a group: its OWN declared markers.
pub async fn interceptor_report(
&self,
owner: ApplyOwner,
) -> Result<Vec<InterceptorInfo>, ApplyError> {
let markers = match owner {
ApplyOwner::App(a) => crate::interceptor_repo::list_on_app_chain(&self.pool, a).await,
ApplyOwner::Group(g) => {
crate::interceptor_repo::list_for_owner(&self.pool, ScriptOwner::Group(g)).await
}
}
.map_err(|e| ApplyError::Backend(e.to_string()))?;
Ok(markers
.into_iter()
.map(|m| InterceptorInfo {
service: m.service,
op: m.op,
phase: m.phase,
script: m.script,
timeout_ms: m.timeout_ms,
})
.collect())
}
pub async fn extension_point_report(
&self,
owner: ApplyOwner,