v0.0.74 — Data Policies + Webapp builder + theme cascade
Released: 2026-05-17. Forty-one commits.
A big batch. Two tracks dominate: Data Policies (Batch H) lands as a complete end-to-end feature across every read surface, and the Webapp asset type (renamed from analytics_app) gets a full Track A2 builder UI plus a per-tenant theme cascade.
Data Policies (Batch H)
A new governance subsystem: row-filter and column-mask policies authored centrally, enforced everywhere data flows out. Six slices land in this release.
Slice 1 — Registry
New honeyframe.data_policies table (row_filter / column_mask, scoped to org / project / dataset, JSONB predicate_model + subject_model, draft / active / disabled status). routers/data_policies.py mounted at /api/governance/data-policies under org.admin. New DataPoliciesPage at /data-policies groups policies by status with collapsible JSON preview.
Slice 1.5 — Create drawer + status flip
PATCH /api/governance/data-policies/{id}/status flips draft ↔ active ↔ disabled. Active flip records approved_by + approved_at as an audit anchor. New-policy drawer with name / description / type / scope / priority + JSON predicate/subject editors and per-card Activate / Disable / Re-draft buttons.
Slice 2 — Enforcement compiler + user-attribute resolver
services/user_attributes.py resolves attribute name → values (V1 wires site_ids → honeyframe.user_sites). services/data_policy_compiler.py exposes compile_data_policy_filters() returning CompiledPolicies(predicates, warnings, bypassed_by_admin, considered_count). Handles four V1 operators (in_user_attribute, equals_user_attribute, equals_literal, in_group_mapping) + empty_behavior + admin bypass + subject targeting + unsafe-column rejection.
Slice 2b/2b-ext — Explore enforcement
dataset_explore compiles every active row_filter policy for the caller's org / project / dataset scope and AND-s the resulting predicates into both the COUNT and the data SELECT. The Postgres path and the DuckDB explore paths (UPLOADED_FILE + LAKEHOUSE) both enforce — viewers see consistent behavior regardless of backing engine. Compiler emits col = ANY(:bind) instead of col IN :bind so plain text() executes without bindparam(expanding=True).
Responses gain a policies metadata block alongside masking, so the frontend can render a "policies applied" chip.
Slice 2c — Simulator
POST /api/governance/data-policies/simulate compiles every active policy against an admin-supplied impersonated subject (user_id / role / groups) and returns predicates + warnings + bypassed_by_admin — pure compile path, no tenant SQL. DataPoliciesPage gets a per-card Simulate button.
Slice 2d — Data API row_scope
api_keys.row_scope JSONB is now read and AND-ed into every Data API SELECT. Service-account semantics — independent of any user-policy compilation, no inheritance from the issuing user. compile_api_key_row_scope(row_scope) supports equals_literal / in_literal / {and:[...]}; anything malformed denies all so a misconfigured key fails closed.
Slice 3 — Dashboard card enforcement
dashboards.execute_card wraps card SQL as a subquery and AND-s active row_filter predicates. Compiles at org+project scope only (dashboards run freeform SQL with no single owning dataset). Admin bypass leaves the SQL untouched. Predicate columns must appear in the inner SELECT's projection — silent drop would let admins write masking-via-projection bugs.
Slice 3b — Dashboard /ai-chat
/ai-chat ANSWER branch now goes through _apply_dashboard_policies so chat-driven SQL obeys row_filter policies the same way card execute does. services/chat_service.execute_chat_sql accepts an optional params dict.
Slice 4 — Agents + Cobuild
AgentContext gains user_role / user_groups / is_superadmin. _tool_run_sql_preview compiles row_filter policies and wraps the LLM-provided SQL as SELECT * FROM (...) AS _agent_pol WHERE <pred>. Cobuild planner forwards identity to all three call sites (/run, /resume, /run-parallel); the parallel helper passes user identity as kwargs so gathered child tasks compile inside their own AsyncSessions.
Slice 5 — Preview-as-user simulator
DataPoliciesPage gets a global Preview as user header button. SimulateDrawer accepts policy: DataPolicy | null; null flips on the scope inputs so an admin can compare scopes without hopping between policies.
Slice 6 — Audit trail + author column
data_policies.created_by_id + a data_policy_audit table land. _audit() writes one event row per created / status_flip / edited action, running in its own transaction so a missing audit table on un-migrated installs can't poison the user's PATCH. History button on every policy card opens an AuditDrawer with before/after diffs.
Structured editors + edit flow
The New-policy drawer's raw JSON textareas are replaced with operator-aware structured inputs for the 4 row_filter operators + the 3 column_mask types. Subject model gets role checkboxes + groups CSV + include_admins toggle. Advanced mode keeps the JSON textareas accessible. Compiled JSON preview is always visible under a collapsible.
PATCH /api/governance/data-policies/{id} edits name / description / priority / predicate_model / subject_model on draft | disabled policies. Refuses active policies (400 "disable first") so the compiler never reads a half-edited predicate. New PoliciesTab on dataset detail lists scoped policies (own + inherited org/project) with Edit / Activate / Disable + a Pin policy button that opens the drawer prescoped.
Webapp asset type (renamed from analytics_app)
Week 1 foundation
New webapp (originally analytics_app) project-scoped asset type composes existing Dashboard Builder cards into a custom left-rail + branded chrome shell. Backend: migration extends published_assets.asset_type CHECK constraint, routers/analytics_apps.py exposes GET /api/analytics-apps and GET /api/analytics-apps/:key. Frontend: types.ts (AnalyticsAppConfig, CardRefSpec, PresetSpec, AppTheme), api.ts, CardRef.tsx (single-card hydrator), AppShell.tsx, FilterBar.tsx, AnalyticsApp.tsx route handler at /apps/:appKey/:pageKey?.
Week 2 Track 1 — 5 Wave 1 card-type additions
Opt-in via card_config so existing dashboards are unchanged:
- G1 — KPI hero / compact variants with
accent_color,target_label_text,delta_label,pct_of_target_label. Hero cards with top accent bar + percent-of-target pill + delta indicator + target line. - G3 —
reference_linesfor dashed target overlays on line / area / bar charts. - G15 —
column_thresholdsper-column tiered RAG coloring + suffix. Legacytable_rulesstill works. - G21 —
badge_columnsrenders cell value as a colored pill with hash-derived background. Branch codes get distinct, stable colors. - G25 —
alert_varianton text card (info|warning|critical) +cta_label+cta_href. Text card becomes a colored banner with leading icon + CTA.
CardRef gains card_config_overrides on CardRefSpec, merged on top of the source dashboard card's config so a webapp can decorate cards without mutating the underlying dashboard. api.ts adds a module-level Promise cache for /api/dashboards/:did so N CardRefs from one source dashboard issue one network request.
Asset-type rename
analytics_app → webapp across DB, backend router, frontend module, and seed scripts. User-facing route stays at /apps/:appKey. /api/analytics-apps becomes /api/webapps. Migration reversible via the matching rollback SQL.
Per-tenant theme cascade (Track B)
AppTheme gains sidebarBackground / sidebarText / sidebarActiveBg / accentText; AppShell applies them via CSS vars so per-app palette cascades end-to-end. Hamburger toggle collapses sidebar to 64px icon-only (persisted in localStorage per appKey). NavItem.icon strings resolve through a new inline-SVG registry.
Header chrome: notification bell + red dot, theme-toggle stub, JWT-initial user avatar; gated by chrome.show_notifications / show_user_avatar. Optional clock pill (chrome.show_clock) ticks every 30s in chrome.timezone (default Asia/Jakarta). Optional bottom-right help ? FAB (chrome.show_help_fab).
FilterBar v2: card-style panel with date-range native inputs + preset chips (MTD / QTD / YTD / L30D / Last Month) + searchable multi-select branch dropdown with checkboxes + active-filter chip row + Reset button. A tenant's branch list seeds directly so the dropdown renders real entries.
FilterBar values wired into card parameters
Threads Period + branch selections through to dashboards.execute_card so cards whose SQL references :start, :end, :branch_id actually filter. A regex extracts :placeholder names from the card's SQL and forwards only the keys the SQL references. Caller params merge over policy params (the regex filter prevents collision in practice). New WebappFilterContext provider; writes debounced 250ms.
Webapp builder (Track A2)
The saas EditWebappPage is ported to paas as a sibling builder (WebappBuilderPage), following the per-asset-type builder convention. Slices:
- Slice 1 — read-only edit page at
/apps/_edit/:keyproving the data path + preview render. - Slice 2 — drag/resize grid via react-grid-layout, hover-revealed drag handle + delete, Add card modal listing project dashboards, Save →
PUT /api/publish/assets/{id}with current_version bump. - Slice 3 —
card_config_overridesdrawer with 8 form fields for the most-used override keys + raw JSON escape hatch + live preview. - Slice 4 — page CRUD (add via modal, inline rename, delete with confirm, reorder with ↑/↓ buttons). Mutations route through a new
updateConfig()helper that touchesconfig.pagesandconfig.navatomically.
The renderer also moves to packages/blocks/webapp/ (paas-first); both PaaS and SaaS now import from @hubstudio/blocks.
Multi-page nav + seed
Sidebar was a one-item rail. A seed can now emit many NavItem entries grouped under labelled sections. Each new nav entry has a matching AppPageSpec stub (empty card_refs + "Coming soon" footnote) so routes resolve and pages don't 404.
Scenarios — Schedules page sunset
Email-report scheduling is fully covered by Scenarios (cron trigger + send_email_report step). Existing tenants already run legacy_report_schedule_* scenarios doing exactly this, replacing the old /schedules surface. Nav entry under Automation removed; both /schedules routes redirect to the matching /scenarios path so existing bookmarks don't 404.
Cross-project AI search
catalog_discovery.project_id is now Optional[int]; None scopes the search to every project the caller's org owns (powers F15 cross-project AI Search). Cobuild callers still pass an explicit project_id.
Infra fixes
- nginx-test — SPA shell now sends
no-cache, no-store, must-revalidate; hashed/assets/*get1y immutable. Fixes the case where browsers held staleindex.htmlreferencing old Vite chunk hashes after a deploy. - nginx-test — explicit
location /app/→ saas dist so the post-login redirect (/app/profiles) resolves instead of falling through to paas. Versioned asnginx-test.confat the repo root. - nginx —
app.example.comroot re-pointed to the current frontenddistpath. A stale path (removed in the 2026-04-27 cutover) was 500-ing every request via try_files rewrite cycle. - Webapp builder Back button —
navigate(-1)jumped to wherever the user was before opening the editor. Now goes to/projects/<slug>/publish(the natural parent — where webapps are listed).