Compare commits

..

1 Commits

Author SHA1 Message Date
m
c2f1c29b10 fix(t-paliad-176): FilterBar timeline narrowing + Nur-direkt subtree skip
Two regressions from SmartTimeline Slices 2-4 dogfood @ 2026-05-09:

m/paliad#32 — clicking timeline_status / timeline_track / project_event_kind
chips changed URL params but the rendered list never narrowed. Two
causes: (1) the Verlauf bar mounted only "time" + "project_event_kind"
axes — the timeline_status / timeline_track chips never appeared. (2)
the customRunner drained predicates into `loadEvents` which writes the
legacy `events` array; the SmartTimeline render reads `timelineRows`,
so the filter pass was a dead branch.

Fix: mount all three axes on the bar; rewrite customRunner to drain
state into `verlaufFilters`; renderTimeline applies them client-side
via `applyTimelineRowFilters` before handing rows to renderSmartTimeline.
project_event_kind is forwarded through the substrate-shaped predicate
map (effective.filter.predicates.project_event.event_types);
timeline_status / timeline_track sit on raw BarState — the customRunner
signature now accepts the BarState snapshot as a second arg so the
bar's first run (before the handle is assigned) can read them.

Backend adds `ProjectEventType` to TimelineEvent + frontend
TimelineEvent — needed so the project_event_kind chip can match against
the underlying paliad.project_events.event_type for milestone rows.

m/paliad#33 — "Nur direkt" pill flipped subtreeMode and re-fetched the
timeline with ?direct_only=true, but ProjectionService.For honoured the
flag only at the deadline / appointment / project_events SQL level. CCR
sub-project lanes (Slice 3) and child-case lanes (Slice 4) loaded
unconditionally, so the "direct" view still showed everything.

Fix: `For` short-circuits to `forDirectSelfOnly` whenever DirectOnly is
set. Single "self" lane, no CCR / parent_context / child-case
aggregation. The level-policy kind/status filter still applies at
higher levels so a Patent-level direct view doesn't leak off_script
custom milestones the aggregated view filters out.

Tests: two new live-DB subtests in TestProjectionService_LevelAggregation_Live
pin the contract — Patent direct_only collapses to a single 'self' lane
and excludes child-case events; Case-A direct_only excludes the CCR
child's milestones (with subtree default still surfacing them).

Build: go build/vet/test clean. bun run build clean (2171 keys).
2026-05-09 18:52:01 +02:00
18 changed files with 342 additions and 845 deletions

View File

