M1: introduces a clone-cheap InterceptorCtx { interceptors, self_engine,
limits } threaded into every SDK register fn (kv/docs/files/queue/pubsub/http)
so the non-KV services carry the hook seam (unused until M7-M11); KvHandle
collapses its three fields into one ictx.
M2: replaces the nearest-only resolver with ordered before/after chains. The
trait becomes resolve(cx, service, op) -> InterceptorChain { before, after }
(migration 0074 adds a phase column + phase-aware unique indexes). The before
-chain runs ancestor->app (depth DESC) so a group compliance guard can't be
bypassed by a descendant; single-marker behavior is byte-identical to before.
An identity cycle guard (thread-local visited-set keyed by script_id) denies a
detected cycle, alongside the existing binary re-entrancy break. Fail-closed
verdict preserved (allow only on #{ allowed: true }; a Dangling entry or a
missing engine back-ref denies); app_id still derives from cx.app_id only.
after-chains resolve but stay unused until M3. Pinned by two new interceptor
journeys (ancestor->app chain ordering; self-referential no-recurse).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A group declares a declaratively-authored [[triggers.dead_letter]] shared=true
handler; when a message in the group's SHARED queue is exhausted, the
dispatcher's q_terminal group branch (after persisting to group_dead_letters)
fans out to it via list_matching_shared_dead_letter(owning_group, "queue", …),
each outbox row stamped the WRITER app_id (the consuming app — the M2 shared
-write model), so the handler runs under the consumer. The per-app
list_matching_dead_letter gained AND t.shared = FALSE (the shared flag is the
namespace boundary); the owning-group filter is the isolation boundary.
Adds BundleTrigger::DeadLetter + a DeadLetterTriggerSpec manifest kind (group
+shared only — validate_bundle_for rejects app-owned or non-shared, and exempts
it from the shared-requires-a-collection rule); insert_trigger_tx now accepts
dead_letter and writes dead_letter_trigger_details; current_trigger_identity
matches a group-shared dead_letter so re-apply is a NoOp (app-owned ones stay
diff-invisible). Pinned by tests/shared_dead_letter.rs (owning group matches,
per-app query does not, foreign group does not).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Aggregates the existing execution_logs table (no hot-path instrumentation):
ExecutionLogRepository::summarize_for_app returns counts, error rate, latency
avg/p50/p95 (percentile_cont), by-status/by-source breakdowns, and an hourly
series over a trailing window (clamped 1h..90d). New metrics_api router at
GET /api/v1/admin/apps/{id}/metrics?window= (AppLogRead), and a dashboard
Metrics tab (summary cards + an inline-SVG per-hour bar chart, no JS dep).
Pinned by a manager-core test asserting the exact rollup (percentiles included)
and the empty-app zero case.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- roles: member invoke asserts the script's actual stdout, not just exit 0;
- api: app-slug script filter asserts the returned script's name;
- workflow_orchestrator: unknown-workflow asserts WorkflowError::NotFound,
not just is_err().
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extends the atomic_write suite with three write-path gaps:
- an app cannot exceed PICLOUD_APP_DOCS_MAX_ROWS (create-only, update
net-zero, delete frees a slot) via a real PostgresDocsWriter;
- concurrent writers cannot push a group past its docs total-bytes quota
(16 racers against a 450-byte ceiling under pg_advisory_xact_lock);
- a files create with a broken outbox leaves no row AND no blob on disk,
while a files delete with a broken outbox keeps both the row and the
readable blob (the disk-ordering invariant).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three related fixes from the test audit.
**The CLI journey fixture gets its own database.** It spawned a real picloud —
whose dispatcher/orchestrator claim loops are global by design (one instance owns
one database) — against the shared dev DB, so it could claim the manager-core
suites' outbox/workflow rows (the same class of bug already fixed for the e2e
suites, one binary over). It now clones one dedicated database per journey run
from the migrated template. test-support gains `named_test_db_url` (explicit
stable name) + a blocking wrapper for the sync `LazyLock` fixture. The one journey
that talks to Postgres directly (dead-letter injection) now uses the fixture's DB
URL, not the base DATABASE_URL, so it hits the database the server reads.
**workflow_orchestrator moves to per-test databases.** Its `claim_ready_step` is
global, so the old harness serialized every test behind a process-wide CLAIM_LOCK
AND ran `DELETE FROM workflow_runs` (unscoped — it wiped every app's runs) before
each one. A private database per test makes the global claim see only that test's
rows, so both the lock and the unscoped DELETE are deleted.
**DB-backed suites fail loud instead of skipping green.** ~15 manager-core suites
`return None` when DATABASE_URL is unset and report PASS — so in any environment
that lost its database the entire integration surface reports green while running
nothing (why the CI gap went unnoticed for so long). New
`picloud_test_support::abort_if_db_required` panics when `PICLOUD_REQUIRE_DB` is
set (CI now sets it) but DATABASE_URL is not, injected into each suite's skip
path. Local runs without the var still skip cleanly.
Mutation-verified: with PICLOUD_REQUIRE_DB=1 and DATABASE_URL unset, a suite
panics; without the var it skips.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`PICLOUD_SESSION_ABSOLUTE_TTL_HOURS` (audit C1, migration 0070) is the whole
mitigation for a stolen-but-warm admin token, and nothing tested it — grepping
`absolute_expires_at` across all tests returned zero hits.
New DB-backed suite pins the authoritative enforcement: the lookup filter
`absolute_expires_at > NOW()`. A session with a future sliding `expires_at` but a
past absolute cap must NOT resolve (the stolen-but-warm case), and `touch` cannot
resurrect it even when it pushes `expires_at` a year out — the filter is the
backstop regardless of the middleware clamp.
Mutation-verified: dropping the `absolute_expires_at > NOW()` predicate makes both
tests fail. Runs on a private database via picloud-test-support.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`resolve_owning_group`'s ancestor-chain CTE is the isolation boundary for EVERY
group shared collection — kv, docs, files, topics, queues. Its own doc says so.
Yet no test exercised the actual SQL: the tests that claimed to
(`unrelated_app_gets_collection_not_shared` in the three older group services,
`realtime_authority`'s SSE 404) inject a `FakeResolver` HashMap the test itself
populates — the foreign app resolves to `None` only because the test never
inserted it. They prove the service propagates a `None`; they cannot see the
query that produces it. Dropping `JOIN chain c ON gc.group_id = c.group_owner`
would let ANY app resolve ANY group's shared collection — a cross-tenant breach —
and the whole unit suite would stay green.
This drives the real query over a real tree: a descendant resolves, a
sibling-subtree app gets nothing, the kind filter is part of the boundary (a kv
and a docs collection of one name are distinct stores), nearest-declaration
shadows (security-relevant — a deep app must reach the nearer group's store), an
app-owned marker shares with nobody, and lookup is case-insensitive.
Mutation-verified: with the chain join dropped, the boundary assertion fires — and
under that breach `app_mid` resolves a FOREIGN group's `catalog` left in the DB by
another suite, i.e. the leak reproduces live.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Turning CI on surfaced `atomic_write` as intermittently failing under load —
self-inflicted. Its fault injection is `CREATE TRIGGER ... ON outbox`, which takes
an ACCESS EXCLUSIVE lock on a table every other suite is concurrently inserting
into. The header even claimed it "cannot affect any test running in parallel";
that was wrong — scoping the trigger by app_id bounds which ROWS it rejects, not
the table lock installing it takes. Shipping that alongside "run the DB tests in
CI" would have poisoned the signal.
The fix is the one already used for the e2e suites: a private database per test.
That logic (build a migrated template once, clone it per test via
`CREATE DATABASE ... TEMPLATE`) was duplicated in `picloud/tests/common`, and this
would have been a third copy — so it moves into a shared `picloud-test-support`
dev-dependency crate, and `picloud/tests/common` now re-exports it. The invariant
it encodes: manager-core's claim loops are global by design, so a test must not
share a database with anything that runs them OR with anything that does DDL.
Moved onto it:
- `atomic_write` — the DDL fault injection above; its per-test cleanup is now
deleted (the database is thrown away).
- `outbox_reclaim` — asserts `reclaim_stale_claims` returns exactly 0, a count
over the WHOLE outbox table. On the shared DB any stale row from any other suite
(or a killed picloud that died holding a claim) would fail it permanently, until
someone cleared the table by hand.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Self-review of this branch caught a data-loss bug I introduced with the
per-app / group files ceilings: the quota check ran AFTER the blob write.
`write_atomic_at` renames the new bytes over the FINAL path, so by the time
the ceiling refused an update the previous bytes were already gone. The
per-app path then unlinked the blob (row survives, file destroyed — every
read 404s); the group path left the new bytes in place under the old row's
checksum (every read fails `Corrupted`). Either way a user permanently lost
a file merely by exceeding a quota — a strictly worse outcome than the
unchecked-update bypass the ceiling was added to close.
The check now precedes the write on create AND update, per-app and group. As
a bonus this stops an over-quota caller driving unbounded write+unlink disk
churn: the ceiling now bounds I/O, not just stored bytes. The blob still goes
down inside the transaction, under the advisory lock, so a rollback unlinks
it and nothing can reference it in between.
The original test passed against the bug: it asserted the refused update did
not change the stored byte TOTAL — true, while the blob was already
destroyed. The regression test now reads the file back through the
checksum-verifying `FsFilesRepo::get`, and was confirmed to fail with
`Corrupted` against the old ordering.
Also in the KV writer (same file): drop the redundant pre-read on the hottest
write path. `set`/`set_if` did a SELECT purely to learn whether the write
would add a row, when the upsert already returns the previous value. Check
after the insert instead (`>` not `>=`) and let the transaction roll back on
refusal — identical outcome, one round-trip fewer, and an update pays nothing
at all. `kv_repo::get_on` and `FsFilesRepo::final_path` are now unused and
deleted; `FilesRepo::delete` delegates to the `delete_meta_on` + `unlink_blob`
helpers rather than re-implementing them.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Chasing the e2e flakiness turned up a production durability bug, not a test
bug.
The dispatcher claims an outbox row, executes it, then either deletes it
(success) or reschedules it (failure) — both of which clear the claim. If
the PROCESS DIES in between, neither runs. And `claim_due` only ever selects
`claimed_at IS NULL`. Nothing else in the codebase clears `outbox.claimed_at`
— grep it: there are exactly three writers, and those are two of them.
So a crash or restart mid-dispatch stranded every in-flight row PERMANENTLY.
Its trigger never fired and no retry could notice. The outbox is the
universal trigger path, so the loss covered kv / docs / files / cron /
pubsub / email / invoke_async / dead-letter alike. This is the same
durability class as the audit's #6 (lost trigger event), which was just
fixed at the WRITE end — this is the same hole at the READ end.
Every other claim-based store already had the safety net: `queue_messages`
and `group_queue_messages` have `reclaim_visibility_timeouts`,
`workflow_steps` has its own reclaim. The outbox was the one that didn't.
`OutboxRepo::reclaim_stale_claims(timeout)` returns rows whose claim is older
than `PICLOUD_OUTBOX_CLAIM_TIMEOUT_SEC` (default 600s), run from the
dispatcher's existing reclaim ticker. The default is deliberately generous —
a script may run for up to 300s (the `scripts.timeout_seconds` CHECK), so a
claim held past twice that is abandoned rather than slow; reclaiming a row a
LIVE dispatcher is still working on would double-execute it (survivable —
dispatch is at-least-once — but not worth courting).
A reclaim does NOT bump `attempt_count`: the handler never ran, so it must
not consume the row's retry budget, or repeated restarts would dead-letter an
event that executed zero times. (Same reasoning as the transient queue
`release` fixed earlier in this branch.)
This is also the root cause of the flaky e2e suites: each test drops its
dispatcher, and `claim_due` is not scoped per app or per dispatcher, so a
test's dispatcher could claim another test's row and strand it on teardown.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An app's OWN store had no ceiling of any kind — only the group-SHARED
collections did, which is the inversion of what you'd expect. And
`authz::script_gate` returns `Ok(())` when there is no principal, so a
PUBLIC UNAUTHENTICATED route could reach `files::collection(..).create(..)`
and write blobs in a loop until the disk filled. `PICLOUD_FILES_MAX_FILE_SIZE_BYTES`
caps ONE file at 100 MB; nothing capped the count. Same for KV keys and docs
against Postgres.
Ceilings: `PICLOUD_APP_KV_MAX_ROWS` / `PICLOUD_APP_DOCS_MAX_ROWS` (100k) and
`PICLOUD_APP_FILES_MAX_TOTAL_BYTES` (10 GiB).
They are enforced DIFFERENTLY from the group ones, on purpose — porting the
group design as-is would have been a serious regression:
* KV/docs check a ROW count, on INSERT only, with NO lock. `kv::set` is the
hottest write path in the system; taking a per-app advisory lock on every
set would serialize an app's entire data plane, and a `SUM(...)` scan would
make write cost grow with the app's stored size. What the lock buys is
small — unlocked overshoot is bounded by write concurrency (32) against a
ceiling of 100k, ~0.03%. These are anti-DoS rails, not billing. An update
adds no row and is bounded by the per-value cap, so it pays nothing at all.
* No per-app BYTE ceiling for KV/docs: every value is already capped at
`PICLOUD_KV_MAX_VALUE_BYTES`, so `max_rows x max_value_bytes` is ALREADY a
finite bound. A second scan would buy nothing.
* FILES are the exception and DO lock + sum: one blob may be 100 MB, so 32
racing uploads could overshoot by gigabytes of real disk, and a file write
is heavy enough that the lock and the SUM are lost in the noise. Checked on
create AND update — an update that skipped the ceiling would be a free
bypass (the same bug just fixed for group files: grow a 1-byte file to
100 MB, repeat).
`group_quota` is renamed `quota`: it now serves both owners, and the module
doc is where the two enforcement strategies are contrasted.
Pinned by tests/atomic_write.rs: the key ceiling refuses a new key while
still allowing an update at the ceiling (rejecting updates would brick an app
the moment it filled up — worse than the DoS the rail exists to stop), and 10
concurrent uploads cannot exceed the disk ceiling, nor can an update grow past it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Audit #6 and #8 for the last of the three stores, plus a bypass found on
the way.
**#6.** create/update/delete wrote the metadata row, then emitted
best-effort, so an outbox failure left a committed file whose trigger never
fired. `atomic_write::FilesWriter` commits the metadata row and the fan-out
together.
Files are the one store where the ordering is subtle, because the BYTES live
on disk and cannot join a transaction:
* create/update — blob first, then commit metadata + fan-out. A rollback
unlinks the blob. (A crash at that exact point still orphans it; that
hazard predates this change — the repo already wrote the blob and then
inserted the row in a separate, failable statement — and the orphan is
inert, referenced by nothing.)
* delete — commit the metadata removal + fan-out FIRST, then unlink. The
reverse order would destroy the bytes of a row that a rollback keeps,
leaving a file that can never be read.
**#8.** `GroupFilesService::create` read `total_bytes` on one connection and
wrote on another, so concurrent uploads each saw the same pre-write total and
together overshot the ceiling. This is the worst instance of the race in the
codebase: the ceiling is DISK (10 GiB by default) and one file may be 100 MB,
so a racing fleet overshoots by gigabytes. `PostgresGroupFilesWriter` takes
the per-group advisory lock (on its own `files` key) across the check and the
write.
**The bypass.** `GroupFilesService::update` checked NO quota at all — so a
1-byte file could be updated to a 100 MB one without the ceiling ever being
consulted, repeatedly, for unbounded disk. It now checks the projected total
(the replaced file's bytes subtracted in SQL, so a same-size-or-smaller
update near the cap still goes through).
Also drive-by: `queue_e2e` asserted the ack the instant the marker appeared,
but the marker is written DURING the handler and the ack happens after it
returns — a zero-tolerance race. It polls now. (This does not fix the
suite's flakiness, which reproduces on the pre-pass commit too.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Audit #6 and #8, applied to the docs store — the same two bugs KV had, in
the same two places.
Per-app docs (#6): create/update/delete wrote the row, then emitted
best-effort. An outbox failure left a committed doc whose trigger never
fired. They now go through `atomic_write::DocsWriter`, whose Postgres impl
writes and fans out on one connection in one transaction.
Group docs (#8): the service read `count_rows`/`projected_total_bytes` on
pooled connections and wrote on another, so concurrent creators each saw the
same pre-write count and together overshot the ceiling.
`PostgresGroupDocsWriter` takes a per-group advisory lock — on its OWN
`docs`-namespaced key, so it serializes against other docs writers to that
group but not against KV writers, which draw on a separate ceiling.
The bespoke `check_total_bytes` (with its own copy of the upper-bound fast
path) is gone; group docs now shares `group_quota::check_group_write` with
group KV, so the row ceiling, the projected-bytes ceiling, and the fast path
that skips the O(n) SUM scan have exactly one implementation between them.
tests/atomic_write.rs gains the docs row-quota race (16 concurrent creators
against a ceiling of 4).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Audit #8. `GroupKvServiceImpl` read the group's usage (`count_rows` /
`projected_total_bytes`) on one pooled connection and wrote on another. N
concurrent writers therefore each observed the same pre-write total, each
concluded it had room, and together sailed past the ceiling. The code
called this out as "best-effort ... quotas are safety rails, not exact
accounting" — but the overshoot is not small. With a ceiling of 5 and 20
concurrent writers, 19 rows land.
Route group-KV mutations through `atomic_write::GroupKvWriter`, whose
Postgres impl runs the quota reads, the row write, and the shared-trigger
fan-out on ONE connection in ONE transaction — and, crucially, takes a
per-group `pg_advisory_xact_lock` first.
The lock is the fix, not the transaction. Putting the check inside the
writing tx is necessary but NOT sufficient: under READ COMMITTED each
transaction's `COUNT(*)`/`SUM(...)` still sees a snapshot without the other
writers' uncommitted rows, so they all still pass. Serializing the
check-then-write per group is what makes a writer see its predecessor's row.
The lock key is namespaced per kind (kv/docs/…) so writes drawing on
different ceilings don't contend. A delete takes no lock — it only frees
space.
The quota POLICY (row ceiling, projected-bytes ceiling, and the
upper-bound fast path that skips the O(n) SUM scan for a group nowhere near
its cap) moves to one place, `group_quota::check_group_write`, over a small
`GroupUsage` trait. Both backends — the transactional one reading through
`&mut *tx`, and the in-memory one the unit tests use — share it, so there is
exactly one copy of the decision.
`set_if` gains a correctness nicety on the way: the row ceiling now keys on
whether the write ADDS a row, so a compare-and-swap against an absent key
with a `Some` precondition (which cannot swap, and so consumes nothing) is
no longer charged for one.
tests/atomic_write.rs pins both ceilings against 20/16 concurrent writers.
Both tests fail without the lock (19 rows stored against a ceiling of 5).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Audit #6. `KvServiceImpl` wrote the row, waited for it to commit, then
asked the emitter to resolve matching triggers and insert outbox rows —
a second transaction on a second connection. If that second step failed,
the row was permanently in the store with its trigger having never
fired: invisible to the caller (the write "succeeded"), unrecoverable by
any retry, and only ever logged. The code said as much:
// Audit finding (Medium): this is non-transactional with the data
// write — the row above has already committed by the time emit()
// runs, so an emit failure means triggers silently don't fire.
Introduce `atomic_write::KvWriter` — the mutating half of the service (the
write AND the fan-out it produces), so the two can share a transaction:
* `PostgresKvWriter` opens a tx, writes via `kv_repo::*_on(&mut *tx, …)`,
runs the fan-out on the SAME connection via `emit_on(&mut *tx, …)`, and
commits. An emit failure now rolls the write back and surfaces to the
script, which is the honest outcome — the caller learns the write did
not happen instead of silently getting a store that disagrees with its
triggers.
* `BestEffortKvWriter` keeps the old write-then-log-on-emit-failure
semantics for the in-memory unit tests (no Postgres).
Both sit behind one trait, so the service body has a single code path and
the choice is a constructor detail (`with_atomic_writes(pool)` in the host).
Pool-deadlock rule, documented on the module: everything inside the tx runs
on the tx's connection. A writer that held a tx and then reached for a
second pooled connection could starve — the pool is sized to the execution
concurrency cap, so N executions each wanting 2 connections deadlock. The
fan-out takes `&mut *tx` and never touches a repo.
`tests/atomic_write.rs` pins it by injecting an outbox failure (a Postgres
BEFORE-INSERT trigger scoped to the test's own app_id, so parallel tests are
unaffected): the set errors and the key is NOT in the store; likewise a
delete rolls back rather than dropping a key nothing downstream hears about.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A group's template suppression is "coarse by reference" (path for routes,
handler-name for triggers), and both resolution points dropped ANY inherited
row matching that reference across the whole subtree — including one a NEARER
descendant group deliberately re-declared. So an ancestor group G that declines
a far-ancestor's `/x` (or `audit` handler) would also silently kill a child
group H's OWN `/x` / `audit`-bound trigger at the same reference, violating the
documented "an owner can only decline what it inherits, never a descendant's
own rows" invariant.
Fix: gate each decline on the chain DEPTH of the suppressor vs the target's
owner — a suppressor at depth `d_s` may only decline a row whose owner is
strictly ABOVE it (`target_depth > d_s`):
- Routes: `list_route_suppressions` now returns `(app, path, suppressor_depth)`;
the rebuild folds it to the min depth per `(app, path)` and
`compile_effective_routes` skips an inherited route only when
`route.depth > suppressor_depth`. (This also subsumes the old `depth > 0`
inherited-only gate.)
- Triggers: the dispatch anti-join gains `AND sc.depth < c.depth` (the
suppressor's chain depth below the trigger owner's), correlating on the outer
`chain c` that every kv/docs/files/pubsub match query already binds.
An app's own suppression is depth 0 → still declines anything it inherits; a
group's suppression declines only what that group itself inherits. `sealed`
still overrides. No schema change.
Pinned by a new `compile_effective_routes` unit test (a depth-1 descendant
route survives a depth-2 suppression that declines a depth-3 template) and a
new `group_suppression` DB test (same for triggers); all existing suppression /
sealed / template journeys stay green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`claim` pre-increments `attempt` before the handler runs, so a real execution
failure legitimately counts toward `max_attempts`. But the two TRANSIENT
release paths — gate saturation (server at capacity) and script-disabled-at-
fire-time — re-queue the message WITHOUT executing it, and previously used the
same `nack` that leaves the pre-increment in place. Under sustained overload a
message could therefore be dead-lettered after N gate-rejections having run
zero times (each rejection burning a retry).
Add a non-counting `release` to both `QueueRepo` and `GroupQueueRepo`
(re-queue + `attempt = GREATEST(attempt - 1, 0)`, mirroring `nack` otherwise)
and route the two transient paths through a new `Dispatcher::q_release`. A real
handler failure still uses `nack` and counts. No schema change.
Pinned by a new `queue_release` DB test (release undoes the claim increment;
nack keeps it) and the updated `disabled_queue_consumer_...` dispatcher test
(now asserts a release, not a nack).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Smallest honest vertical slice of §9.4: a `[[interceptors]]` block (app OR
group) binds a script to run BEFORE `kv::set`/`delete`; it reads the operation
context (`ctx.request.body`: service, action, collection, key, value, caller
ids) and returns `#{ allowed, reason }` — `allowed == false` denies the op (the
write never runs, the caller gets a runtime error).
Reuses two existing mechanisms rather than inventing new ones:
- Registration mirrors extension points (§5.5): a marker table
`0073_interceptors.sql` (owner-polymorphic app_id/group_id XOR, keyed
(service, op) → script), `interceptor_repo` (insert/delete/list + the
nearest-owner-wins `resolve_before` chain walk), reconciled through the
declarative apply exactly like `vars` (create/update/delete, prunable).
- Execution reuses the `invoke()` re-entry path: the new `InterceptorService`
(shared trait + Postgres-backed impl) only RESOLVES the script name (keeping
executor-core Postgres-free); the executor's `sdk::interceptor::run_before`
resolves that name and runs it via `run_resolved_blocking` (extracted from
`invoke_blocking` — shared depth bound + AST cache). An un-hooked write pays
one indexed `Ok(None)` resolve; no interceptor ⇒ zero overhead.
Nearest-owner-wins so an app overrides a group's interceptor, and a group
interceptor is inherited by every descendant app — the chain walk is the
isolation boundary (a sibling subtree never matches). `validate_bundle_for`
restricts the MVP to `service = "kv"`, `op ∈ {set, delete}`, one marker per
(service, op).
Deferred (documented in §9.4): the `data` transform return, services other
than kv, `after_*` hooks, chaining + circular-dependency guard, the timeout
policy, and a `pic interceptors ls` read surface (needs a server route).
Pinned by `tests/interceptors.rs` (deny blocks the write; allow passes;
group→app inheritance), schema snapshot re-blessed. 154/154 journeys pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adversarial review of the v1.2 Workflows track surfaced two HIGH footguns and
several correctness/hardening gaps. Fixes:
HIGH
- pull round-trip: `pic pull` dropped `[[workflows]]`, so a later
`apply --prune` silently deleted them. The list endpoint now returns the full
`definition`; pull rebuilds the manifest block (inverse of workflow_to_wire).
- case-colliding names: two workflows differing only by case collided in the
reconcile diff (keyed by lower(name)), silently dropping one. Rejected up
front in validate_bundle_for.
MEDIUM
- reclaim retry budget: a crashed attempt (no outcome) consumed the retry
budget. reclaim_stale_steps now decrements `attempt` (floored) and clears
`next_attempt_at`, so a crash no longer counts as a failed try.
- on_error/backoff: typed the manifest fields against the shared enums so a
bad value fails at TOML parse with a clear message, not an opaque 500.
- dedupe depends_on: a repeated dependency inflated the Kahn in-degree past the
single decrement, reporting a spurious cycle for a valid DAG. Count distinct.
- canceled child: a canceled sub-workflow resolved the parent step with a
message naming the cause instead of a generic "failed"; documented that a
run-level cancel op is not yet supported.
LOW
- list_run_steps is now app-scoped at the query (JOIN workflow_runs) rather
than relying on caller pre-verification.
- partial failure is surfaced: a run that succeeds with on_error=continue
failures records them in the run's `error` field.
- admin-started runs log the root_execution_id against the principal.
- documented the deliberate when(missing→false) vs template(missing→fail)
asymmetry; corrected the claim's atomicity comment.
Tests: unit (dedupe-deps), DB-gated reclaim-budget assertion, and two new CLI
journeys (duplicate-name rejection, pull→plan clean round-trip). fmt + clippy
-D warnings clean; 450 lib + 14 orchestrator DB + 5 workflow journeys pass;
schema snapshot unchanged (no migration).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The user-facing surface for the durable DAG engine. A workflow run can now be
started three ways, all resolving the workflow by name in the caller's app
(`cx.app_id` / the resolved app — never a passed-in arg, the isolation
boundary), seeding a run the orchestrator advances.
SDK seam (shared):
- `WorkflowService` trait + `NoopWorkflowService` + `WorkflowError` in
`shared::workflow` (mirrors `InvokeService`); added to `Services` as a
noop-defaulted `with_workflow` builder (all `Services::new` call sites
unchanged).
- `WorkflowServiceImpl` (manager-core): resolves + seeds a run; authenticated
callers gated on `AppInvoke` (anonymous skips, script-as-gate), the same gate
`invoke()` uses.
- `executor-core::sdk::workflow` — the Rhai bridge `workflow::start(name, input)`
/ `workflow::start(name)`, registered in `register_all`. Returns the run id.
Admin API (manager-core `workflows_api`, mounted in the binary):
- `GET /apps/{app}/workflows` — definitions (AppRead)
- `POST /apps/{app}/workflows/{name}/runs` — start a run (AppInvoke)
- `GET /apps/{app}/workflows/{name}/runs` — run history (AppRead)
- `GET /apps/{app}/workflow-runs/{run_id}` — one run + its steps (AppRead)
`app_id` scopes every query; a foreign run 404s.
CLI (`pic workflows`): `ls` · `run <name> [--input JSON]` · `runs <name>` ·
`run-status <run_id>` — all `--app`-scoped; run definitions stay declarative
(`[[workflows]]` → `pic apply`).
Also: `list_runs_for_workflow` repo reader.
Tests: `workflow_service_start_seeds_a_run` (DB-gated — the SDK/API-shared
entry) + `workflow_run_executes_end_to_end` CLI journey (apply → `pic workflows
run` → poll `run-status` → succeeded, through the real binary + orchestrator).
fmt + clippy -D warnings clean, 449 manager-core lib tests, 14 orchestrator DB
tests, 18 executor-core lib tests, 3 workflow CLI journeys green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A `workflow`-kind step now starts a nested sub-workflow run instead of a
function. The orchestrator's step processing is restructured into prepare →
dispatch (`Prep`): after the shared context/`when`/input resolution, a
function step runs through the executor as before, while a workflow step:
- checks the nesting depth ceiling (`PICLOUD_WORKFLOW_MAX_DEPTH`; the child
would run at parent depth + 1) — over-deep → the step fails, not infinite;
- resolves the child workflow by name in the run's app scope;
- `start_child_and_park`: in one token-gated tx, seeds a child run (depth + 1,
`parent_run_id`/`parent_step_id` linkage, correlated under the same
`root_execution_id`) and PARKS the parent step (`running`, claim_token
cleared, `child_run_id` set). A parked step is never re-claimed (only `ready`
steps are) nor reclaimed (only *leased* running steps). The token-gated park
runs before the child insert, so a stale claim writes nothing (no orphan).
Each tick first runs `resume_finished_children`: a parent step parked on a
now-terminal child is resolved (child output → parent step output, or child
error → parent step failed) and the parent run advanced — idempotent via a
`status='running'` gate. The child's own steps are claimed by the same global
scan, so nesting is just more runs. A failed sub-workflow honors the parent
step's `on_error`.
Shared plumbing extracted for reuse: `advance_run_tx` (out of
`complete_step_and_advance`) and `seed_run_tx` (out of `start_run`).
Apply-time soft-warn (`workflow_nesting_warnings`, wired into `plan_warnings`):
a workflow that nests into itself — directly or via a mutual cycle within the
bundle — is flagged at plan (bounded by the depth ceiling, but almost always a
bug). Not an error.
Tests: nested output-flows-to-parent + depth-ceiling-fails-the-run (DB-gated,
end-to-end), self/mutual-cycle warning (pure unit). fmt + clippy -D warnings
clean, 449 manager-core lib tests, 13 orchestrator DB tests, 26 CLI journeys
(workflows/apply/plan/prune) green, binary boots.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wire the two pure M1 modules into the orchestrator's execute path:
- Before running a step, build a `RunContext` from the run input + every prior
succeeded step's output (`load_run_step_outputs`, read fresh at execution
time so a step sees all upstream results).
- `workflow_expr::eval` the step's `when` condition (if any): false → the step
is `skipped` — never executed — and counts as satisfied-but-empty for its
dependents (a new `StepOutcome::Skipped`, written in `complete_step_and_advance`
then advanced; `compute_advance` already treats skipped as satisfying deps).
- `workflow_template::resolve` the step's `input` against the context: an
exact single `{{ ref }}` preserves the referenced JSON type, an embedded ref
interpolates as text; a missing reference is a hard step failure (a definition
bug surfaced, not hidden), never a silent null.
`when` and templates were already parse-validated at apply time (M1); this is
the runtime half.
Tests (DB-gated, end-to-end via a scripted fake executor):
- `when_false_skips_step` — b skipped, never executed, omitted from run output
- `step_output_flows_into_downstream_input` — a's `{n:7}` feeds b's input,
type-preserved for a bare ref and interpolated in a larger string
- `missing_input_ref_fails_the_step` — an unresolved ref fails b → run fails
Verified: cargo fmt, clippy -D warnings clean, 448 manager-core lib tests,
11 workflow_orchestrator DB tests (3 new).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The v1.2 Workflows durable DAG engine gains its runtime. A dedicated
background worker (`workflow_orchestrator.rs`, mirroring `cron_scheduler`,
NOT folded into the dispatcher) advances every in-flight run step-by-step,
durably, surviving restarts.
Per tick, two phases:
A. Claim + execute — up to a small batch of `ready`, due steps are claimed
with the same `FOR UPDATE SKIP LOCKED` competing-consumer lease the queue
uses (`claim_ready_step`), one execution-gate permit per step acquired
BEFORE the claim so the shared gate bounds real parallelism. Each step
resolves its function by name in the run's app scope (never a
script-passed arg — the isolation boundary), builds an `ExecRequest`, and
runs through the injected `ExecutorClient`. Claimed steps run concurrently.
B. Advance — the outcome is written and the DAG advanced in one token-gated
transaction (`complete_step_and_advance`): pending steps whose deps are
satisfied flip to `ready`, and the run's terminal status is recomputed.
Fan-in falls out (a join waits until its last dep flips it); a stale worker
matches zero rows and writes nothing.
The graph-advance decision is a pure, DB-free function (`compute_advance`) —
promotions + terminal run status, folding `on_error` fail-vs-continue and
skipped/failed dependency satisfaction — so it is unit-tested in isolation.
Retry uses the step's own policy via `compute_backoff`; a second, slower
cadence reclaims steps leased by a crashed worker (`reclaim_stale_steps`) — the
durability safety net. Steps run with no principal (like invoke_async), and log
under the new `ExecutionSource::Workflow` so `pic logs` surfaces them.
M2 executes function steps only; `when` + input templating land in M3, nested
sub-workflows in M4. Seams present (`StepTarget`, `run_input`, `workflow_depth`).
- migration 0072: widen the `execution_logs.source` CHECK with `workflow`
- shared: `ExecutionSource::Workflow`, `StepStatus::is_terminal`
- workflow_repo: run/step state — `start_run`, `claim_ready_step`,
`complete_step_and_advance`, `reclaim_stale_steps`, `get_run`,
`list_run_steps`, `compute_advance` (+ 7 pure unit tests)
- workflow_orchestrator: the worker + config (`PICLOUD_WORKFLOW_*` knobs),
spawned in `picloud/src/lib.rs` beside the dispatcher/cron scheduler
- tests: 8 DB-gated integration tests (linear, parallel fan-out/fan-in, retry,
on_error fail/continue, double-complete idempotency, stale-lease reclaim,
end-to-end tick with a fake executor)
Verified: cargo fmt, clippy -D warnings clean, 448 manager-core lib tests,
8 workflow_orchestrator DB tests, schema snapshot reblessed, M1 workflow CLI
journeys still pass (binary boots with the orchestrator wired in).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First milestone of the v1.2 Workflows track (blueprint §9.1/§9.2): the durable
DAG engine's foundation — the definition model, its validation, and the
declarative `apply` reconcile path. No execution yet (the orchestrator is M2).
- Migration 0071_workflows: `workflows` (owner-polymorphic like scripts, app-
owned in M1), `workflow_runs`, and `workflow_run_steps` (the per-step
competing-consumer lease table the M2 orchestrator will claim). Schema golden
reblessed.
- `shared::workflow`: the `WorkflowDefinition` / `WorkflowStepDef` DTOs (shared
by the CLI, apply, and the future orchestrator) + run/step status enums.
- Pure, DB-free `workflow_template` (input `{{ input.x }}` / `{{ steps.a.output.y }}`
resolution, type-preserving) and `workflow_expr` (a small safe JSON-predicate
evaluator for `when` — keeps manager-core free of a scripting engine).
- `workflow_repo`: read trait + reconcile tx free-fns (insert/update/delete).
- apply_service: `BundleWorkflow` + `Plan.workflows` + `CurrentState.workflows`
+ `ApplyReport.workflows_*`; server-side `validate_workflow_definition`
(unique/acyclic steps via topological sort, deps exist, function XOR workflow,
`when`/template parse) rejecting on a group node; `diff_workflows` by
lower(name) (Update on a definition change) + in-tx reconcile.
- CLI: `[[workflows]]` + `[[workflows.steps]]` manifest structs → wire bundle;
plan/apply render + report counts.
- Tests: 16 manager-core lib tests (template, expr, definition validation) +
the `workflows` CLI journey (apply → NoOp → prune; cyclic DAG rejected).
Deferred to later milestones: the orchestrator worker (M2), conditional/template
runtime wiring (M3), nested sub-workflows (M4), the SDK/API/CLI run surface
(M5), the dashboard (M6). The `group_id` column + run/step tables ship now so
those slot in without a migration churn.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remediate the HIGH and security-relevant findings from the 2026-07-11 audit.
H1 — the per-env approval gate is now server-authoritative. The governing
project is resolved from the target node's nearest-claimed ancestor
(`governing_env_policy`/`_tree` + `governing_project_id` +
`ProjectRepository::get_environments_by_id`), independent of the client-supplied
`[project]`. Omitting or spoofing the project block can no longer skip a gate the
owning project established; a to-create group resolves its declared parent's
chain so a fresh subtree node inherits the gate. Fails closed on any read error.
H2 — the API-key prefix slice (`&rest[..8]`) is now the boundary-safe
`rest.get(..8)`, so an attacker-supplied multibyte bearer can't panic the request
task (unauthenticated per-request DoS). Regression test added.
C1 — admin sessions gain an absolute lifetime cap (migration 0070,
`PICLOUD_SESSION_ABSOLUTE_TTL_HOURS`, default 30d): `lookup` filters it, `touch`
clamps the sliding bump to it, so a continuously-used or stolen-but-warm token
self-expires. Mirrors the data-plane app-user cap.
C2 — `Cache-Control: no-store` on the login and API-key-mint responses (the two
that return a raw credential), so a proxy/CDN/browser cache can't retain it.
B8 — file downloads are header-safe: `sanitize_stored_filename` guarantees a
valid `HeaderValue` (no panic on a control-char name) and BOTH the per-app and
group download paths now set attachment + `X-Content-Type-Options: nosniff` +
a restrictive CSP, closing a group-path stored-XSS gap.
Also folds in the server-side plan-warning plumbing (`plan_warnings`,
`PlanResult::warnings`) and the `app_only_reject` message helper that the CLI
plan-preview change builds on, plus operator security notes (reads-open shared-
topic SSE; the `--env` label is advisory, not a boundary).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
email_trigger_details.inbound_secret_encrypted was sealed v0 (no AAD, bound only
to the master key) because the table had no version column — a ciphertext could
in principle be relocated between rows (audit 2026-06-11 H-D1, Medium). Bring the
AAD versioning the `secrets` table gained in 0042 to email secrets.
- Migration 0069: `email_trigger_details.inbound_secret_version SMALLINT DEFAULT 0`.
- secrets_service: `seal_email`/`open_email` seal v1 with AAD bound to the
SEALING OWNER (`email:{app}` / `email:group:{group}`) — deliberately NOT the
per-row trigger_id, so a group template's sealed bytes stay valid when the
materializer copies them verbatim to each descendant (all share the group AAD).
v0 rows keep their exact legacy no-AAD read path.
- Both email-trigger create paths (declarative apply resolve_and_seal +
triggers_api create_email_trigger) seal v1 under the trigger's owner; the
version threads through CreateEmailTrigger + insert_email_trigger_tx.
- materialize copies inbound_secret_version verbatim with the bytes.
- email_inbound_target recovers the sealing owner (materialized_from ->
template.group_id, else the app) so receive_inbound_email opens a v1 secret
under the right AAD.
Cross-app/tenant relocation now fails the GCM tag; a same-owner swap is not
distinguished (accepted low-severity residual). Pinned by
email_secret_aad::materialized_email_copy_decrypts_under_the_group_aad (the
novel materialized-copy path) + the extended secret_round_trips_through_seal_open
lib test. Schema golden reblessed; materialization + email e2e regressions green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An exhausted SHARED durable-queue message was `drop_exhausted()`-ed with a
warning — silent data loss. Per-app queues persist to `dead_letters` (0010);
add the symmetric group store so an exhausted shared-queue message is preserved
and operator-visible.
- Migration 0068: `group_dead_letters`, keyed by (group_id, collection),
CASCADE on the group (an app delete leaves the data — it belongs to the
group, not the consuming app).
- `GroupQueueRepo::dead_letter` (replaces `drop_exhausted`): one tx that INSERTs
the dead-letter + DELETEs the live message, filtered by claim_token so a lost
lease can't dead-letter a re-claimed message (mirrors queue_repo::dead_letter).
- Dispatcher `q_terminal` shared arm now dead-letters instead of dropping. It
returns None (not the dl id) so the per-app `fan_out_dead_letter` is SKIPPED —
firing the consuming app's per-app handlers on a shared message (competing
consumers → nondeterministic app) would be wrong. Fan-out to a *shared*
dead_letter trigger is deferred (needs a new trigger kind).
- Read side: `GroupDeadLetterRepo::list_for_group` backs a new read-only
operator endpoint GET /api/v1/admin/groups/{id}/dead-letters (GroupKvRead,
mirrors the M4 group-blobs surface). `pic dead-letters ls --group` deferred
(optional; the HTTP endpoint is the operator surface).
Pinned by group_queue::dead_letter_moves_an_exhausted_message_to_the_group_store
(store move + claim-token-mismatch guard + operator read). Schema golden
reblessed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The per-env approval policy was applier-supplied — a hand-crafted request
that omitted `project.environments` was ungated, and flipping a gate to
`confirm = false` in the same request un-gated it. Persist the policy
server-side and enforce against `persisted ∪ declared`.
- Migration 0067: `project_environments (project_id, env_name, confirm)`,
CASCADE on the project. Written declaratively (delete-then-insert) inside
`upsert_project_tx`, same tx as the project row + node claim.
- `ProjectRepository::get_environments_by_slug` (read side) +
`ApplyService::effective_env_policy` union the persisted policy with the
request's declared one (confirm if EITHER says so — monotonic).
- `env_gate_check` now evaluates the effective policy; the three handlers load
it before the gate. `plan.approvals_required` is the effective gated set, so
CI sees a persisted gate even when the manifest omits it.
- The union rule closes both bypasses: an omitted policy still trips a
persisted gate, and an ungating apply must itself pass the gate (the
declarative replace then takes effect next time) — TOCTOU-safe.
Pinned by env_approval::{persisted_policy_gates_a_request_that_omits_it,
flipping_a_gate_to_false_in_the_same_request_still_gates} +
projects_repo::get_environments_by_slug_returns_persisted_policy. Schema golden
reblessed.
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>
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>
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>
- 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>
- `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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- apply_service: trigger_report(group) → TriggerTemplateInfo
(kind/target/script/enabled); resolve_inherited_targets_for(Group) now
surfaces the group's OWN endpoint scripts so a template's handler
validates (fixes "binds to unknown script" when the handler is a
pre-existing group script, not declared in the same manifest).
- apply_api: GET /groups/{id}/triggers (GroupScriptsRead).
- CLI: `pic triggers ls --group <g>` (--app/--group mutually exclusive)
+ the client method + DTO.
- tests/group_trigger_templates.rs (manager-core, live DB): the chain
union matches a descendant app's kv insert against the group template
and NOT a sibling subtree — the isolation boundary, deterministic.
- tests/group_triggers.rs (journey): apply a kv template, ls --group
shows it, re-apply NoOp, cron-on-group rejected.
- docs: design §4.5 (live-event-kinds decision + deferrals), CLAUDE.md.
Full journey suite 119/119; workspace tests 0 failures; clippy -D clean;
schema unchanged (blessed in T1).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>