Commit Graph

153 Commits

Author SHA1 Message Date
MechaCat02
adc719975f style: fmt + clippy cleanup after Phase C
cargo fmt regroups; three clippy fixes:
- F-S-006: hoist MAX_API_KEY_CANDIDATES to module scope to satisfy
  clippy::items_after_statements.
- F-S-009: use map_or instead of map().unwrap_or() per
  clippy::map_unwrap_or.
- F-P-012: replace `|c| c.encode()` closure with the method itself
  per clippy::redundant_closure_for_method_calls.

No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 21:19:00 +02:00
MechaCat02
f2b16da2b5 fix(manager-core): F-S-012 add AppInvoke capability + gate invoke() / invoke_async()
invoke_service::resolve and enqueue_async performed no authz check —
no AppInvoke capability existed. Same-app isolation was preserved
(cross-app guards work), but within one app an anonymous public-HTTP
script could trigger any other script (e.g. an admin-only worker that
hits secrets/files/external HTTP). Worse: invoke_async runs the
callee with principal: None, so the callee could hold capabilities
the original public caller shouldn't.

- Add Capability::AppInvoke(AppId). app_id() / scope_for_capability
  (script:write) / role_satisfies (editor+) are all updated.
- InvokeServiceImpl gains an optional `authz: Option<Arc<dyn AuthzRepo>>`
  + a `with_authz` builder. When set, resolve() runs script_gate on
  AppInvoke before doing the cross-app id check.
- picloud/src/lib.rs wires it: `InvokeServiceImpl::new(...).with_authz(...)`.
- Anonymous callers (cx.principal == None) continue to skip the check
  via script_gate, preserving the public-HTTP convention.

Existing 5 invoke_service unit tests still pass (the tests use the
authz-less constructor, so the gate is a no-op there).

AUDIT.md anchor: F-S-012.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 21:13:45 +02:00
MechaCat02
1911691420 fix(manager-core): F-Q-011 blanket Arc<T: ScriptRepository> impl; delete PostgresScriptRepoHandle
A 70-line newtype lived in picloud/src/lib.rs wrapping
Arc<PostgresScriptRepository> and re-implementing every ScriptRepository
method as `self.0.method(...).await`. Hand-delegation invited silent
skew when the trait gained a method, and forced 8 call sites to know
about the wrapper.

Add a blanket
  impl<T: ScriptRepository + ?Sized> ScriptRepository for Arc<T>
in manager-core::repo. The implementations forward via (**self).method(...)
so any owner of an Arc<dyn ScriptRepository> (or Arc<ConcreteImpl>) can
be passed where the trait is expected.

Migrate the 7 picloud-binary call sites:
  Arc::new(PostgresScriptRepoHandle(script_repo.clone())) → script_repo.clone()

Delete the newtype + its hand-delegated impl. Leaves a 6-line comment
documenting why the boilerplate is gone.

AUDIT.md anchor: F-Q-011.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 21:10:46 +02:00
MechaCat02
73d3befb06 fix(manager-core): F-Q-009 promote dispatcher tick + async-exec timeout to env-overridable
TICK_INTERVAL (100ms) and ASYNC_EXEC_TIMEOUT (300s) were `const`. Every
other timing knob in the file (cron tick, queue reclaim, retry policy)
is env-overridable via TriggerConfig::from_env. Operators on a
constrained Pi or a busier instance could tune retries but not
dispatcher cadence.

Add env knobs:
- PICLOUD_DISPATCHER_TICK_INTERVAL_MS (default 100)
- PICLOUD_DISPATCHER_ASYNC_EXEC_TIMEOUT_SEC (default 300)

Read via tick_interval_from_env() / async_exec_timeout_from_env() at
dispatcher startup. Invalid values fall back with a tracing-warn.

AUDIT.md anchor: F-Q-009.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 21:04:13 +02:00
MechaCat02
59d4c043b0 fix(http): F-Q-008 add HttpError::InvalidArgs for user-input validation failures
http_service::run mapped `Method::from_bytes` failure on a user-supplied
HTTP method to HttpError::Backend with format!("invalid method: {}", …).
Same for HeaderName/HeaderValue validation in build_headers. But that's
user input, not a backend problem — misclassifying as Backend corrupts
retry policies (operators may retry "backend" but not "invalid input").

Add a new HttpError::InvalidArgs(String) variant. Reroute the three
validation paths in http_service (method, header name, header value) to
emit InvalidArgs instead of Backend.

The executor-side `map_http_err` uses Display, so the new variant
surfaces to scripts as "http: invalid method: …" — same format, more
structured behind it.

AUDIT.md anchor: F-Q-008.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 21:02:57 +02:00
MechaCat02
6b5da50a38 fix(orchestrator-core): F-Q-006 + F-Q-010 InboxRegistry expect on poison
InboxRegistry::register returned (Uuid, Receiver) even when the inner
Mutex was poisoned — `if let Ok(mut g) = self.inner.lock()` swallowed
the error, the sender was never inserted, the later deliver(id, …)
saw no entry and returned Abandoned, and the caller's `await rx`
blocked until the orchestrator's outer timeout.

Sister registries (RouteTable, AppDomainTable) already .expect()
panic on poison. Make InboxRegistry::{register, cancel, deliver} loud
in the same way.

A poisoned Mutex means a prior panic inside a critical section — the
process is unrecoverable; silently swallowing it just defers the
crash and corrupts the inbox protocol on the way.

AUDIT.md anchors: F-Q-006 (register), F-Q-010 (consistency policy).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 20:52:05 +02:00
MechaCat02
547c9f9950 fix(executor-core): F-P-014 thread-local LRU regex cache (128 entries)
regex::is_match, find, find_all, replace, replace_all, split, captures
all called Regex::new(pattern) per invocation. A script doing
`regex::is_match("\\d+", x)` in a tight loop paid the compile every
iteration. Regex compile dominates is_match on short strings.

Add a thread-local LruCache<String, Arc<Regex>> (cap 128). The
compile helper now memoises by pattern string, returning Arc<Regex>
on hit. Cap is well above what any sensible script uses but bounded
enough to keep memory predictable on long-running threads.

AUDIT.md anchor: F-P-014.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 20:51:20 +02:00
MechaCat02
fea95bd63b fix(manager-core): F-P-012 keyset cursor on app_user_repo::list (created_at, id) tiebreaker
ORDER BY created_at DESC, id DESC but cursor was `WHERE created_at <
$2` — when two users were created at the same instant, pagination
could skip the second row at a page boundary or return it twice.

- Add ListCursor { created_at, id } with `<rfc3339>_<uuid>` encode /
  decode helpers.
- Change WHERE predicate to `(created_at, id) < ($2, $3)` matching the
  ORDER BY.
- Surface the opaque cursor string through UsersListOpts /
  UsersListPage / users_admin_api ListUsersResponse instead of the raw
  DateTime — keeps the wire format stable while the id half stops the
  boundary bug.

Same shape as F-P-005 (execution_logs).