@@ -186,30 +186,20 @@ func main() {
if err != nil {
log.Fatalf("paliadin: remote config: %v", err)
}
// Per-user RLS auth (t-paliad-156): hand the JWT secret to
// the service so RunTurn mints a fresh user-scoped token
// per turn. The secret is the same SUPABASE_JWT_SECRET the
// auth client uses to verify session cookies; we just sign
// short-lived tokens with it.
cfg.JWTSecret = []byte(jwtSecret)
svcBundle.Paliadin = services.NewRemotePaliadinService(pool, users, cfg)
log.Printf("paliadin: remote mode → ssh %s@%s:%d (owner=%s, rls=per-user)",
log.Printf("paliadin: remote mode → ssh %s@%s:%d (owner=%s)",
cfg.SSHUser, cfg.SSHHost, cfg.SSHPort, services.PaliadinOwnerEmail)
} else if _, err := exec.LookPath("tmux"); err == nil {
sessionPrefix := os.Getenv("PALIADIN_SESSION_PREFIX")
responseDir := os.Getenv("PALIADIN_RESPONSE_DIR")
local := services.NewLocalPaliadinService(pool, users, sessionPrefix, responseDir)
// Per-user RLS auth (t-paliad-156): wire the JWT secret so
// RunTurn mints a fresh user-scoped token and writes it to
// <responseDir>/<turnID>.jwt for the claude pane to read.
local.SetJWTAuth([]byte(jwtSecret), 0)
// Late-response janitor — patches rows when Claude writes the
// response file after the 60 s pollForResponse window expires.
// Runs for the process lifetime; cleaned up when bgCtx
// cancels on SIGTERM.
local.StartJanitor(bgCtx)
svcBundle.Paliadin = local
log.Printf("paliadin: local tmux mode (owner=%s, janitor=on, rls=per-user)", services.PaliadinOwnerEmail)
log.Printf("paliadin: local tmux mode (owner=%s, janitor=on)", services.PaliadinOwnerEmail)
} else {
svcBundle.Paliadin = services.NewDisabledPaliadinService(pool, users)
log.Printf("paliadin: disabled (no PALIADIN_REMOTE_HOST, no local tmux; owner=%s)",

View File

@@ -71,7 +71,10 @@ export function mountFilterBar(host: HTMLElement, opts: MountOpts): BarHandle {
try {
let result: ViewRunResult;
if (opts.customRunner) {
result = await opts.customRunner(effective);
// Hand the runner a frozen snapshot of the bar state so it can
// read axes the EffectiveSpec doesn't round-trip (SmartTimeline
// timeline_status / timeline_track on the Verlauf surface).
result = await opts.customRunner(effective, Object.freeze({ ...state }));
} else {
const slug = opts.systemViewSlug as string; // ctor guard guarantees this
const r = await fetch(`/api/views/${encodeURIComponent(slug)}/run`, {
@@ -202,6 +205,11 @@ export function mountFilterBar(host: HTMLElement, opts: MountOpts): BarHandle {
if (lastEffective) return lastEffective;
return computeEffective(opts.baseFilter, opts.baseRender, state);
},
getState() {
// Hand back a frozen snapshot so callers can't smuggle mutations
// back into the bar's owned state — the bar is the single writer.
return Object.freeze({ ...state });
},
destroy() {
destroyed = true;
toolbar.remove();

View File

@@ -112,12 +112,14 @@ export interface MountOpts {
systemViewSlug?: string;
// Custom runner. When set, the bar bypasses the substrate POST and
// hands the effective spec to this function instead. Used by surfaces
// that haven't migrated to the substrate yet (Verlauf tab still hits
// /api/projects/{id}/events to keep subtree expansion + cursor
// pagination, t-paliad-170). Must be either this OR systemViewSlug —
// the bar throws if both / neither are provided.
customRunner?: (effective: EffectiveSpec) => Promise<ViewRunResult>;
// hands the effective spec + raw BarState to this function instead.
// Used by surfaces that need axes the EffectiveSpec doesn't round-trip
// (e.g. SmartTimeline's timeline_status / timeline_track, t-paliad-176).
// The state argument is a frozen snapshot — same shape getState()
// returns on the handle, but available on the very first run before
// the handle has been assigned. Must be either this OR systemViewSlug
// — the bar throws if both / neither are provided.
customRunner?: (effective: EffectiveSpec, state: Readonly<BarState>) => Promise<ViewRunResult>;
// Per-surface override of the time-axis chip presets. Order is
// preserved. Default presets are forward-looking (next_*+past_30d+any)
@@ -150,4 +152,10 @@ export interface BarHandle {
// Read-only effective spec at this moment (post URL + localStorage
// overlay). Pages use this to construct deep-link URLs etc.
getEffective(): EffectiveSpec;
// Read-only raw BarState. Surfaces with axes the EffectiveSpec doesn't
// round-trip (timeline_status / timeline_track on the SmartTimeline
// surface — the substrate FilterSpec has no per-source predicate for
// those) read state directly to drive client-side filtering. Returns
// a frozen snapshot; callers must not mutate.
getState(): Readonly<BarState>;
}

View File

@@ -255,16 +255,30 @@ let timelineSelectedLanes: string[] | null = null;
// and back keeps the user's choice.
let timelineClientShowLanes = false;
// t-paliad-170 — Verlauf FilterBar state.
// t-paliad-170 / t-paliad-176 — Verlauf FilterBar state.
//
// The bar mounts once, owns the URL params (?time=, ?pe_kind=, …), and
// drives loadEvents through its customRunner. Filtering is client-side
// against the legacy /api/projects/{id}/events response so subtree mode
// + cursor pagination stay intact (substrate-side scope expansion lands
// with t-paliad-169 SmartTimeline). Empty filter → identity passthrough.
// The bar mounts once, owns the URL params (?time=, ?pe_kind=, ?tl_status=,
// ?tl_track=, …), and drives a client-side filter pass over `timelineRows`
// before render. The SmartTimeline endpoint has no built-in predicate for
// timeline_status / timeline_track / project_event_kind axes — they sit on
// BarState only — so we filter rendered rows in `applyTimelineRowFilters`
// rather than re-fetching on every chip click. The customRunner drains the
// bar's state into `verlaufFilters` and triggers a re-render via onResult.
let verlaufBar: BarHandle | null = null;
interface VerlaufFilters {
// project_event_kind chip — values from KnownProjectEventKinds (see
// internal/services/filter_spec.go). Only filters rows whose underlying
// project_events.event_type is non-empty (deadline / appointment /
// projected rows pass through unaffected — they have no event_type).
eventKinds?: Set<string>;
// timeline_status chip — matches TimelineEvent.status verbatim
// (done | open | overdue | predicted | predicted_overdue | court_set | off_script).
timelineStatuses?: Set<string>;
// timeline_track chip — chip values are "parent" / "counterclaim" /
// "off_script" but row.track may carry suffixed forms like
// "counterclaim:<id>" or "parent_context:<id>". Filtering normalises
// by matching the chip's prefix against the row's track tag.
timelineTracks?: Set<string>;
// Bounds are inclusive lower / exclusive upper, matching
// computeViewSpecBounds in internal/services/view_service.go so the
// semantics align when this surface eventually moves to the substrate.
@@ -273,6 +287,65 @@ interface VerlaufFilters {
}
let verlaufFilters: VerlaufFilters = {};
// applyTimelineRowFilters narrows the SmartTimeline rows to whatever
// the FilterBar's BarState declares. Empty filter → identity passthrough.
// Called from renderTimeline immediately before handing rows to
// renderSmartTimeline (single-column or lane-strip alike).
function applyTimelineRowFilters(rows: SmartTimelineEvent[]): SmartTimelineEvent[] {
const f = verlaufFilters;
if (
!f.eventKinds &&
!f.timelineStatuses &&
!f.timelineTracks &&
!f.fromDate &&
!f.toDate
) {
return rows;
}
return rows.filter((r) => {
// project_event_kind narrows project_events specifically: deadline /
// appointment / projected rows pass through unaffected (they carry no
// project_event_type). A milestone whose project_event_type isn't in
// the picked subset drops out.
if (f.eventKinds && r.project_event_type) {
if (!f.eventKinds.has(r.project_event_type)) return false;
}
if (f.timelineStatuses && !f.timelineStatuses.has(r.status)) return false;
if (f.timelineTracks && !timelineTrackChipMatches(r.track, f.timelineTracks)) return false;
if (f.fromDate || f.toDate) {
// Undated rows (court-set decisions, counterclaim-pending) escape
// the time horizon — same convention as the renderer's "Datum offen"
// bucket. Otherwise compare the row's date against the bounds.
if (r.date) {
const d = new Date(r.date);
if (f.fromDate && d < f.fromDate) return false;
if (f.toDate && d >= f.toDate) return false;
}
}
return true;
});
}
// timelineTrackChipMatches normalises the chip vocabulary against the
// row's track tag — chip "counterclaim" matches both "counterclaim" and
// "counterclaim:<id>"; chip "parent" matches "parent" only (NOT
// "parent_context:<id>", which is a CCR-child-viewing-parent overlay).
function timelineTrackChipMatches(rowTrack: string, chips: Set<string>): boolean {
const tag = rowTrack || "parent";
if (chips.has(tag)) return true;
for (const chip of chips) {
if (chip === "counterclaim" && tag.startsWith("counterclaim:")) return true;
}
return false;
}
// applyVerlaufFilters narrows the legacy /api/projects/{id}/events
// response to the bar's filter state. The render path no longer reads
// this `events` array (the SmartTimeline took over), but loadEvents +
// loadMoreEvents still call it so the cursor pagination state stays
// consistent for any future re-introduction. Keeps the project_event_kind
// + time-horizon filter intact; the SmartTimeline-only axes don't apply
// to the legacy ProjectEvent shape.
function applyVerlaufFilters(rows: ProjectEvent[]): ProjectEvent[] {
const f = verlaufFilters;
if (!f.eventKinds && !f.fromDate && !f.toDate) return rows;
@@ -505,7 +578,13 @@ function renderTimeline() {
return;
}
renderSmartTimeline(host, timelineRows, {
// t-paliad-176 — apply FilterBar predicates client-side. The
// SmartTimeline endpoint returns the unfiltered superset; the bar's
// BarState (timeline_status / timeline_track / project_event_kind /
// time horizon) narrows what we render. Empty filter → identity.
const filteredRows = applyTimelineRowFilters(timelineRows);
renderSmartTimeline(host, filteredRows, {
projectId,
lang: getLang() === "en" ? "en" : "de",
lookahead: timelineLookahead,
@@ -1968,19 +2047,18 @@ async function main() {
}
// mountVerlaufFilterBar mounts the universal FilterBar inside the
// Verlauf tab (t-paliad-170). The bar owns URL params (?time=, ?pe_kind=)
// and the displayed filter chrome; on every state change it invokes the
// customRunner below, which calls loadEvents (the legacy
// /api/projects/{id}/events endpoint) and applies client-side filtering.
// Verlauf tab (t-paliad-170 → t-paliad-176). The bar owns URL params
// (?time=, ?pe_kind=, ?tl_status=, ?tl_track=) and the displayed filter
// chrome; on every state change it invokes the customRunner below, which
// drains the bar state into `verlaufFilters` and lets the bar's onResult
// callback trigger renderTimeline — narrowing happens client-side over
// `timelineRows` in `applyTimelineRowFilters`.
//
// Why customRunner instead of the substrate POST: the legacy endpoint
// expands the project's descendant subtree server-side and returns
// cursor-paginated rows, both of which the substrate's project_event
// runner doesn't yet support (substrate only does ScopeExplicit on a
// flat ID list, no "include descendants", no cursor). Migrating to the
// substrate is the SmartTimeline redesign (t-paliad-169) — this slice
// avoids the regression by keeping the data path and wiring the bar as
// a UI primitive on top.
// Why customRunner instead of the substrate POST: the SmartTimeline
// endpoint isn't a substrate-managed system view, and timeline_status /
// timeline_track / project_event_kind don't all map cleanly onto the
// substrate's per-source predicates. The customRunner stays as the bar's
// integration point with the SmartTimeline read pipeline.
function mountVerlaufFilterBar(id: string): void {
const host = document.getElementById("project-events-filter-bar");
if (!host) return;
@@ -2000,17 +2078,29 @@ function mountVerlaufFilterBar(id: string): void {
verlaufBar = mountFilterBar(host, {
baseFilter,
baseRender,
axes: ["time", "project_event_kind"],
// t-paliad-176 — exposing timeline_status + timeline_track on the
// Verlauf tab. They were declared in the bar's axis catalogue from
// Slice 2 onward but never mounted on this surface; chips were
// therefore invisible and the wire-up was a no-op.
axes: ["time", "timeline_status", "timeline_track", "project_event_kind"],
surfaceKey: "project-history",
showSaveAsView: false,
timePresets: ["past_7d", "past_30d", "past_90d", "any"],
customRunner: async (effective) => {
customRunner: async (effective, state) => {
// project_event_kind rides through effective.filter.predicates
// (substrate-shaped); timeline_status / timeline_track live on raw
// BarState. The bar passes both to keep first-run hydration honest
// (the bar handle hasn't been assigned to verlaufBar yet on first
// run, so we can't reach getState() — the state argument fixes that).
const kinds = effective.filter.predicates?.project_event?.event_types;
const tlStatus = state.timeline_status;
const tlTrack = state.timeline_track;
verlaufFilters = {
eventKinds: kinds && kinds.length ? new Set(kinds) : undefined,
timelineStatuses: tlStatus && tlStatus.length ? new Set(tlStatus) : undefined,
timelineTracks: tlTrack && tlTrack.length ? new Set(tlTrack) : undefined,
...horizonBounds(effective.filter.time?.horizon ?? "any"),
};
await loadEvents(id);
return { rows: [], inaccessible_project_ids: [] };
},
onResult: () => renderTimeline(),

View File

@@ -72,6 +72,12 @@ export interface TimelineEvent {
// Empty / missing is treated as "self" (the legacy single-lane case).
lane_id?: string;
bubble_up?: boolean;
// t-paliad-176 — underlying paliad.project_events.event_type for
// milestone rows. Empty for deadline / appointment / projected rows.
// Powers the FilterBar's project_event_kind chip on the Verlauf tab
// (matched against KnownProjectEventKinds in filter_spec.go).
project_event_type?: string;
}
export interface LaneInfo {

View File

@@ -1,19 +0,0 @@
-- Reverse t-paliad-156. REVOKE is no-op-safe when the right isn't held.
REVOKE EXECUTE ON FUNCTION paliad.can_see_project(uuid) FROM authenticated;
REVOKE SELECT ON paliad.users,
paliad.projects,
paliad.project_teams,
paliad.deadlines,
paliad.appointments,
paliad.parties,
paliad.partner_unit_members,
paliad.project_partner_units,
paliad.deadline_rules,
paliad.courts,
paliad.event_types,
paliad.notes
FROM authenticated;
REVOKE USAGE ON SCHEMA paliad FROM authenticated;

View File

@@ -1,59 +0,0 @@
-- t-paliad-156: Paliadin per-user RLS auth.
--
-- The Paliadin claude pane reaches the DB through the supabase MCP at
-- ystudio.msbls.de, which authenticates as `supabase_admin` (BYPASSRLS).
-- Without a role switch, every Paliadin query sees every user's data and
-- the predicate `paliad.can_see_project(...)` becomes self-discipline,
-- not enforcement (one missed predicate = a leak across users).
--
-- The fix is to wrap each paliad.* query in:
--
-- BEGIN;
-- SET LOCAL ROLE authenticated;
-- SET LOCAL request.jwt.claims = '{"sub":"<userID>","role":"authenticated"}';
-- <query>
-- ROLLBACK;
--
-- Once the role is switched, the existing RLS policies (defined for the
-- `authenticated` role on every paliad table) fire automatically because
-- `auth.uid()` resolves to the user from the claims. RLS becomes
-- enforcement, not self-discipline.
--
-- For that to work, the `authenticated` role needs:
-- * USAGE on the paliad schema (it doesn't, by default — paliad's
-- existing Go services run with a connection that authenticates as
-- `postgres`, which has BYPASSRLS too and didn't need an `authenticated`
-- grant path).
-- * SELECT on each table the Paliadin recipes read.
-- * EXECUTE on paliad.can_see_project — belt-and-braces; the function
-- is SECURITY DEFINER so the inner SELECTs already ignore the caller's
-- role, but granting the call surface eliminates a potential
-- "permission denied for function" error if a recipe ever falls back
-- to the predicate after t-156.
--
-- The grants are read-only — Paliadin has no write path to the DB. Agent-
-- suggested writes (t-paliad-161) go through `/api/paliadin/suggest/*`
-- HTTP endpoints, which run server-side as the existing service-role
-- connection and audit through paliad.approval_requests. This migration
-- never gives the `authenticated` role write privileges on paliad.
GRANT USAGE ON SCHEMA paliad TO authenticated;
-- Read-only access to the tables Paliadin's SQL recipes touch. Listed
-- explicitly (not GRANT SELECT ON ALL TABLES) so a future audit-only
-- table accidentally landing in this schema doesn't open up to every
-- authenticated user.
GRANT SELECT ON paliad.users TO authenticated;
GRANT SELECT ON paliad.projects TO authenticated;
GRANT SELECT ON paliad.project_teams TO authenticated;
GRANT SELECT ON paliad.deadlines TO authenticated;
GRANT SELECT ON paliad.appointments TO authenticated;
GRANT SELECT ON paliad.parties TO authenticated;
GRANT SELECT ON paliad.partner_unit_members TO authenticated;
GRANT SELECT ON paliad.project_partner_units TO authenticated;
GRANT SELECT ON paliad.deadline_rules TO authenticated;
GRANT SELECT ON paliad.courts TO authenticated;
GRANT SELECT ON paliad.event_types TO authenticated;
GRANT SELECT ON paliad.notes TO authenticated;
GRANT EXECUTE ON FUNCTION paliad.can_see_project(uuid) TO authenticated;

View File

@@ -102,18 +102,6 @@ type LocalPaliadinService struct {
sessionPrefix string
responseDir string
// jwtSecret is paliad's SUPABASE_JWT_SECRET. When set, RunTurn mints
// a per-turn user-scoped JWT, writes it next to the response file
// (`<responseDir>/<turnID>.jwt`, chmod 600, removed in a deferred
// cleanup), and embeds the path in the tmux envelope's `|jwt=<path>`
// segment. The Paliadin skill reads it and uses
// `SET LOCAL request.jwt.claims = …` before every paliad.* query so
// RLS evaluates as the user (t-paliad-156). Empty → legacy envelope
// shape, claude continues to query as supabase_admin (the pre-156
// behaviour, kept so dev environments without the secret still boot).
jwtSecret []byte
jwtTTL time.Duration // 0 → DefaultPaliadinJWTTTL
// Cached pane targets per user-session, keyed by tmux session name.
// A session entry maps to "session:window-idx"; cleared when the
// pane dies or ResetSession is called for that user.
@@ -132,16 +120,6 @@ type LocalPaliadinService struct {
janitorOnce sync.Once
}
// SetJWTAuth wires the per-turn JWT mint into LocalPaliadinService.
// Called from cmd/server/main.go after the auth client has read
// SUPABASE_JWT_SECRET; left as a setter (rather than a constructor arg)
// so the existing NewLocalPaliadinService callsite stays compatible
// while we ship per-user RLS auth (t-paliad-156).
func (s *LocalPaliadinService) SetJWTAuth(secret []byte, ttl time.Duration) {
s.jwtSecret = secret
s.jwtTTL = ttl
}
// IsOwner returns true when the given user_id corresponds to m's
// account (the only Paliadin PoC user). Resolves via paliad.users.email
// rather than caching a UUID so a DB rebuild that reassigns auth UUIDs
@@ -380,26 +358,14 @@ func (s *LocalPaliadinService) RunTurn(ctx context.Context, req TurnRequest) (*T
return nil, fmt.Errorf("paliadin: mkdir response dir: %w", err)
}
// Mint + write the per-turn JWT (t-paliad-156). Returns ("", nil)
// when the secret isn't configured; in that case the envelope keeps
// its legacy shape and claude queries as supabase_admin (pre-156
// behaviour). The deferred cleanup runs before RunTurn returns so
// the file never outlives the turn.
jwtSegment, jwtCleanup, err := s.writeTurnJWT(req.UserID, turnID)
if err != nil {
_ = s.markTurnError(ctx, turnID, "jwt_mint_failed")
return nil, fmt.Errorf("paliadin: write turn jwt: %w", err)
}
defer jwtCleanup()
// Send the framed prompt. The Paliadin skill at
// ~/.claude/skills/paliadin/SKILL.md description-matches on this
// envelope and writes the response to the per-turn file. The optional
// [ctx …] prefix carries structured page context from the inline
// widget (t-paliad-161); SKILL.md branches on it before answering.
primer := s.buildPrimerIfFresh(ctx, isFresh, req)
envelope := fmt.Sprintf("[PALIADIN:%s%s] %s%s%s",
turnID, jwtSegment, primer, req.Context.EnvelopePrefix(), sanitiseForTmux(req.UserMessage))
envelope := fmt.Sprintf("[PALIADIN:%s] %s%s%s",
turnID, primer, req.Context.EnvelopePrefix(), sanitiseForTmux(req.UserMessage))
if err := s.sendToPane(ctx, target, envelope); err != nil {
_ = s.markTurnError(ctx, turnID, "tmux_unresponsive")
return nil, fmt.Errorf("%w: send prompt: %v", ErrTmuxUnavailable, err)
@@ -445,40 +411,6 @@ func (s *LocalPaliadinService) RunTurn(ctx context.Context, req TurnRequest) (*T
}, nil
}
// writeTurnJWT mints + persists the per-turn JWT (t-paliad-156). Returns
// the envelope segment ("|jwt=<path>") to splice into the [PALIADIN:…]
// header and a cleanup function the caller must defer. When the JWT
// secret isn't configured, returns ("", noop) so RunTurn falls back to
// the legacy envelope shape; SKILL.md detects the missing segment and
// surfaces "JWT missing — paliad bug" rather than running queries as
// service role.
//
// File layout: <responseDir>/<turnID>.jwt (chmod 600). Lives next to the
// response file so the same `responseDir` cleanup window covers it; the
// returned cleanup function removes it eagerly on RunTurn return so a
// crashed turn doesn't leave the JWT lingering longer than the turn
// itself.
func (s *LocalPaliadinService) writeTurnJWT(userID, turnID uuid.UUID) (string, func(), error) {
noop := func() {}
if len(s.jwtSecret) == 0 {
return "", noop, nil
}
tok, err := mintTurnJWT(userID, s.jwtTTL, s.jwtSecret)
if err != nil {
return "", noop, err
}
path := filepath.Join(s.responseDir, turnID.String()+".jwt")
if err := os.WriteFile(path, []byte(tok), 0o600); err != nil {
return "", noop, fmt.Errorf("paliadin: write %s: %w", path, err)
}
cleanup := func() {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
log.Printf("paliadin: cleanup turn jwt %s: %v", path, err)
}
}
return "|jwt=" + path, cleanup, nil
}
// ResetSession kills the user's tmux session entirely so the next
// RunTurn boots a fresh claude pane. With skill-based persona load
// (~/.claude/skills/paliadin/SKILL.md) the new pane re-acquires the

View File

@@ -1,74 +0,0 @@
package services
// Per-turn supabase JWT minting for Paliadin (t-paliad-156).
//
// Each Paliadin turn carries a short-lived JWT scoped to the calling
// user. The JWT is signed with paliad's existing SUPABASE_JWT_SECRET so
// it has the same shape Supabase Auth itself issues — same claims, same
// signature, same role. The shim hands it to the claude pane (via a
// per-turn file at /tmp/paliadin-<turn>.jwt) and the SKILL.md teaches
// claude to extract the claims and `SET LOCAL request.jwt.claims = …`
// before every paliad.* query, which makes RLS evaluate as the user.
//
// TTL: short (default 2 min) — long enough to cover the 60 s shim
// timeout plus generous slack for queueing, short enough that a leaked
// JWT is uninteresting. Each turn mints fresh; nothing is cached.
import (
"errors"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
// ErrJWTSecretMissing signals that mintTurnJWT was called without the
// SUPABASE_JWT_SECRET configured. paliad's auth layer fails fast on the
// same condition at boot, but the per-turn mint path is reachable from
// tests + the disabled stub, so we surface a typed error rather than
// panicking.
var ErrJWTSecretMissing = errors.New("paliadin: SUPABASE_JWT_SECRET not configured")
// DefaultPaliadinJWTTTL is the JWT lifetime when the caller doesn't
// override. 2 minutes covers the shim's 120 s run-turn budget plus a
// few seconds of buffer for SSH overhead and clock skew.
const DefaultPaliadinJWTTTL = 2 * time.Minute
// mintTurnJWT signs a Supabase-shaped access token for the given user.
// Claims:
//
// sub : userID — RLS reads this via auth.uid()
// role : "authenticated" — required so SET LOCAL ROLE matches
// aud : "authenticated" — Supabase convention
// iss : "paliad/paliadin" — distinguishes from real GoTrue tokens in
// audit traces; not validated by RLS
// iat : now
// exp : now + ttl
//
// Signed HS256 with SUPABASE_JWT_SECRET (same secret paliad already
// verifies session cookies against in internal/auth.Client). The
// returned string is a standard 3-segment JWT.
func mintTurnJWT(userID uuid.UUID, ttl time.Duration, secret []byte) (string, error) {
if len(secret) == 0 {
return "", ErrJWTSecretMissing
}
if ttl <= 0 {
ttl = DefaultPaliadinJWTTTL
}
now := time.Now()
claims := jwt.MapClaims{
"sub": userID.String(),
"role": "authenticated",
"aud": "authenticated",
"iss": "paliad/paliadin",
"iat": now.Unix(),
"exp": now.Add(ttl).Unix(),
}
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := tok.SignedString(secret)
if err != nil {
return "", fmt.Errorf("paliadin: sign turn JWT: %w", err)
}
return signed, nil
}

View File

@@ -1,122 +0,0 @@
package services
import (
"errors"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
// JWT mint tests (t-paliad-156). Every Paliadin turn signs a per-user
// access token using paliad's existing SUPABASE_JWT_SECRET. Tests pin
// the claim shape (sub, role, aud, exp) that the SKILL.md SET LOCAL
// recipe relies on, plus the hard failure when the secret is missing.
const testJWTSecret = "test-secret-not-real-paliad-paliadin-only"
func TestMintTurnJWT_ClaimShape(t *testing.T) {
uid := uuid.MustParse("aaaaaaaa-1111-2222-3333-444444444444")
tok, err := mintTurnJWT(uid, time.Minute, []byte(testJWTSecret))
if err != nil {
t.Fatalf("mintTurnJWT: %v", err)
}
if strings.Count(tok, ".") != 2 {
t.Errorf("token = %q; want 3-segment JWT", tok)
}
parsed, err := jwt.Parse(tok, func(t *jwt.Token) (any, error) {
return []byte(testJWTSecret), nil
}, jwt.WithValidMethods([]string{"HS256"}))
if err != nil {
t.Fatalf("parse: %v", err)
}
claims, ok := parsed.Claims.(jwt.MapClaims)
if !ok || !parsed.Valid {
t.Fatalf("invalid claims: %T", parsed.Claims)
}
if got, _ := claims["sub"].(string); got != uid.String() {
t.Errorf("sub = %q; want %q", got, uid.String())
}
if got, _ := claims["role"].(string); got != "authenticated" {
t.Errorf("role = %q; want authenticated (so SET LOCAL ROLE matches)", got)
}
if got, _ := claims["aud"].(string); got != "authenticated" {
t.Errorf("aud = %q; want authenticated (Supabase convention)", got)
}
if got, _ := claims["iss"].(string); got != "paliad/paliadin" {
t.Errorf("iss = %q; want paliad/paliadin (audit-trace marker)", got)
}
}
func TestMintTurnJWT_HonoursTTL(t *testing.T) {
uid := uuid.MustParse("bbbbbbbb-1111-2222-3333-444444444444")
tok, err := mintTurnJWT(uid, 30*time.Second, []byte(testJWTSecret))
if err != nil {
t.Fatalf("mintTurnJWT: %v", err)
}
parsed, _ := jwt.Parse(tok, func(t *jwt.Token) (any, error) {
return []byte(testJWTSecret), nil
}, jwt.WithValidMethods([]string{"HS256"}))
claims := parsed.Claims.(jwt.MapClaims)
exp, err := claims.GetExpirationTime()
if err != nil || exp == nil {
t.Fatalf("exp claim missing: %v", err)
}
iat, err := claims.GetIssuedAt()
if err != nil || iat == nil {
t.Fatalf("iat claim missing: %v", err)
}
delta := exp.Sub(iat.Time)
if delta < 25*time.Second || delta > 35*time.Second {
t.Errorf("ttl ≈ %s; want ~30s", delta)
}
}
func TestMintTurnJWT_DefaultTTL(t *testing.T) {
uid := uuid.MustParse("cccccccc-1111-2222-3333-444444444444")
// ttl=0 → DefaultPaliadinJWTTTL (2 minutes)
tok, err := mintTurnJWT(uid, 0, []byte(testJWTSecret))
if err != nil {
t.Fatalf("mintTurnJWT: %v", err)
}
parsed, _ := jwt.Parse(tok, func(t *jwt.Token) (any, error) {
return []byte(testJWTSecret), nil
}, jwt.WithValidMethods([]string{"HS256"}))
claims := parsed.Claims.(jwt.MapClaims)
exp, _ := claims.GetExpirationTime()
iat, _ := claims.GetIssuedAt()
delta := exp.Sub(iat.Time)
if delta != DefaultPaliadinJWTTTL {
t.Errorf("default ttl = %s; want %s", delta, DefaultPaliadinJWTTTL)
}
}
func TestMintTurnJWT_RejectsEmptySecret(t *testing.T) {
uid := uuid.MustParse("dddddddd-1111-2222-3333-444444444444")
_, err := mintTurnJWT(uid, time.Minute, nil)
if !errors.Is(err, ErrJWTSecretMissing) {
t.Errorf("err = %v; want ErrJWTSecretMissing", err)
}
_, err = mintTurnJWT(uid, time.Minute, []byte(""))
if !errors.Is(err, ErrJWTSecretMissing) {
t.Errorf("err = %v; want ErrJWTSecretMissing for empty slice", err)
}
}
func TestMintTurnJWT_SignatureRejectsWrongSecret(t *testing.T) {
uid := uuid.MustParse("eeeeeeee-1111-2222-3333-444444444444")
tok, err := mintTurnJWT(uid, time.Minute, []byte(testJWTSecret))
if err != nil {
t.Fatalf("mintTurnJWT: %v", err)
}
_, err = jwt.Parse(tok, func(t *jwt.Token) (any, error) {
return []byte("wrong-secret"), nil
}, jwt.WithValidMethods([]string{"HS256"}))
if err == nil {
t.Error("parse with wrong secret succeeded; want signature failure")
}
}

View File

@@ -53,18 +53,6 @@ type RemotePaliadinConfig struct {
SSHKeyPath string // /tmp/paliadin-id_ed25519-<rand> (chmod 600)
KnownHostsPath string // /tmp/paliadin-known_hosts
SessionPrefix string // tmux session prefix; per-user session is "<prefix>-<userid8>"
// JWTSecret is paliad's SUPABASE_JWT_SECRET. RemotePaliadinService
// uses it to mint per-turn user-scoped JWTs (t-paliad-156), which
// the shim writes to a per-turn file the claude pane reads to set
// `request.jwt.claims` before every paliad.* SQL query — so RLS
// evaluates as the user instead of as service role. When empty the
// service falls back to legacy 3-arg run-turn calls (no |jwt=…
// envelope segment) so dev environments without the secret still
// boot, at the cost of leaving Paliadin queries running as
// supabase_admin.
JWTSecret []byte
JWTTTL time.Duration // 0 → DefaultPaliadinJWTTTL
}
// RemotePaliadinService implements Paliadin against a remote
@@ -197,16 +185,7 @@ func (s *RemotePaliadinService) RunTurn(ctx context.Context, req TurnRequest) (*
msg := primer + req.Context.EnvelopePrefix() + sanitiseForTmux(req.UserMessage)
msgB64 := base64.StdEncoding.EncodeToString([]byte(msg))
// Mint a per-turn JWT scoped to req.UserID (t-paliad-156). The shim
// writes the JWT to a per-turn file on mRiver, names it in the
// envelope's `|jwt=<path>` segment, and SKILL.md teaches claude to
// extract claims and `SET LOCAL request.jwt.claims = …` before each
// paliad.* query — turning the visibility check into RLS enforcement
// rather than skill-discipline. When the secret is unset (older
// dev environments) we fall back to the legacy 3-arg run-turn so
// the service still boots; SKILL.md detects the missing |jwt=…
// segment and reports the bug rather than silently leaking.
body, err := s.runTurnViaShim(ctx, session, turnID, req.UserID, msgB64)
body, err := s.callShim(ctx, "run-turn", session, turnID.String(), msgB64)
if err != nil {
_ = s.markTurnError(ctx, turnID, classifySSHError(err))
return nil, err
@@ -318,30 +297,6 @@ func (s *RemotePaliadinService) healthGate(ctx context.Context, session string)
return nil
}
// runTurnViaShim mints the per-turn JWT (t-paliad-156) and dispatches
// the shim's run-turn verb. Two argv shapes are emitted depending on
// whether the JWT secret is configured:
//
// JWT path (preferred): run-turn <session> <jwt-b64> <turn_id> <msg-b64>
// legacy (fallback): run-turn <session> <turn_id> <msg-b64>
//
// The shim accepts both shapes for one release: a 4-arg run-turn
// triggers the per-user RLS pipeline, while a 3-arg run-turn keeps the
// pre-156 service-role behaviour for environments that haven't been
// reconfigured yet. Once every paliad deploy carries SUPABASE_JWT_SECRET
// AND mRiver carries the t-156 shim, the legacy branch can be removed.
func (s *RemotePaliadinService) runTurnViaShim(ctx context.Context, session string, turnID, userID uuid.UUID, msgB64 string) ([]byte, error) {
if len(s.cfg.JWTSecret) == 0 {
return s.callShim(ctx, "run-turn", session, turnID.String(), msgB64)
}
tok, err := mintTurnJWT(userID, s.cfg.JWTTTL, s.cfg.JWTSecret)
if err != nil {
return nil, fmt.Errorf("paliadin: mint turn jwt: %w", err)
}
jwtB64 := base64.StdEncoding.EncodeToString([]byte(tok))
return s.callShim(ctx, "run-turn", session, jwtB64, turnID.String(), msgB64)
}
// callShim runs `ssh <user>@<host> -- <verb> <args...>` against the
// paliadin-shim. The shim's authorized_keys command= directive ensures
// the verb + args are passed via $SSH_ORIGINAL_COMMAND regardless of

View File

@@ -2,7 +2,6 @@ package services
import (
"context"
"encoding/base64"
"errors"
"fmt"
"strings"
@@ -10,7 +9,6 @@ import (
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
@@ -282,88 +280,6 @@ func TestCallShim_SSHArgvShape(t *testing.T) {
}
}
func TestRunTurnViaShim_LegacyShapeWhenNoJWTSecret(t *testing.T) {
// When JWTSecret is empty, runTurnViaShim must fall back to the
// pre-156 3-arg run-turn shape so dev environments without the
// secret still boot. The 4-arg shape is exclusively triggered by
// having the secret configured.
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{SSHHost: "x"})
var captured []string
s.callShimHook = func(ctx context.Context, args ...string) ([]byte, error) {
captured = append([]string(nil), args...)
return []byte("ok"), nil
}
uid := uuid.MustParse("aaaaaaaa-1111-2222-3333-444444444444")
turn := uuid.MustParse("11111111-2222-3333-4444-555555555555")
if _, err := s.runTurnViaShim(context.Background(), "paliad-paliadin-aaaaaaaa", turn, uid, "bWFkZQ=="); err != nil {
t.Fatalf("runTurnViaShim: %v", err)
}
want := []string{"run-turn", "paliad-paliadin-aaaaaaaa", turn.String(), "bWFkZQ=="}
if len(captured) != len(want) {
t.Fatalf("legacy callShim args = %v; want %v", captured, want)
}
for i := range want {
if captured[i] != want[i] {
t.Errorf("legacy callShim arg[%d] = %q; want %q", i, captured[i], want[i])
}
}
}
func TestRunTurnViaShim_PerUserShapeWithJWTSecret(t *testing.T) {
// With JWTSecret set, runTurnViaShim must mint a JWT and emit the
// 4-arg shape: <session> <jwt-b64> <turn_id> <msg-b64>. The
// resulting JWT must (a) be valid HS256-signed with the configured
// secret, (b) carry sub=userID and role=authenticated so the SKILL.md
// SET LOCAL recipe sets RLS up correctly.
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{
SSHHost: "x",
JWTSecret: []byte(testJWTSecret),
})
var captured []string
s.callShimHook = func(ctx context.Context, args ...string) ([]byte, error) {
captured = append([]string(nil), args...)
return []byte("ok"), nil
}
uid := uuid.MustParse("bbbbbbbb-1111-2222-3333-444444444444")
turn := uuid.MustParse("99999999-aaaa-bbbb-cccc-dddddddddddd")
if _, err := s.runTurnViaShim(context.Background(), "paliad-paliadin-bbbbbbbb", turn, uid, "Zm9v"); err != nil {
t.Fatalf("runTurnViaShim: %v", err)
}
if len(captured) != 5 {
t.Fatalf("per-user callShim got %d args, want 5: %v", len(captured), captured)
}
if captured[0] != "run-turn" {
t.Errorf("verb = %q; want run-turn", captured[0])
}
if captured[1] != "paliad-paliadin-bbbbbbbb" {
t.Errorf("session = %q", captured[1])
}
// argv[2] is JWT-base64 — decode + parse to assert claims.
jwtBytes, err := base64.StdEncoding.DecodeString(captured[2])
if err != nil {
t.Fatalf("captured[2] not valid base64: %v", err)
}
parsed, err := jwt.Parse(string(jwtBytes), func(t *jwt.Token) (any, error) {
return []byte(testJWTSecret), nil
}, jwt.WithValidMethods([]string{"HS256"}))
if err != nil {
t.Fatalf("captured JWT failed signature: %v", err)
}
claims := parsed.Claims.(jwt.MapClaims)
if got, _ := claims["sub"].(string); got != uid.String() {
t.Errorf("JWT.sub = %q; want %q (per-user RLS gate broken)", got, uid.String())
}
if got, _ := claims["role"].(string); got != "authenticated" {
t.Errorf("JWT.role = %q; want authenticated", got)
}
if captured[3] != turn.String() {
t.Errorf("turn_id = %q; want %s", captured[3], turn.String())
}
if captured[4] != "Zm9v" {
t.Errorf("msg-b64 = %q; want Zm9v", captured[4])
}
}
func TestCallShim_StderrSurfacesInError(t *testing.T) {
// When the real exec path fails, callShim wraps stderr into the
// returned error so classifySSHError can pattern-match. Simulate

View File

@@ -1,13 +1,8 @@
package services
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
// TestTruncateForPrimer pins the per-side truncation contract used by
@@ -290,101 +285,3 @@ func TestSanitiseForTmux_TruncatesLong(t *testing.T) {
t.Errorf("expected truncation marker, got tail: %q", got[len(got)-20:])
}
}
// TestLocalPaliadin_WriteTurnJWT_NoSecretReturnsEmpty pins the legacy
// fallback: when SUPABASE_JWT_SECRET isn't wired, RunTurn must skip the
// `|jwt=…` envelope segment so the SKILL.md "JWT missing — paliad bug"
// branch fires explicitly rather than the pane silently keeping
// service-role visibility.
func TestLocalPaliadin_WriteTurnJWT_NoSecretReturnsEmpty(t *testing.T) {
dir := t.TempDir()
s := NewLocalPaliadinService(nil, nil, "test-prefix", dir)
// jwtSecret left empty by construction.
uid := uuid.MustParse("aaaaaaaa-1111-2222-3333-444444444444")
turn := uuid.MustParse("11111111-2222-3333-4444-555555555555")
seg, cleanup, err := s.writeTurnJWT(uid, turn)
if err != nil {
t.Fatalf("writeTurnJWT: %v", err)
}
if seg != "" {
t.Errorf("envelope segment = %q; want empty when no secret", seg)
}
if cleanup == nil {
t.Fatalf("cleanup must be a no-op func, not nil")
}
cleanup() // must not panic
// No file should have been created.
if entries, _ := os.ReadDir(dir); len(entries) != 0 {
t.Errorf("temp dir not empty: %v", entries)
}
}
// TestLocalPaliadin_WriteTurnJWT_WritesAndCleansUp verifies the per-turn
// JWT lifecycle: file written at <dir>/<turn>.jwt with chmod 600,
// envelope segment populated, cleanup removes the file.
func TestLocalPaliadin_WriteTurnJWT_WritesAndCleansUp(t *testing.T) {
dir := t.TempDir()
s := NewLocalPaliadinService(nil, nil, "test-prefix", dir)
s.SetJWTAuth([]byte(testJWTSecret), 0)
uid := uuid.MustParse("bbbbbbbb-1111-2222-3333-444444444444")
turn := uuid.MustParse("22222222-3333-4444-5555-666666666666")
seg, cleanup, err := s.writeTurnJWT(uid, turn)
if err != nil {
t.Fatalf("writeTurnJWT: %v", err)
}
wantSeg := "|jwt=" + filepath.Join(dir, turn.String()+".jwt")
if seg != wantSeg {
t.Errorf("segment = %q; want %q", seg, wantSeg)
}
path := filepath.Join(dir, turn.String()+".jwt")
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat: %v", err)
}
if mode := info.Mode().Perm(); mode != 0o600 {
t.Errorf("file mode = %o; want 0o600 (token contents must not be world-readable)", mode)
}
body, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read: %v", err)
}
parsed, err := jwt.Parse(string(body), func(t *jwt.Token) (any, error) {
return []byte(testJWTSecret), nil
}, jwt.WithValidMethods([]string{"HS256"}))
if err != nil {
t.Fatalf("parse jwt from file: %v", err)
}
claims := parsed.Claims.(jwt.MapClaims)
if got, _ := claims["sub"].(string); got != uid.String() {
t.Errorf("file JWT sub = %q; want %q", got, uid.String())
}
cleanup()
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("cleanup must remove the JWT file; stat err = %v", err)
}
}
// TestLocalPaliadin_WriteTurnJWT_CleanupIdempotent verifies cleanup
// doesn't error if the file is already gone (e.g. caller ran cleanup
// twice, or another process raced ahead).
func TestLocalPaliadin_WriteTurnJWT_CleanupIdempotent(t *testing.T) {
dir := t.TempDir()
s := NewLocalPaliadinService(nil, nil, "test-prefix", dir)
s.SetJWTAuth([]byte(testJWTSecret), 0)
uid := uuid.New()
turn := uuid.New()
_, cleanup, err := s.writeTurnJWT(uid, turn)
if err != nil {
t.Fatalf("writeTurnJWT: %v", err)
}
// Manually remove the file so cleanup hits the not-exist branch.
_ = os.Remove(filepath.Join(dir, turn.String()+".jwt"))
cleanup() // must not panic, must not log a confusing error
}

View File

@@ -238,6 +238,95 @@ func TestProjectionService_LevelAggregation_Live(t *testing.T) {
}
})
t.Run("Patent-level: direct_only collapses to single 'self' lane (m/paliad#33)", func(t *testing.T) {
rows, meta, err := projection.For(ctx, userID, patentID, ProjectionOpts{DirectOnly: true})
if err != nil {
t.Fatalf("For patent direct_only: %v", err)
}
// Lanes should NOT include child cases — just one "self" entry
// pointing at the patent itself.
if len(meta.Lanes) != 1 || meta.Lanes[0].ID != "self" {
t.Errorf("DirectOnly Lanes = %v, want a single 'self' lane", meta.Lanes)
}
if len(meta.Lanes) > 0 && meta.Lanes[0].ProjectID != patentID.String() {
t.Errorf("self lane ProjectID = %q, want patent id", meta.Lanes[0].ProjectID)
}
// Case-A's deadline / milestones must NOT surface — they belong to
// the case subtree and direct_only excludes them.
for _, r := range rows {
if r.DeadlineID != nil && *r.DeadlineID == deadlineA {
t.Errorf("Case-A deadline should NOT surface at Patent level with direct_only=true (got %v)", r)
}
if r.ProjectEventID != nil && *r.ProjectEventID == bubbledMilestoneA {
t.Errorf("Case-A bubbled milestone should NOT surface at Patent level with direct_only=true")
}
}
})
t.Run("Case-level: direct_only drops CCR sub-project lane", func(t *testing.T) {
// Seed a CCR child of Case-A so the default (subtree) path
// includes a "counterclaim:<id>" lane and direct_only excludes it.
ccrID := uuid.New()
ccrMilestoneID := uuid.New()
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.projects (id, type, parent_id, counterclaim_of, path, title, status, created_by)
VALUES ($1, 'case', $2, $2, $2::text || '.' || $1::text, 'Case A — CCR', 'active', $3)`,
ccrID, caseAID, userID); err != nil {
t.Fatalf("seed CCR: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
VALUES ($1, $2, 'lead', 'lead', false, $2)`,
ccrID, userID); err != nil {
t.Fatalf("seed CCR team: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.project_events
(id, project_id, event_type, title, event_date, created_by, metadata,
created_at, updated_at, timeline_kind)
VALUES ($1, $2, 'custom_milestone', 'CCR-side note', $3, $4,
'{}'::jsonb, $5, $5, 'custom_milestone')`,
ccrMilestoneID, ccrID, now.AddDate(0, 0, -1), userID, now); err != nil {
t.Fatalf("seed CCR milestone: %v", err)
}
defer func() {
pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE id = $1`, ccrMilestoneID)
pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1`, ccrID)
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, ccrID)
}()
// Default (subtree) path: Case-A timeline carries both "self" +
// "counterclaim:<ccrID>" lanes.
_, defaultMeta, err := projection.For(ctx, userID, caseAID, ProjectionOpts{})
if err != nil {
t.Fatalf("For caseA default: %v", err)
}
var sawCCRLane bool
for _, l := range defaultMeta.Lanes {
if l.ID == "counterclaim:"+ccrID.String() {
sawCCRLane = true
}
}
if !sawCCRLane {
t.Fatalf("default Case-A meta.Lanes should include the CCR child: %v", defaultMeta.Lanes)
}
// Direct-only path: only the "self" lane survives, CCR milestones
// are excluded.
rows, directMeta, err := projection.For(ctx, userID, caseAID, ProjectionOpts{DirectOnly: true})
if err != nil {
t.Fatalf("For caseA direct_only: %v", err)
}
if len(directMeta.Lanes) != 1 || directMeta.Lanes[0].ID != "self" {
t.Errorf("direct_only Lanes = %v, want only 'self'", directMeta.Lanes)
}
for _, r := range rows {
if r.ProjectEventID != nil && *r.ProjectEventID == ccrMilestoneID {
t.Errorf("CCR milestone should NOT surface at Case-A with direct_only=true")
}
}
})
t.Run("Patent-level: bubble_up false → row dropped", func(t *testing.T) {
// Re-write the regular milestone with bubble_up=true and confirm
// it surfaces. Then revert.

View File

@@ -116,6 +116,13 @@ type TimelineEvent struct {
// one column per lane and groups rows by LaneID.
LaneID string `json:"lane_id,omitempty"`
// ProjectEventType carries the underlying paliad.project_events.event_type
// for milestone rows (t-paliad-176). Empty for deadline / appointment /
// projected rows. The FilterBar's project_event_kind chip narrows the
// rendered list against this field; KnownProjectEventKinds in
// internal/services/filter_spec.go is the canonical vocabulary.
ProjectEventType string `json:"project_event_type,omitempty"`
// BubbleUp signals that a project_event milestone is marked to
// bubble up to higher-level SmartTimelines (t-paliad-175 §5.3 + §7.2).
// Read from metadata.bubble_up on the underlying paliad.project_events
@@ -298,6 +305,17 @@ func (s *ProjectionService) For(ctx context.Context, userID, projectID uuid.UUID
policy := levelPolicy(proj.Type)
// DirectOnly collapses every level to a single-lane "self" view —
// no CCR sub-project lanes (Case level), no parent_context lane (CCR
// child viewpoint), no child-case / child-patent / child-litigation
// lanes (Patent / Litigation / Client levels). The level-policy
// kind/status filter still applies at higher levels so that, e.g., a
// Patent-level direct view doesn't suddenly leak off_script custom
// milestones that the aggregated view filters out (t-paliad-176).
if opts.DirectOnly {
return s.forDirectSelfOnly(ctx, userID, proj, policy, opts, meta)
}
// Patent / Litigation / Client levels — lane-aggregated rendering.
if policy.LaneAxis != "self_plus_ccr" {
return s.forAggregatedLevel(ctx, userID, proj, policy, opts, meta)
@@ -309,6 +327,51 @@ func (s *ProjectionService) For(ctx context.Context, userID, projectID uuid.UUID
return s.forCaseLevel(ctx, userID, proj, opts, meta)
}
// forDirectSelfOnly handles every level when DirectOnly is requested
// (m/paliad#33). Renders this project's own actuals + (at Case level)
// projection only — no CCR / parent_context / child-case lanes. The
// policy's kind/status filter still applies at higher levels so the
// "Nur direkt" Patent view honours the same milestone-only contract as
// the aggregated default. Produces a single "self" lane.
func (s *ProjectionService) forDirectSelfOnly(
ctx context.Context,
userID uuid.UUID,
proj *models.Project,
policy LevelPolicy,
opts ProjectionOpts,
meta ProjectionMeta,
) ([]TimelineEvent, ProjectionMeta, error) {
includeProjection := policy.LaneAxis == "self_plus_ccr"
rows, mainMeta, err := s.loadProjectTrack(ctx, userID, proj, opts, "parent", nil, includeProjection)
if err != nil {
return nil, meta, err
}
meta.HasProjection = mainMeta.HasProjection
meta.ProjectedTotal = mainMeta.ProjectedTotal
meta.ProjectedShown = mainMeta.ProjectedShown
meta.PredictedOverdue = mainMeta.PredictedOverdue
allowKind := stringSet(policy.Kinds)
allowStatus := stringSet(policy.Statuses)
out := make([]TimelineEvent, 0, len(rows))
for i := range rows {
row := rows[i]
row.LaneID = "self"
if !rowSurvivesPolicy(row, allowKind, allowStatus) {
continue
}
out = append(out, row)
}
meta.Lanes = append(meta.Lanes, LaneInfo{
ID: "self",
Label: proj.Title,
ProjectID: proj.ID.String(),
})
sortTimeline(out)
return out, meta, nil
}
// forCaseLevel runs the original Slice-1-through-3 flow: parent track +
// CCR sub-projects (when this project is the parent) or parent_context
// (when this project is a CCR child). Lanes mirror tracks one-for-one
@@ -1100,6 +1163,9 @@ func (s *ProjectionService) listProjectEvents(ctx context.Context, userID, proje
ProjectEventID: &r.ID,
BubbleUp: extractBubbleUp(r.Metadata, r.EventType, r.TimelineKind),
}
if r.EventType != nil {
ev.ProjectEventType = *r.EventType
}
if r.Description != nil {
ev.Description = *r.Description
}

View File

@@ -11,21 +11,9 @@
# Verbs (every verb takes the tmux session name as the first positional
# argument; per-user sessions are created on demand):
#
# health <session> -> "ok" iff tmux + claude reachable
# run-turn <session> <jwt-b64> <uuid> <msg-base64> -> per-user RLS path (t-paliad-156)
# run-turn <session> <uuid> <msg-base64> -> legacy 3-arg path (no JWT, supabase_admin RLS bypass)
# reset <session> -> kill the session entirely
#
# The 4-arg run-turn (t-paliad-156) carries a per-turn user-scoped JWT
# alongside the message. The shim writes the JWT to a per-turn file at
# /tmp/paliadin-<uuid>.jwt (chmod 600, removed via trap on EXIT) and
# splices `|jwt=<path>` into the [PALIADIN:<uuid>…] envelope so the
# Paliadin skill can read claims and `SET LOCAL request.jwt.claims = …`
# before every paliad.* SQL query — turning visibility from skill-
# discipline into RLS enforcement.
#
# The 3-arg run-turn (legacy) is kept for one release so paliad
# deployments without SUPABASE_JWT_SECRET wired through still boot.
# health <session> -> "ok" iff tmux + claude reachable
# run-turn <session> <uuid> <msg-base64> -> send framed prompt, poll, return
# reset <session> -> kill the session entirely
#
# The persona + response protocol live in the Paliadin skill at
# ~/.claude/skills/paliadin/SKILL.md (see scripts/skills/paliadin/SKILL.md
@@ -180,53 +168,18 @@ case "$verb" in
;;
run-turn)
# 4-arg shape (t-paliad-156): <session> <jwt-b64> <turn_id> <msg-b64>
# 3-arg shape (legacy): <session> <turn_id> <msg-b64>
#
# Disambiguation: argv[2] is a JWT-base64 iff argv[3] looks like a
# UUID. Otherwise we're on the legacy path and argv[2] is the UUID.
# $1 = session, $2 = turn_id (UUID), $3 = base64-encoded user message.
session=$(require_session)
jwt_b64=""
if [[ -n "${argv[3]:-}" && "${argv[3]}" =~ $TURN_ID_RE ]]; then
# Per-user RLS path. argv[2] is JWT-base64, argv[3] is turn_id, argv[4] is msg-b64.
jwt_b64="${argv[2]:-}"
turn_id="${argv[3]}"
msg_b64="${argv[4]:-}"
else
# Legacy path. argv[2] is turn_id, argv[3] is msg-b64.
turn_id="${argv[2]:-}"
msg_b64="${argv[3]:-}"
fi
turn_id="${argv[2]:-}"
if [[ ! "$turn_id" =~ $TURN_ID_RE ]]; then
log_err "run-turn: bad turn_id"; exit 2
fi
if [[ -z "$msg_b64" ]]; then
if [[ -z "${argv[3]:-}" ]]; then
log_err "run-turn: missing message"; exit 2
fi
if ! msg=$(printf '%s' "$msg_b64" | base64 -d 2>/dev/null); then
if ! msg=$(printf '%s' "${argv[3]}" | base64 -d 2>/dev/null); then
log_err "run-turn: invalid base64 message"; exit 2
fi
# Write the per-turn JWT to disk and arrange for cleanup. The skill
# reads it through the `|jwt=<path>` envelope segment and uses the
# claims to gate every paliad.* SQL query through RLS. Cleanup
# happens on every exit path (including timeout, error, kill); the
# turn budget is short enough that lingering files would be a leak.
jwt_segment=""
if [[ -n "$jwt_b64" ]]; then
jwt_path="$RESPONSE_DIR/$turn_id.jwt"
if ! jwt=$(printf '%s' "$jwt_b64" | base64 -d 2>/dev/null); then
log_err "run-turn: invalid base64 jwt"; exit 2
fi
# umask 077 + explicit chmod for clarity; the file holds a token
# that's good for one turn but trivially abused if leaked.
( umask 077; printf '%s' "$jwt" > "$jwt_path" )
chmod 600 "$jwt_path"
trap 'rm -f "$jwt_path"' EXIT
jwt_segment="|jwt=$jwt_path"
fi
target=$(ensure_pane "$session")
out="$RESPONSE_DIR/$turn_id.txt"
rm -f "$out"
@@ -234,9 +187,8 @@ case "$verb" in
# Envelope. The Paliadin skill (~/.claude/skills/paliadin/SKILL.md)
# description-matches on this exact prefix, so Claude routes to the
# skill on every turn regardless of conversation state — surviving
# /clear, fresh sessions, and pane restarts. The optional `|jwt=…`
# segment (t-paliad-156) carries the per-turn JWT path.
send_to_pane "$target" "[PALIADIN:$turn_id$jwt_segment] $msg"
# /clear, fresh sessions, and pane restarts.
send_to_pane "$target" "[PALIADIN:$turn_id] $msg"
# Poll for the response file. Same shape as Go pollForResponse
# (paliadin.go). Settle delay so we don't read mid-flush.

View File

@@ -1,6 +1,6 @@
---
name: paliadin
description: Use this skill whenever a user message arrives prefixed with `[PALIADIN:<uuid>]` or `[PALIADIN:<uuid>|jwt=<path>]` — that prefix means the request comes from the Paliad backend and a Markdown answer must be written to `/tmp/paliadin/<uuid>.txt` (with a `[paliadin-meta]` trailer) so the polling Go service can return it to the user. Trigger on the literal `[PALIADIN:` prefix, even when m's question is short ("Hey", "wer bin ich?") and looks like normal chat — the prefix is the contract, not the question content. The `|jwt=<path>` segment (t-paliad-156) marks per-user RLS auth: every paliad.* query MUST wrap in BEGIN/SET LOCAL ROLE authenticated/SET LOCAL request.jwt.claims/ROLLBACK so RLS sees the user, not service role. Persona: m's Patentpraxis-Plattform-Assistent — terse, juristisch präzise German, no emojis, every concrete claim backed by a tool-call.
description: Use this skill whenever a user message arrives prefixed with `[PALIADIN:<uuid>]` — that prefix means the request comes from the Paliad backend and a Markdown answer must be written to `/tmp/paliadin/<uuid>.txt` (with a `[paliadin-meta]` trailer) so the polling Go service can return it to the user. Trigger on the literal `[PALIADIN:` prefix, even when m's question is short ("Hey", "wer bin ich?") and looks like normal chat — the prefix is the contract, not the question content. Persona: m's Patentpraxis-Plattform-Assistent — terse, juristisch präzise German, no emojis, every concrete claim backed by a tool-call.
---
# Paliadin
@@ -9,18 +9,12 @@ You are the in-app AI assistant inside **Paliad**, m's Patentpraxis-Plattform f
## Quick start — one turn
Every Paliad request looks like one of:
Every Paliad request looks like:
```
[PALIADIN:<turn_id>] [ctx route=… entity=…:<id> selection="…" view=… filter="…"] <Frage>
[PALIADIN:<turn_id>|jwt=<path>] [ctx …] <Frage>
```
The `|jwt=<path>` segment (t-paliad-156) is the **per-user RLS gate**:
when present, every paliad.* SQL query MUST run under that user's
identity, not service role. See *Per-user RLS auth* below — this is
load-bearing for privacy.
The `[ctx …]` block is **optional** — present only when the request comes
from the inline widget (t-paliad-161); the standalone `/paliadin` page omits
it. When present, treat its contents as **authoritative context**, not as
@@ -29,7 +23,7 @@ ask which project / deadline / appointment they mean.
Per turn:
1. **Extract `<turn_id>`** from the prefix. If the prefix contains `|jwt=<path>`, also extract `<path>` and load the claims (see *Per-user RLS auth*).
1. **Extract `<turn_id>`** from the prefix.
2. **Parse `[ctx …]`** if present. See *Context envelope* below.
3. **Research** with tools (max 13 calls — backend timeout is 60s). See [references/sql-recipes.md](references/sql-recipes.md) **before any project/deadline/court/glossary/UPC lookup**.
4. **Write the file** with `Write("/tmp/paliadin/<turn_id>.txt", …)` containing the Markdown answer + `[paliadin-meta]` trailer.
@@ -37,65 +31,6 @@ Per turn:
> Skip every greeting / preamble in the chat pane. The file is the user-visible artefact; everything else is irrelevant.
## Per-user RLS auth (`|jwt=<path>`)
When the envelope contains `|jwt=<path>`, paliad has minted a per-turn
JWT scoped to the calling user. The file at `<path>` contains a
3-segment JWT (`header.payload.signature`); we only need the **payload
claims** (sub, role, aud, exp).
**Extract claims once at the top of the turn**, then reuse the resulting
JSON for every paliad.* query:
```bash
JWT_FILE="<path>" # the value after |jwt= in the envelope
PALIADIN_CLAIMS=$(jq -R 'split(".") | .[1] | gsub("-"; "+") | gsub("_"; "/") | . + ("=" * ((4 - (length % 4)) % 4)) | @base64d' "$JWT_FILE")
echo "$PALIADIN_CLAIMS" # → e.g. "{\"sub\":\"…\",\"role\":\"authenticated\",\"aud\":\"authenticated\",\"exp\":…}"
```
Then **wrap every `paliad.*` query** in this 3-line prelude (use the
`mcp__supabase__execute_sql` tool — wrapping is just SQL, no MCP-level
auth involved):
```sql
BEGIN;
SET LOCAL ROLE authenticated;
SET LOCAL request.jwt.claims = '<paste $PALIADIN_CLAIMS verbatim, single-quoted>';
-- your actual query (no can_see_project predicate needed — RLS handles it)
SELECT id, kind, label, status FROM paliad.projects ORDER BY path LIMIT 25;
ROLLBACK;
```
`ROLLBACK` (not `COMMIT`) is intentional: Paliadin is read-only, and
rolling back is harmless — `SET LOCAL` is scoped to the transaction, so
the role + claims are gone the moment the transaction ends regardless of
COMMIT/ROLLBACK. Using ROLLBACK signals intent and protects against any
accidental DML.
### Hard rules for per-user RLS
1. **Never query paliad.* without the wrapper.** The `mcp__supabase__execute_sql` tool authenticates as `supabase_admin` (BYPASSRLS); a raw query sees every user's data and breaks the privacy contract m built this whole pipeline to enforce.
2. **`data.*` queries (UPC case law, recipe 8) DO NOT need the wrapper** — those are firm-wide reference data, not per-user. Run them as supabase_admin (the default).
3. **JWT missing or unreadable**: when the envelope has no `|jwt=<path>` segment, OR the file at `<path>` is missing/empty/malformed, write this answer and stop:
```
Paliadin kann gerade nicht auf deine Daten zugreifen — der per-Turn-JWT fehlt. Bitte paliad neu deployen oder den Admin um eine Diagnose bitten (t-paliad-156 Auth-Pipeline).
---
[paliadin-meta]
used_tools:
rows_seen:
classifier_tag: meta
[/paliadin-meta]
```
Do **not** fall back to a query without the wrapper — that defeats the privacy gate.
4. **No `can_see_project(project_id)` predicate in queries.** RLS now enforces visibility automatically because `auth.uid()` resolves to the user from the claims. Keeping the old predicate is harmless but obscures the actual gate.
5. **The JWT TTL is 2 minutes.** If a query hits a `JWT expired` error, that means the turn took longer than expected; surface it honestly rather than retrying with a stale token.
## Crash-recovery primer (`[primer …][/primer]`)
When a tmux pane on mRiver was killed (reboot, OOM, manual `tmux
@@ -282,7 +217,7 @@ curl -s -X POST http://localhost:8080/api/paliadin/suggest/deadline \
1. **Keine Erfindungen.** Liefert ein Tool nichts, sag das. Niemals Aktenzeichen, Daten, Gerichts- oder Parteinamen erfinden.
2. **Jede konkrete Aussage über m's Arbeit MUSS aus einem Tool-Call der aktuellen Antwort kommen.** Erinnerung an frühere Gespräche reicht nicht — Daten ändern sich.
3. **Read-only.** Schreibe nichts in die DB. Wenn m etwas ändern will, sag wo in Paliad.
4. **Per-user RLS-Gate respektieren** (t-paliad-156). Wenn die Envelope `|jwt=<path>` trägt: jede `paliad.*`-Query MUSS im `BEGIN; SET LOCAL ROLE authenticated; SET LOCAL request.jwt.claims = '…'; <q>; ROLLBACK;`-Wrapper laufen — siehe *Per-user RLS auth* oben. Eine Roh-Abfrage ohne Wrapper sieht alle User-Daten (BYPASSRLS) und bricht den Privacy-Contract.
4. **Visibility-Gate respektieren.** Auch wenn m global_admin ist: jede projekt-bezogene Abfrage MUSS `paliad.can_see_project(project_id)` enthalten.
5. **Nicht über andere User spekulieren** — frag nach Projekt-ID/Slug, selbst wenn m sie namentlich erwähnt.
6. **Niemals auf `psql`, `curl PostgREST`, `nix-shell` oder andere DB-Fallbacks ausweichen.** Die einzig zulässige DB-Quelle ist `mcp__supabase__execute_sql` (project-scoped MCP). Wenn dieser Tool-Aufruf nicht verfügbar ist, schreibe sofort: *"DB nicht erreichbar — bitte paliad neu deployen oder PALIADIN_REMOTE_CWD prüfen."* mit `classifier_tag: meta`. Niemals 60+ Sekunden im Fallback-Tanz verbringen — der Backend-Timeout schlägt sonst zu, bevor du eine Antwort schreibst.

View File

@@ -2,160 +2,99 @@
Read this file **before any project / deadline / appointment / court / glossary / deadline-rule / UPC-judgment lookup**. Every query goes through the Supabase MCP via `mcp__supabase__execute_sql`. Two schemas in the same physical DB:
- `paliad.*` — Patentpraxis-Daten (projects, deadlines, appointments, parties, courts, deadline_rules, users)**per-user RLS-gated**
- `data.*` — youpc.org UPC case law (judgments, headnotes, knowledge graph)**firm-wide reference, no per-user gating**
- `paliad.*` — Patentpraxis-Daten (projects, deadlines, appointments, parties, courts, deadline_rules, users)
- `data.*` — youpc.org UPC case law (judgments, headnotes, knowledge graph)
## Per-user RLS wrapper (t-paliad-156)
When the envelope contains `|jwt=<path>` (it does on every recent paliad
deploy), every `paliad.*` query MUST run inside this 3-line prelude.
Read SKILL.md → *Per-user RLS auth* for the JWT-claims extraction
one-liner; the resulting JSON goes into `SET LOCAL request.jwt.claims`.
```sql
BEGIN;
SET LOCAL ROLE authenticated;
SET LOCAL request.jwt.claims = '<claims JSON, single-quoted>';
-- recipe body
ROLLBACK;
```
`ROLLBACK` over `COMMIT` is intentional — Paliadin is read-only and
SET LOCAL is tx-scoped, so rollback is harmless and intent-revealing.
**Never run `paliad.*` queries outside this wrapper.** The MCP
authenticates as `supabase_admin` (BYPASSRLS); a raw query sees every
user's data and breaks the privacy contract.
`data.*` queries (recipe 8) **do not** need the wrapper — UPC case law
is firm-wide reference data, no per-user gating. Run them as the
default supabase_admin role.
Every project-scoped query MUST include `paliad.can_see_project(project_id)` — even when m is global_admin (see SKILL.md rule 4).
## 1. whats_on_my_plate — Dashboard-Übersicht
```sql
BEGIN;
SET LOCAL ROLE authenticated;
SET LOCAL request.jwt.claims = '<claims>';
SELECT
(SELECT count(*) FROM paliad.deadlines
WHERE status = 'pending' AND due_date < current_date) AS overdue,
(SELECT count(*) FROM paliad.deadlines
WHERE status = 'pending' AND due_date = current_date) AS today,
(SELECT count(*) FROM paliad.deadlines
WHERE status = 'pending'
AND due_date BETWEEN current_date AND current_date + 7) AS this_week,
(SELECT count(*) FROM paliad.appointments
WHERE start_at::date = current_date) AS appointments_today;
ROLLBACK;
(SELECT count(*) FROM paliad.deadlines d
WHERE paliad.can_see_project(d.project_id)
AND d.status = 'pending' AND d.due_date < current_date) AS overdue,
(SELECT count(*) FROM paliad.deadlines d
WHERE paliad.can_see_project(d.project_id)
AND d.status = 'pending' AND d.due_date = current_date) AS today,
(SELECT count(*) FROM paliad.deadlines d
WHERE paliad.can_see_project(d.project_id)
AND d.status = 'pending'
AND d.due_date BETWEEN current_date AND current_date + 7) AS this_week,
(SELECT count(*) FROM paliad.appointments a
WHERE (a.project_id IS NULL OR paliad.can_see_project(a.project_id))
AND a.start_at::date = current_date) AS appointments_today;
```
## 2. list_my_projects
```sql
BEGIN;
SET LOCAL ROLE authenticated;
SET LOCAL request.jwt.claims = '<claims>';
SELECT id, kind, label, status, parent_id, path
FROM paliad.projects
WHERE status = 'active'
WHERE paliad.can_see_project(id)
AND status = 'active'
ORDER BY path
LIMIT 25;
ROLLBACK;
```
## 3. get_project_detail (per slug oder id)
```sql
BEGIN;
SET LOCAL ROLE authenticated;
SET LOCAL request.jwt.claims = '<claims>';
SELECT p.*,
(SELECT json_agg(d ORDER BY d.due_date)
FROM paliad.deadlines d WHERE d.project_id = p.id) AS deadlines,
FROM paliad.deadlines d WHERE d.project_id = p.id
AND paliad.can_see_project(d.project_id)) AS deadlines,
(SELECT json_agg(a ORDER BY a.start_at)
FROM paliad.appointments a WHERE a.project_id = p.id) AS appointments,
FROM paliad.appointments a WHERE a.project_id = p.id
AND paliad.can_see_project(a.project_id)) AS appointments,
(SELECT json_agg(pa) FROM paliad.parties pa WHERE pa.project_id = p.id) AS parties
FROM paliad.projects p
WHERE p.id::text = '<UUID>' OR p.slug = '<slug>'
WHERE paliad.can_see_project(p.id)
AND (p.id::text = '<UUID>' OR p.slug = '<slug>')
LIMIT 1;
ROLLBACK;
```
## 4. search_my_deadlines (status / Datum / Projekt)
```sql
BEGIN;
SET LOCAL ROLE authenticated;
SET LOCAL request.jwt.claims = '<claims>';
SELECT d.id, d.title, d.due_date, d.status, p.label AS project_label, d.event_id
FROM paliad.deadlines d
JOIN paliad.projects p ON p.id = d.project_id
WHERE ($status::text IS NULL OR d.status = $status)
WHERE paliad.can_see_project(d.project_id)
AND ($status::text IS NULL OR d.status = $status)
AND ($due_after::date IS NULL OR d.due_date >= $due_after)
AND ($due_before::date IS NULL OR d.due_date <= $due_before)
ORDER BY d.due_date ASC
LIMIT 25;
ROLLBACK;
```
## 5. list_my_appointments (Zeitfenster)
```sql
BEGIN;
SET LOCAL ROLE authenticated;
SET LOCAL request.jwt.claims = '<claims>';
SELECT a.id, a.title, a.start_at, a.end_at, a.location, p.label AS project_label
FROM paliad.appointments a
LEFT JOIN paliad.projects p ON p.id = a.project_id
WHERE a.start_at >= $from
WHERE (a.project_id IS NULL OR paliad.can_see_project(a.project_id))
AND a.start_at >= $from
AND a.start_at <= $to
ORDER BY a.start_at ASC
LIMIT 25;
ROLLBACK;
```
## 6. lookup_court (firm-wide reference)
Courts are firm-wide reference; the wrapper is still recommended for
consistency (and so the recipe template stays uniform across paliad.*
recipes), but RLS on `paliad.courts` allows authenticated to read every
row regardless.
```sql
BEGIN;
SET LOCAL ROLE authenticated;
SET LOCAL request.jwt.claims = '<claims>';
SELECT c.slug, c.name, c.country, c.kind, c.address
FROM paliad.courts c
WHERE c.name ILIKE '%' || $q || '%'
OR c.slug ILIKE '%' || $q || '%'
ORDER BY similarity(c.name, $q) DESC
LIMIT 10;
ROLLBACK;
```
## 7. lookup_deadline_rule (Fristenrechner-Konzepte)
```sql
BEGIN;
SET LOCAL ROLE authenticated;
SET LOCAL request.jwt.claims = '<claims>';
SELECT r.rule_code, r.concept_label, r.trigger_event, r.deadline_text,
r.deadline_text_en, r.legal_source, r.deadline_notes, r.deadline_notes_en
FROM paliad.deadline_rules r
@@ -164,16 +103,10 @@ SELECT r.rule_code, r.concept_label, r.trigger_event, r.deadline_text,
OR r.legal_source ILIKE '%' || $q || '%'
ORDER BY similarity(r.concept_label, $q) DESC
LIMIT 5;
ROLLBACK;
```
## 8. lookup_youpc_case (UPC-Rechtsprechung — cross-schema)
**No wrapper.** UPC case law is firm-wide reference data; the `data`
schema isn't part of paliad's per-user gate. Run as the default
supabase_admin role.
```sql
SELECT j.node_id, j.upc_number, j.court_division, j.judgment_type,
j.proceedings_type, j.decision_date, j.headnote_summary,
@@ -199,9 +132,3 @@ SELECT content
## Glossar — keine SQL-Tabelle
Der Patent-Glossar lebt statisch in `internal/handlers/glossary.go` (JSON beim Boot geladen). Für reine Begriffsfragen reicht dein Wissen + optional Cross-Check via `paliad.deadline_rules.legal_source`.
## Why no `paliad.can_see_project(...)` predicate?
Pre-156 every paliad.* recipe ended with `WHERE paliad.can_see_project(project_id)`. That predicate was self-discipline — the MCP ran as service role with BYPASSRLS, so the function call was the only thing keeping queries scoped. One forgotten predicate = a leak across users.
Post-156 the role switch + claims feed RLS directly. `auth.uid()` resolves to the user from `request.jwt.claims.sub`; the policies on `paliad.projects`, `paliad.deadlines`, `paliad.appointments` etc. all already gate on `paliad.can_see_project(project_id)` themselves. The predicate-in-query layer is redundant once the role layer is in place. RLS is enforcement; the predicate was self-discipline.