pf_app/CLAUDE.md
Paul Trowbridge 1b1452b625 Rewrite the docs against the code, and move the design docs to docs/
An audit against the code found every doc had drifted. pf_spec.md mostly
described behaviour the app no longer has and missed auth, territory,
layouts, load plans, segment naming and scope entirely; CLAUDE.md had
grown to carry the history as well as the rules, and loads into every
session.

- CLAUDE.md: rules, invariants and single-definition pointers only, with
  stale claims corrected (project layout, routes, schema, the pf_ slice
  columns, the price/volume and footing rules, known issues, deferred).
  Each section that lost its reasoning links to the spec.
- docs/pf_spec.md: rewritten as the current design doc under twelve fixed
  headings, holding the history and rationale moved out of CLAUDE.md, the
  code issues found on the way, and a proposed (not built) design for
  additional measures -- cost, rebates, surcharges -- as rates on a driver.
- docs/PERSPECTIVE.md: cut to the cross-app lessons that still hold; the
  fork and its rebuild are in ui/vendor/README.md.
- docs/pf_perspective_options.md: marked as a dated decision record.
- ui/vendor/README.md: depth is sent floored at 1, not d + 1; rebuild steps.
- ui/README.md: removed (Vite template boilerplate).
- Two stale code comments (server.js, Forecast.jsx).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:19:06 -04:00

38 KiB
Raw Blame History

Pivot Forecast — CLAUDE.md

What this app is

A web app for building named forecast scenarios against any PostgreSQL table. The workflow: load historical actuals as a baseline (optionally date-shifted into the forecast period), then apply incremental adjustments (scale, recode, clone) to build a plan. All changes are append-only, fully audited, and reversible by log entry.

Full spec, including the rationale and history this file leaves out: docs/pf_spec.md Data transport architecture options: docs/pf_perspective_options.md