AUDIT.md anchor: F-P-012.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 20:50:25 +02:00
MechaCat02
9cd1213aac fix(manager-core): F-S-013 partial unique index on app_user_invitations pending rows
No unique constraint on (app_id, lower(email)) for pending rows meant
calling users::invite("alice@…") N times created N rows with N valid
tokens. Combined with accept_invite returning Ok(None) silently when
the email already exists, an attacker who learned one token could
permanently consume it without effect.

Migration 0040 adds a partial unique index keyed on
(app_id, lower(email)) WHERE accepted_at IS NULL. Re-inviting after a
previous invite was accepted still works — the accepted row falls
outside the index.

The service-layer 409-conflict UX (the audit's secondary suggestion)
is a separate follow-up; this commit closes the data-shape hole.

AUDIT.md anchor: F-S-013.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 20:45:34 +02:00
MechaCat02
0b7ef11333 fix(manager-core): F-M-002 coupled-nullness CHECK on encrypted-secret column pairs
Two (encrypted, nonce) column pairs are each nullable independently in
the current schema:
- email_trigger_details.inbound_secret_encrypted / _nonce
- app_secrets.realtime_signing_key_encrypted / _nonce

A bug or partial write could leave one populated and the other NULL,
silently bypassing signature verification (receivers decrypt only if
the encrypted column is set).

Migration 0038 adds CHECK ((enc IS NULL) = (nonce IS NULL)) on both
tables — defence-in-depth: catches an invariant violation at the DB
boundary even if the writing code regresses.

AUDIT.md anchor: F-M-002.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 20:45:16 +02:00
MechaCat02
0bce113d28 fix(manager-core): F-M-001 drop unused idx_cron_triggers_due
Migration 0017_cron_triggers.sql created idx_cron_triggers_due on
(last_fired_at) with a comment claiming it serves the scheduler. The
actual scheduler query has no last_fired_at predicate — it filters
purely on `t.enabled = TRUE FOR UPDATE OF d SKIP LOCKED`. The index
has been pure write amplification with no read payoff.

Migration 0037 drops it. Reversible by re-running 0017's CREATE INDEX
if the planner story ever changes.

AUDIT.md anchor: F-M-001.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 20:44:56 +02:00
MechaCat02
3c5978190e fix(manager-core): F-P-010 add idx_triggers_kind_enabled
list_active_queue_consumers fires every 100ms from the dispatcher
queue arm and predicates on `WHERE t.kind='queue' AND t.enabled=TRUE`
with no app_id filter — but the only available index
`idx_triggers_app_kind_enabled` is keyed on `(app_id, kind)` and so
requires an app_id predicate to be useful. Without one, the planner
falls back to a sequential scan every tick.

Add migration 0036 with a partial index on `kind` (WHERE enabled =
TRUE) so the hot dispatcher query becomes an index-only lookup.

AUDIT.md anchor: F-P-010.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 20:44:37 +02:00
MechaCat02
fbbe7676ae fix(shared): F-S-009 require PICLOUD_DEV_INSECURE_KEY acknowledgement for dev master key
PICLOUD_DEV_MODE=true alone fell through to a fully public deterministic
master key — SHA-256("picloud-dev-master-key-v1.1.7"). The warning was
correct but the gate was a single env var. An operator copying a dev
docker-compose file into prod silently encrypted everything with a
world-known key.

Require both:
- PICLOUD_DEV_MODE=true
- PICLOUD_DEV_INSECURE_KEY=i-understand-this-is-insecure

Without the literal-string acknowledgement, MasterKey::from_env returns
the new DevModeUnacknowledged error and the process refuses to start.
Production deployments aren't affected (they set PICLOUD_SECRET_KEY).

AUDIT.md anchor: F-S-009.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 20:43:17 +02:00
MechaCat02
4923fa89e3 fix(manager-core): F-S-008 TTL + invalidate hook on realtime_authority key cache
key_cache: Mutex<HashMap<AppId, Vec<u8>>> was populated on first read
and never evicted, bounded, or cleared on app deletion. Two problems:
(1) Once key rotation lands, every running process keeps accepting
tokens signed by the old key until restart.
(2) Dropping and re-creating an app (FK CASCADE removes app_secrets,
fresh row inserted) made the cache hand out the OLD key bytes.

- Wrap the cache value as `(Instant, Vec<u8>)`; entries expire after
  KEY_CACHE_TTL = 5min.
- Stale reads fall through to the secrets repo (no error, just slower).
- New `invalidate_signing_key(app_id)` for explicit eviction once the
  app-secrets rotation path lands.

AUDIT.md anchor: F-S-008.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 20:42:08 +02:00
MechaCat02
f4189fc52f fix(manager-core): F-S-006 cap API-key candidate set at 16 (Argon2 CPU amplifier)
verify_api_key Argon2-verifies every candidate sharing the 8-char
prefix. With unlimited candidates, an attacker who can provision many
keys against one indexed prefix could force the server into per-request
M×Argon2 — a few hundred concurrent connections trivially DoSes the
manager since the verify runs before any concurrency cap.

Cap the candidate set at MAX_API_KEY_CANDIDATES = 16; log a warn when
truncation kicks in so operators can spot it. With 32-byte random key
bodies the natural collision rate is negligible, so truncation under
benign load shouldn't happen — its presence is the alarm.

Switching the verify hash from Argon2 to SHA-256 (the audit's other
suggested fix) is a larger refactor (new hash column, backfill, dual-
hash verify during migration) and is left for a follow-up.

AUDIT.md anchor: F-S-006 (cap; hash swap deferred).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 20:40:52 +02:00
MechaCat02
7d045b2a0b fix(manager-core): F-S-005 gate dashboard delete_user on AppUsersAdmin
users_admin_api::delete_user was a comment-only acknowledgement that
"this should require AppUsersAdmin" while actually calling
service.delete which only gates on AppUsersWrite. v1.1.8 HANDBACK §7
listed this as known. Effect: any editor could delete app users via
the admin HTTP API and dashboard button.

Add an explicit `authz::require(... AppUsersAdmin(app_id))` before the
service call. Delete the stale comment.

AUDIT.md anchor: F-S-005.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 20:40:12 +02:00
MechaCat02
9247680ab6 fix(manager-core): F-S-004 close timing oracle in users::request_password_reset
Handler returned immediately when email shape was invalid or user was
unknown; on a found user it generated an Argon2 token, INSERT'd into
app_user_password_resets, built a link, and called email.send (tens of
ms + DB write + SMTP RTT). The wall-clock delta was enormous and
externally observable.

Add a dummy generate_session_token() call on both no-match branches so
the observable cost matches the matching branch up to the Argon2 hash.
Doesn't equalise the DB INSERT + SMTP send (those would require either
real side effects or a deterministic dev-mode null sink), but it
neutralises the highest-bit-of-info side channel (handler-immediate-
return vs handler-token-mint).

Combined with F-S-003 (find_by_email now requires a principal),
enumeration via this surface from an anonymous public route is closed.

