`upsert_project_tx` defaulted a missing `[project].name` to the slug and then
`ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name` wrote that fabricated
value unconditionally — so a re-apply from a clone whose manifest omits `name`
silently clobbered the stored display name back to the slug (visible in
`pic projects ls` / `pic groups ls`).
Bind the raw optional name once and guard both sides on it:
`VALUES ($1, COALESCE($2, $1), $3)` (first apply falls back to the slug for the
NOT NULL column) and `DO UPDATE SET name = COALESCE($2, projects.name)` (a
re-apply updates the name only when the manifest actually declares one). An
omitted optional field now preserves persisted data instead of mutating it.
Pinned by a new `reapply_without_name_preserves_project_name` journey.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
§7 M3 (part 2): planning a change to a GROUP node now reports the descendant
apps owned by OTHER projects that the change fans out to — the cross-repo
signal an operator needs before touching shared config.
- OwnershipPreview gains `blast_radius: Vec<ProjectImpact>`; `group_blast_radius`
walks the group's subtree (recursive CTE) and groups descendant apps by their
nearest-claimed-ancestor project, excluding the planning project. Ancestor
resolution is memoized per group (one walk per distinct descendant group, not
per app).
- `pic plan` renders `blast_radius` rows (mode-agnostic).
- the apply_ownership journey gains a plan-preview case pinning both the
ownership annotation (claim / conflict) and the blast radius (two other
projects' apps surfaced) end to end.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
§7 M3 (part 1): `pic plan` now surfaces the ownership outcome BEFORE apply.
- plan / plan_owner / plan_tree take the declaring `[project]`; each result
carries an `ownership` preview (pure `preview_ownership`, unit-tested):
claim (unclaimed + project), owned (already this project), conflict (owned by
X — named), or unclaimed. A group node previews its OWN owner; an app node its
nearest claimed ancestor (read-only, on the pool).
- the attach-point ceiling (M2) is now previewed at plan too, so `pic plan`
outside the subtree fails early — consistent with apply.
- wire: PlanRequest / TreePlanRequest wrap { bundle, project } (project
`#[serde(default)]` → a pre-M3 CLI still plans); the plan DTOs + `pic plan`
gain an `ownership` row (mode-agnostic: shows in tsv + json).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
§6/§7 M2: a project's attach point is its ceiling — applies are refused for
any node not strictly within the declared subtree, so a repo can't reach (or
claim) above its local root even with the RBAC to do so.
- `[project] parent_group = "<slug>"` (ManifestProject + ProjectDecl); absent
= instance root = no ceiling (the default, backward-compatible).
- `check_within_attach`: the attach group must be a PROPER ancestor of a group
node (you can't apply the attach point itself) or an ancestor (inclusive) of
an app node's group; resolved via `groups.ancestors`. Enforced read-only in
apply_owner (prologue) + apply_tree (per node), before the claim.
- `ApplyError::OutsideAttachPoint` → 422 (a scope error, like Invalid).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Makes §7 ownership usable end to end from the CLI.
- manifest: a `[project]` block (slug + optional name), independent of the
[app]/[group] XOR; threaded onto every apply from the repo. --dir reads it
from the tree ROOT's picloud.toml (a [project] elsewhere is ignored with a
note).
- client: apply_node/apply_tree carry project + takeover; the 409 branch now
surfaces the server message verbatim (covers StateMoved AND OwnershipConflict,
both self-contained). New groups_list_with_owner captures the owner slug.
- pic apply --takeover flag; pic groups ls gains an `owner` column (the owning
project's slug, or — when unclaimed).
- server: /api/v1/admin/groups now returns each group flattened with its owner
slug (via list_with_owner) — the shared Group deserialize ignores the extra
field, so pic groups tree / the dashboard are unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The heart of multi-repo ownership: an apply now claims/verifies node ownership
before it reconciles, so two repos can't clobber each other's subtree.
- Pure policy (DB-free, unit-tested): `decide_group_claim` (unclaimed→claim,
owner→noop, foreign→conflict unless --takeover, no-project-into-claimed→
conflict) and `decide_app_owner` (an app must match its nearest claimed
ancestor; an unclaimed subtree stays open — backward-compatible).
- Execution under the per-node advisory lock: `upsert_project_tx` (first apply
registers the project), `read/write_group_owner_tx`, `claim_group_owner`
(takeover additionally requires `GroupAdmin` — ownership ⟂ RBAC),
`check_app_owner_single` + tree `nearest_claimed_in_tx` (in-tree Phase-A
claim overlays committed ancestors). No structure_version bump — a claim
isn't a diff change, so it must not churn a pending bound plan.
- `apply`/`apply_owner`/`apply_tree` take an `OwnershipClaim` (project +
takeover + principal); the claim slots after the lock, before load_current,
so a conflict short-circuits before any diff work.
- New `ApplyError::OwnershipConflict` → HTTP 409 (actionable); wired through
`ApplyRequest`/`TreeApplyRequest` (both `#[serde(default)]`, so the existing
CLI stays compatible until it learns [project]).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Read side of §7 ownership, ahead of the claim logic.
- project_repo: `ProjectRepository` trait + `PostgresProjectRepository`
(list / get_by_id / get_by_slug). Writes are NOT here — a claim registers
its project via an in-tx upsert (next commit).
- group_repo: `list_with_owner()` LEFT-JOINs projects for each group's owner
slug (backs `pic groups ls`); columns qualified since id/slug/name exist on
both tables.
- ApplyService gains a `projects` handle; constructed in the picloud binary.
- tests/projects_repo: round-trip pinning list_with_owner (claimed→slug,
unclaimed→None) and that ancestors() surfaces owner_project nearest-first —
the fold input the claim path walks.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First commit of §7 multi-repo ownership. A 'project' is a repo-root that
declaratively manages a slice of the shared group tree; each group node is
owned by exactly one project (single-owner-per-node). This lands the registry
+ shared types and wires the inert 0047 `owner_project` seam — no behavior
change yet (claim logic is the next commit).
- migration 0066: `projects` table (UUID pk, unique slug, name, created_by →
admin_users ON DELETE SET NULL); `groups.owner_project` gains its FK →
projects(id) ON DELETE SET NULL (un-claim, never cascade-destroy a tree) +
an index.
- shared: `ProjectId` (id_type! macro) + `Project` struct; `Group` gains
`owner_project: Option<ProjectId>`.
- group_repo: GROUP_COLS + the ancestors recursive-CTE term now carry
`owner_project`, so `ancestors()` surfaces ownership for the nearest-claimed
fold; GroupRow + From updated.
- schema snapshot re-blessed; /version schema assertion 65 → 66.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The consumption side of shared durable queues:
- Thread `shared` through BundleTrigger::Queue + QueueTriggerSpec + the
trigger identity; validate_bundle_for requires a shared queue on a group
to name a declared kind='queue' collection (a shared queue on an app is
rejected by the existing app-owner shared guard).
- materialize: a shared queue template materializes a consumer per
descendant (the M5 one-consumer-slot skip is bypassed for shared —
competing consumers are intended; each descendant gets one copy).
- dispatcher: ActiveQueueConsumer gains shared_group (from the materialized
copy's source template via LEFT JOIN); dispatch_one_queue +
handle_queue_failure route claim/ack/nack/terminal to the group store
when shared_group is Some, via q_claim/q_ack/q_nack/q_terminal helpers. A
group claim is normalized to a ClaimedMessage under the consuming app so
the handler path is unchanged; the reclaim task also drains the group
store. Exhausted shared messages are dropped (no group dead-letter store
yet — documented deferral).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Deterministic manager-core test (tests/shared_topics.rs) proves the
namespace boundary both ways: a shared publish fans out only to the
group's shared pubsub trigger (not a per-app or non-shared group
template), and a per-app publish never hits the shared trigger. CLI
journey (shared_topics) covers authoring + `pic triggers ls --group`
shared column + the two validation rejections (undeclared topic; shared
on an app), mirroring the shared_triggers norm. Docs: CLAUDE.md + design
doc §11.6 record D1 + D2 as shipped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The runtime for shared topics:
- pubsub_repo: fan_out_publish gains `AND t.shared = FALSE` (a shared
trigger never fires on a per-app publish); new fan_out_shared_publish
matches `shared = true` pubsub triggers on the resolved owning group,
each delivery stamped with the writer app_id (M2 model).
- GroupPubsubService trait (shared) + GroupPubsubServiceImpl: resolve the
owning group (kind='topic') from cx.app_id's chain, require editor+
(GroupPubsubPublish, fails closed for anon), size-cap, fan out.
- SDK: `pubsub::shared_topic("name")` -> GroupTopicHandle with
`.publish(subtopic, msg)` / `.publish(msg)`; wired through Services +
the picloud binary (reuses the one PubsubRepo).
- authz: Capability::GroupPubsubPublish(GroupId), editor+ / script:write.
The owning-group chain walk is the isolation boundary; a sibling-subtree
app never resolves the topic.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add `shared` to BundleTrigger::Pubsub + PubsubTriggerSpec + the trigger
identity (so toggling re-diffs) + current_trigger_identity. validate_bundle_for
now allows a `shared` pubsub trigger on a group and requires the topic
pattern's ROOT segment (events.* -> events) be a declared kind='topic'
collection; a wildcard root is a runtime match. Apply-side plumbing only —
the shared publish path + dispatch boundary land next.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Widen the group_collections kind allow-list (migration 0064) + the
manifest CollectionKind enum + apply_service COLLECTION_KINDS to include
'topic' (D2, storeless publish namespace) and 'queue' (D3, group-keyed
durable queue — store lands in 0065). Foundation shared by both
milestones; routes through the existing owner-generic reconcile +
resolve_owning_group path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A materialized copy of an M5 group stateful template (cron/queue/email) now
reads as `materialized = true` — inherited, read-only — distinct from a
hand-authored trigger. Threads a derived `materialized` bool (=
`materialized_from IS NOT NULL`) row → domain → API → CLI, mirroring the
`sealed`/`shared` columns; `list_for_app` SELECTs `materialized_from`. No
migration.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adversarial review NIT: the #[cfg(test)] InMemoryTriggerRepo's
email_inbound_target `.expect()`s app_id, which would panic if a future
unit test fetched a group-owned email TEMPLATE. Filter `app_id.is_some()`
like the production query's `AND t.app_id IS NOT NULL` so the mock stays
faithful. Test-only; no production change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CLI journey: set a group secret, apply a group [[triggers.email]] template
referencing it, create an app under the group → `pic triggers ls --app`
shows a materialized email trigger; re-apply is a NoOp.
Required loading a group's own set-secret names into CurrentState (was
hardcoded empty) so the apply plan-time email-secret check resolves the
ref against the GROUP store. Informational only — the secrets diff is
never executed on apply (secrets are set out-of-band via `pic secret
set`); the state token now folds in group secrets, matching apps.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drive rematerialize directly with a group email template carrying a fake
sealed secret: a descendant app gets one copy whose inbound-secret
ciphertext + nonce byte-equal the template's (verbatim copy, no reseal),
the reconcile is idempotent, and reparenting the app out of the subtree
de-materializes the copy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Close the last M5 gap: a [group] may now declare an email trigger
template, materialized into a per-descendant-app row like cron/queue.
Uses the shared-group-secret model: the template resolves its
inbound_secret_ref against the GROUP's own secret store once at apply and
seals it (resolve_and_seal + insert_email_trigger_tx generalized to a
SecretOwner / ScriptOwner). Email secrets are v0/no-AAD, so the sealed
blob is portable across rows — materialize copies the bytes verbatim into
each descendant (no master key, no reseal, no new schema column, no
threading into the CRUD hooks). Each copy has its own trigger_id / inbound
webhook URL.
- apply_service/manifest: drop the two group-email rejections; a group
email template with an unset group secret still fails apply hard.
- materialize: add "email" to MATERIALIZED_KINDS + a byte-copy detail arm.
- trigger_repo: email_inbound_target gains AND t.app_id IS NOT NULL so a
group TEMPLATE row is never directly invocable via its own webhook URL
(mirrors the cron/queue app_id guards).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The reconciler read `should` and `existing` in separate statements under READ
COMMITTED, and its queue one-consumer check wasn't locked — so two concurrent
reconcilers (e.g. two parallel app-creates) could (a) spuriously DELETE a valid
materialized cron copy when one committed a copy between the other's two reads
(a lost copy self-healing only on the next mutation), or (b) both pass the
queue slot check and double-fill a consumer slot. Take a fixed xact-scoped
advisory lock (REMATERIALIZE_LOCK_KEY) at the top of the reconcile so each pass
is atomic w.r.t. the others. Reconciles are infrequent + fast, so serializing
is cheap; the ON CONFLICT guard stays as defense in depth.
Found by the M5 review. Pinned by the existing stateful_templates test + the
full journey suite (which previously flaked on the race).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- 0063: partial unique index on (app_id, materialized_from) + ON CONFLICT DO
NOTHING in the reconciler, so a materialized copy is idempotent under
concurrent reconciles (two parallel app-creates no longer duplicate a copy).
- stateful_templates journey: a group cron template + a descendant app → a
materialized cron copy in `pic triggers ls --app`; re-apply is a NoOp.
- docs (§4.5 + CLAUDE.md): cron+queue materialization implemented; group EMAIL
templates deferred (per-descendant inbound-secret reseal needs the master key
threaded through the hooks + a secret-ref schema) and cleanly rejected at
apply for now, alongside a `materialized` ls column.
Completes the v1.2 near-term batch (M1-M5, cron+queue); group email is the one
scoped follow-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
materialize::rematerialize_stateful_templates reconciles a group cron/queue
template into one app-owned copy per descendant (materialized_from = template),
via the all-apps app_chain CTE ⋈ group-owned stateful templates. A precise
create/delete diff preserves cron last_fired_at on unchanged copies; a queue
copy is skipped-with-warning when the app already fills that queue's consumer
slot (the one-consumer invariant). Called full-live at the route-rebuild
chokepoints: apply (single + tree), app create/delete (apps_api), group
reparent (groups_api) — AppsState/GroupsState gain a pool. Pinned by
tests/stateful_templates.rs (per-app copy, idempotent, new-app materializes,
reparent de-materializes).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
0062 adds `materialized_from` on triggers (a managed app-owned copy links back
to its group template; CASCADE). The scheduler + queue-consumer queries gain
`AND t.app_id IS NOT NULL` so a group-owned stateful TEMPLATE is never
dispatched directly — only the per-descendant materialized app rows are.
validate_bundle_for + manifest parse now allow cron/queue on a group (email
stays rejected pending its per-app inbound-secret handling in M5.5). The
materialization reconcile that expands templates into app rows lands in M5.2.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
group_blobs_api mirrors the per-app kv_api/files_api for groups: GET
/groups/{id}/kv[/{c}/{k}], /docs[/{c}/{id}], /files[/{c}/{id}] — list +
fetch/download a group's §11.6 shared KV/docs/files. Authz GroupKvRead/
GroupDocsRead/GroupFilesRead (viewer tier, reads-open trust model). Read-only
by design (writes stay script-only). The host hoists the three group repos so
the SDK services and this router share one instance.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Global env-var ceilings enforced in the group write path (mirrors the
per-value caps): PICLOUD_GROUP_KV_MAX_ROWS / _DOCS_MAX_ROWS (per-group row
count, checked only on a NEW key/doc — updates exempt) and
_FILES_MAX_TOTAL_BYTES (per-group total blob bytes). New group_quota env
helpers; count_rows/total_bytes repo methods (trait-default 0 for non-Postgres);
QuotaExceeded error variants on the three group error enums; a with_max_rows
test builder. Pinned by a group_kv_service unit test (3rd key rejected, update
of an existing key at the cap allowed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- `shared` column in `pic triggers ls --group` (TriggerTemplateInfo + DTO +
report + renderer).
- manager-core/tests/shared_triggers.rs: a shared write matches the group's
shared trigger and NOT a same-named per-app trigger; a per-app write matches
the app trigger and NOT the shared one (the `shared` flag is the boundary).
- shared_triggers journey: declarative authoring applies, ls --group shows
shared, an undeclared-collection shared trigger + an app shared trigger are
rejected.
- docs: §11.6 + CLAUDE.md move shared-collection triggers from Deferred to
implemented.
Completes M2. (Pre-existing async-dispatcher timing flakes pass in isolation.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`shared = true` on a group [[triggers.kv|docs|files]] flows manifest → Bundle
→ insert_trigger_tx (new shared column), mirroring sealed: shared lives on the
Trigger DTO + is part of the apply diff identity (bundle + current sides) so a
toggle re-applies. validate_bundle_for rejects shared on an app owner, on a
non-collection kind (pubsub/cron/etc.), and on a concrete collection the group
does not declare as a shared collection of that kind. Emission (M2.3) now has
authored triggers to match.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ServiceEventEmitter gains emit_shared(cx, owning_group, event) (default no-op);
OutboxEventEmitter implements it — matches shared triggers on the owning group
and writes outbox rows stamped with the WRITER app_id (dispatch runs under the
writer). GroupKv/Docs/FilesServiceImpl gain an events field (Noop default +
with_events builder) and emit on set/create/update/delete; the host wires the
outbox emitter via with_events. Closes the "group trigger has no app to watch"
gap. Authoring + validation land in M2.4.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The per-app list_matching_kv/docs/files gain `AND t.shared = FALSE` (a shared
trigger never matches a per-app write). New list_matching_shared_{kv,docs,files}
select enabled `shared = TRUE` triggers on the OWNING group (glob+op filtered
in Rust), returning the same match types — no chain, no suppression anti-join.
Trait defaults return empty so non-Postgres impls degrade. Emission wiring in
M2.3.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A group-owned trigger marked `shared = true` watches a §11.6 shared
collection instead of per-app collections — the namespace boundary that lets
a shared-collection write fire a trigger. Mirrors the sealed column; existing
rows default false (per-app, unchanged). Match-query split + emission land in
M2.2/M2.3.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- dangling_suppress_warnings takes an ApplyOwner and walks the group's own
chain (GROUP_CHAIN_LEVELS_CTE) for a group node; runs for both owners.
- GET /groups/{id}/suppressions + `pic suppress ls --group` (client + cmd +
main.rs; --app/--group mutually exclusive).
- suppress journey: a child group declines a parent template for its subtree;
suppress ls --group shows the marker. Docs §4.5 + CLAUDE.md move group-level
suppression from Deferred to implemented.
Completes M1. (An unrelated pre-existing email_inbound dispatch-timing flake
passes in isolation.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Both suppression filters now match an owner's own OR any ancestor group's
suppression on the firing app's chain:
- trigger anti-join joins the `chain` CTE (ts.app_id = sc.app_owner OR
ts.group_id = sc.group_owner) instead of ts.app_id = $1;
- list_route_suppressions expands group-owned suppressions across descendants
via the all-apps app_chain CTE, yielding (effective_app_id, path) the rebuild
consumes unchanged.
A child group declining a parent template opts out its whole subtree; a sibling
subtree still inherits; sealed still overrides. Pinned by
tests/group_suppression.rs; per-app suppression + sealed regressions green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
suppression_repo takes a ScriptOwner (app or group), binding the matching
owner column. apply_service load_current / create / prune / suppression_report
all thread owner.as_script_owner(); the validate_bundle_for group-suppress
rejection and the manifest parse guard are dropped — a [group] may now declare
[suppress] to decline a template it inherits from a higher ancestor. No
runtime consumption effect yet (the chain filters land in M1.3/M1.4).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reshape the app-only suppression marker to a group/app polymorphic owner
(mirrors 0056/0057 + the 0051/0052 config markers): nullable group_id
(CASCADE), nullable app_id, exactly-one CHECK, per-owner partial unique
indexes. Lets a [group] decline a template it inherits from a higher
ancestor for its whole subtree; consumption filters land in M1.3/M1.4.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- `pic triggers ls --group` / `routes ls --group` gain a `sealed` column
(threaded through TriggerTemplateInfo/RouteTemplateInfo + the two DTOs).
- dangling_suppress_warnings now also warns when a suppress reference matches
only SEALED inherited templates ("... is sealed — the suppression has no
effect") via `bool_or(NOT sealed)` per handler/path — making the mandatory
guarantee observable at plan/apply time, alongside the existing typo guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The runtime effect: a sealed group template ignores a descendant's opt-out.
- Trigger dispatch: `AND t.sealed = FALSE` inside TRIGGER_SUPPRESSION_ANTIJOIN
— a sealed row is never excluded, so it fires through the suppression (one
edit covers list_matching_kv/docs/files + pubsub fan-out).
- Route rebuild: compile_effective_routes gates its suppression `continue` on
`!er.route.sealed`, so a sealed inherited route stays in the app's slice.
Pinned by tests/sealed_templates.rs (live-DB): a sealed trigger + route
survive an app's suppression of both; an unsealed sibling with the same
suppression is still declined — the gate is exactly `sealed`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Thread `sealed` from the manifest through to the DB column:
- manifest: `sealed` on ManifestRoute + the four event-kind trigger specs
(Kv/Docs/Files/Pubsub), flowing to Bundle via serde.
- persist: NewRoute.sealed + insert_route_tx; insert_trigger_tx `sealed`
param; the reconcile passes br.sealed / bt.sealed().
- validate_bundle_for rejects `sealed` on an app owner (meaningless — an
app route/trigger is never inherited).
- diff: `sealed` joins the route Update comparison and the trigger identity
(both bundle + current sides), so toggling it re-applies rather than NoOp.
`sealed` lives on shared Route + manager-core Trigger (both pure DTOs) so
the diff can see the current value; RouteRow/TriggerRow default it so
RETURNING clauses that omit it still hydrate. No runtime read effect yet —
the two suppression gates land in M3.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A group template can be marked `sealed = true` so the per-app suppression
filters skip it — closing the advisory-by-default compliance footgun the
suppression review flagged. Only meaningful on a group-owned template;
existing rows default unsealed. Consumption gates land in M3.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Visibility + typo guard.
- `pic suppress ls --app <a>` — read-only (kind, reference):
ApplyService::suppression_report(App) → GET /apps/{id}/suppressions
(viewer-tier AppRead), client + cmd + main.rs wiring.
- Dangling-suppress warning: at apply, a suppress reference that matches no
inherited template on the app's chain (no ancestor-group trigger bound to
that script name / no ancestor-group route at that path) emits an
ApplyReport warning — the suppression silently does nothing otherwise.
Soft (never fails the apply), reusing the existing warnings channel.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The runtime half — suppressions now actually decline inherited templates.
- Triggers (live, per-event): a correlated NOT EXISTS anti-join
(TRIGGER_SUPPRESSION_ANTIJOIN) appended to all four dispatch match
queries (list_matching_kv/docs/files + the pubsub fan-out). It excludes a
group-owned trigger whose handler script name the firing app suppresses;
`$1` is the firing app (already bound), and the `t.group_id IS NOT NULL`
guard keeps an app's OWN trigger unsuppressable.
- Routes (rebuild-time): compile_effective_routes takes a
`suppressed_paths: &HashSet<(AppId, path)>` and drops an inherited
(`depth > 0`) route at a suppressed path — the binding 404s.
rebuild_route_table loads the set via a new
RouteRepository::list_route_suppressions, so every existing rebuild edge
(route CRUD, apply, tree mutations) already applies it. No new
invalidation edges; the marker CASCADEs on app delete.
Pinned by tests/template_suppression.rs (live DB): a suppressing app
matches NEITHER the inherited trigger NOR route; a sibling that did not
suppress still inherits both; the app's OWN trigger on the suppressed
handler still fires (suppression is inheritance-only).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The declarative half — a `[suppress]` block persists as markers; no runtime
effect yet (S3 consumes them).
- manifest: `[suppress]` table on an app with `triggers = [...]` (handler
script names) + `routes = [...]` (paths), `deny_unknown_fields`; rejected
on a `[group]` (a group just wouldn't declare the template).
- suppression_repo.rs: app-keyed `list_for_app` / `insert` / `delete_tx`
over `(app_id, target_kind, reference)`, mirroring extension_point_repo
minus the owner polymorphism.
- apply_service: the extension-point marker-reconcile pattern —
`Bundle.suppress_triggers/_routes`, `Plan.suppressions`,
`CurrentState.suppressions`, `load_current(App)` load, `diff_suppressions`
(key `"{kind}:{reference}"`, split on the first `:` so a route param path
survives), create + prune reconcile blocks, `validate_bundle_for` group
reject, `ApplyReport` counters.
- CLI: `build_bundle` carries the two vecs; `pic plan` + apply report gain a
suppressions row (DTOs + display).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A group TRIGGER/ROUTE template inherits to every descendant app; until now
a descendant could shadow one but not decline it. This marker records that
an app opts OUT of a specific inherited template — coarse by REFERENCE (a
handler script name for triggers, a path for routes), not row id (template
ids churn on re-apply, references are stable so re-apply is a NoOp).
App-only (a group would just not declare the template) → app_id NOT NULL,
no polymorphic owner; pure app config → ON DELETE CASCADE. A target_kind
discriminator ('trigger'|'route') keeps it one table, one reconcile loop.
Schema blessed at 58 migrations.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two review findings.
1. After R3 every production rebuild path uses `compile_effective_routes`;
`compile_routes` (the old app-only compile) was called only by its own
two unit tests, and its "runs in build_app" doc was stale — so the
lenient-skip + disabled-drop regression guards no longer covered the
live path. Remove `compile_routes` + its re-export, fold the lenient-skip
rationale onto `compile_one`, and port both guards to
`compile_effective_routes` (app-only rows are just depth-0 effective
rows). Add a third guard pinning nearest-owner shadowing at the pure-
compile layer. Fix the stale `compile_routes` mention in apply_service.
2. Document the disabled + inherited semantic: the `enabled` filter runs
before the shadow check, so a disabled own-route does not claim its
binding and an enabled ancestor template at the same path falls through
("disabled = absent", §4.3). Recorded on `compile_effective_routes` and
in design §4.5 — the corollary (can't 404 an inherited route by
disabling a same-path own route) points at the deferred per-app opt-out.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Visibility + end-to-end coverage + docs for group route templates.
- ApplyService::route_report(Group) → GET /api/v1/admin/groups/{id}/routes
(viewer-tier GroupScriptsRead), surfacing a group's own route templates
(method, host, path, handler script, dispatch, enabled).
- CLI: `pic routes ls --group <g>` (the script-id positional now conflicts
with --group), client `group_routes_list` + RouteTemplateDto.
- Journey `group_routes.rs`: a group declares a `[[routes]]` template
binding a group handler; a DESCENDANT app serves it (via `routes match`
against the live RouteTable), a SIBLING-subtree app does not, `routes ls
--group` shows it, re-apply is a NoOp. Shadowing + isolation are
additionally pinned at the repo layer by group_route_templates.rs.
- Update the manifest unit test: a [group] now accepts route templates and
rejects only the stateful trigger kinds.
- Docs: design §4.5 (the live route-template model + full-live
invalidation + nearest-wins shadowing + deferrals), CLAUDE.md focus.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Open the declarative path so a [group] can declare route TEMPLATES.
- manifest.rs: drop the blanket "[group] cannot declare [[routes]]"
rejection (routes join the event trigger templates a group may own).
- apply_service:
- validate_bundle_for: remove the group routes rejection. A group
route's `script` is validated as binding a group-owned ENDPOINT via
`resolve_inherited_targets_for(Group)`, now extended to scan
bundle.routes as well as bundle.triggers — so a template binding the
group's own (declared or pre-existing) endpoint resolves, and one
pointing at a module or a missing name is a 422.
- insert_bundle_route takes a ScriptOwner (was app-only): a group node
writes a group_id route TEMPLATE, an app node an app-owned route.
- load_current(Group) loads the group's own routes via list_for_group
so re-apply is a NoOp and the template is prune-able.
Host-claim validation stays App-only (already guarded) — a template has
no single app; a descendant serves it on whatever host it claims (so
templates use host_kind = any in practice).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The runtime half. Group route TEMPLATES now expand into every descendant
app's in-memory match slice, live, with no per-app materialization.
- `compile_effective_routes` consumes the `list_effective` expansion and
applies NEAREST-OWNER-WINS shadowing: for one app, identical binding
tuples (method+host+path) owned at multiple chain levels collapse to the
nearest (an app's own route shadows an ancestor-group template); a nearer
group beats a farther one. Non-identical bindings coexist and the
existing matcher precedence resolves each request. `compile_route` now
takes the effective app_id, so the matcher + dispatch are unchanged.
- `rebuild_route_table` is the single refresh chokepoint (list_effective →
compile_effective_routes → replace_all). Route CRUD, the apply reconcile,
and startup all route through it; the apply guards drop the "a group node
touches no routes" assumption (a group apply may now create templates).
- FULL-LIVE INVALIDATION: app create/delete (apps_api) and group reparent
(groups_api) rebuild the snapshot, so inherited routes take effect the
instant the tree changes — matching the live model of triggers/vars.
Best-effort, mirroring apply: a failed rebuild self-heals on the next
write or restart, never failing the committed mutation.
The isolation boundary is the chain expansion: a template lands only in the
slices of apps whose ancestor chain contains the owning group. Pinned by a
deterministic live-DB integration test (descendant inherits, sibling
subtree does not, app shadows by identical binding, non-identical coexists).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the route layer owner-aware ahead of the live rebuild. `Route.app_id`
becomes Option + a `group_id` (mirrors `Trigger`); `NewRoute` carries a
`ScriptOwner` so the interactive API passes App and the reconcile can pass
a group. `insert_route_tx` writes both owner columns; `RouteRow` selects
`group_id` everywhere (the interactive delete/list 404 a group template,
which is apply-managed, not app-addressable).
Adds the two repo methods the live rebuild + apply need:
- `list_for_group` — a group's own route templates (apply diff, ls).
- `list_effective` — the all-apps generalization of CHAIN_LEVELS_CTE: for
every app, walk its ancestor chain and join routes owned at each level,
tagging each with the effective (firing) app + the owner's chain depth.
Runs only on a RouteTable rebuild, never per request; validated with
EXPLAIN against the live schema.
`compile_route` now takes the effective app_id explicitly (reused next
commit for the per-app expansion); `compile_routes` skips group templates
(no single app) — they materialize via the effective path. Runtime
behavior is unchanged this commit: the table still rebuilds from
`list_all`, and no group routes can exist yet (authoring lands in R4).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A group-owned route is a TEMPLATE, expanded into every descendant app's
in-memory RouteTable slice at rebuild (live, no materialization). The
schema reshape mirrors 0056_group_triggers exactly: a nullable group_id
FK→groups ON DELETE RESTRICT (a route template is a binding, not data),
app_id made nullable, an exactly-one owner CHECK, and the per-app unique
binding index split into per-owner partials. A group can't declare two
identical templates; an app declaring an identical binding is a
deliberate shadow resolved at rebuild (nearest-owner-wins), not a DB
conflict. Adds routes_group_id_idx for the expansion join.
Schema blessed at 57 migrations.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`get` can fetch any trigger by id, including a group-owned TEMPLATE. It
omitted `group_id` and leaned on `#[sqlx(default)]`, so a template would
hydrate with both owners None — silently breaking the exactly-one-owner
invariant in memory. No caller hits this today (interactive trigger
management is app-scoped; prune deletes by id), but the fetch now
round-trips the owner faithfully and stays honest for future callers.
`list_for_app` is left as-is: its rows are all app-owned, so group_id is
legitimately NULL there.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>