Tech stack

  • Backend: Node.js / Express (server.js)
  • Database: PostgreSQL — isolated pf schema
  • Frontend: React + Vite + Tailwind CSS in ui/; built output lands in public/app/
  • Pivot: Perspective (@perspective-dev/* distribution, not FINOS @finos/perspective) 5.4.0 from a patched build vendored in ui/vendor — see its README for what is patched and which host-tool versions the rebuild needs; bundled inline via the /inline entrypoints — never from a CDN (the CDN bundle resolves its server WASM to an unversioned path and silently pulls whatever is newest). See docs/PERSPECTIVE.md.
  • Dev: npm run dev (nodemon) in root; npm run build in ui/

Project layout

server.js               Express entry point; pg pool; session; type parsers for bigint/numeric
pf.sh                   Account and territory admin (add-user, passwd, set-territory, …)
install.sh              Fresh install
routes/
  auth.js               POST /api/login, /api/logout, GET /api/me; login throttle
  tables.js             GET /api/tables, /api/tables/:schema/:tname/preview
  sources.js            Source registration, col_meta, SQL generation;
                        GET /sources/:id/values/:col, /sources/:id/lookup,
                        /sources/:id/dim/:group (+ POST …/refresh), /dim-period/cols
  versions.js           Version CRUD and clone; GET /versions/:id/tags (+ PATCH),
                        /versions/:id/bridge, /versions/:id/values/:col, /versions/:id/table-info
  operations.js         Data streams (GET /versions/:id/data, /versions/:id/agg); loads
                        (baseline, reference); scale, recode, clone
  log.js                GET /api/versions/:id/log, /log/:logid/debug; DELETE and PATCH /log/:logid
  layouts.js            Named pivot layouts — list per version, create, patch, delete
  plans.js              Planned load segments — list, create, patch, delete
lib/
  sql_generator.js      grainOf, buildWhere/buildWhereAny, SCOPE_OPS, token substitution helpers
  auth.js               scrypt hash/verify, requireAuth, requireAdmin, sessionUser; `node lib/auth.js hash` CLI
  utils.js              RELATION_COLUMNS_SQL, mapType
setup_sql/
  01_schema.sql         pf schema DDL — idempotent, not a migration runner
  02_auth.sql           pf.app_user + pf.session
  gen_dim_period.sql    Populates pf.dim_period; fiscal year start month is configurable
ui/vendor/              Patched Perspective build and its patches (see its README)
ui/src/
  main.jsx              ThemeProvider, AuthProvider, Gate (mounts the app only once signed in)
  App.jsx               Page routing; source/version selection and its localStorage restore
  theme.jsx             ThemeContext / useTheme
  auth.jsx              AuthProvider/useAuth; wraps fetch so any 401 returns to login
  views/
    Login.jsx           Sign-in form
    Setup.jsx           DB browser, source registration, col_meta editor
    Baseline.jsx        Version management, baseline workbench, reference load, tags panel
    Forecast.jsx        Perspective pivot, selection handling, operation dispatch
  components/
    DepthButtons.jsx    One axis's collapse stepper; both axes are drawn from it
    LayoutMenu.jsx      The Layout ▾ control — Published / Mine, with the write actions
    OperationPanel.jsx  The adjustment workbench — ledger + scale/recode/clone forms; effectiveScaleInputs
    BridgeView.jsx      Baseline → current waterfall by tag (exports buildSteps/layoutSteps)
    Sidebar.jsx         Forecast list + the 3 steps; collapsible
    StatusBar.jsx       Forecast name · row count · calculating · theme; source on Setup only
    ReadOnlyNotice.jsx  The banner over a page a non-admin may read but not change
    Timeline.jsx        Date-range preview bar for baseline segments

Database schema (pf)

  • pf.source — registered source tables
  • pf.col_meta — column roles: dimension | value | units | date | filter | ignore; dim_group groups functionally dependent columns (e.g. a part and its attributes, or a date and its derived year/month dimensions); dim_period_col maps a dimension to a pf.dim_period column so date-adjacent values are derived at load time rather than copied raw; in_grain flags dimension/date columns that define the display grain; is_territory marks the column territories are expressed in (one per source); is_key is described under §is_key below
  • pf.dim_member — members of each dim_group, keyed on the group's key column; refreshed by POST /sources/:id/dim/:group/refresh
  • pf.version — named forecast scenarios; exclude_iters (default ["reference"]) blocks those iter values from all operations; adjustment_segment / adjustment_bucket name new adjustments; is_default (one per source)
  • pf.fc_{tname}_{version_id} — one forecast table per version; contains both operational rows (pf_iter = baseline|scale|recode|clone) and reference rows (pf_iter = reference). Indexed on pf_logid, which is how undo, the change-log aggregate and the grain key all find their rows. Tables created before that index was added do not have it.
  • pf.log — audit log; every write gets one entry; slice + params stored as jsonb; label / bucket required (log_named CHECK). Stamped at write time: row_count, value_total, units_total, measure_cols (which columns the totals are denominated in), env (the state the intent executed against), sql_text (the statement as executed)
  • pf.layout — named Perspective view configs; see §Pivot layouts
  • pf.load_plan — load segment definitions, before and apart from having been run; see §Cloning a version's definition
  • pf.sql — generated SQL templates per source/operation; tokens substituted at request time
  • pf.app_user — login accounts; scrypt pass_hash, is_active, last_login_at, is_admin, territory (jsonb array)
  • pf.session — express-session store (connect-pg-simple layout)
  • pf.dim_period — calendar lookup table (2018–2035); one row per month keyed on sdat (month start date); provides cal/fiscal year, quarter, and month columns; populated by setup_sql/gen_dim_period.sql

Dead columns, kept but read by nothing: pf.version.unlabeled_load, pf.version.bucket_order, pf.log.seq, pf.source.default_layout.

is_key

Read in four places, and not the one it sounds like — slices are validated against filterCols, which is every dimension plus every date column regardless of is_key.

  1. The key of a dim_group — resolveGroup() in routes/sources.js takes members.find(c => c.is_key), the column every other member is keyed on for pf.dim_member
  2. Value completion — only is_key columns get a dropdown, and /sources/:id/values/:col refuses anything else
  3. Sibling autofill — fires on blur only when is_key && dim_group
  4. The dim_period anchor — role === 'date' && is_key && dim_group picks the date whose siblings are derived from the calendar

Uses 2 and 3 want several columns flagged; use 1 needs exactly one per group. When a group has more than one, .find() silently takes the lowest opos — a refresh keyed on the wrong column matches nothing and reports success with zero members. Until the two meanings are separated, a refresh that finds two keys should refuse rather than guess. Rationale: docs/pf_spec.md → §Data model.

Key token substitution tokens

{{fc_table}}, {{where_clause}}, {{exclude_clause}}, {{territory_clause}}, {{logid}}, {{pf_user}}, {{label}}, {{bucket}}, {{value_incr}}, {{units_incr}}, {{pct}}, {{set_clause}}, {{scale_factor}}, {{date_offset}}, {{filter_clause}}


Core data flow

Initial load (Forecast view)

Forecast.jsx fetches col_meta first, then picks the endpoint (both in routes/operations.js):

  • grain mode (any in_grain column) — GET /api/versions/:id/agg, rows pre-aggregated to the grain, table indexed on pf_gkey
  • raw mode (no grain) — GET /api/versions/:id/data, raw forecast rows, table indexed on pf_id

Either way: Arrow IPC binary stream → worker.table(buffer) in Perspective WASM. fetchArrow() handles both.

One batch, not streaming. Per-batch Arrow encoding creates independent dictionaries that crash Perspective WASM on dictionary replacement messages, so the server accumulates all rows and emits one record batch. pg returns bigint/numeric as strings by default — the type parsers in server.js coerce them to numbers; do not remove them.

Display grain

Aggregating to the grain the pivot displays is the load-time fix, and it keeps the native Perspective engine, so expand/collapse/depth/sort/filter all still work. Set the grain in Setup (in_grain per column); it is baked into pf.sql at Generate SQL time so load and operations agree. grainOf() in lib/sql_generator.js is the single definition of the grain — routes/log.js calls it directly and Setup.jsx mirrors it. Design: docs/pf_spec.md → §Display-grain pre-aggregation. Why not a DuckDB virtual server: docs/pf_perspective_options.md → §Spike findings.

Log-derived columns

/data and /agg both LEFT JOIN pf.log and emit four columns the forecast table does not itself carry:

  • pf_segment — pf.log.label (SEGMENT_EXPR)
  • pf_bucket — pf.log.bucket (BUCKET_EXPR)
  • pf_note — tag, else note, on a scale/recode/clone; null on loads (NOTE_EXPR)
  • pf_op — pf.log.operation

Only segment and bucket are stored names (see §Every log entry is named); the note is derived. SEGMENT_EXPR / BUCKET_EXPR / NOTE_EXPR in lib/sql_generator.js are the single definition, shared with the /data cursor in routes/operations.js — /agg is generated, /data is not, and the two have to agree. LABEL_GROUP_COLS lists the pf.log columns they read, for /agg's GROUP BY. In grain mode pf_logid is part of the grain, so the join adds no rows. The operation routes stamp the same fields onto the rows they push back incrementally, since those come from RETURNING * and would otherwise arrive without them.

Column order is stored text

Perspective orders column groups by the value string, so a "01 - " prefix in pf.log.label / pf.log.bucket, typed by whoever names the segment, is the only lever; nothing derives it. ADJUSTMENT_SEGMENT = '99 - Adjustments' lands last only when the loads carry 01–0n prefixes (digits precede letters). Do not reintroduce ordering as Perspective expressions — restore() replaces expressions wholesale and ExprTK cannot handle non-ASCII labels; DEAD_ORDER_EXPRS in Forecast.jsx strips the old ones from saved layouts. Rationale: docs/pf_spec.md → §Naming: segments, buckets, notes.

Every log entry is named

pf.log.label is a row's segment and pf.log.bucket what it counts toward, and every entry carries both — the log_named CHECK refuses one without. A report gets the pivot's segment and bucket by joining the forecast table to pf.log on pf_logid; there is no rule to copy. Never derive them at read time.

They are stamped when the entry is written:

  • A load — label is required (the routes answer 400 without one); bucket defaults to the label. loadNames() in routes/operations.js.
  • An adjustment — scale, recode and clone have no name inputs, so they are stamped with the version's adjustment_segment / adjustment_bucket, else the built-ins ADJUSTMENT_SEGMENT = 99 - Adjustments and ADJUSTMENT_BUCKET = 04 - Forecast (ADJUSTMENT DEFAULTS in lib/sql_generator.js). adjustmentNames() resolves them once per request; they go into the template's INSERT as {{label}} / {{bucket}} and onto the rows pushed back to the pivot.

The two version defaults are set on the Baseline page under Defaults for new adjustments. Changing one names the next adjustment; existing entries keep what they were written with. Renaming past entries is PATCH /api/log/:logid, one at a time, which renames but refuses to clear. A relabel needs a page reload to show, since the label is part of the aggregated row. A source registered before names were stored needs Generate SQL run once so its load templates write label and bucket at all. Rationale: docs/pf_spec.md → §Naming: segments, buckets, notes.

Forecast operations

POST to /api/versions/:id/{scale|recode|clone} → SQL executed with RETURNING * → new rows returned as JSON → pspTable.update(rows) — no full reload. In grain mode the operation's final CTE aggregates its own new rows to grain first; since pf_logid is part of pf_gkey those keys are always new, so update() appends and the view re-sums.

Price or volume (plug)

Price does not move unless it is named. A price move is invisible in the figure being edited — the dollars land on target either way — so every default holds price. All resolution is in resolveIncrs() in routes/operations.js; the semantics come from the predecessor Excel model (/opt/forecast_api/VBA/fpvt.frm → calc_val / calc_price).

  • Dollars only — plug decides: 'volume' (the default: price holds, units scale in proportion) or 'price' (volume holds, price moves).
  • Qty only — dollars follow at the current price. A slice with no units has no price to hold, so the dollars stay put.
  • Dollars and qty — both land as named; price falls out.
  • A named price (target_price) holds, and the other named measure decides the rest: price alone → qty holds; price + qty → qty lands as named, dollars follow; price + dollars → dollars land, qty follows. All three is refused server-side (400).
  • The held price is the adjustable rows' own, never blended with the fixed (excluded-iter) rows, even under target_basis: 'selected' — the scale SQL prorates over the adjustable rows only, so a blended price drives qty and dollars apart (and qty negative when a slice is taken to its fixed total).

The three boxes foot. At most two are inputs and the third is derived. effectiveScaleInputs() in OperationPanel.jsx is the single resolver: the panel renders from it and buildPayload in Forecast.jsx builds the request from it, so what is posted is what is shown. Typing a third drops one — the plug decides when dollars came last, else the older of the other two. The ledger's Result line previews value, units and price by the same rules, and the column that cannot move is labelled · holds.

The plug control is labelled "Moves": it lights whatever the edit moves (both if both do) and takes a click only while it is the one deciding; otherwise it stays on screen, not hidden.

Logged. resolved.method (price | both | volume | the plug) records which side moved, since the request alone cannot say. params also keeps plug and expected (what the Result line promised), beside resolved, so a panel/server disagreement can be found by reading the log — nothing compares them yet. The client's stored preference is keyed pf_scale_plug_v2; do not reuse the old key, which could carry a stale 'price'. Rationale: docs/pf_spec.md → §Operations.

Undo

DELETE /api/log/:logid → removes rows by logid → table.remove() of the affected index values (pf_gkeys in grain mode, pf_ids in raw mode); the view re-sums. No full reload.


Axis depth (collapse / expand)

Both axes collapse the same way: a depth in ViewConfig, set through restore() — group_by_depth via applyDepth(), split_by_depth via applySplitDepth(). Both steppers are drawn by components/DepthButtons.jsx from the axis's own field list (groupFull / splitFull), as a stepper ◂ level ▸, the middle button opening the full level list. depth is null until the first layout has been read back; the stepper then shows an em dash.

Depths are 1-based counts of levels to show, on both axes and in DepthButtons (levels[d - 1]); the value crosses unchanged in each direction. The engine agrees (set_depth(n - 1), and the traversal adds the 1 back). Never send 0: it reaches the engine as set_depth(-1) on an unsigned t_depth, underflows, and renders fully expanded. applyDepth floors at 1, so the stepper's "Total" slot shows the first level rather than a bare total — kept deliberately. A real total would need group_rollup_mode: 'total', unverified against the local engine.

A depth lives in the config, so it survives view rebuilds and rides into saved layouts through viewer.save(). This required patching the engine — ViewConfig::apply_update dropped both depth fields; see ui/vendor/0001-apply-depth-fields-on-config-update.patch. Do not go back to imperative view.set_depth() alone: the viewer discards that view on every re-render. split_full is still read on load, for layouts saved under the old truncated-split_by scheme.

Auto-pause is off. initViewer() calls viewer.setAutoPause(false) right after viewer.load(). Pausing deletes the view, so returning to the tab would rebuild the whole grain and lose per-node expansion.

Per-node expansion is not solved. expand(row_index) / collapse(row_index) are set-only with no getter, so they cannot be captured and replayed and are lost on every rebuild. The route is a ViewConfig field of expanded row paths applied in server.cpp. Depth is also whole-axis — one level collapses every group at that level. Rationale: docs/pf_spec.md → §The pivot.

Tag completions are per version

GET /versions/:id/tags — the tags used on this version, with uses, last_used, owners and can_edit; Forecast.jsx refetches on versionId and after every tagged write. A tag is not a row anywhere — only a value repeated across pf.log entries.

Managed on the Baseline page's Tags panel, which lists each with its use count and last-used date. PATCH /versions/:id/tags takes { from, to } and rewrites every entry carrying from; a to that already exists merges, an empty to clears; the panel confirms both. Permission is the pf.log rule applied to every entry the tag is on (a half-applied rename leaves the initiative split), so a row with can_edit false is greyed out. One entry's tag is edited in the Forecast page's change log.

Completion matches on words, not substrings. TagInput in OperationPanel.jsx normalises both sides (case, punctuation, stopwords, light stemming) and says so when a typed tag reduces to an existing one; nothing is merged automatically. Rationale: docs/pf_spec.md → §Bridge and change log.


Choosing a forecast

The sidebar lists every source's versions (allVersions, a client-side fan-out over /sources/:id/versions) and sets sourceId from the one picked. versions stays scoped to the selected source, because Baseline and Forecast are — do not widen it. The source picker is only on Setup. Known collision: "forecast" is both the scenario and the third step; undecided.

Where a browser lands. App.jsx restores pf_sourceId / pf_versionId from localStorage, re-checked against the live list. With nothing stored it falls back to pf.version.is_default (one per source, partial unique index version_one_default), not versions[0], which is the newest. A stored selection wins. Setting the default is admin only, in PUT /api/versions/:id, as two statements in one transaction — the index refuses the instant between.

Renaming a version is safe at any time; nothing derives from the name. PUT /api/versions/:id answers 409 on a (source_id, name) collision, and COALESCEs a falsy name back to the existing one, so a blank box is refused client-side.


Cloning a version's definition

POST /sources/:id/versions takes clone_from. It copies the template version's settings (description, exclude_iters, the fallback names, bucket_order), its version-scoped layouts, and its load segments — and runs nothing, so the dates can be changed before anything loads.

Not copied: adjustments, in any form (a scale's target was sized against the rows that were there), and source-wide layouts (version_id IS NULL), which already apply to the new version.

pf.load_plan

A plan is a load definition on its own — kind, label, bucket, tag, note, filters, raw_where, date_offset, seq — with run_logid naming the pf.log entry it produced, or null while unrun. Deliberately not pf.log: every row there means a write happened.

run_logid is ON DELETE SET NULL, so undoing a load returns its plan to unrun. The plans list reads the row count back through that FK.

cloneDefinition() in routes/versions.js reads pf.load_plan when the template has plans and otherwise derives them from its baseline/reference log entries (pf.log.params stores filters, raw_where, date_offset).

Run all stops at the first failure — segments are not independent. Rationale: docs/pf_spec.md → §Loads and segments.


Pivot layouts

A layout is a named ViewConfig in pf.layout, owned by an account: published (everyone on the forecast lists and can apply it; only its owner or an admin may change it) or private. Scope is the version by default; version_id IS NULL means every version of the source. is_default (at most one per scope, by partial unique index) is what initViewer() restores on a first load — a version-scoped default beating a source-wide one.

Permissions are the pf.log rule verbatim — your own, or an admin's override; can_edit rides on every row and the UI greys out the rest. Applying is never restricted; Save is withheld on a layout that isn't yours, Save as… forks it. No territory clause — a layout is display config.

Local state. LAYOUT_KEY (pf_layout_v{vid}), the unnamed last-used config, stays in localStorage; loadLayouts() lifts the old named list (LAYOUTS_KEY) into pf.layout as private rows once, then clears it.

The dirty dot is a comparison, not a flag. restore() itself fires perspective-config-update, so activeConfigRef holds what the pivot last matched and sameConfig() compares against it, ignoring table and theme.

Grand totals ride in the layout under pf_totals — { row, col }. The engine has no "subtotals but no grand total" mode, so both are hidden in the grid's DOM; captureConfig() adds the key and cleanLayout() strips it before restore(). Without it: row shown, column hidden.

cleanLayout() drops anything the live schema lacks — axes, aggregates (including the weight column of the multi-arg form), theme, pf_totals, dead expressions — dropping the offending entry rather than the layout, since restore() is all-or-nothing. Rationale: docs/pf_spec.md → §The pivot.


The Forecast toolbar is two zones

Left, view — Layout ▾ and the Rows / Columns steppers with their Total toggles; all ViewConfig, nothing reaches the server. Right, forecast — Refresh data, Change log, Bridge, Operations. Blue is for actions, pressed grey (.pf-pressed) for state. Fit widths was removed deliberately; do not restore it without fixing auto-size (see git history).

Calculating… in the status bar: Forecast.jsx raises a counter (rebuilds overlap) in applyDepth, applySplitDepth and applyLayout, lowers it after viewer.flush(), and broadcasts pf-pivot-busy on the window for StatusBar. Not driven from perspective-config-update, which arrives after the rebuild. Rationale: docs/pf_spec.md → §The pivot.


Slice mechanics

A click (perspective-click) or a dragged region becomes a slice — a {col: value} object. A plain click replaces the selection; ctrl/⌘/shift-click toggles a slice in or out, so the panel holds a list sent as slices in operation POST bodies (a single slice is still accepted server-side).

The slice is re-derived by cellFilters() in Forecast.jsx, from detail.row.__ROW_PATH__ (row axis, raw values, so dates stay epoch millis) and the clicked column name (column axis, split by |). The click and a dragged region go through the same derivation. detail.config.filter is used only as the fallback when there is no cell to index the column axis with — it cannot be trusted otherwise, because the engine builds it with a falsy test that drops blank and 0 pivot values, and a dropped key widens the write.

Regions. The datagrid runs in edit_mode: SELECT_REGION (forced on restore). perspective-select carries a ViewWindow — { start_row, end_row, start_col, end_col } — and fires on every mouseover, so the handler records the latest window and a window-level mouseup commits it. A single-cell region is ignored there: perspective-click owns it, and handling it twice would undo a ctrl-click toggle.

A total cell is its displayed amount. The grand-total row resolves to no row dimension, so its slice is whatever the column axis and the pivot's == filters leave — possibly {}. An empty slice is accepted only alongside a non-empty scope; with no filter at all it would be the whole version, and is refused on both sides. buildWhereAny() reads a union containing {} as TRUE, the scope then ANDed on — dropping it would narrow the write.

The pivot's pf_ columns stay in the slice. pf_segment, pf_bucket, pf_note and pf_op are resolved through the log (COMPUTED_SLICE_COLS); pf_iter, pf_user and pf_logid are compared directly (TABLE_SLICE_COLS). Stripping any of them made the write wider than the ledger's total. They narrow a slice but do not make it selective on their own. Duplicates are collapsed before the request — otherwise apply_mode: each applies the change twice.

Selection highlight. The datagrid wipes model._selection_state.selected_areas on every mousedown, so Forecast.jsx keeps areasRef (sliceKey -> rectangles, parallel to slices) and an effect pushes the full set back and redraws after every change. Every deselect path prunes it by live slice key.

A blank dimension value is a value. null and '' are different rows and stay distinct through to the WHERE clause: IS NULL vs = ''. Only undefined means absent (a subtotal row's shorter path, a collapsed column level). On the column axis, t_tscalar::to_string renders a null as the literal text "null" and an empty string as '', so cellFilters() maps "null" → null. The ledger filters a null with is null, never == null (the engine matches on type). The panel prints (null) and (blank), and — only when the column is absent from the slice.

The pivot's own filter travels alongside, as scope — [col, op, value] triples read off viewer.save(), which is also what the ledger totals against. SCOPE_OPS in lib/sql_generator.js is the whole allowed list, under Perspective's operator names; anything outside it is refused, not dropped.

  • Substring operators (contains, begins with, ends with and negations) map to ILIKE, matching the engine's case-insensitive t_tscalar::contains; %, _ and \ are escaped out of the term.
  • matches / not matches stay refused: the engine is RE2, Postgres is not.
  • Every operator that needs a term refuses an empty one (an unfinished filter row arrives as [col, '==', null]).

Limitation: computed columns created by Perspective expressions don't map back to raw rows — only native columns work for slice extraction. Rationale: docs/pf_spec.md → §Selection.


Operation SQL patterns

All three operations follow the same structure: insert a pf.log row in a CTE, then insert forecast rows referencing its id. {{where_clause}} is built from the slices; {{exclude_clause}} blocks exclude_iters rows; the territory clause is ANDed on last.

buildWhere() in lib/sql_generator.js turns a slice into SQL: it allows dimension + date columns plus COMPUTED_SLICE_COLS and TABLE_SLICE_COLS, skips unknown non-pf_ keys, and refuses an unknown pf_ key (skipping one would widen the write). A null renders as IS NULL; in a list the null arm is ORed on, since IN (NULL) is never true. Values are escaped but not parameterized — consistent with existing patterns, debuggable in pg logs. The "must name a filterable column" refusal is assertSelective() in routes/operations.js.

Every date column travels. dataCols is [...dims, ...dateCols, value, units] — never just the first date column, or scale and recode write rows with null secondary dates under correctly labelled period dimensions.

  • Scale — distributes value_incr/units_incr proportionally across rows in the slice using window functions; refuses a pool whose net is ~zero relative to its gross (assertProratable)
  • Recode — inserts negative rows (zero out original) + positive rows with {{set_clause}} dimension overrides; both share the same logid
  • Clone — copies the slice with {{set_clause}} overrides and {{scale_factor}} multiplier; original untouched

Recode takes a date shift too. date_offset moves the put-back leg only, so a shifted recode is a timing move, and with a shift set may be empty. The moved rows' period dimensions are re-derived from pf.dim_period against the shifted date; a row whose date is null keeps the label it had. Without a shift nothing is derived. Clone still blanks those labels on a null date.

A date offset must be whole days. assertInterval() refuses anything finer, because every shifted date is cast back to a date — and Postgres reads a bare number as seconds. 30 days is not a month; use 1 month.

Scale refuses with "Nothing to scale…" and recode with "Nothing to recode…" when there is nothing to write. Rationale: docs/pf_spec.md → §Operations.


Authentication

Everything under /api except the auth routes sits behind a session; the React app is only mounted once there is one (Gate in main.jsx).

  • Accounts: pf.app_user — scrypt hashes from lib/auth.js, never plaintext. Managed with ./pf.sh add-user | passwd | list-users | disable-user | enable-user; the password is read on stdin and hashed before it reaches psql.
  • Sessions: pf.session (connect-pg-simple); revoke by deleting the row. Cookie pf.sid: httpOnly, SameSite=Lax, Secure unless COOKIE_SECURE=false, 12h rolling.
  • Config: SESSION_SECRET is required — the server exits at boot without one. TRUST_PROXY (default 1) for req.ip and secure cookies behind the TLS proxy. CORS_ORIGIN is the only way CORS is enabled; never a wildcard with a session cookie.
  • Login hardening: routes/auth.js throttles to 10 failures per IP per 15 minutes (in-memory), returns one message for unknown/wrong/disabled alike, and regenerates the session id on success.

Identity is server-side. pf_user, created_by and closed_by come from sessionUser(req), never from the request body.

Setup and Baseline are admin-only to write

requireAdmin (lib/auth.js) is on:

  • sources.js — POST /sources, PUT /sources/:id/cols, POST /sources/:id/generate-sql, POST /sources/:id/dim/:group/refresh, DELETE /sources/:id
  • versions.js — POST /sources/:id/versions, PUT /versions/:id, POST /versions/:id/{close,reopen}, DELETE /versions/:id
  • operations.js — the loads: POST /versions/:id/{baseline,reference}, PUT /versions/:id/baseline/:logid, DELETE /versions/:id/baseline
  • plans.js — every write

Not gated, deliberately: reads (gating what a page needs to render turns it into a 403); scale/recode/clone/undo, which carry their own owner and territory rules; PATCH /versions/:id/tags, whose permission is ownership.

The pages stay visible and go read-only — one <fieldset disabled> per page plus a ReadOnlyNotice banner. Setup wraps a real box; Baseline uses display: contents. Baseline's Tags panel sits outside the fieldset. None of this is the permission — the server check is. Setup's list fetches coerce responses to an array: an error body rendered with .map throws during render and unmounts the whole app. Rationale: docs/pf_spec.md → §Access.

Source columns come from pg_catalog

information_schema.columns omits materialized views, and gs.osm_skinny is one. RELATION_COLUMNS_SQL in lib/utils.js is the replacement, used by version creation, source registration and the table preview; it returns the information_schema shape (data_type from format_type with the modifier stripped, precision/scale from atttypmod). The table browser lists from pg_class by relkind. Do not query information_schema.columns for source columns.

Territory scoping

An account sees and changes only its own territory: pf.app_user.territory (jsonb array), is_admin for accounts that see everything, and col_meta.is_territory marks the column (one per source).

Fail closed. buildTerritoryClause() returns FALSE, not TRUE, for an empty list or an unflagged source.

Built from the session, never the request. Unlike scope, the territory predicate is ANDed on last, in sliceUnits() for writes and per route for reads, where nothing in the payload can remove it.

Enforced at:

  • /data — clause on the cursor and on the count behind X-Row-Count
  • /agg — {{territory_clause}}, applied before the GROUP BY, since the territory column need not be in the grain
  • every operation, through sliceUnits()
  • /sources/:id/values/:col — completion reads the source table
  • DELETE /log/:logid and PATCH /log/:logid — by owner, not territory: your own entries, or an admin's override. Undo removes an entry wholesale, and label/bucket name the pivot's columns for everyone in the version
  • recode's set — a scoped account cannot set the territory column at all

The change log lists everyone's entries, with an Everyone / Mine control (resets to Mine on every open, not persisted) and the reader's own entries marked. isMine() is strict authorship, deliberately not canUndo(). The fetch stays unfiltered, since the control's counts need the whole set. It shows an entry's full impact — the totals on pf.log are company-wide, deliberately.

Managed with ./pf.sh set-territory | set-admin | orphan-territory. orphan-territory lists values present in the data that no account owns. Rationale: docs/pf_spec.md → §Access.

Light / dark mode

ui/src/theme.jsx — ThemeProvider (in main.jsx) and useTheme() → { dark, setDark }. Stored as pf_dark in localStorage, else prefers-color-scheme; the effect toggles .dark on <html>. ui/src/index.css defines custom properties under :root and .dark, with Tailwind overrides written as .dark .bg-white { ... } — no Tailwind dark-mode config. Palette is Perspective's "Pro Dark".

The viewer's theme is viewer.restore({ theme: dark ? 'Pro Dark' : 'Pro Light' }), on load and in useEffect([dark, versionId]) — a partial restore, no rebuild. Not setAttribute('theme', …), which does nothing in 5.4.0. cleanLayout drops theme and sameConfig() ignores it: the theme is the reader's choice, never a layout's.

After a change: restart, and Generate SQL

  • Restart the server for anything in routes/, lib/ or server.js.
  • Generate SQL (Setup, per source) whenever lib/sql_generator.js changes. The templates are stored in pf.sql, so editing the generator changes nothing until they are rebuilt — and a template carrying a token the running code does not substitute fails at the database, which reads as an unrelated client-side error.

Order matters: restart first, then Generate SQL, or the old code writes the templates.

Schema changes are applied to the live database directly and mirrored into setup_sql/ for a fresh install; 01_schema.sql is idempotent but is not a migration runner.

Known issues / active work

  • Rows written before the every-date-column fix have null secondary dates (rdate/sdate_e) on scale and recode rows. Deleting and replaying those log entries is the honest repair; a backfill would be inference
  • Zero-row clones report success — clone commits an empty log entry and returns rows_affected: 0, where scale and recode refuse
  • The change log does not show an entry's id, so there is no way to name one when asking about it
  • Territory is read onto the session at login, so a change does not reach a signed-in account until it signs in again
  • The bridge dedupes overlapping slices by pf_id, which grain-mode rows do not carry, so overlapping slices double-count there; and it keeps only dimension/date slice keys, dropping the pf_ keys operations now honour (BridgeView.jsx, the per-slice view loop)
  • Depth buttons rebuild the view — restore() tears down and rebuilds, a full traversal, where a manual collapse mutates in place. Fix: detect a depth-only change and call view.set_depth() too, at the cost of drift
  • Load time is dominated by row count, not payload size — serialising rows out of Postgres into JS dominates. The lever is dynamic grain; see docs/pf_spec.md → §Display-grain pre-aggregation
  • Per-node expand/collapse is lost whenever the view rebuilds; see §Axis depth
  • Col_meta / version schema drift: if col_meta roles change after a version's forecast table is created, SQL and DDL go out of sync — delete and recreate the version
  • Grain drift: changing in_grain after a load requires Generate SQL + a page reload. routes/log.js derives the grain from live col_meta, so a grain changed mid-session yields pf_gkeys that don't match the loaded table and undo silently removes nothing
  • Grain is static per source — an unflagged dimension cannot be pivoted on

Deferred (not in v1)

Baseline replay (not built), approval workflow, export, version comparison, multi-DB connections. Live server-side aggregation (Path A / DuckDB virtual server) is parked on branch spike/duckdb-virtual-server. See docs/pf_spec.md → §Open questions.