docs(v1.1.9): CHANGELOG + HANDBACK.md

CHANGELOG.md: new v1.1.9 entry above v1.1.8 — covers queue::* SDK,
queue:receive trigger kind, dispatcher queue arm + visibility-timeout
reclaim, invoke() + invoke_async(), retry:: SDK (including the
retry::run rename deviation), admin HTTP endpoints, dashboard 0.15.0,
Services::new + Limits + Engine + register_all signature changes,
migrations 0034/0035, F1-F5 follow-ups, env vars + SDK schema bump.
No upgrade-order constraint (pure additive).

HANDBACK.md: replaces v1.1.8's at repo root. 12 sections per the brief:
  1. Scope coverage table — 21 line items, all 
  2. Queue dispatcher design (claim SQL, ack/nack/dead-letter, reclaim)
  3. invoke() re-entrancy (Engine self_weak, fresh SdkCallCx, cross-app guard, depth bound)
  4. retry::* (closure passing, sleep mechanics, clamping, jitter)
  5. Dashboard notes
  6. F1-F5 implementation per follow-up
  7. Deviations beyond the brief — 7 numbered:
     D1: retry::with → retry::run (both with/call are Rhai reserved)
     D2: Trait move skipped (Engine in scope, AST cache loss deferred)
     D3: Retry columns on parent only (avoids duplicating source of truth)
     D4: Limits::trigger_depth_max mirrored from TriggerConfig
     D5: One-consumer-per-queue via pg_advisory_xact_lock
     D6: invoke() exposed globally (not invoke::*)
     D7: invoke_async runs once
  8. Verification with LITERAL output (fmt + clippy + test totals)
  9. Open questions for the reviewer (4)
  10. Latent findings (lints surfaced + fixed; no carry-forward from main)
  11. Deferred items (nothing slipped; standing v1.2+ list)
  12. Known limitations (closures across invoke, in-process only, etc.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-07 11:14:20 +02:00
parent c3baa87415
commit 7d3ced0776
2 changed files with 628 additions and 436 deletions

View File

@@ -1,483 +1,460 @@
# v1.1.8 — User Management — HANDBACK
# v1.1.9 — HANDBACK
Branch: `feat/v1.1.8-user-management`
Base commit: `5cbb6ca` (v1.1.7 head).
Tip: see `git log --oneline main..HEAD`.
The reviewer should read **§7 (deviations)** first — it lists every
judgment call beyond a strict literal reading of the brief.
---
Branch: `feat/v1.1.9-queues-invoke` (cut from `main` at `b9e002a`).
Workspace: `picloud@1.1.9`. Dashboard: `0.15.0`. SDK schema: `1.10`.
**Pure additive — no upgrade-order constraint.**
## 1. Scope coverage
| # | Piece | Status | Migration | Trait method(s) | SDK function(s) | Admin HTTP | Dashboard |
|---|---|---|---|---|---|---|---|
| 1 | `users::*` CRUD | OK | 0026 | `create/get/find_by_email/update/delete/list` | same | `GET/POST/PATCH/DELETE /users[/{id}]` | users tab |
| 2 | Sessions | OK | 0027 | `login/verify/logout/verify_session_for_realtime` | `login/verify/logout` | `POST /users/{id}/revoke-sessions` | revoke-sessions button |
| 3 | Email verification | OK | 0028 | `send_verification_email/verify_email` | same | — | — (script-controlled flow) |
| 4 | Password reset (script) | OK | 0029 | `request_password_reset/complete_password_reset` | same | — | — |
| 5 | Password reset (admin) | OK | (reuses 0029) | `admin_reset_password_token` | — | `POST /users/{id}/reset-password` | one-shot token modal |
| 6 | Invitations | OK | 0030 | `invite/accept_invite/admin_create_invitation/list_invitations/revoke_invitation` | `invite/accept_invite` | `GET/POST/DELETE /invitations[/{id}]` | invitations sub-tab |
| 7 | Per-app roles | OK | 0031 | `add_role/remove_role/has_role` | same | — (roles managed via SDK) | shown read-only on edit modal |
| 8 | Admin HTTP surface | OK | (no new migration) | — | — | 10 endpoints | users tab + invitations sub-tab |
| F1 | Drop plaintext realtime signing-key column | OK | 0032 | — | — | — | — |
| F2 | Clippy `--all-targets` clean | PARTIAL | — | — | — | — | — (see §6 + §7) |
| F3 | Realtime `auth_mode = 'session'` | OK | 0033 | `verify_session_for_realtime` | — (server-side) | radio in Topics edit | radio added |
| Brief item | Status | Where |
|---|---|---|
| `queue::*` SDK (`enqueue` / `depth` / `depth_pending`) | | `crates/executor-core/src/sdk/queue.rs`, `crates/shared/src/queue.rs`, `crates/manager-core/src/queue_service.rs` |
| `queue:receive` trigger kind | ✅ | `TriggerKind::Queue`, `TriggerDetails::Queue`, `queue_trigger_details` |
| One-consumer-per-queue enforcement | ✅ (API-layer via `pg_advisory_xact_lock`) | `crates/manager-core/src/trigger_repo.rs::create_queue_trigger` |
| Dispatcher queue arm (FOR UPDATE SKIP LOCKED) | ✅ | `crates/manager-core/src/dispatcher.rs::tick_queue_arm` + `dispatch_one_queue` |
| Visibility-timeout reclaim task | ✅ | `crates/manager-core/src/dispatcher.rs::spawn` reclaim block + `QueueRepo::reclaim_visibility_timeouts` |
| Auto-ack / auto-nack / dead-letter | ✅ | `QueueRepo::ack` / `nack` / `dead_letter` + `handle_queue_failure` |
| Dead-letter fan-out for queue | ✅ (free — existing `fan_out_dead_letter` reads `source = "queue"`) | `dispatcher.rs::handle_failure` already wired |
| `invoke()` (sync) | | `crates/executor-core/src/sdk/invoke.rs` |
| `invoke_async()` + `OutboxSourceKind::Invoke` arm | ✅ | `sdk/invoke.rs::invoke_async` + `dispatcher::build_invoke_request` |
| Cross-app invoke rejected | ✅ | `InvokeService::resolve``InvokeError::CrossApp` |
| Depth bound shared with trigger fan-out | ✅ | `Limits::trigger_depth_max`, synced from `TriggerConfig::max_trigger_depth` |
| `retry::*` SDK (Policy + run + on_codes) | ✅ (with `retry::run` rename — see §7) | `crates/executor-core/src/sdk/retry.rs` |
| Migrations 0034 + 0035 | ✅ | as named |
| Admin HTTP — `/triggers/queue` + `/queues` + `/queues/{name}` | ✅ | `crates/manager-core/src/triggers_api.rs` + `queues_api.rs` |
| Dashboard Queues tab + Triggers form + 0.15.0 | ✅ | `dashboard/src/routes/apps/[slug]/queues/*` + extended `+page.svelte` + `package.json` |
| Schema snapshot re-blessed | ✅ | `crates/manager-core/tests/expected_schema.txt` |
| CHANGELOG | ✅ | this branch |
| Version bumps (1.1.8 → 1.1.9, SDK 1.9 → 1.10) | ✅ | `Cargo.toml`, `crates/shared/src/version.rs` |
| F1 — integration test density | ✅ | 4 new test binaries: queue_e2e (4) + invoke_e2e (4) + retry_e2e (3) + migration_queue_messages (4) + 50+ inline unit tests |
| F2 — schema snapshot BLESS=1 in same branch | ✅ | committed |
| F3 — literal fmt/clippy output | ✅ | §8 |
| F4 — TIMING_FLAT_DUMMY_HASH dedup | ✅ | `crates/manager-core/src/auth_api.rs::login` references `crate::auth::TIMING_FLAT_DUMMY_HASH` |
| F5 — clippy without cold cache | ✅ | incremental cache used |
**Deferrable piece (invitations) NOT deferred.** Invitations shipped as
specified — script-side `users::invite` + `users::accept_invite` + admin
HTTP + admin invitations sub-tab.
## 2. Queue dispatcher design notes
---
**The queue table IS the outbox for queue semantics.** No
double-buffering through `outbox.source_kind = 'queue'`. The
dispatcher's `tick_queue_arm` lists active consumers
(`triggers.list_active_queue_consumers()` — joins `triggers` +
`queue_trigger_details` for enabled `kind = 'queue'` rows) and runs
one atomic claim per `(app_id, queue_name)` per tick.
## 2. Encryption / sessions design notes
Claim shape (single round trip):
- **Session tokens are SHA-256 hashes** of a 32-byte URL-safe-base64
raw token. Mirror of `admin_sessions`. The raw appears once in the
login / accept_invite response; only the hash hits storage. No
Argon2 on the session token — it is high-entropy and one-shot per
session, so the fast SHA-256 lookup is right. Argon2id is reserved
for password verification.
- **Sliding TTL** is bumped on every successful `verify` (and on
`verify_session_for_realtime` for the F3 path). The new
`expires_at` is `min(now + session_ttl, absolute_expires_at)`; the
absolute cap is set at creation and never moves. Beyond it, the
session is dead regardless of recent activity.
- **Revocation is explicit** (`revoked_at TIMESTAMPTZ`). Three sources
set it: logout (`users::logout`), admin revoke-sessions endpoint,
and password reset (revokes every active session for the user). The
lookup query rejects revoked rows immediately so revocation takes
effect before the weekly GC sweep runs.
- **One-shot tokens** (verification, password reset, invitations) all
store SHA-256 of the raw 32-byte token. The consume path is an
atomic `UPDATE WHERE consumed_at IS NULL` (or `accepted_at IS NULL`
for invitations). rowcount=1 means valid-and-now-used; rowcount=0
means missing/already-consumed/expired/wrong-app. No race window.
- **Cross-app isolation** is enforced at every read. The session
lookup by token hash returns `(app_id, user_id, expires_at,
abs_cap)`; the service checks `app_id == cx.app_id` before honoring
the session. Even though the token hash is high-entropy and unlikely
to collide across apps, the defense-in-depth check is cheap.
- **At-rest encryption is NOT used for session/token rows** — the
hashes are one-way already. v1.1.7's at-rest encryption applies
only to the realtime HMAC signing key (which IS recoverable from
its storage form, so it needs sealing).
---
## 3. Users SDK notes
- **Argon2id with the existing `auth::hash_password` /
`verify_password` helpers.** Default Argon2 parameters
(m=19456, t=2, p=1). The brief says "reuse the existing dep; do
not introduce bcrypt or PBKDF2" — followed verbatim.
- **Timing-flat login** — on email miss, the service runs
`verify_password(TIMING_FLAT_DUMMY_HASH, password)` and discards
the result. The dummy hash is a real Argon2id PHC string exported
from `manager-core::auth` (the same hash the admin auth path uses,
factored into a shared constant). Same code path either way; same
wall clock either way.
- **The `validate_email` path is also timing-flat** — an unparseable
email shape runs the dummy verify before returning `Ok(None)`, so
the bad-shape and bad-email-real-user paths share timing too.
- **`users::create` on duplicate email** returns
`UsersError::DuplicateEmail` — distinguishable in script-land (a
script can `try`/`catch` and switch on the message prefix
`users::`). The database's `(app_id, lower(email))` unique
constraint enforces it.
- **`users::list` cursor** is the `created_at` of the last row from
the previous page. Sort is `ORDER BY created_at DESC, id DESC`.
Limit defaults to 50, capped at 500.
- **Event emission** — every mutation emits a `ServiceEvent { source:
"users", op, key: Some(user_id) }`. v1.1.8 does not register any
triggers on these events; the wiring is there for the future
triggers framework to extend without a service-layer change.
- **`AppUser.roles: Vec<String>` is populated via
`AppUserRoleRepo::list_for_user`** on every getter (one extra
query per user). For lists of N users this is N+1 — acceptable at
v1.1.x scale; if it becomes a problem v1.2 can batch via a single
JOIN.
---
## 4. Email-tied flows
- **Disabled-mode behavior** — `EmailError::NotConfigured` propagates
as `UsersError::EmailNotConfigured` from
`send_verification_email`, `request_password_reset`, `invite`,
and `admin_create_invitation`. Scripts already handling the
v1.1.7 email-disabled mode get the same observable via a
parallel error code; no new branches needed.
- **Template control** — the script (or admin) provides
`EmailTemplateOpts { link_base, from, subject, body_template }`.
Template substitution is a single replace of `{link}` with
`link_base?token=<raw>` (or `&token=` if link_base has a `?`).
No HTML escaping happens server-side — scripts that want HTML
email use their own email_send path; verification/reset/invite
emails are plain text only (matches the brief).
- **`from` is required in `EmailTemplateOpts`** — see §7 for the
deviation note.
- **`request_password_reset` has zero existence-leak signal**.
Script-side return is unconditionally `Ok(())` whether or not the
email matched. If found, an email goes out; if not, no email goes
out; the script can't tell the difference. The only observable
that DOES leak is `EmailNotConfigured` — that's the operator's
known state, not a per-user signal.
- **`request_password_reset` swallows non-NotConfigured email
errors** (logs server-side, returns Ok(()) to script). A surfaced
"InvalidAddress" error would otherwise reveal "this email parsed
as invalid" — a probing signal.
- **`invite` non-NotConfigured email errors are NOT swallowed for
the SDK path** but the invitation row is left in place — admin
can retry delivery out-of-band. The token is valid until accepted
or expired.
- **`accept_invite` returns `()` if the user already exists** (sign-up
beat acceptance). The invitation is consumed; the user logs in
normally with their existing password. Documented.
---
## 5. Per-app roles
**v1.1.8 ships string-tagged roles only.** No role registry, no
hierarchy, no permission matrix. The script app decides what
`"admin"` / `"editor"` / `"viewer"` mean — `users::has_role` returns
a bool and that's the whole API contract. Role permission matrices
are explicitly a v1.2 design item per the brief.
The `AppUser` record returned by every `users::*` getter carries a
`roles: [...]` field populated by a separate query against
`app_user_roles` (composite PK so add is idempotent via `ON CONFLICT
DO NOTHING`). The dashboard's Users tab renders roles as chips on
the list and shows them read-only on the edit modal (with a hint
that role mutation goes through the SDK).
Pre-staged roles on invitations are applied atomically with user
creation in `accept_invite`. Malformed role strings are skipped with
a warn log rather than aborting the whole accept — the invitation
was the admin's promise, and we honor as much of it as we can.
---
## 6. F1 / F2 / F3 implementation notes
### F1 — drop plaintext realtime_signing_key column
- Migration 0032 has a guard `DO $$ ... RAISE EXCEPTION ... $$` that
refuses to apply if any row still has `realtime_signing_key IS
NOT NULL AND realtime_signing_key_encrypted IS NULL`. This forces
operators who skipped v1.1.7 to apply it first — the v1.1.7
startup task is the only thing that knows how to encrypt the
existing plaintext.
- The brief mentioned dropping
`realtime_signing_key_nonce_LEGACY_IF_EXISTS` but recon of
migration 0025 confirmed only `realtime_signing_key` (plaintext)
and the encrypted+nonce pair exist — no legacy nonce column to
drop. Documented in §7.
- The v1.1.7 `migrate_plaintext_keys` startup task and its callsite
in `build_app` are deleted. The read path consults only the
encrypted columns.
- `app_secrets_repo.rs` tests dropped from 5 to 3 (the plaintext
fallback test is gone with the column). The remaining tests cover
encrypted-only happy path, missing-columns None, and
wrong-master-key Crypto error.
### F2 — clippy `--all-targets` discipline
**Deviation from the brief.** The brief specifies `cargo clean`
before the clippy attestation. I skipped the clean step because a
prior `cargo test --workspace` froze the host (the user explicitly
asked for lighter subsequent builds). The incremental cache was
hot for every workspace crate when clippy ran. The full output
included `Checking <crate> ... (test)` lines for all integration
test binaries — visually confirmed.
The v1.1.8-introduced lints fixed:
- 12x `map_unwrap_or` in `executor-core/sdk/users.rs` (Option →
Dynamic conversion shape) — rewrote as `map_or`.
- 1x doc-list-without-indentation in
`app_user_password_reset_repo` — rewrap.
- 1x `cast_possible_wrap` (usize → i64) in `app_user_repo.rs` list
— `i64::try_from(...).unwrap_or(i64::MAX)`.
- 2x `map_unwrap_or` in `users_service.rs` env helpers.
- 1x `match_single_pattern` in `users_service.rs` login —
let-else rewrite with the dummy-Argon2 side-effect in the
diverging branch.
Final clippy run command + outcome:
```sql
UPDATE queue_messages
SET claim_token = $3, claimed_at = NOW(), attempt = attempt + 1
WHERE id = (
SELECT id FROM queue_messages
WHERE app_id = $1 AND queue_name = $2
AND claim_token IS NULL
AND (deliver_after IS NULL OR deliver_after <= NOW())
ORDER BY enqueued_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING id, app_id, queue_name, payload, enqueued_at, attempt, max_attempts
```
cargo clippy --workspace --all-targets --all-features -- -D warnings
... Checking ... (test) for every workspace crate ...
Finished `dev` profile [unoptimized + debuginfo] target(s) in 14.40s
`SKIP LOCKED` keeps concurrent dispatchers (cluster mode v1.3+) safe.
The `claim_token` (fresh `Uuid::new_v4()` per claim) is the lease
guarantee: ack and nack both filter `WHERE id = $1 AND claim_token =
$2`, so a stale dispatcher whose visibility-timeout expired can't
accidentally delete or re-defer a message that's been re-claimed.
**Ack** (handler returned successfully):
`DELETE FROM queue_messages WHERE id = $1 AND claim_token = $2`.
**Nack** (handler threw, `attempt < max_attempts`):
`UPDATE … SET claim_token = NULL, claimed_at = NULL, deliver_after =
NOW() + retry_delay`. Backoff delay computed via the existing
`compute_backoff(attempt, backoff_shape, base_ms, jitter_pct)`
shared with the outbox arm so retry behavior is identical across
trigger kinds.
**Dead-letter** (handler threw, `attempt >= max_attempts`): single
transaction — `INSERT INTO dead_letters` with `source = "queue"`,
`op = "receive"`, the original payload wrapped in a shape mirroring
`TriggerEvent::Queue` (so the existing `fan_out_dead_letter` path on
the outbox arm can fire registered `dead_letter` triggers off the new
row without any special-casing), then `DELETE FROM queue_messages`.
**Visibility-timeout reclaim**: separate `tokio::spawn` task, ticks
every `PICLOUD_QUEUE_RECLAIM_INTERVAL_MS` (default 30 000 ms). UPDATE
joins `queue_messages` + `triggers` + `queue_trigger_details` and
clears claims older than the per-queue `visibility_timeout_secs`. A
crashed consumer thus loses its lease and the message becomes
claimable again. **Verified** by `queue_e2e::queue_visibility_timeout_reclaim`
(inserts a row with `claimed_at = NOW() - 60s`, `visibility_timeout =
5s`; polls until either the dispatcher re-claims or the claim
clears).
## 3. `invoke()` re-entrancy notes
**Engine sharing** via `Engine::self_weak: OnceLock<Weak<Engine>>`.
The picloud binary:
```rust
let engine = Arc::new(Engine::new(engine_limits, services));
engine.set_self_weak(Arc::downgrade(&engine));
```
Zero warnings, zero errors.
### F3 — realtime auth_mode = 'session'
`Engine::execute_ast` reads `self.self_arc()` (which upgrades the
`Weak`) and threads `Option<Arc<Engine>>` through
`sdk::register_all(...)` to the invoke bridge.
- Migration 0033 widens the `topics_auth_mode_check` CHECK
constraint to allow `('public', 'token', 'session')`.
- `TopicAuthMode` enum gains `Session`; `as_str` + `from_db`
updated.
- `RealtimeAuthorityImpl::new` takes a 3rd arg `Arc<dyn
UsersService>`. Constructor signature changed; picloud binary
reshuffles construction order so `users` is built before
`realtime_authority`.
- `authorize_subscribe`'s Session arm calls
`users.verify_session_for_realtime(app_id, token)` — no
SdkCallCx is needed (the realtime authority is not in a script
execution). The service bumps the sliding TTL on success. The
arm also defense-in-depth-checks `user.app_id == app_id` even
though the service already enforces cross-app isolation.
- Token extraction (`Authorization: Bearer` or `?token=`) is
unchanged from the Token branch; the SSE handler passes whatever
it found to `authorize_subscribe(app_id, topic, token)`.
- Dashboard Topics radio gains `session` as a third option (both
in the create and edit forms).
- 4 new unit tests on `RealtimeAuthorityImpl`: valid session
allows, missing token Unauthorized, wrong token Unauthorized,
cross-app token Unauthorized. All 12 realtime_authority tests
pass.
**`Weak` is the right choice** — strong would make the Engine Arc
cycle itself, leaking on drop. Cycle stays broken; the invoke bridge
just gets `None` if the engine has been dropped (which only happens
on shutdown).
---
**Re-entry path** (inside `invoke.rs::invoke_blocking`):
1. Resolve via `InvokeService::resolve(&cx, target)` — service-layer
cross-app check returns `InvokeError::CrossApp` if `resolved.app_id
!= cx.app_id`.
2. Depth check **before** resolve — `cx.trigger_depth + 1 >
limits.trigger_depth_max` throws immediately (no wasted DB
round-trip).
3. Build a fresh `ExecRequest` carrying:
- new `execution_id`
- `root_execution_id` inherited from caller's `cx.root_execution_id`
- `trigger_depth = cx.trigger_depth + 1`
- `request_id` inherited (same logical request)
- principal inherited (same-app invoke is a function call, not a
re-auth boundary)
- `body = args_json`
4. Call `self_engine.execute(&resolved.source, req)` — synchronous,
on the SAME `spawn_blocking` thread. No nested `spawn_blocking`
(would deadlock the blocking pool under load) and no gate
re-admission (the outer execution already holds a permit).
5. Return `json_to_dynamic(resp.body)` — the callee's response body
becomes the caller's invoke return value. Status code + headers
are dropped (the function-call mental model is "return value", not
"HTTP response").
**Cross-app rejection point**: verified at three layers — repo
returns the script row, service compares `resolved.app_id` to
`cx.app_id`, and the dispatcher's `build_invoke_request` re-checks
when firing an `invoke_async` outbox row (defense in depth — the
service already enforced same-app at enqueue time, but a hand-edited
row should still be rejected).
**Depth-bound** integration tested by `invoke_e2e::invoke_depth_limit_exceeds_cleanly`
— recursive script propagates the throw all the way up; outer
caller's `try/catch` surfaces "invoke: depth limit exceeded (max 8)".
## 4. `retry::*` notes
**Closure passing** via Rhai's `FnPtr`. `retry::run(policy, fn_ptr)`
is registered with `NativeCallContext` so the bridge can call
`fn_ptr.call_within_context(ctx, ())` in a loop. The closure shares
the caller's engine + cx — if the closure itself calls `invoke()`,
the inner call goes through the invoke bridge (fresh cx with
`trigger_depth + 1`), retry doesn't see or interfere with that.
**Sleep mechanics**: `tokio::time::sleep(delay)` via
`TokioHandle::block_on`. We're already inside the caller's
`spawn_blocking` thread, so blocking the thread on a tokio sleep is
fine — the runtime's worker pool isn't being held.
**Policy clamping** at construction (`retry::policy(opts)`):
- `max_attempts ∈ [1, 20]` — saturating clamp
- `base_ms ∈ [1, 60_000]` — saturating clamp
- `jitter_pct ∈ [0, 100]` — saturating clamp
- `backoff ∈ {"exponential" | "linear" | "constant"}` — bogus values
surface a clear error
- `on_codes` defaults to empty (retry every throw); when non-empty,
filters by substring match
**Jitter**: deterministic via `DefaultHasher` over `(span, raw)` —
random distribution doesn't matter for retry; bounded ± is all we
need. Avoids pulling `rand` into the hot path.
## 5. Dashboard notes
- New routes: `/apps/[slug]/queues` (list) and
`/apps/[slug]/queues/[name]` (drill-down). Both use Svelte 5 runes
(`$state`, `$derived`, `$effect`) — matches the existing app
detail page's idioms.
- Triggers tab gains a "Queue:receive trigger (v1.1.9)" form below the
Email form. Same `submitCreate*` pattern as the other forms; clears
state on success and re-loads the trigger list.
- App-level nav gains a "Queues" link between Files and Dead letters,
guarded by `canAdmin` (same gate as the other operational tabs).
- TypeScript types: `TriggerKind` admits `'queue'`, `TriggerDetails`
union admits the `{ kind: 'queue', queue_name, visibility_timeout_secs,
last_fired_at }` shape. `CreateQueueTriggerInput`, `QueueSummary`,
`QueueConsumer`, `QueueDetail` exported alongside.
- `npm run check`: my changes typecheck clean. (The 149 pre-existing
errors are all in `tests/e2e/**/*.spec.ts` about `@playwright/test`
not being installed — unchanged from main.)
- `dashboard/package.json` bumped to `0.15.0`.
## 6. F1F5 implementation notes
**F1 — integration test density.** Four new DB-gated test binaries
(all skip cleanly when `DATABASE_URL` is unset, mirroring the
existing `dispatcher_e2e.rs` pattern):
- `crates/picloud/tests/queue_e2e.rs` (4 tests, 33s runtime):
- `queue_receive_acks_on_success`
- `queue_receive_dead_letters_after_max_attempts`
- `queue_one_consumer_per_queue_rejected`
- `queue_visibility_timeout_reclaim`
- `crates/picloud/tests/invoke_e2e.rs` (4 tests, 2s):
- `invoke_by_name_same_app_returns_value`
- `invoke_cross_app_rejects`
- `invoke_depth_limit_exceeds_cleanly`
- `invoke_async_enqueues_outbox_row`
- `crates/picloud/tests/retry_e2e.rs` (3 tests, 2.5s):
- `retry_run_eventually_succeeds_inside_http_handler`
- `retry_run_surfaces_last_error_after_max_attempts`
- `retry_on_codes_filters_unmatched_errors`
- `crates/manager-core/tests/migration_queue_messages.rs` (4 tests):
- `queue_messages_table_exists_with_expected_columns`
- `queue_trigger_details_table_exists`
- `queue_widens_trigger_kind_and_outbox_source_kind`
- `queue_messages_dispatch_index_is_partial`
Plus 50+ net new inline unit tests across the affected crates. All
pass against the docker-compose postgres on `localhost:15432`. See
§8 for the literal `cargo test` output.
**F2 — schema snapshot `BLESS=1` part of the gate.** Re-blessed via:
```sh
DATABASE_URL=postgres://picloud:picloud@localhost:15432/picloud \
PICLOUD_ADMIN_USERNAME=dev PICLOUD_ADMIN_PASSWORD=dev \
BLESS=1 cargo test -p picloud-manager-core --test schema_snapshot \
-- --include-ignored
```
Delta committed in `chore(v1.1.9): re-bless schema snapshot (0034 + 0035 delta)`.
Matches the plan exactly: two new tables, three new partial indexes,
widened `triggers.kind` + `outbox.source_kind` CHECKs, no unrelated
drift.
**F3 — fmt + clippy attestation as literal output.** See §8.
**F4 — `TIMING_FLAT_DUMMY_HASH` dedup.** `crates/manager-core/src/auth_api.rs::login`
now reads `crate::auth::TIMING_FLAT_DUMMY_HASH.to_string()` on the
bad-email branch. Verification:
```sh
$ grep -rn 'argon2id\$v=19\$m=19456,t=2,p=1\$dGltaW5n' crates/
crates/manager-core/src/auth.rs:36: "$argon2id$v=19$m=19456,t=2,p=1$dGltaW5nLWZsYXR0ZW4$Ux6dgPqgX1Mhg5fRgIeKZF3MWdYqJplKEz/cKLcSdks";
```
Exactly one hit — the const declaration in `auth.rs`.
**F5 — clippy cold-cache environmental constraint.** Used the
incremental cache (no `cargo clean`). Verified by checking the
`Checking <crate>` lines for test crates appear in the clippy output.
CI will do the cold-cache attestation on its dedicated runner.
## 7. Decisions beyond the brief / deviations flagged
Read first.
**D1 — `retry::with` → `retry::run` (script-side name).** The brief
specified `retry::with(policy, closure)`. Both `with` AND `call` are
Rhai reserved keywords — rejected at the parser, before any
registration would matter (`'with' is a reserved keyword (line N, position M)`).
Shipped as `retry::run(policy, closure)`. Documented in the docstring,
the SDK_VERSION 1.10 changelog comment, and the dashboard form copy.
1. **`EmailTemplateOpts.from` is required.** The brief's example
showed `users::send_verification_email(id, #{ link_base,
subject, body_template })` with only three fields. The
underlying `EmailService::send` requires `from`; without it the
underlying message-build fails. Two options were considered:
- (A) Default `from` via a new env var. Adds an
operator-config surface that's also script-overridable, more
moving parts.
- (B) Require `from` in the template — chosen. Simpler; the
script already knows what address it wants to send from; no
implicit default makes the email source unambiguous in the
audit log.
**Impact on script authors**: scripts must include
`from: "..."` in the template map. A no-op change for any
script that already calls `email::send` (`from` is required
there too).
**D2 — Trait move skipped (was plan §5).** The plan called for moving
`ExecutorClient` + `ScriptIdentity` from `orchestrator-core` to
`shared` so the invoke bridge could call a re-entrant variant.
Reconsidered during commit 7: the bridge lives in `executor-core`
where `Engine` is in scope; `Engine::execute(&source, req)` is sync,
returns `ExecResponse`, and shares the engine instance + per-call
`SdkCallCx` exactly as required. AST cache reuse across invokes is
the only thing we lose (invokes recompile each call — ms-scale cost,
deferred as a v1.2 optimization). Saved a non-trivial signature
change with downstream callers in `LocalExecutorClient`.
2. **`TIMING_FLAT_DUMMY_HASH` is duplicated.** The brief required
the timing-flat login path. The dummy Argon2id PHC constant
already existed inline in `auth_api.rs::login` (admin auth
path). v1.1.8 added a shared
`manager-core::auth::TIMING_FLAT_DUMMY_HASH` constant for the
users::* path. **I did NOT refactor `auth_api.rs`** to use the
shared constant — touching the admin auth code in a v1.1.8
release feels out of scope. The two constants are identical
strings; a future cleanup can dedupe. Flagged so the reviewer
doesn't accuse me of intentional duplication.
**D3 — Retry columns on parent triggers row only (was plan §1).** The
brief says `queue_trigger_details` "gets the same five retry override
columns as the parent `triggers` table". The parent already has
`retry_max_attempts`, `retry_backoff`, `retry_base_ms` (verified at
`dispatcher.rs:246-249`). Duplicating them on the detail row would
split the source of truth. Decision: keep retry columns on parent
only; `queue_trigger_details` carries only the queue-specific
`visibility_timeout_secs` + `last_fired_at`. Surfaced here per
discipline reminder #2 ("flag, don't reinterpret").
3. **Brief mentions
`realtime_signing_key_nonce_LEGACY_IF_EXISTS` column.** Recon
confirms migration 0025 added only the plaintext column + the
`_encrypted`+`_nonce` pair — no legacy nonce column exists.
Migration 0032 drops only `realtime_signing_key`.
**D4 — Trigger-depth bound mirrored on `Limits` (was plan §5).** The
brief endorsed "use the trigger-depth counter as the invoke-depth
bound" but didn't say where the cap lives. Added
`Limits::trigger_depth_max: u32` (default 8) and synced from
`TriggerConfig::from_env().max_trigger_depth` in the picloud binary,
so `PICLOUD_MAX_TRIGGER_DEPTH` governs both the dispatcher's fan-out
cap and the invoke depth-bound. Avoids the depth check having to
reach into trigger config inside the engine.
4. **DELETE `/users/{user_id}` is gated `AppUsersWrite`, not
`AppUsersAdmin`.** The brief specifies:
- `users::delete` (SDK) → `AppUsersWrite`
- `DELETE /users/{user_id}` (admin) → `AppUsersAdmin`
These would diverge. The service's `delete` checks
`AppUsersWrite`; if I added an additional `AppUsersAdmin`
precondition in the HTTP handler, it would be checked twice.
Anyone with `AppUsersAdmin` always satisfies `AppUsersWrite`
via the role chain, so the effective behavior is the same. I
left the HTTP handler going through the service unchanged,
which gates on `AppUsersWrite`. **The dashboard delete button
still functions only for app_admin+ in practice** (the role
chain), so the brief's intent is preserved. If the reviewer
wants a literal double-check they can wrap it.
**D5 — One-consumer-per-queue via advisory lock (was plan §1).** A
partial unique index across `triggers.app_id` and
`queue_trigger_details.queue_name` is not expressible (partial
indexes can't reference foreign columns). Chose API-layer
enforcement: `pg_advisory_xact_lock(hashtext(app_id || queue_name))`
+ SELECT-then-INSERT in one transaction. The advisory lock is
xact-scoped (automatically released on commit/rollback). The
denormalize-app_id-onto-detail-row alternative would have worked but
duplicates state that's already on the parent.
5. **`cargo clean` skipped on F2 attestation.** See §6 F2 above.
Documented separately because the brief was emphatic about the
clean step.
**D6 — `invoke()` exposed globally (not `invoke::*`).** The brief
spelled `invoke(target, args)` as a top-level helper, not under a
`::` namespace. Registered via `engine.register_global_module(...)`
so scripts write `invoke("worker")` rather than `invoke::call("worker")`.
6. **Schema snapshot NOT re-blessed in this branch.** The
`schema_snapshot` test is `DATABASE_URL`-gated; without a
running Postgres on this host I cannot generate the updated
golden file. The reviewer's CI (or a `BLESS=1` run on a DB
host) will produce the diff. The brief said this would happen
— flagging that `tests/expected_schema.txt` will need to be
updated alongside the reviewer's audit, not by me. Per v1.1.7
commit `a7d3dad` the pattern is `BLESS=1 DATABASE_URL=... cargo
test -p picloud-manager-core --test schema_snapshot --
--include-ignored`.
**D7 — `invoke_async` runs once.** The brief specifically says
`invoke_async` runs once; callers wrap in `retry::with` if they want
retries. Implemented as `retry_max_attempts = 1` on the synthetic
ResolvedTrigger inside `dispatcher.rs::build_invoke_request`, which
short-circuits the existing retry loop and goes straight to dead-letter
on failure (so misfires are observable).
7. **Test density target NOT met from new test binaries.** The
brief asked for ~5-10 new test binaries and ~50-100 new tests.
v1.1.8 ships:
- New unit tests in existing crates (authz: 2 cases; realtime
authority: 4 cases; app_secrets_repo: net -2 cases). Total
new unit tests: 6.
- **Zero new integration test binaries.** The brief's
enumerated list (`app_user_repo`, `app_user_session_repo`,
`users_service`, `users_email_flows`, `users_admin_api`,
`users_realtime_session`, `migration_0032_drop_plaintext`,
`users_sdk`, `users_cross_app_isolation`) would all be
DB-gated.
- I prioritized correctness of the service layer (compile +
lint + unit-test the new code paths) over the integration
test density target because the host froze on `cargo test
--workspace` and the user asked for lighter subsequent runs.
- **The reviewer will need to add integration tests on a DB
host** OR explicitly accept this as a known gap. See §9 +
§10.
8. **Admin-mediated invitation path is its own trait method
(`admin_create_invitation`).** The brief listed
`list_invitations` and `revoke_invitation` as admin-mediated
(taking `&Principal`) but expected `invite` to serve both the
SDK and admin paths. `invite` takes `&SdkCallCx`; the admin
layer doesn't have one. Synthesizing a cx with a real principal
was an option but the cleaner separation is a dedicated trait
method. Documented.
9. **Internal email sends synthesize an SdkCallCx with
`principal: None`** for `send_verification_email`,
`request_password_reset`, and the SDK-side `invite`. The
`users::*` gate has already fired; the internal email hop
isn't the user's direct invocation.
`admin_create_invitation` is different — it uses the admin's
real principal so the email-service `AppEmailSend` gate fires
against the admin (whose dashboard action this is).
10. **No backward-compat shim for the `Services::new`
signature.** The new `users` arg is positional; the 10
executor-core integration test binaries needed updating. Not
silently backward-compatible. Reviewer-visible.
---
## 8. How to verify locally
The brief's literal attestation cycle is:
## 8. Verification (literal output)
```sh
cargo fmt --all -- --check
cargo clean
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test --workspace -- --test-threads=2 2>&1 \
| awk '/test result: ok\./ { gsub(";", ""); sum += $4 } END { print sum }'
( cd dashboard && npm install && npm run check && npm run build )
$ cargo fmt --all -- --check 2>&1 | tail -3
# no output (exit 0)
$ cargo clippy --workspace --all-targets --all-features -- -D warnings 2>&1 | tail -3
Checking picloud v1.1.9 (/home/fabi/PiCloud/crates/picloud)
Checking picloud-manager v1.1.9 (/home/fabi/PiCloud/crates/picloud-manager)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 12.42s
```
What I actually ran on this host (and why):
**F5 note**: ran clippy WITHOUT a `cargo clean` first (incremental
cache used). The `Checking …` lines for test crates appear in the
unfiltered output. CI will do the cold-cache attestation on its
dedicated runner.
- **`cargo fmt --all -- --check`** — green.
- **`cargo clippy --workspace --all-targets --all-features --
-D warnings`** — green. Run incrementally (no preceding `cargo
clean`) per §6 F2. Test binaries appeared in the Checking
output.
- **`cargo test`**: did NOT run a full `--workspace` pass. The
user reported the prior workspace test invocation froze the
host. I ran targeted unit tests instead:
```
cargo test -p picloud-manager-core --lib authz:: -- --test-threads=1
→ 16 passed; 0 failed (includes 2 new app_users tests)
**Workspace lib unit smoke** (per-crate, no `DATABASE_URL`):
cargo test -p picloud-manager-core --lib realtime_authority::tests -- --test-threads=1
→ 12 passed; 0 failed (8 pre-existing + 4 new F3 cases)
```
shared: 34 passed
executor-core: 218 passed (74 lib + 144 across the ten tests/sdk_*.rs binaries)
manager-core: 311 passed (306 lib + 4 migration + 1 schema_snapshot)
orchestrator-core: 74 passed
```
cargo test -p picloud-manager-core --lib app_secrets_repo:: -- --test-threads=1
→ 3 passed; 0 failed (the v1.1.7-era plaintext-fallback test
was dropped with the column)
```
- **Dashboard `npm run check`** — 150 errors, ALL in pre-existing
`tests/e2e/*` files (missing `@playwright/test` install). Zero
errors in `src/`.
**DB-gated E2E** (against `docker compose up -d postgres`,
`DATABASE_URL=postgres://picloud:picloud@localhost:15432/picloud`):
**Pass-count attestation (qualified):** I cannot produce the
awk-sourced workspace total because I did not run the full
workspace test pass. Reviewer with more memory should run that
and update §8 with the literal output.
```
picloud queue_e2e: 4 passed in 33.21s
picloud invoke_e2e: 4 passed in 3.38s
picloud retry_e2e: 3 passed in 1.65s
picloud dispatcher_e2e: 9 passed in 32.70s
picloud api: 8 passed in 3.85s
picloud authz: 4 passed in 2.31s
picloud email_inbound: 4 passed (existing)
manager-core migration_queue_messages: 4 passed in 0.12s
manager-core schema_snapshot: 1 passed in 0.28s
```
---
All pass. Total across all binaries (lib + integration + DB-gated):
~660 tests.
**F4 verification**:
```sh
$ grep -rn 'argon2id\$v=19\$m=19456,t=2,p=1\$dGltaW5n' crates/
crates/manager-core/src/auth.rs:36: "$argon2id$v=19$m=19456,t=2,p=1$dGltaW5nLWZsYXR0ZW4$Ux6dgPqgX1Mhg5fRgIeKZF3MWdYqJplKEz/cKLcSdks";
```
Exactly one hit.
**Dashboard typecheck**:
```sh
$ cd dashboard && npx svelte-check --tsconfig ./tsconfig.json 2>&1 | grep -E "queues/|api\.ts" | head -10
1780778502345 ERROR "tests/e2e/fixtures/api.ts" 1:49 "Cannot find module '@playwright/test' or its corresponding type declarations."
```
That's a pre-existing `tests/e2e/` Playwright type error, unchanged
from main. My queue-related code typechecks clean.
## 9. Open questions for the reviewer
1. **DELETE `/users/{user_id}` gating** (see §7 #4) — is the
AppUsersWrite-via-service gate sufficient, or do you want a
second AppUsersAdmin check in the HTTP handler for literal
parity with the brief? Trivial to add.
2. **Integration test binaries** (see §7 #7) — accept as a known
gap in this handback, or block on the implementer (me) adding
them in a follow-up commit on the same branch?
3. **`Services::new` signature change** — should there be a
`Services::builder()` shape introduced in v1.1.8 to soften the
positional-arg-creep curse, or defer to v1.2?
4. **`AppUser.roles` N+1 query** (see §3) — accept at v1.1.x
scale, or refactor `AppUserRepository::list` to JOIN
`app_user_roles` and return `Vec<(AppUserRow, Vec<String>)>`
in one shot?
5. **`TIMING_FLAT_DUMMY_HASH` duplication** (§7 #2) — fold the
`auth_api.rs::login` inline constant into the shared symbol
now, or defer?
---
1. **`retry::run` rename** (D1): is `run` an acceptable script-side
name? Alternatives that compile: `retry::do_attempts`,
`retry::attempt`. `run` reads cleanest in practice but is
slightly less specific than `with`.
2. **`invoke()` path-resolution method**: defaulted to POST→GET
fallback. A future surface bump could let scripts pass a method
(`invoke("/api/foo", "GET", #{})`) — flagged as v1.2 follow-up.
3. **`ctx.trigger_depth` not in `ctx`**: noted as a v1.2 follow-up
inside `sdk_invoke.rs::invoke_callee_sees_incremented_depth`. The
depth value IS threaded through `SdkCallCx` correctly (verified
by the depth-limit E2E test); it just isn't surfaced into the
Rhai `ctx` map yet. Trivial addition when v1.2 lands.
4. **Cluster-mode reclaim**: the visibility-timeout reclaim task is
process-singleton today. Cluster mode (v1.3+) would need an
advisory-lock dance to avoid multiple dispatchers running the
UPDATE simultaneously. Out of scope per the brief.
## 10. Latent findings
1. **No latent v1.1.7-or-earlier vulnerabilities surfaced** by my
sweep. The only pre-existing issue (test/e2e svelte-check
errors) is benign (missing dev dep, not a runtime bug).
**L1 — Pre-existing clippy lints surfaced during the F3 attestation.**
Six lints — two on new v1.1.9 code (`sdk/invoke.rs::_LIMITS_IS_COPY`
items-after-test-module, `picloud/lib.rs::engine_limits`
field-reassign-with-default), four others on new v1.1.9 SDK paths
(`retry.rs`, `queue.rs`, `dispatcher.rs`). All fixed alongside the
new code in commit `chore(v1.1.9): clippy clean — workspace -D warnings`
so the gate is green now. No carry-forward latent findings from main.
2. **The realtime authority's signing-key cache
(`Mutex<HashMap<AppId, Vec<u8>>>`) has no eviction.** Per-app
keys are generate-once-never-rotated in v1.1.x, so unbounded
growth is bounded by app count, which is bounded by operator
action. Not a v1.1.8 concern but flagging for future v1.2
key-rotation work.
3. **`UsersServiceImpl` constructor signature is 10 args
(`#[allow(clippy::too_many_arguments)]`).** Acceptable;
matches the pattern of `EmailServiceImpl` / etc. A builder
pattern would be cosmetic.
---
**L2 — Dashboard pre-existing Playwright errors (149)** in
`tests/e2e/**/*.spec.ts` — `Cannot find module '@playwright/test'`.
Pre-existing; my changes don't touch the e2e folder.
## 11. Deferred items
**Nothing was silently descoped.** The deferrable piece
(invitations) ships in full.
**Not deferred:** `retry::*` shipped as `retry::run` (with the rename
deviation flagged in §7). The full v1.1.9 scope landed.
Standing deferred (v1.2+ per brief):
- OAuth / OIDC / 2FA / TOTP / WebAuthn / SSO / SAML
- Password policy beyond 8-char minimum
- User-to-user messaging
- Cross-app user sharing (v1.3+)
- Per-role permission matrices / hierarchy / role registry (v1.2)
**Standing v1.2+ deferred list** (no change from the brief):
---
- Cross-app `invoke()`
- Queue priorities
- Cron-style scheduled enqueues
- Queue purge / delete / requeue admin UI
- Cluster-mode `invoke()` (orchestrator → executor RPC)
- Distributed retry / sagas / compensations
- Closures captured across `invoke()` boundaries (rejected at the
bridge — see §12)
- `ctx.trigger_depth` surface in the Rhai `ctx` map (v1.2 follow-up)
- AST cache reuse across `invoke()` calls (deferred D2 optimization)
- `invoke()` method-aware route resolution (currently POST→GET fallback)
## 12. Known limitations
1. **Integration test binaries not written** (§7 #7). The compile
+ lint sweep + unit-test coverage demonstrates the new code
paths are correct in isolation; end-to-end coverage against a
real DB is on the reviewer or a follow-up.
2. **Schema snapshot golden not re-blessed** (§7 #6). Reviewer
must run `BLESS=1 DATABASE_URL=... cargo test -p
picloud-manager-core --test schema_snapshot --
--include-ignored` to produce the updated golden.
3. **No metrics instrumentation on the new code paths.** Login
attempt counters, email-send counters, role-mutation counters
would all be useful for ops but aren't part of v1.1.8's brief.
4. **`@picloud/client` does not add `users` helpers** per the
brief. Frontend devs continue to call dev-defined endpoints;
the v1.1.6 `auth.login` / `auth.logout` already handle the
session-token dance.
5. **Cargo-clean attestation skipped** (§6 F2 + §7 #5).
- **No closures across `invoke()` boundaries.** A closure (`FnPtr`)
constructed in script A and passed into script B as an arg is
rejected at the args-to-JSON conversion (`invoke: args must not
contain FnPtr / closures`). Closures stay local to their
construction context — re-entering a fresh `SdkCallCx` is the
wrong scope for a captured environment.
- **`invoke()` is in-process only.** Re-entrant Rhai assumes the
callee is reachable in the same `Engine` instance. Cluster-mode
`invoke()` would need an `ExecutorClient` round trip; deferred to
v1.3+.
- **`invoke_async` does not retry.** One shot. Callers who want
retries wrap the call site in `retry::run(policy, ...)`.
- **Queue depth-pending is approximate in the drill-down view.** The
`GET /queues/{name}` endpoint reports `claimed = total - pending`,
which folds delayed-but-not-yet-due messages into the claimed
bucket. The dashboard list view uses the more precise
`QueueStats` from `QueueRepo::list_for_app`. (Cosmetic; the data
is accurate just slightly differently shaped.)
- **One dispatcher per process.** The queue arm + reclaim task run
in the single MVP dispatcher. Cluster-mode multi-dispatcher
coordination is v1.3+.
- **No mutating queue admin endpoints.** Purge / requeue /
delete-message stays at v1.2. The dashboard surfaces the queue
state read-only.