AUDIT.md anchor: F-S-004.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 20:39:34 +02:00
MechaCat02
22e77e02f0 fix(manager-core): F-S-003 require authenticated principal for users::find_by_email
find_by_email gated on AppUsersRead via `require`, which short-circuits
when cx.principal == None. An anonymous public-HTTP script could
iterate emails to enumerate registered users, then pivot to login or
request_password_reset.

Tighten: explicitly return Forbidden when cx.principal is None — scripts
that need a "does this email exist" probe from a public route must
wrap the call in their own auth gate.

AUDIT.md anchor: F-S-003.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 20:39:04 +02:00
MechaCat02
dc40bc7926 style: fmt + clippy cleanup after Phase B
cargo fmt regroups, plus three Phase-B clippy fixes:
- F-P-004: #[allow(clippy::type_complexity)] on the invoke_ast_cache Mutex.
- F-P-008: hoist `futures::stream::{self, TryStreamExt}` to the top-of-
  file imports to satisfy clippy::items_after_statements.
- F-P-005: rename `_legacy_offset` to `legacy_offset` + #[allow(dead_code)]
  to satisfy clippy's "field is pub but `_`-prefixed" check.

No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 20:38:11 +02:00
MechaCat02
617c216429 fix(manager-core): F-P-009 cache resolved Principals (60s TTL) in auth middleware
attach_principal_if_present runs on every request that carries a
Bearer header, including data-plane paths that may not even need
authz. Each call paid three DB round-trips on the session path
(lookup + admin_users.get + touch) plus an Argon2 verify per
prefix-colliding candidate on the API-key path. A hot user with
multiple keys serialized every request behind N×Argon2.

Add a process-shared PrincipalCache keyed on hash_token(bearer) →
(Instant, Principal), TTL 60s. resolve_principal checks the cache
first; on miss falls through to the verify_api_key / verify_session
path and writes back on success.

- Lazy GC: when the cache exceeds 1024 entries, sweep expired before
  inserting (kept simple — production sees few unique hot tokens).
- Cache wired through AuthState; constructed once in picloud/src/lib.rs.
- Skip-when-route-doesn't-need-auth scaffolding is deferred to a
  follow-up — it requires touching the router shape, which is more
  invasive than the 60s cache alone warrants.

AUDIT.md anchor: F-P-009 (cache; skip-path deferred).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 20:23:17 +02:00
MechaCat02
c80ee194f2 fix(manager-core): F-P-008 concurrent trigger hydration in list_for_app (wall-clock fix)
TriggerRepo::list_for_app selected parent rows then called
hydrate_one(...) per row serially — one SELECT against the kind-
specific details table per trigger. For N triggers on an app, that's
N+1 queries serialized on every dashboard `GET /apps/{id}/triggers`.

This commit shrinks the wall-clock cost via buffered concurrency
(`try_buffered(8)`): each row's details fetch still runs but they run
in parallel up to a small fan-out cap. Cuts dashboard load time from
sum(N latencies) to max(N latencies) / 8.

Collapsing to a single per-kind LEFT JOIN (the audit's preferred shape)
would cut the query count to ~7 — large enough to defer to v1.2 as
documented in the inline comment.

AUDIT.md anchor: F-P-008 (partial: wall-clock only; round-trip count
deferred).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 20:20:29 +02:00
MechaCat02
8ceb1352dd fix(manager-core): F-P-007 concurrent queue dispatch per tick
tick_queue_arm called list_active_queue_consumers() and then iterated
serially, awaiting one queue.claim(app, queue) per consumer. With N
consumers and a 100ms tick the worst-case throughput was
N × (claim + executor) / tick — one slow handler blocked every other
queue's progress on the dispatcher's task.

Replace the for-loop with `futures::stream::iter(consumers)
.for_each_concurrent(QUEUE_DISPATCH_PARALLELISM, …)`. The execution
gate already caps real script-concurrency to its permits, so this just
removes the dispatcher-side serialization.

QUEUE_DISPATCH_PARALLELISM = 32 (matches the default gate). Workspace
gains `futures = 0.3` so `for_each_concurrent` is available.

AUDIT.md anchor: F-P-007.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 20:19:15 +02:00
MechaCat02
97a6bd732f fix(manager-core): F-P-006 cap queue::depth scans at 10k rows
Scripts called queue::depth(name) / queue::depth_pending(name) per
invocation; each ran an unbounded COUNT(*) over the queue_messages
partial index. On a backed-up queue with millions of rows, every call
scanned the whole partition.

Wrap the predicate in a LIMIT-capped subquery so the worst-case scan
is bounded:

  SELECT COUNT(*) FROM (
      SELECT 1 FROM queue_messages WHERE ... LIMIT 10000
  ) sub

QUEUE_DEPTH_SCAN_CAP = 10_000. Callers that need an exact depth on
queues larger than 10k use the admin /apps/{id}/queues endpoint which
tolerates the slower COUNT for its much lower read frequency.

`list_for_app` (dashboard queue overview) is left at full COUNT —
separate finding F-P-006 second-bullet, deferred to v1.2 along with
per-queue running counters.

AUDIT.md anchor: F-P-006 (first half).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 20:16:33 +02:00
MechaCat02
fbe1ccc127 fix(manager-core): F-P-005 keyset cursor on execution_logs list_for_script
list_for_script used ORDER BY created_at DESC LIMIT $2 OFFSET $3.
Postgres has to scan + discard OFFSET rows on every page, so deep
pagination on a script with many log rows gets linearly slower. Every
sister-table list endpoint in the repo already uses cursor pagination
— this is the outlier.

Add ExecutionLogCursor { created_at, id } with `<rfc3339>_<uuid>`
encode/decode. New trait signature:
  list_for_script(script_id, limit, cursor: Option<ExecutionLogCursor>)

Predicate: WHERE script_id=$1 AND (created_at, id) < ($cursor_ts, $cursor_id).
ORDER BY also gains `id DESC` for deterministic ordering at equal-time
boundaries.

API surface:
- /api/v1/admin/scripts/{id}/logs?cursor=<token>&limit=50 is the new
  shape (dashboards adopt later — separate finding F-U-012).
- Legacy `offset` query param accepted-and-ignored to keep older
  dashboards from 400'ing during rollout.

AUDIT.md anchor: F-P-005.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 20:15:34 +02:00
MechaCat02
aae107a5ff fix(executor-core): F-P-004 cache AST across invoke() re-entry (per-Engine cache)
Two paths bypassed the AST cache: LocalExecutorClient::execute (tests +
fallback) and the synchronous invoke() re-entry in the executor SDK.
The latter is the hot one — composed workflows multiplied parse cost
by depth, so a 4-deep invoke chain on a 200-line script paid the parse
budget × 4 per call.

Add a per-Engine HashMap<ScriptId, (updated_at, Arc<AST>)> + a
`compile_for_identity(script_id, updated_at, source)` helper that
behaves like LocalExecutorClient::get_or_compile but lives on the
Engine. Update the SDK invoke synchronous re-entry to:
  resolved → compile_for_identity → execute_ast

The orchestrator-core LocalExecutorClient cache (HTTP-path dispatch)
is left untouched — it caches a different access pattern at a
different boundary.

AUDIT.md anchor: F-P-004.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 20:13:28 +02:00
MechaCat02
1cd8791bff fix(manager-core): F-P-002 move Argon2id verify off the Tokio async worker
verify_password (Argon2id, OWASP defaults m=19456 KiB, t=2) is CPU-bound
at tens-to-hundreds of ms and was invoked synchronously on the Tokio
worker. Worse, verify_api_key Argon2-verifies every candidate sharing
the 8-char prefix — a hot user with N keys serialized every admin
request behind N×Argon2.

Wrap each call site in tokio::task::spawn_blocking:
- auth_middleware::verify_api_key (per request carrying a Bearer key)
- auth_api::login (admin login)
- users_service::login (data-plane app-user login, both real-hash and
  TIMING_FLAT_DUMMY_HASH branches)

Cold-cache login is now ~2× current latency due to one spawn_blocking
hop, but the worker no longer parks on Argon2 so steady-state under
load is dramatically better. The LRU cache for the hot-path (token →
principal) is finding F-P-009 — separate commit.

AUDIT.md anchor: F-P-002.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 20:10:54 +02:00
MechaCat02
5303419eec fix(manager-core): F-P-001 batch app_user_roles lookups in users::list (N+1 → 1)
users::list page-fetched users then looped calling
self.fetch_roles(cx.app_id, row.id) — one query per user row, with
default limit 50 and max 500 — so the admin "users" page did 51 to 501
round-trips. The same fetch_roles is also called from
verify_session_for_realtime on every authenticated SSE subscribe.

Add AppUserRoleRepo::list_for_users(app_id, &[user_ids]) returning a
HashMap<AppUserId, Vec<String>>; rewrite list to use it. Single query
with `WHERE user_id = ANY($2)`.

Empty-input fast path returns an empty map without touching Postgres.

AUDIT.md anchor: F-P-001.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 20:09:31 +02:00
MechaCat02
e7f9200c8f fix(manager-core): F-S-002 rate-limit users::request_password_reset + send_verification_email
Both methods short-circuit the authz gate when cx.principal == None.
An attacker hitting any public route could trigger unbounded outbound
email per call to arbitrary or attacker-supplied addresses — exhausting
SMTP-relay quota, getting the relay blacklisted, or burning provider
credits.

Add an in-memory EmailRateLimiter on UsersServiceImpl with two scopes:
- Per-(app, recipient): 5 calls per rolling 1h window
- Per-app daily cap: 200 calls per rolling 24h window

Both windows reset lazily. Token-bucket counts increment only after
both checks pass — a denied recipient doesn't consume the app counter.

Wired:
- send_verification_email: returns UsersError::EmailRateLimited (→ 429
  on the admin HTTP surface).
- request_password_reset: returns Ok(()) silently on rate-limit (would
  otherwise enable an existence-leak side channel).

UsersError::EmailRateLimited variant added; AppUsersApiError gains the
same variant + 429 mapping. NoopUsersError trait stub unaffected.

AUDIT.md anchor: F-S-002.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 20:07:53 +02:00
MechaCat02
47ea239eb6 fix(picloud): F-P-003 PICLOUD_DB_MAX_CONNECTIONS env knob, default 32 (matches gate)
init_db hard-coded max_connections(10). The execution gate
(ExecutionGate, PICLOUD_MAX_CONCURRENT_EXECUTIONS default 32) lets 32
script executions run concurrently, each doing multiple sequential
DB calls — plus the dispatcher tick every 100ms, the cron tick, three
GC sweeps, and the auth middleware running per request all draw from
the same pool. With max=10 vs gate=32, pool starvation surfaces as
acquire_timeout errors and tail-latency spikes under load.

- DEFAULT_DB_MAX_CONNECTIONS = 32 (matches gate default).
- Env knob: PICLOUD_DB_MAX_CONNECTIONS overrides; invalid/zero → default.
- Documented in CLAUDE.md runtime config table.

AUDIT.md anchor: F-P-003.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 20:03:56 +02:00
MechaCat02
6f2bd3e949 style: cargo fmt after Phase A
Whitespace + import-grouping fixups in the 9 SDK modules touched by
F-Q-002. No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 20:02:19 +02:00
MechaCat02
e29ac1c03d fix(manager-core): F-S-001 cap kv/docs/pubsub/queue payload sizes (default 256 KB)
Files (per-file cap), secrets (64 KB default), and email (25 MB default)
already enforce limits; kv::set, docs::create/update, pubsub::publish_durable
and queue::enqueue accepted any JSON value straight to a JSONB column with
no size validation. An anonymous public-HTTP script could fill disk via
queue::enqueue or amplify a single publish into N outbox rows × payload bytes.

Adds four new error variants:
- KvError::ValueTooLarge { limit, actual }
- DocsError::ValueTooLarge { limit, actual }
- PubsubError::MessageTooLarge { limit, actual }
- QueueError::PayloadTooLarge { limit, actual }

Each stateful service grows a `max_value_bytes` field with:
- Conservative 256 KB default (DEFAULT_KV_MAX_VALUE_BYTES etc.).
- New `with_max_*` constructor preserving the old `new()` signature.
- Env-knob reader (`*_max_*_from_env()`) — mirrors SecretsConfig::from_env.

Validation runs at the entry point BEFORE authz so an anonymous DoS doesn't
pay a membership lookup per attempt.

Wired via env knobs:
- PICLOUD_KV_MAX_VALUE_BYTES
- PICLOUD_DOCS_MAX_VALUE_BYTES
- PICLOUD_PUBSUB_MAX_MESSAGE_BYTES
- PICLOUD_QUEUE_MAX_PAYLOAD_BYTES

Documented in CLAUDE.md runtime config table.

New unit test in queue_service verifying the cap fires before authz.

AUDIT.md anchor: F-S-001. Depends on F-Q-004 (Backend variant).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 20:00:36 +02:00
MechaCat02
5c50ce2e11 fix(executor-core): F-Q-002 promote sdk::bridge::block_on, migrate 9 SDK modules
Replace ten near-identical `block_on` helpers (one per SDK module)
with a single shared `sdk::bridge::block_on(service: &str, fut)`.
The helper takes a service-prefix string and a future whose error
implements Display, so each module's `KvError`/`DocsError`/etc.
still self-formats.

Migrated:
- kv, docs, pubsub, users, dead_letters, secrets, files, email
- queue.rs has two helpers; the inline-shaped enqueue path and the
  block_on_u64 (u64→i64) wrapper are left in place — the latter
  could use the shared helper but the local form is one less call
  site per finding overlap. Will revisit on a later pass if needed.
- http.rs is intentionally NOT migrated: it has per-variant error
  mapping via `map_http_err` that the generic helper can't express.

Net: -218 / +97 lines across the 9 migrated files. Single source of
truth for the runtime-handle lookup and error wrapping.

AUDIT.md anchor: F-Q-002.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 19:54:37 +02:00
MechaCat02
04dc81115e fix(manager-core): F-Q-003 promote authz::script_gate helper, migrate 7 service call sites
Replace nine open-coded `if cx.principal.is_some() {
authz::require(...).await.map_err(...) }` blocks with a single
`authz::script_gate(repo, cx, cap, forbidden_fn, backend_fn)` helper.

The helper enshrines the script-as-gate semantics (anonymous public-
HTTP scripts skip the check) and the AuthzDenied::{Denied,Repo}
mapping in one place — eliminating drift between services.

Call sites migrated:
- kv_service::check_read / check_write
- docs_service::check_read / check_write
- files_service::check_read / check_write
- pubsub_service::check_publish
- queue_service::enqueue

The pubsub_service::mint_subscriber_token path keeps the explicit
match because it does a separate `principal` early-bind for other
validation; converting it would obscure intent.

AUDIT.md anchor: F-Q-003. Depends on F-Q-004 (Backend variant) and
F-Q-005 (Repo-passthrough pattern).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 19:48:23 +02:00
MechaCat02
655c3ab97e fix(manager-core): F-Q-005 preserve AuthzDenied::Repo through service-layer authz checks
Every check_read/check_write/check_publish helper across kv, docs,
files, pubsub, queue services used `.map_err(|_| KvError::Forbidden)?`,
collapsing AuthzDenied::Denied AND AuthzDenied::Repo(repo_err) into
a single Forbidden. A transient Postgres blip during the membership
lookup surfaced as 403 to scripts; operators couldn't distinguish
"real forbidden" from "DB flap during permission check".

Replace each call site with the explicit match pattern already used by
users_service::require (users_service.rs:177-181):

  Ok(())                          → continue
  Err(AuthzDenied::Denied)        → Err(ServiceError::Forbidden)
  Err(AuthzDenied::Repo(e))       → Err(ServiceError::Backend(e.to_string()))

7 call sites updated (2 in kv_service, 2 in docs_service, 2 in
files_service, 2 in pubsub_service, 1 in queue_service).

AUDIT.md anchor: F-Q-005. Depends on F-Q-004 (which added Backend
to QueueError/PubsubError).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 19:46:37 +02:00
MechaCat02
b192cf2cc9 fix(shared): F-Q-004 unify error variants — Forbidden on QueueError, rename Unavailable→Backend
Brings QueueError / PubsubError / InvokeError in line with sibling
shape used by KvError / DocsError / FilesError / SecretsError:

- QueueError gains an explicit Forbidden variant (previously authz
  denial was squashed into Rejected("forbidden") in queue_service.rs:68,
  losing the structured variant a 403-translation layer would need).
- QueueError::Unavailable renamed → Backend.
- PubsubError::Unavailable renamed → Backend.
- InvokeError::Unavailable renamed → Backend.
- Call sites updated (queue_service, pubsub_service, invoke_service,
  Noop* stubs, From<PubsubRepoError> impl, one test assertion).
- New unit test verifying authed-denied → QueueError::Forbidden.

AUDIT.md anchor: F-Q-004.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 19:44:23 +02:00
MechaCat02
c3baa87415 chore(v1.1.9): clippy clean — workspace -D warnings
Surfaced + fixed during the F3 attestation:

- executor-core/sdk/queue.rs: drop redundant .map(|()| ()) call sites;
  enqueue_blocking discards QueueMessageId via .map(|_id| ()) (intentional)
- executor-core/sdk/retry.rs: hoist use std::collections::hash_map::DefaultHasher
  + use std::hash::Hasher to the top of the file (clippy::items_after_statements);
  replace `as i64` casts with i64::try_from + clear comment about jitter
  bound (clippy::cast_possible_wrap)
- executor-core/sdk/invoke.rs: move _LIMITS_IS_COPY const before #[cfg(test)]
  mod tests (clippy::items_after_test_module)
- manager-core/dispatcher.rs: dispatch_one_queue gains #[allow(too_many_lines)]
  (it's the queue tick's whole logic — split makes it less readable than
  the lint); .map(...).unwrap_or(default) → .map_or(default, ...)
- picloud/lib.rs: Limits { trigger_depth_max, ..Limits::default() } via
  struct-update instead of let-mut-assign (clippy::field_reassign_with_default)
- tests: r#"..."# → r"..." where there are no `"` inside (clippy::needless_raw_string_hashes);
  combine InvokeTarget::Name | InvokeTarget::Path arms with identical bodies
  in sdk_invoke.rs (clippy::match_same_arms)

cargo fmt --all -- --check 2>&1 | tail -3: no output (exit 0)
cargo clippy --workspace --all-targets --all-features -- -D warnings:
  Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.41s
Workspace lib smoke: 432 passing (306 manager-core + 74 executor-core + 34 shared + 18 orchestrator-core).
DB-gated E2E (against docker compose postgres on localhost:15432):
  queue_e2e: 4 ok / invoke_e2e: 4 ok / retry_e2e: 3 ok / migration_queue_messages: 4 ok / schema_snapshot: 1 ok

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 11:02:22 +02:00
MechaCat02
c38c46b8bc test(v1.1.9): fix invoke_e2e + retry_e2e — admin bypass + rhai shape
Running the E2E suites against real Postgres surfaced three shape bugs
in the test scripts that caused false failures:

invoke_e2e:
- invoke_cross_app_rejects used two TestServer instances (one per app),
  but the second server's Owner admin isn't a member of the first
  server's app. Replaced with a single server that creates both apps
  via the same Owner admin (which has implicit access to every app).
- invoke_depth_limit_exceeds_cleanly: the recurser script had its own
  try/catch, so when the depth limit fired inside the deepest call the
  caught error became the BODY of a 200 response (which invoke()
  returns to the caller). The outer caller's try/catch never saw a
  throw → assertion failed. Rewrote so the recurser propagates throws
  (no inner try-catch); the outer caller's try-catch surfaces the
  depth error all the way up.

retry_e2e:
- All three tests used HTTP routes which need a domain claim the test
  apps don't have (`no app claims host ""` 404s). Switched to the
  admin bypass POST /api/v1/execute/{id} — same pattern dispatcher_e2e
  uses. Sidesteps the per-app domain matcher entirely.
- retry_run_surfaces_last_error_after_max_attempts: try-catch is a
  statement in Rhai, not an expression, so the block didn't evaluate
  to the catch arm's map. Refactored to bind to `let out` inside the
  catch arm, then return `#{ statusCode: 200, body: out }` as the
  final expression.

All 11 v1.1.9 E2E tests now pass against Postgres:
  queue_e2e: 4 passed (33s — exercises retry + dead-letter)
  invoke_e2e: 4 passed (2s)
  retry_e2e: 3 passed (2.5s)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 10:54:52 +02:00
MechaCat02
106394bef2 chore(v1.1.9): re-bless schema snapshot (0034 + 0035 delta)
Re-blessed via:
  DATABASE_URL=postgres://picloud:picloud@localhost:15432/picloud \
  BLESS=1 cargo test -p picloud-manager-core --test schema_snapshot \
    -- --include-ignored

Delta matches the plan exactly — no unrelated drift:
  - new table queue_messages (id, app_id, queue_name, payload,
    enqueued_at, deliver_after, claim_token, claimed_at, attempt,
    max_attempts, enqueued_by_principal)
  - new table queue_trigger_details (trigger_id, queue_name,
    visibility_timeout_secs, last_fired_at)
  - 3 new indexes on queue_messages: idx_queue_messages_dispatch
    (partial WHERE claim_token IS NULL), idx_queue_messages_claimed
    (partial WHERE claim_token IS NOT NULL), idx_queue_messages_app_queue
  - 1 new index on queue_trigger_details: idx_queue_trigger_details_queue_name
  - widened triggers.kind CHECK to admit 'queue'
  - widened outbox.source_kind CHECK to admit 'invoke'
  - migrations 0034 + 0035 entries in the migrations log
  - FK + PK declarations for both new tables

Migration test (crates/manager-core/tests/migration_queue_messages.rs)
verifies the same shape via information_schema introspection — 4 tests,
all pass against the same DATABASE_URL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 10:49:33 +02:00
MechaCat02
271747da24 chore(v1.1.9): bump workspace versions 1.1.8 → 1.1.9 + SDK_VERSION 1.10
- Cargo.toml: workspace.package.version 1.1.8 → 1.1.9 (all 9 crates
  inherit via version.workspace = true)
- Cargo.lock: regenerated by cargo check
- crates/shared/src/version.rs: SDK_VERSION "1.9" → "1.10" with a
  doc-comment changelog entry covering queue::*, invoke()/invoke_async,
  retry::* (note the reserved-keyword rename of retry::with → retry::run)
- @picloud/client unchanged — no client-library work this release
- dashboard/package.json already bumped to 0.15.0 in commit 13

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 10:27:53 +02:00
MechaCat02
0c85fb67d3 chore(v1.1.9): F4 — dedup TIMING_FLAT_DUMMY_HASH
Replace the inline copy of the Argon2id PHC dummy hash in
crates/manager-core/src/auth_api.rs::login with a reference to the
shared TIMING_FLAT_DUMMY_HASH constant in
crates/manager-core/src/auth.rs (added in v1.1.8 but not yet adopted
by the login path).

Verification: `grep -rn 'argon2id\$v=19\$m=19456,t=2,p=1\$dGltaW5n' crates/`
returns exactly one hit — the const declaration in auth.rs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 10:26:37 +02:00
MechaCat02
328d7d55e6 test(v1.1.9): queue_e2e + invoke_e2e + retry_e2e + migration test
Four new test binaries, DB-gated via the established pool_or_skip()
pattern (returns Some(pool) when DATABASE_URL is set; prints a skip
notice and returns None otherwise — so plain `cargo test` stays green
locally while CI runs them).

crates/picloud/tests/queue_e2e.rs (4 tests):
  - queue_receive_acks_on_success: enqueue → consumer fires → KV
    marker observable → queue row deleted (ack worked)
  - queue_receive_dead_letters_after_max_attempts: throwing handler
    retries 3× via the dispatcher's compute_backoff path → dead_letters
    row written, queue row deleted
  - queue_one_consumer_per_queue_rejected: second trigger create for the
    same (app_id, queue_name) returns 4xx with the documented "already
    has a consumer trigger" message (advisory-lock guard works)
  - queue_visibility_timeout_reclaim: insert message with stale claim
    (claimed_at = NOW() - 60s, visibility_timeout = 5s); either the
    dispatcher re-claims after reclaim runs, or the claim clears

crates/picloud/tests/invoke_e2e.rs (4 tests):
  - invoke_by_name_same_app_returns_value: callee returns body.x+1;
    caller invokes by name and writes the result to KV (42 round-trips)
  - invoke_cross_app_rejects: callee in app_B, caller in app_A; error
    string contains "different app" or "CrossApp"
  - invoke_depth_limit_exceeds_cleanly: recursive script throws with
    "depth" in the message at the bound (Limits::trigger_depth_max=8)
  - invoke_async_enqueues_outbox_row: invoke_async returns execution_id,
    dispatcher fires the OutboxSourceKind::Invoke arm, callee marker
    appears

crates/picloud/tests/retry_e2e.rs (3 tests):
  - retry_run_eventually_succeeds_inside_http_handler: counter mutates
    across 3 retries through a real HTTP route, returns 3
  - retry_run_surfaces_last_error_after_max_attempts: max_attempts=2,
    always throws — caller's try/catch surfaces "boom"
  - retry_on_codes_filters_unmatched_errors: counter == 1 after a
    throw NOT in the codes list (immediate surface, no retry)

crates/manager-core/tests/migration_queue_messages.rs (4 tests):
  - queue_messages_table_exists_with_expected_columns: shape +
    nullability of every column
  - queue_trigger_details_table_exists: shape
  - queue_widens_trigger_kind_and_outbox_source_kind: triggers.kind
    CHECK admits 'queue'; outbox.source_kind admits 'invoke'
  - queue_messages_dispatch_index_is_partial: idx_queue_messages_dispatch
    has WHERE claim_token IS NULL (guards against future migrations
    accidentally dropping the partial)

All 15 tests pass clean skip when DATABASE_URL is unset. Workspace unit
suite remains at 432 passing (306 manager-core + 74 executor-core + 34
shared + 18 orchestrator-core).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 10:25:22 +02:00
MechaCat02
714f6dae18 feat(v1.1.9): admin HTTP endpoints — queue triggers + read-only queues
triggers_api.rs adds:
  POST /api/v1/admin/apps/{id}/triggers/queue
    body: { script_id, queue_name, visibility_timeout_secs?,
            dispatch_mode?, retry_max_attempts?, retry_backoff?,
            retry_base_ms? }
    cap: AppManageTriggers
    409-equiv via the repo's "queue 'X' already has a consumer" error
    (TriggerRepoError::Invalid → 422 Invalid in the existing mapping)

queues_api.rs (new) adds read-only inspection endpoints:
  GET /api/v1/admin/apps/{id}/queues
    -> [{queue_name, total, pending, claimed}]

  GET /api/v1/admin/apps/{id}/queues/{queue_name}
    -> {queue_name, total, pending, claimed,
        consumer: Some(QueueConsumer) | None}

  QueueConsumer = { trigger_id, script_id, script_name,
                    visibility_timeout_secs, last_fired_at }

Capability: AppLogRead (same trust tier as execution-log read — these
are read-only operational views, not config changes).

No mutating queue endpoints — enqueue lives on the SDK side; purge /
requeue / delete-message stays at v1.2.

picloud/lib.rs wires the queues router into the guarded /admin chain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 22:36:50 +02:00
MechaCat02
e142d471e1 feat(v1.1.9): retry:: Rhai SDK module — caller-controlled retry
executor-core/sdk/retry.rs adds the retry:: namespace with:

  retry::policy(opts)           -> Policy
  retry::on_codes(policy, codes) -> Policy
  retry::run(policy, closure)    -> Dynamic  (closure's return value)

NOTE: the brief specified retry::with(...), but `with` is a Rhai
reserved keyword (parse error before registration even matters). `call`
is also reserved. Shipping retry::run instead — flagged in HANDBACK §7.

Policy:
  - max_attempts clamped to [1, 20]
  - base_ms clamped to [1, 60_000]
  - jitter_pct clamped to [0, 100]
  - backoff ∈ {"exponential" | "linear" | "constant"} — bogus values
    surface a clear error
  - on_codes is an empty default ("retry any throw"); when non-empty,
    only error messages containing one of the codes retry — others
    surface immediately

retry::run uses NativeCallContext + FnPtr::call_within_context to
re-invoke the closure inside the caller's engine. Closures share the
caller's cx (Rhai semantics). If the closure calls invoke(), the inner
call gets a fresh cx with trigger_depth + 1 via the invoke bridge —
retry doesn't see or interfere with that.

Sleep between attempts: tokio::time::sleep through TokioHandle::block_on.
Safe because we're already inside the caller's spawn_blocking thread.

register_all gains retry::register positionally between queue and
secrets.

Unit tests (7): policy default, max_attempts clamping (0→1, 999→20),
base_ms + jitter clamping, bogus backoff reject, exponential doubles,
linear sums, constant.

Integration tests (sdk_retry.rs, 6 binaries):
  - succeeds first try (returns 42)
  - max_attempts exhausted surfaces last error ("boom")
  - on_codes filters — non-matching error throws immediately
  - jitter clamping is silent (script still runs)
  - bogus backoff string surfaces a clear error
  - "fails 2 then succeeds" — counter mutates across retries, 3rd
    attempt returns 3 (validates Rhai closure-capture semantics across
    re-invocations through NativeCallContext)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 22:32:06 +02:00
MechaCat02
2247c4d804 feat(v1.1.9): dispatcher Invoke arm — invoke_async runs once
Replaces the commit 2 placeholder with the real OutboxSourceKind::Invoke
handler.

build_invoke_request(row) hydrates an (ResolvedTrigger, ExecRequest)
pair from a payload InvokeService::enqueue_async wrote. Validates:
  - script_id present + parses as UUID
  - script exists
  - script.app_id == row.app_id (defensive — re-check the cross-app
    boundary; same-app was already verified at enqueue time)

Synthesized ResolvedTrigger carries retry_max_attempts = 1 so the
existing handle_failure path skips the retry loop. invoke_async runs
exactly once; on throw the row is deleted + a dead_letters row written
(handle_failure already does this when attempt >= max_attempts), so a
caller who wants retry semantics wraps the call site in retry::with.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 19:57:53 +02:00
MechaCat02
302c1df577 feat(v1.1.9): invoke:: Rhai SDK module — sync re-entry + invoke_async
executor-core/sdk/invoke.rs adds `invoke()` and `invoke_async()` as
top-level Rhai helpers (no `::` namespace, mirroring the brief):

  invoke(target, args)        -> Dynamic  (sync, returns callee's value)
  invoke_async(target, args)  -> String   (fire-and-forget; returns execution_id)

Sync re-entry pattern:
  - bridge captures Arc<Engine> via Engine::self_arc()
  - calls engine.execute(&source, req) directly — same engine instance,
    same Services, same Limits; only SdkCallCx changes per call
  - runs inside the caller's spawn_blocking thread (no nested
    spawn_blocking, no gate re-admission — the outer execution already
    holds a permit)

Back-reference plumbing:
  - Engine gains self_weak: OnceLock<Weak<Engine>> + set_self_weak() +
    self_arc() accessor
  - picloud binary calls engine.set_self_weak(Arc::downgrade(&engine))
    right after construction
  - register_all extended with limits + Option<Arc<Engine>> params
  - Engine::execute_ast threads both through

Limits.trigger_depth_max added — mirrors TriggerConfig::max_trigger_depth.
Default 8; the picloud binary syncs from TriggerConfig::from_env() so
PICLOUD_MAX_TRIGGER_DEPTH governs both the dispatcher's fan-out cap and
the invoke depth bound.

Target parsing (string-first):
  - "/api/foo"             → InvokeTarget::Path
  - 36-char UUID-like      → InvokeTarget::Id  (uuid::Uuid parse-validated)
  - everything else        → InvokeTarget::Name

Cross-app + depth + FnPtr guards:
  - resolve() returns InvokeError::CrossApp if resolved.app_id != cx.app_id
  - bridge throws "invoke: depth limit exceeded (max N)" when
    cx.trigger_depth + 1 > limits.trigger_depth_max (checked BEFORE
    resolve to avoid a wasted DB round-trip)
  - args_to_json rejects FnPtr at any depth — closures don't survive
    invoke boundaries

invoke_async path:
  - calls services.invoke.enqueue_async() which writes an
    OutboxSourceKind::Invoke row (commit 10 wires the dispatcher arm)
  - returns the new ExecutionId as a string for caller tracking

Integration tests (sdk_invoke.rs, 6 binaries):
  - sync invoke returns callee's value (script returns body.x + 1)
  - cross-app invoke rejected (target in other app)
  - depth limit exceeded (recursive script that calls itself; throws
    before stack overflow)
  - callee receives incremented depth in cx (smoke — strict assertion
    needs ctx.trigger_depth surface, deferred to v1.2)
  - args_to_json rejects FnPtr in payload
  - invoke_async returns valid UUID string + queues args payload

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 19:56:05 +02:00
MechaCat02
563c44ad95 feat(v1.1.9): InvokeServiceImpl + RouteTable wiring in picloud binary
manager-core/invoke_service.rs implements InvokeService over:
  - ScriptRepository (for Id + Name resolution)
  - RouteTable (for Path resolution via the orchestrator's matcher)
  - OutboxRepo (for invoke_async)

Same-app guard runs at the service entry point: resolve_id() always
verifies resolved.app_id == cx.app_id and returns InvokeError::CrossApp
otherwise. resolve_name() reads cx.app_id when querying (so a wrong
app_id from somewhere else couldn't slip through), but defense-in-depth
won't hurt — future hardening if it surfaces.

resolve_path() runs the matcher against this app's routes only
(RouteTable::snapshot_for_app). Method assumed POST→GET fallback for
v1.1.9 — surfacing method via the SDK is a future addition.

enqueue_async() resolves the target, then writes one outbox row with
source_kind = 'invoke'. The payload carries script_id, script_name,
args, fresh execution_id, root_execution_id (inherited from caller),
trigger_depth + 1. caller principal recorded as origin_principal
(forensic only — the executing script runs with no principal). One
shot — no retry policy on the row; users wrap in retry::with() if
they want retries.

picloud/lib.rs:
  - route_table construction moved BEFORE Services::new so the invoke
    service can hold it. populates from route_repo as before.
  - InvokeServiceImpl wired into Services::new in place of the
    NoopInvokeService placeholder.

Unit tests cover: resolve by Id same-app, cross-app rejected, resolve
by Name finds + not-found, enqueue_async writes Invoke outbox row with
trigger_depth=1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 19:48:53 +02:00
MechaCat02
36bf046791 feat(v1.1.9): InvokeService trait + InvokeTarget + ScriptRepository::get_by_name
shared/invoke.rs introduces the InvokeService trait + InvokeTarget enum:

  - InvokeTarget::Path(String)  — resolves via the route trie
  - InvokeTarget::Name(String)  — (cx.app_id, name) -> script_id via get_by_name
  - InvokeTarget::Id(ScriptId)  — direct lookup

InvokeService::resolve enforces same-app at the service layer
(InvokeError::CrossApp). InvokeService::enqueue_async writes a v1.1.9
OutboxSourceKind::Invoke row for fire-and-forget composition.

Errors: NotFound, CrossApp, DepthExceeded(u32), Forbidden, Rejected,
Unavailable. NoopInvokeService surfaces all calls as Unavailable for
harnesses that don't wire the service.

ScriptRepository gains get_by_name(app_id, name) backed by the existing
(app_id, name) uniqueness constraint. Postgres impl + in-memory mock +
the picloud crate's PostgresScriptRepoHandle delegate added.

Services::new gains invoke: Arc<dyn InvokeService> positionally after
queue. with_noop_services wires NoopInvokeService. 12 test sites
threaded through. picloud/lib.rs binds NoopInvokeService at this commit
boundary — the real InvokeResolver lands in commit 8.

Deviation from plan (flagged for HANDBACK §7): the plan called for
moving ExecutorClient + ScriptIdentity from orchestrator-core to shared
so the invoke bridge could call a re-entrant variant. Re-evaluated:
the bridge lives in executor-core which can hold Arc<Engine> directly,
making the trait move unnecessary. Engine::execute is sync, returns
ExecResponse, and shares the engine instance + per-call SdkCallCx
exactly as required. AST cache reuse across invokes is a v1.2 optimization
(invokes recompile each call — ms-scale cost, acceptable).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 19:44:43 +02:00
MechaCat02
6891eda66c feat(v1.1.9): dispatcher queue arm + visibility-timeout reclaim task
Extends Dispatcher with the v1.1.9 queue path. The queue table IS the
outbox for queue semantics, so the dispatcher polls queue_messages
directly via FOR UPDATE SKIP LOCKED — no outbox indirection.

Per tick (every 100ms):
  1) outbox arm — unchanged
  2) queue arm: list_active_queue_consumers() → per (app_id, queue_name)
     attempt one claim. Bounded by registered-consumer count so one busy
     queue can't starve others.

Per claimed message:
  - Build TriggerEvent::Queue + ExecRequest (executes as the trigger's
    registering principal, matching design notes §4)
  - dispatch through executor.execute_with_identity (reuses AST cache)
  - success → queue.ack(id, claim_token) — DELETE WHERE id AND token
  - throw + attempt < max_attempts → queue.nack(...) — clear claim, set
    deliver_after = NOW() + compute_backoff(attempt, backoff, base_ms, jitter)
  - throw + exhausted → queue.dead_letter(...) atomic move to dead_letters
    + DELETE in one transaction. fan_out_dead_letter on the outbox arm
    fires registered dead_letter handlers off the new row without changes.

Visibility-timeout reclaim task: separate tokio::spawn ticking on
queue_reclaim_interval_ms (default 30000). UPDATE clears claim_token /
claimed_at on rows whose claimed_at exceeds the per-queue
visibility_timeout_secs (joined from queue_trigger_details). A crashed
consumer thus loses its lease and the message becomes claimable again.

Dispatcher gains queue: Arc<dyn QueueRepo>; picloud/lib.rs threads
queue_repo.clone() into the construction.

ActiveQueueConsumer extended with app_id so the queue claim can be
performed without a follow-up trigger lookup; list_active_queue_consumers
SQL extended to SELECT t.app_id.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 19:37:36 +02:00
MechaCat02
cae2269932 feat(v1.1.9): queue:: Rhai SDK module
executor-core/sdk/queue.rs registers the queue:: namespace into the
per-call Rhai engine:

  queue::enqueue("name", message)               -> ()
  queue::enqueue("name", message, opts)         -> ()  // opts: Map
  queue::depth("name")                          -> i64
  queue::depth_pending("name")                  -> i64

No handle pattern (queues are the grouping unit, mirroring pubsub).
Opts map accepts optional delay_ms (i64) and max_attempts (u32);
unknown keys are silently ignored to match Rhai's Map semantics.

Bridge details:
- captures Arc<dyn QueueService> + Arc<SdkCallCx> per closure (cheap
  clone)
- block_on via TokioHandle::try_current() — same pattern pubsub/kv use
- message → JSON via a local message_to_json that base64-encodes Blobs
  at any depth (mirrors pubsub) and REJECTS FnPtr / closures with a
  clear error (queue messages must survive Postgres round-trip)
- depth / depth_pending clamp u64 → i64::MAX (Rhai's INT is i64)

register_all gains queue::register(...) positionally after pubsub.

Unit tests (executor-core --lib): blob → base64, nested-map round-trip,
FnPtr rejection, opts parsing (partial / max_attempts only / non-integer
delay rejection).

Integration tests (executor-core/tests/sdk_queue.rs, 6 binaries):
enqueue map payload, enqueue with opts threads through, blob base64
encodes, depth returns service value, FnPtr in payload throws, empty
queue_name throws.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 19:33:06 +02:00
MechaCat02
73e19ca626 feat(v1.1.9): QueueService trait + Postgres impl + Services bundle wiring
- shared/services.rs: Services::new gains queue: Arc<dyn QueueService>
  positionally after users (mirrors v1.1.8's append of users).
  with_noop_services adds NoopQueueService.
- manager-core/queue_service.rs: QueueServiceImpl wraps QueueRepo with
  script-as-gate authz on AppQueueEnqueue. enqueue clamps max_attempts
  to [1,20] and delay_ms to [0, 86_400_000ms]. depth/depth_pending are
  read-only — no authz check (scripts in the app can see their own
  queue depths). cx.principal threads through as enqueued_by_principal
  (forensic only).
- manager-core/authz.rs: AppQueueEnqueue(AppId) capability — script:write
  scope, granted to editor+ (same trust shape as AppPubsubPublish).
- picloud/lib.rs: wires PostgresQueueRepo + QueueServiceImpl into
  Services::new alongside the existing v1.1.7+ services.
- 11 sdk test binaries + manager-core/realtime_authority.rs updated to
  pass NoopQueueService to Services::new.

Unit tests cover empty queue_name reject, max_attempts clamping
(0/21 → invalid), delay_ms negative-reject, anonymous principal skips
authz, depth/depth_pending pass-through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 19:30:34 +02:00