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>
82 KiB
Pivot Forecast — Design
What the app does, how the pieces fit, and why — including what each design
replaced, where that history still explains the current shape. CLAUDE.md
holds the rules an agent needs while editing; this holds the reasoning. Where
either disagrees with the code (routes/, lib/, ui/src/, setup_sql/),
the code is the truth.
Related: pf_perspective_options.md (transport options, the DuckDB spike, why
the pre-aggregated path won), PERSPECTIVE.md (bundling), ui/vendor/README.md
(the patched Perspective build and how to rebuild it).
Overview
A web app for building named forecast scenarios against a PostgreSQL table:
- Load actuals as a baseline — segments pulled from the source, optionally date-shifted into the forecast period, so the starting point is "no change from last year".
- Load reference rows — prior year, plan, open orders — for comparison. Operations do not adjust them.
- Adjust — select pivot cells and scale, recode or clone them.
Every write is append-only and produces exactly one pf.log entry. An
adjustment inserts new rows carrying its entry's id; undo deletes those rows
and the entry. So a version is always the sum of its log, every figure is
attributable to the entry that wrote it, and any entry can be reversed alone.
Postgres holds the raw rows and is the single source of truth. The browser holds a copy in Perspective (WASM), pre-aggregated to the display grain, and updates it incrementally from each operation's response rather than reloading.
Lineage
A port of an Excel model: a data tab of pre-aggregated rows, a pivot cache over
it, a double-click on a cell opening a VBA form (/opt/forecast_api/VBA/fpvt.frm)
that appended incremental rows, and a cache refresh. The append-and-let-the-pivot-sum
model follows that directly, and the price/volume rules in §Operations come from
the form's calc_val / calc_price.
Stack
Node/Express (server.js, one pg pool, type parsers coercing bigint and
numeric to numbers so Arrow does not infer them as dictionary text);
PostgreSQL, everything in schema pf; React + Vite + Tailwind in ui/, built to
public/app/; Perspective 5.4.0 from a vendored patched fork (§The pivot).
Pages and navigation
Three steps in a collapsible sidebar: Setup (register sources, assign column roles, Generate SQL), Baseline (versions, load segments, version defaults, tags), Forecast (the pivot and the operation panel). Setup and Baseline are admin-only to write (§Access).
The forecasts are listed in the sidebar, above the steps, each with its status and a star for the default. They used to be two dropdowns in the status bar, which is otherwise read-only, so the one pair of controls in it did not read as controls. The list is not nested under the Forecast step, where the idea started: Baseline works on the same version, so hanging it off one step would misrepresent its scope or duplicate it. The sidebar reads what am I working on, then what step am I on.
The source is not in the nav. A raw table name means nothing to someone
editing a forecast. Picking a forecast sets the source, since
pf.version.source_id comes with it; the source picker appears only on Setup.
versions (the selected source's, which Baseline and Forecast need) and
allVersions (the sidebar's, a client-side fan-out over /sources/:id/versions)
are kept apart so widening one does not change the other.
The status bar keeps what follows from the selection: the forecast's name and row count, with the physical table in a click-to-copy hover panel — pasting it into psql is why it was ever on screen.
Known collision: "forecast" names both the scenario and the third step. The
scenario should own the word, leaving the step wanting another (Adjust).
Undecided.
Where a browser lands
App.jsx restores the last source and version from localStorage, re-validated
against the live list so a deleted version re-points rather than 404ing every
call. With nothing stored it falls back to pf.version.is_default (one per
source, partial unique index). It used to fall back to versions[0] — newest
first, so whatever somebody created last, a scratch copy as readily as the plan —
and accounts that never pick a version, which is every sales account, followed
the forecasting work around.
Setting the default is admin only, since it decides where everyone else lands; the Baseline checkbox is greyed for others. It is two statements (clear old, set new) in one transaction, because the index refuses the instant between. A stored selection still wins — the default is a fallback, so an account that landed in the wrong scenario keeps landing there until it switches once.
Renaming a version
Safe at any time, including closed. Nothing derives from the name: the table is
fc_{tname}_{version_id}, pf.log and pf.layout key on the id, and client
storage keys are id-based. The Baseline page's This forecast panel saves name
and description on blur via PUT /versions/:id; a name collision answers 409,
and a blank name is refused client-side because the route COALESCEs it away.
API surface
All under /api; all but the auth routes need a session.
| Area | Routes |
|---|---|
| Auth | POST /login, POST /logout, GET /me |
| Browser | GET /tables, GET /tables/:schema/:tname/preview |
| Sources | GET/POST /sources, GET/PUT /sources/:id/cols, POST /sources/:id/generate-sql, GET /sources/:id/sql, GET /sources/:id/values/:col, GET /sources/:id/dim/:group, POST /sources/:id/dim/:group/refresh, GET /sources/:id/lookup, GET /dim-period/cols, DELETE /sources/:id |
| Versions | GET/POST /sources/:id/versions, PUT/DELETE /versions/:id, POST /versions/:id/{close,reopen}, GET /versions/:id/table-info, GET /versions/:id/values/:col, GET/PATCH /versions/:id/tags, GET /versions/:id/bridge |
| Data | GET /versions/:id/data, GET /versions/:id/agg |
| Loads | POST /versions/:id/{baseline,reference}, PUT /versions/:id/baseline/:logid, DELETE /versions/:id/baseline |
| Plans | GET/POST /versions/:id/plans, PATCH/DELETE /plans/:pid |
| Operations | POST /versions/:id/{scale,recode,clone} |
| Log | GET /versions/:id/log, GET /log/:logid/debug, PATCH/DELETE /log/:logid |
| Layouts | GET/POST /versions/:id/layouts, PATCH/DELETE /layouts/:lid |
DELETE /sources/:id answers 409 while the source has versions
(pf.version.source_id is ON DELETE RESTRICT) and never drops forecast tables.
Data model
Install scripts in setup_sql/: 01_schema.sql (metadata tables, with
ADD COLUMN IF NOT EXISTS backfills and one-off data migrations inline),
02_auth.sql (pf.app_user, pf.session), gen_dim_period.sql (creates and
fills pf.dim_period; fiscal start month set at the top; re-runnable).
01_schema.sql is idempotent but not a migration runner. Schema changes are
applied to the live database directly and mirrored into setup_sql/ for fresh
installs.
pf.source
schema, tname, label, status, created_by. default_layout is dead —
migrated into pf.layout, read by nothing. Registration seeds one col_meta row
per column as dimension.
Columns come from pg_catalog, not information_schema, which omits
materialized views — they are not in the SQL standard — and the app's primary
source is one. It looked columnless: registration seeded nothing and version
creation failed "No usable columns in col_meta" while col_meta plainly held them.
RELATION_COLUMNS_SQL in lib/utils.js returns the shape information_schema
did (format_type minus modifier; precision and scale unpacked from
atttypmod), so mapType and callers did not change. The table browser lists
from pg_class by relkind for the same reason.
pf.col_meta
| Column | Meaning |
|---|---|
role |
dimension | value | units | date | filter | ignore |
label, opos |
display name; ordinal, which orders everything generated |
is_key |
four separate uses, below |
dim_group |
groups functionally dependent columns — a part and its attributes, a date and its periods |
dim_period_col |
the pf.dim_period column this dimension is derived from at load |
in_grain |
part of the display grain (§Display-grain pre-aggregation) |
is_territory |
the column territory is expressed in; at most one per source, enforced on save |
Roles: dimension is categorical and sliceable. value (required) and units
(optional) are the measures — several may be marked, loads carry all, but the
operations use only the first of each by opos (§Proposed: additional
measures). date (at least one) — every date column is stored, shifted and
carried by every operation, and sliceable. filter feeds the baseline filter
builder and is not stored. ignore is not stored. Slices are validated against
every dimension and date column regardless of is_key.
dim_group + dim_period_col. A date column that is is_key with a
dim_group anchors the group: its sibling dimensions with a dim_period_col are
looked up in pf.dim_period against the shifted date at load time instead of
copied raw, so fiscal periods follow a baseline moved forward a year. Every such
group is honoured; this used to be a .find(), so a source with order, requested
and ship dates derived one set and copied the other two raw — shifted dates under
unshifted labels.
is_key is read in four places, none of them what the name suggests:
- The key of a
dim_group—resolveGroup()inroutes/sources.jstakesmembers.find(c => c.is_key)as the columnpf.dim_memberis keyed on - Value completion —
/sources/:id/values/:colserves onlyis_keycolumns (the operation panel now completes from/versions/:id/values/:colinstead, which takes any dimension or date) - Sibling autofill —
/sources/:id/lookup, foris_key && dim_group - The
dim_periodanchor —role = 'date' && is_key && dim_group
Uses 2 and 3 want several flagged; use 1 needs exactly one per group, and with
more .find() silently takes the lowest opos. That has happened: a column null
throughout outranked its group's real key, so a member refresh matched nothing
and reported success with zero members. The meanings want separating — a
per-group key choice, or the rule that the key is the column the group is named
after — and until then a refresh finding two keys should refuse rather than guess.
pf.dim_period
One row per month keyed on sdat, a GiST-indexed drange, and calendar and
fiscal year/quarter/month columns in several spellings. Joined as
LEFT JOIN pf.dim_period dpN ON dpN.drange @> (date + offset)::date — left,
because an unshipped order has no ship date and must keep its row.
pf.dim_member
Master data per dim_group: one row per key value, siblings in attrs, with
is_active and source_seen. The source is transactional, often a view over all
history, so deriving members from it is slow (a full scan) and wrong (it describes
only what has been transacted — it cannot say a part is discontinued, or that a
new one exists before it sells). This is the app's own list, refreshed from the
source by an admin action but curatable apart from it. A refresh merges:
members gone from the source become source_seen = false, not deleted, so
curation survives; a key with several attribute sets across history takes the
most recent. It stamps an explicit run time rather than now(), which inside a
transaction is the transaction start.
pf.version
| Column | Meaning |
|---|---|
name, description |
(source_id, name) unique |
status, closed_at, closed_by |
a closed version refuses every write but annotation |
exclude_iters |
jsonb, default ["reference"] — pf_iter values scale and recode will not write |
is_default |
where a browser with no saved selection lands |
adjustment_segment, adjustment_bucket |
names a new adjustment is stamped with (§Naming) |
unlabeled_load, bucket_order |
dead — read by nothing |
Creating a version inserts the row and creates its forecast table and pf_logid
index in one transaction. Deleting drops the table; pf.log, pf.layout and
pf.load_plan cascade.
pf.fc_{tname}_{version_id}
One table per version holding operational rows (pf_iter = baseline, scale,
recode, clone) and reference rows (reference):
CREATE TABLE pf.fc_{tname}_{id} (
pf_id bigserial PRIMARY KEY,
-- every non-ignored col_meta column, typed from the source by mapType
pf_iter text NOT NULL,
pf_logid bigint NOT NULL,
pf_user text,
pf_created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX fc_{tname}_{id}_logid_idx ON pf.fc_{tname}_{id} (pf_logid);
No version_id column: the table is the version. pf_logid is not a foreign
key — undo deletes rows then log row in one transaction, and those are the only
two paths that remove either. The index is how undo, the change-log recount
and the grain key find an entry's rows; without it each is a sequential scan.
Tables created before it was added lack it.
pf.log
| Column | Meaning |
|---|---|
version_id |
ON DELETE CASCADE |
pf_user, stamp |
the session account; when |
operation |
baseline | reference | scale | recode | clone |
slice, params |
the slice(s) written to (null on loads); intent plus what the server resolved |
label, bucket |
segment name and what it counts toward; required by the log_named CHECK |
tag, note |
initiative name, which the bridge groups on; free commentary |
row_count, value_total, units_total, measure_cols |
what the entry wrote, stamped at write time |
env, sql_text |
what it ran against; the statement as executed |
seq |
dead |
Who writes what. Load templates insert label, bucket and tag in their
ilog CTE. Adjustment templates insert label and bucket; their tag goes on
in a follow-up UPDATE in the same transaction (tagLog()), a hold-over from
when a new {{tag}} token would have stranded sources that had not re-run
Generate SQL. Totals, env and sql_text are written after commit by
stampLogTotals().
Stamped totals are not a cache. An entry's rows never change after it is
written — only its own operation inserts them and only undo removes them, taking
this row too — so the totals are fixed. The change log used to join the whole
forecast table on every open to recompute them. measure_cols records which
columns they are in, since roles can be reassigned. Stamping is best-effort: a
failure does not roll back a write that succeeded, since totals can be recomputed
and the adjustment cannot. GET /versions/:id/log?recount=1 recomputes by join
and writes back — the repair for rows deleted by hand, and the backfill for
entries older than the columns.
params and sql_text are both kept on purpose. One is the intent (slices,
scope, resolved increments), the other what it became. Keeping only one assumes
the translation is correct, which is exactly what is in doubt when rows look
wrong. env holds what cannot be reconstructed afterwards — the territory, the
version's exclude_iters, and the template's generated_at, a fingerprint (not
a version) showing whether an entry ran under a different template.
GET /log/:logid/debug returns the whole row; the list omits sql_text and
reports has_sql.
pf.sql
One generated template per (source_id, operation): get_data, get_agg (only
with a grain), baseline, reference, scale, recode, clone, undo.
generateSQL() in lib/sql_generator.js bakes in column names and leaves
{{token}}s — fc_table, version_id, pf_user, note, tag, label,
bucket, params, slice, filter_clause, date_offset, where_clause,
exclude_clause, value_incr, units_incr, set_clause, scale_factor,
territory_clause — for applyTokens() at request time. Generation deletes any
operation it no longer produces, since a stale get_agg after clearing the grain
would still be picked up. The stored undo is documentation; the route runs its
own statements.
Values are escaped (esc() doubles quotes), not parameterised — consistent with
the codebase, and the executed SQL reads as-is in the server log and
pf.log.sql_text.
Editing the generator changes nothing until Generate SQL runs, since the templates are stored; a template carrying a token the running code does not substitute fails at the database, reading as an unrelated client error. Restart first, then Generate SQL, or the old code writes the templates.
Other tables
pf.layout (§The pivot), pf.load_plan (§Loads and segments), pf.app_user and
pf.session (§Access).
Loads and segments
A version's rows start as segments: independent queries against the source,
each appended as one log entry, each undoable alone. Two kinds, differing only in
pf_iter:
- baseline — the rows operations adjust
- reference — kept out of scale and recode by
exclude_iters
pf_iter answers can operations write these rows. Whether rows belong in the
forecast number is the segment's bucket (§Naming): open orders load as
reference so nothing adjusts them, and are still part of the forecast. Neither
can be derived from the other. All load routes are admin-only.
How a load's WHERE is built
The Baseline page's filter builder takes groups of conditions — AND within,
OR between — over date and filter columns (=, !=, IN, NOT IN,
BETWEEN, IS NULL, IS NOT NULL). A period is a filter like any other. A
toggle swaps the builder for a raw WHERE body.
The client builds the clause; the route injects it. buildFilterClause() in
Baseline.jsx renders (a AND b) OR (c) and sends it as where_clause beside
the structured filters (or raw_where). The route substitutes
raw_where || where_clause as {{filter_clause}} verbatim and keeps filters /
raw_where in params only so a segment can be re-opened and a plan derived.
Nothing server-side validates columns or escapes values, and the client does not
escape them either; lib/sql_generator.js's own buildFilterClause(), which
does, is called by no route. The protection is requireAdmin: whoever can reach
the route can already send raw SQL, so validating the structured path would not
narrow what they can do. An empty clause becomes TRUE — the whole source — and
the client refuses to send one.
Date offsets
date_offset is a Postgres interval (1 year, -90 days) applied to every
date column, so order and ship dates stay in step, with period dimensions
looked up against the shifted dates. Filter columns are never shifted: the offset
moves where rows land, not which are chosen.
It must be whole days (assertInterval()): shifted dates are cast back to
dates, so anything finer is rounded away, and Postgres reads a bare number as
seconds — "30" used to be accepted and move nothing. 30 days is not a month
either; use 1 month. Checked on baseline loads, segment edits, recode and clone
— not on reference loads, where a bad offset surfaces as a Postgres error.
The offset is typed as an interval string. It replaced year/month spinners, which could not express days and clamped at zero, so a segment could only move forward in whole months. The timeline preview parses it only far enough to draw the source and shifted periods; Postgres is the authority.
Naming, editing, clearing
A load's label is required (400 without) — the segment name the pivot shows
and reports group on — and its bucket defaults to it (loadNames()).
PUT /versions/:id/baseline/:logid edits a segment by deleting its rows and log
row and re-running it in one transaction, under a new logid. Refused (409)
once any adjustment exists, since those were sized against the old totals.
Annotations carry over with ??, not ||: the form has no tag input, so an
absent tag must survive, while an explicit '' clears.
DELETE /versions/:id/baseline removes every baseline row and entry (not
reference). Not undoable.
Load plans — pf.load_plan
A segment used to exist only once run, so cloning a definition meant re-running
every load, and there was nowhere to hold "this cut, but with the dates changed
first". A plan is the definition alone — kind, label, bucket, tag, note,
filters, raw_where, date_offset, seq — with run_logid naming the entry
it produced, or null.
- Deliberately not
pf.log: every row there means a write happened, and unexecuted intent would cost it that meaning. run_logidisON DELETE SET NULL, so undoing a load returns its plan to unrun instead of deleting the definition. The list reads the entry'srow_countthrough the FK to tell run from unrun.- Running is two round trips — the ordinary load POST with the plan's fields,
then
PATCH /plans/:pidwith thelog_id. The alternative was a second copy of the load route's substitution; a plan that reads unrun while its rows exist is visible and fixable, a divergent copy of the load SQL is neither. - Run all stops at the first failure. A definition's segments are not independent — references date-shift off a baseline — so carrying on past a broken one builds something nobody described.
Known issue: runPlan() does not send the plan's tag, so a tagged plan loads
untagged.
Cloning a version's definition
POST /sources/:id/versions with clone_from copies the template's settings
(description unless given, exclude_iters, adjustment names), its
version-scoped layouts, and its segments as plans — and runs nothing. The
point of cloning a definition is to change the dates before anything loads; a
clone that fetched the baseline first would have to be undone to be edited.
Not copied: adjustments, in any form — a scale's target was sized against the rows that were there, so replaying it against another load is arithmetic nobody asked for; and source-wide layouts, which already apply and would duplicate by name.
cloneDefinition() reads the template's plans, or derives them from its
baseline/reference entries when it has none — every version built before
plans existed. params already stores filters, raw_where and date_offset,
so this is a read, not a reconstruction.
Replay
Re-running a version's adjustments against a restated baseline is not built.
Nothing accepts a replay flag and nothing answers 501, whatever earlier specs
said. See §Open questions.
Naming: segments, buckets, notes
Four pivot columns come from pf.log, joined on pf_logid, not from the
forecast table:
| Column | From |
|---|---|
pf_segment |
l.label — which load, or which kind of adjustment |
pf_bucket |
l.bucket — what it counts toward: Actual, Plan, Forecast, Prior Year |
pf_note |
adjustments: the tag, else the note; loads: null |
pf_op |
l.operation — /agg and pushed-back rows only |
Separate on purpose: commingling a segment name with an adjustment's note makes
neither pivotable. In grain mode pf_logid is in the grain, so the join adds no
rows. SEGMENT_EXPR / BUCKET_EXPR / NOTE_EXPR in lib/sql_generator.js are
the single definition, shared by the generated /agg and the hand-written
/data cursor, which must agree. The operation routes stamp the same fields on
the rows they return, since RETURNING * lacks them. The /data cursor does not
emit pf_op, so a raw-mode pivot cannot group on it.
Every log entry is named
label and bucket are stored, not derived, and every entry has both —
log_named refuses one without. A report gets the pivot's segment and bucket by
joining pf.log and selecting two columns, with no rule of its own. Stamped at
write time:
- A load —
labelrequired,bucketdefaulting to it. - An adjustment — no name inputs, so the version's
adjustment_segment/adjustment_bucket, elseADJUSTMENT_SEGMENT = '99 - Adjustments'andADJUSTMENT_BUCKET = '04 - Forecast'.adjustmentNames()resolves them once per request, for the INSERT and the returned rows alike.
The version defaults are set on the Baseline page (Defaults for new
adjustments) and name the next adjustment; existing entries keep theirs.
Renaming an entry is PATCH /log/:logid — from the Baseline page's segment list —
which renames but refuses to clear, owner or admin only because the names are the
pivot's columns for everyone. Relabelling 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 once so its templates write them.
What this replaced. The names were derived at read time: a load's label,
else tag, else note, else the version's unlabeled_load, else Unlabeled;
an adjustment's label, else the version default, else the constant — through a
join on pf.version. That made the pivot the only reader that knew a row's
bucket: the CASE was copied into /agg, the /data cursor and a reporting
procedure in /opt/forecast, and every direct query had to copy it again. And
editing a version's fallback silently renamed every past adjustment. The backfill
in 01_schema.sql stamped existing entries with that rule, so nothing on screen
changed name.
Column order is stored text
Perspective orders column groups by the value string, and asc/desc only
reverses that, so Prior Year → Plan → Actual → Forecast is expressible in neither
direction. A "01 - " prefix is the only lever, and it lives in the stored label
and bucket, typed by whoever names the segment. Nothing derives it.
The one hardcoded ordinal is 99 - Adjustments, keeping unnamed adjustments
after every numbered segment. That proviso is the scheme: ordering is string
ordering, so 99 lands last only while loads carry 01–0n, and an unnumbered
segment sorts after it (digits precede letters). The old '(adjustment)' sorted
first for the same reason read the other way. Relabelling an adjustment's log
row is how one kind of adjustment is split out from the rest.
What this replaced. The prefix was computed client-side as Perspective
expression columns (pf_bucket_ord, pf_segment_ord) from pf.log.seq and
pf.version.bucket_order. It ordered the pivot and nothing else, so every other
reader disagreed; restore() replaces expressions wholesale, so it had to be
re-applied after every layout load; and ExprTK's scanner tests each byte with
isprint(), so a label with anything outside printable ASCII could not be
ordered at all. DEAD_ORDER_EXPRS in Forecast.jsx strips those names from old
layouts.
Tags
A tag names an initiative — a line in the ledger and the bridge. It never affects rows, so it stays editable, even on a closed version. It is not a row anywhere, only a value repeated across a version's entries; it exists because somebody typed it.
Completions are per version. GET /versions/:id/tags returns this version's
tags with uses, last_used, owners, can_edit, refetched on version change
and after every tagged write. It used to be GET /sources/:id/tags, across every
version of the source — which put last year's initiatives in front of this
year's and reported use counts earned elsewhere. The cost is that a fresh version
completes from nothing until it has a tag, which is honest: cloning a definition
copies no adjustments.
Managed on the Baseline page. PATCH /versions/:id/tags { from, to }
rewrites every entry carrying from. A to that already exists merges —
usually the point, since "Pull Forward" and "Pulled Forward" are one initiative
reported as two. An empty to clears. Both are confirmed first, being invisible
as a diff afterwards. Permission is ownership of every entry the tag is on — a
half-applied rename leaves the initiative split, the state being fixed — so rows
the reader cannot rename are greyed; the route locks the rows before checking, so
check and rewrite see the same set. The Tags panel sits outside the page's
read-only fieldset: a rep may rename what they named.
Completion matches on words. TagInput in OperationPanel.jsx lowercases,
strips punctuation and stopwords and lightly stems both sides, so "Pulled
Forward" finds "Pull Forward" and one inserted word does not hide an existing
tag. Something that reduces to an existing tag without being it says so
("Already used as … — use that"). Nothing merges automatically. The change
log's inline tag cell still uses a plain datalist.
The panel used to carry six quick-pick pills under the tag field; they wrapped to two rows once tags were longer than a word and pushed the docked form past its border. The list shows the tag alone — an option's text renders as a second column, so a use count widened every row for a number nobody picks on.
Selection
A selection is a list of slices ({column: value}) plus the pivot's own
filter as scope. The server turns them into a WHERE clause; the ledger totals
the loaded table through the same filters, so the panel and the write agree.
From a click to a slice
perspective-click's own filter list cannot be trusted: the datagrid's
getCellConfig builds it with pivot_value ? [pivot, '==', pivot_value] : undefined, so the engine drops a blank group key — and, being a falsy test,
a 0. A dropped key widens the write: clicking the blank node under a customer
selected the whole customer, and the ledger agreed, totalling through the same
list. So cellFilters() in Forecast.jsx re-derives both axes:
- Rows from
detail.row.__ROW_PATH__— raw values, so dates stay epoch millis. A subtotal's shorter path leaves deeper levels absent. - Columns from the clicked column's name (taken from the mousedown's cell;
the event names only the measure). The name is the visible split values joined
with
|, measure last, so the measure is popped before mappingsplit_bypositionally. Mapping every segment used to read the measure as a value of the first collapsed dimension — a slice matching no row.
A plain click replaces the selection; ctrl/⌘/shift toggles a slice. The event
carries no modifier flags, so they are read off the preceding mousedown.
buildEffectiveSlice() keeps dimension, date and pf_ keys, turns dates into
ISO strings, and drops the rest; duplicates are collapsed, since under each a
repeat would apply twice. A Perspective computed column does not map back to
stored rows, so only native columns slice.
Dragged regions
The datagrid runs in edit_mode: SELECT_REGION, forced on every restore so a
layout cannot switch it off. A drag fires perspective-select with a
ViewWindow — { start_row, end_row, start_col, end_col } — on every
mouseover, so the handler keeps the latest and a window mouseup commits it,
each cell going through cellFilters(). A single-cell region is ignored:
perspective-click owns clicks, and handling both would undo a ctrl-click.
Blanks: null and '' are different values
Both occur in real data as different rows, so they stay distinct from click to
WHERE; only undefined means absent.
buildWhere()renders null asIS NULL. It used to fall throughesc()to= '', selecting the other blank — the slice named one node and the operation wrote the other. In a list the null arm is ORed on, sinceIN (NULL)is never true.- The ledger filters null with
is null, not== null: the engine matches on type, and a null threshold on a string column is another dropped predicate. - On the column axis,
t_tscalar::to_stringrenders''as''and a null as the text"null", so"null"is read back as null. This was believed impossible — a null was thought to join as''too — so''cells were refused and"null"was taken literally, a slice matching nothing. The one ambiguity left is a value that really is the textnull. - The panel prints
(null)/(blank), and—only for a column not in the slice: isolating the blank node and ignoring the column are opposite statements and used to look identical.
The pivot's pf_ columns are part of the slice
- Table columns —
pf_iter,pf_user,pf_logid(TABLE_SLICE_COLS) — compared directly. - Log columns —
pf_segment,pf_bucket,pf_note,pf_op(COMPUTED_SLICE_COLS) — resolved topf_logid IN (SELECT id FROM pf.log WHERE version_id = … AND <expr> …), exact because the name lives on the log row. - Any other
pf_key is refused — skipping is how selections widened.
They narrow a slice but do not make it selective (pf_iter = scale alone is
every adjustment): assertSelective() needs a dimension, date, pf_segment or
pf_bucket key.
What this replaced. pf_iter was stripped on the theory that cells differing
only by iter band are one slice; pf_note and pf_op were refused, and the
client pre-empted that by stripping; pf_segment and pf_bucket were dropped
before that. The ledger stripped none of them. So under the default layout,
grouped by pf_iter, a click on a customer's scale row totalled the scale rows
and wrote to all the customer's rows; a click on one bucket's cell scaled every
bucket there.
Several slices, and total cells
Several slices become an OR of AND-groups, not per-column IN lists —
{A:1,B:1} + {A:2,B:2} as A IN (1,2) AND B IN (1,2) would also match
A:1,B:2 — parenthesised, since callers append AND pf_iter NOT IN (…).
A total cell is its displayed amount. The grand-total row names no row
dimension, so its slice is whatever the column axis and the pivot filter leave —
possibly {}, everything the filter shows. {} is accepted only with a
non-empty scope (normalizeSlices(), assertSelective()); alone 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 to the other cells. The panel names it "Total".
Scope: the pivot's filter travels with the slices
A slice can only mean equality, so a bounded season, an in-list or a substring
has no slice form. The filter is read off viewer.save() — in the table's own
types, with the user's operators — and sent as scope, [col, op, value]
triples; the ledger totals against the same list. Without it the ledger counted
rows the grid was hiding and the operation wrote them. It is ANDed onto each
statement rather than folded into the slices, which under each would repeat it
per statement.
SCOPE_OPS in lib/sql_generator.js is the whole allowed list, under
Perspective's names: ==, !=, >, >=, <, <=, in, not in,
is null, is not null, and the substring family. Anything else is refused —
a scope silently dropped is a write wider than the panel that authorised it.
pf_ log columns take == or in only.
- Substring operators map to
ILIKE, because the engine'st_tscalar::contains/begins_with/ends_withlowercase both sides.LIKEwould write a fraction of what the grid showed, and narrower is no safer than wider — neither is the set the ledger counted. The engine matches the term literally, so%,_and\are escaped. Residual difference: the engine folds ASCII only,ILIKEfollows the collation. matches/not matchesstay refused. RE2 in the engine, not in Postgres —\bis a word boundary in one and a backspace in the other — so a pattern valid in both could match different rows, which is worse than a 400.- An empty term is refused. An unfinished filter row arrives as
[col, '==', null], andILIKE '%%'or= ''would read as every row or none rather than as the mistake it is.
The ledger used to drop a slice's column whenever the scope named it, assuming
they could not disagree; under a substring filter, clicking one part then totalled
every matching part. It now drops only an exact == repeat. Territory (§Access)
is ANDed on after the scope, from the session.
Selection highlight
The datagrid highlights model._selection_state.selected_areas and wipes it on
every mousedown, so a ctrl-click selection would show only its last cell.
areasRef, a sliceKey → rectangles map beside slices, is pushed back after
every change and pruned by live slice key on any deselect, so grid and panel
cannot disagree.
Rectangles are re-derived when the grid moves. A sort reorders rows and a new
segment column shifts every column; tracked coordinates would then highlight
whoever took the position while slices, being value-based, still named the
right row — the grid would be the only thing lying. relocateAreas() sweeps the
displayed rows as a drag would and keeps cells whose slice is still selected. A
slice can therefore light more cells than were clicked (a slice not naming the
segment matches the customer under old and new segments alike), which is what it
covers. Past MAX_RELOCATE_ROWS the highlight is cleared rather than left wrong.
Operations
POST /api/versions/:id/{scale|recode|clone}, common body:
{ "slices": [ { "channel": "WHS" } ], "scope": [ ["season", "<=", 2027] ],
"apply_mode": "prorate", "tag": "volume_push", "note": "optional" }
slices — one or more (a legacy single slice is accepted). apply_mode —
prorate (default) is one statement over the union and one entry; each is one
per slice, so each reaches its target alone and undoes separately (for recode and
clone it only decides how many entries). pf_user is the session's. A closed
version answers 403. Operations are not admin-gated; territory and ownership
govern them. The response is the new rows — whole in raw mode, aggregated in
grain mode — for table.update(); no reload.
SQL shape
An ilog CTE inserts the log row and the insert CTEs reference its id;
{{where_clause}} is the selection and {{exclude_clause}} keeps
exclude_iters out.
Every date column travels — the written columns are every dimension, every date and the first value and units column. Scale and recode used to carry only the first date, so on a source with order, requested and ship dates they wrote the others null. The period dimensions were fine, being dimensions — which hid it: labels right, dates gone. Those dates are sliceable, so an adjustment scoped to a ship date matched no adjustment ever written. Clone and loads were always right. Rows written before the fix keep the nulls; deleting and replaying those entries is the honest repair, since re-deriving them would be inference.
One row per coordinate, not per row read. Reads GROUP BY every dimension and
date. One-for-one, every operation inherited the row count of everything before
it at that coordinate, and the table grew super-linearly with the work done.
Only pf_logid and pf_iter collapse, so nothing goes null or unsliceable; the
maths is unchanged, sum(sum(x)) OVER () being the aggregate of aggregates.
Scale
Distributes an increment in proportion to each coordinate's current amount:
round(("value" / NULLIF(total_value, 0)) * {{value_incr}}, 2)
round(("units" / NULLIF(total_units, 0)) * {{units_incr}}, 5)
resolveIncrs() in routes/operations.js computes the two increments per
statement, which is what makes each land per slice. Value and units each take
at most one of a target (target_value/target_units), a percentage
(value_pct/units_pct) or a change (value_incr/units_incr), resolved
independently; target_price is a third input; legacy pct: true is honoured.
target_basis — what a target or percentage measures against:
adjustable— only the rows the operation can write. The server default.selected— everything the pivot shows,exclude_itersrows included. Those cannot move, so the adjustable rows absorb the difference and the pivot lands on the number asked for; otherwise a target set against a visible total overshoots by the fixed rows.
The panel defaults to selected and sends it only when the selection holds
excluded rows (otherwise they are identical). The basis row is tinted and tagged
"target", and clicking either row switches.
Price does not move unless it is named
A price move is invisible in the figure being edited — the dollars land either way, and only the implied price says whether volume followed. So:
plug answers the one ambiguous case, dollars alone: volume (default,
price holds, units scale in proportion) or price (volume holds). From the Excel
form:
plug volume: pchange = fVal/(pVal+bVal); fVol = (pVol+bVol)*pchange
plug price: fVol = pVol + bVol
A qty input follows the same rule: with dollars unnamed they follow at the
current price, value = (curValue / curUnits) * unitsIncr. Holding them made
every qty edit a price edit — more cases at the same revenue is a price cut
nobody asked for. A slice with no units has no price, so its dollars stay put.
The price held is the adjustable rows' own, never blended with the fixed rows. The SQL prorates over the adjustable rows only, so an increment sized at a blended price moved qty by a different factor than dollars wherever reference rows priced differently — and taking a selection to its fixed total drove qty past zero, grains with positive sales and negative qty. The ledger preview uses the same price.
Naming the price (target_price, the Excel form's Edit Price) holds it, and
the other named measure decides the rest, on the chosen basis:
| Named | Result |
|---|---|
| price alone | qty holds; dollars = price × qty |
| price + qty | qty lands as named; dollars = price × that qty |
| price + dollars | dollars land as named; qty = dollars / price |
| all three | refused — two degrees of freedom, three numbers |
This branch used to read only a qty target (a qty change sized dollars on the
old qty), took the fixed qty off a target but not the fixed dollars, and let a
dollar figure outrank the price. Worse, the panel sends no plug with a price,
so the server read volume and scaled qty by the dollar change too, putting
price back where it started. A named price now bypasses the plug.
So the deliberate ways to move price are dollars and qty together and naming the price; both outrank the plug.
What this replaced. plug defaulted to price, being what the API did
before the flag existed — so an unqualified dollar edit and every qty edit moved
price, and only a hand-picked volume did not. The client's preference is keyed
pf_scale_plug_v2 so a browser carrying the old price choice does not go on
silently moving price.
The ledger and its three boxes
The scale form is one continuous statement: baseline, adjustments grouped by tag
(untagged ones collapsed), fixed segments, total, then the edit, with a running
total closing on the selected total. Value, qty and price each have New /
Change / % change boxes; typing in one derives the others, and the box typed
in is what is sent — the row you type in is the mode. A Result line
previews all three under the server's rules and marks the column that cannot
move · holds.
The boxes foot — price = value / qty — so at most two are inputs.
effectiveScaleInputs() in OperationPanel.jsx decides which when all three are
typed: value typed last lets the plug decide (volume drops qty, price drops
price), since a dollar change says nothing about price or volume; otherwise the
more recently touched of the other two stays. The dropped box keeps its text, so
flipping the plug brings it back. buildPayload() in Forecast.jsx uses the same
function, so what is posted is what is shown. Clicking into a derived figure pins
it — "units are this" rather than "units may move" — and unpin releases it.
Rough edge: all three typed, dollars last, the choice is really which earlier
number to keep, and "Volume" there moves price too.
The Moves control is the plug. It lights whatever the edit moves — both if both do — and takes a click only while it is the one deciding. It used to render only for a dollars-only edit, so it was absent before anything was typed and vanished when a measure was pinned, even by an accidental click — two states that both looked like a missing control. It now stays, greyed when the inputs have answered it, showing their answer.
A price change or percentage is resolved client-side to an absolute
target_price; the server has no price increment form.
What the log records
params carries the intent (pickIntent(): inputs, target_basis, scope,
plug) plus:
resolved—value_incr,units_incrandmethod:price(named),both(dollars and qty named),volume(qty named), else the plug. The request alone cannot say which moved — the plug is a per-browser preference — so a price edit used to be findable only by noticing qty had not moved.expected— the panel's Result line as a change on its basis (undereach, combined and flagged so), besideresolvedso a panel/server disagreement shows in the log. Nothing compares them yet.
Refusals
- A pool netting to ~zero (below 1% of gross): shares are
row / total, so as the net nears zero rows fly to extreme opposite values. Offsetting slices are the usual cause;eachhandles them. - Nothing to scale — every unit already on target or empty; individually
skipped slices come back in
slices_skipped. - Holding price on a selection with no value — no price to hold.
Rounding each row to 2dp can leave a target a cent off; landing exactly would need a correction row.
Recode
Moves the selection to other dimension values in one entry: negative rows
zero it out where it was, positive rows put it back with {{set_clause}}
replacing the named dimensions.
A date shift moves the put-back leg only, so a shifted recode is a timing move
— pull forward, push out — and set may be empty. Moved rows' period dimensions
are re-derived against the shifted date, as clone's are; an explicit set wins;
a row with a null date keeps its label, since a lookup on nothing would blank it.
Without a shift nothing is derived.
Coordinates already netting to zero are skipped (HAVING sum(x) <> 0),
rather than writing two empty rows each — which a second recode over cells a
first one emptied would do for all of them. If nothing is left the request is
refused ("Nothing to recode") rather than committing an empty entry. A scoped
account cannot set the territory column (§Access).
Clone
Copies the selection as new business: set overrides dimensions, scale
multiplies (default 1), date_offset shifts every date; the original is
untouched. Period dimensions come from the calendar against the shifted date —
else a mix moved forward a year keeps last year's labels — and set wins. Unlike
recode, a null date blanks them.
exclude_iters does not apply to clone. It stops operations modifying
reference rows — scale would attribute movement to prior-year rows, recode would
write negatives against them. Clone does neither; copying a plan or prior year
out of reference is the operation working as intended, and excluding them made a
deliberate selection silently produce nothing. from_logid narrows instead,
for a selection spanning segments where only one is wanted.
Known issue: a clone matching nothing commits an empty entry with
rows_affected: 0, where scale and recode refuse.
Undo
DELETE /log/:logid deletes the entry's rows then its log row in one transaction
and returns pf_gkeys (grain mode) or pf_ids (raw) for table.remove(); the
view re-sums. Undo is by ownership, not territory — your own entries or an
admin's override — because it removes rows wholesale, and half-undoing one would
leave a state nothing describes (and two accounts can share a territory). Closed
versions refuse. Undoing a load returns its plan to unrun.
Known issues: the pf_gkeys come from live col_meta, so after a grain change
they match nothing in the loaded table and the pivot keeps the rows (the database
is still right); recode and clone log apply_mode: unit.mode, never set, so
their params omit it.
Display-grain pre-aggregation
Built, with a static grain. Path B of pf_perspective_options.md — a
pre-aggregated extract in a native Perspective table — rather than Path A,
live aggregation through a DuckDB virtual server (parked on
spike/duckdb-virtual-server), which loses expand, collapse, depth, sort and
filter.
The problem. Shipping raw rows makes the browser pivot all of them and load time grows with raw rows. Aggregating server-side to the grain the pivot displays cuts the row count by orders of magnitude while Perspective still does all the rollup locally.
Grain definition — pf.col_meta.in_grain
The flagged dimension/date columns, plus always pf_iter and pf_logid.
grainOf() in lib/sql_generator.js is the single definition (Setup.jsx and
routes/log.js mirror it), baked into the templates at Generate SQL so load and
operations agree by construction.
- Additive measures only. Sums are exact; an average would ship as
sum + count;
count(DISTINCT …)cannot be pre-aggregated. Forecast measures are sums. - An unflagged dimension cannot be pivoted or filtered on. Leaving high-cardinality columns (part numbers, raw dates) out is what keeps the grain from exploding back toward raw; the price is that they are unavailable. Dates usually enter through their derived period dimensions.
- Generation refuses a grain column the table never populates, or a grain with no measure.
in_grain means eligible; today the grain is exactly the eligible set.
The model: append deltas, let the view sum
The Excel pivot cache again: pre-aggregated rows go to the table, the pivot cuts
them locally, and each adjustment is appended, never a recomputed total. Rows
are aggregated per pf_logid — one per grain tuple × pf_iter × entry — and
the view sums them. It was also the smallest change from raw mode, which already
appended results with table.update().
Synthetic key. Perspective indexes on one column, so each row carries
md5(concat_ws(chr(31),
COALESCE(col1::text, chr(30)), …, pf_iter, pf_logid::text)) AS pf_gkey
pf_logid keeps each entry's contribution distinct, so an append accumulates
rather than replacing a bucket and undo removes exactly that entry. Plain
concat_ws skips NULLs, so ('a', NULL) and (NULL, 'a') would collide and
silently merge two groups; chr(30) also keeps NULL apart from ''. The md5
because the key is an opaque handle read only by update()/remove(): raw, it
was long and, unique per row, defeated Arrow's dictionary encoding — the largest
column in the payload. 128 bits keeps collisions unreachable.
Initial load — GET /api/versions/:id/agg
SELECT
t.<grain cols>, t.pf_iter, t.pf_logid, <pf_gkey>
,SUM(t.<value>) AS <value>, SUM(t.<units>) AS <units>
,l.label AS pf_segment, l.bucket AS pf_bucket
,<NOTE_EXPR> AS pf_note, l.operation AS pf_op
FROM {{fc_table}} t
LEFT JOIN pf.log l
ON l.id = t.pf_logid
WHERE {{territory_clause}}
GROUP BY <grain cols>, pf_iter, pf_logid, l.operation, l.label, l.tag, l.note, l.bucket
The pf.log columns depend on pf_logid, but Postgres will not infer it, so
they are grouped (LABEL_GROUP_COLS). Territory applies before the GROUP BY,
since its column need not be in the grain. The route sets LOCAL enable_sort and
enable_incremental_sort off: seeing pf_logid pre-sorted off its index, the
planner picks a sort-based GroupAggregate for the "free" sort, but comparing
dozens of keys per row makes it several times slower than a HashAggregate,
whatever work_mem or statistics say. The client loads the result with
worker.table(buffer, { index: 'pf_gkey' }).
Write and delete paths
Operations insert raw rows under a new logid as always; in grain mode the final
select aggregates that entry's rows to grain (FROM ins, or recode's
neg UNION ALL ins) — safe because they all carry the new logid. The keys are
therefore always new, update() appends, and nothing is recomputed.
Undo removes an entry's keys and lets the view re-sum — no re-aggregation, no
emptied-bucket handling. RETURNING does not take DISTINCT, so:
WITH del AS (
DELETE FROM {{fc_table}} WHERE pf_logid = $1
RETURNING <grain cols>, pf_iter, pf_logid
)
SELECT count(*)::int AS rows_deleted, array_agg(DISTINCT <pf_gkey>) AS pf_gkeys
FROM del;
Raw mode
With nothing flagged, no get_agg exists and the client uses /data: every row
plus pf_segment, pf_bucket and pf_note, indexed on pf_id; operations
return whole rows and undo returns pf_ids.
Transport
Both endpoints return Arrow IPC in one record batch with X-Row-Count.
/data reads a cursor in batches into column arrays (row objects multiplied peak
heap) and builds one table with tableFromArrays; /agg does the same from its
result. One batch because per-batch encoding creates independent dictionaries,
and Perspective's WASM reader crashes on the replacement messages. An earlier
plan to stream batches into update() behind a large-dataset banner was never
built, for that reason.
Load time is dominated by row count, not payload size. Even a good grain on a wide source leaves enough rows that getting them out of Postgres and into JS dwarfs the aggregation and the Arrow build; halving the payload (the md5 key) barely moved it. The remaining lever is the dynamic grain.
The dynamic variant
The static grain means an unflagged dimension is never pivotable and every
flagged one is paid for even when the pivot uses none of them. The additive next
step: group by the fields the current pivot uses — the viewer's group_by,
split_by and filter intersected with the eligible set — and re-fetch when they
change. That needs the grain passed per request rather than baked into pf.sql,
with operation responses and undo keys computed at the loaded table's grain.
Grain drift. Changing in_grain after a load needs Generate SQL and a page
reload, since the loaded table's index and columns are fixed; until then undo's
keys, from live col_meta, match nothing.
The pivot
Perspective, vendored
Perspective 5.4.0, @perspective-dev/* — not FINOS @finos/perspective —
built from a patched fork and committed as tarballs in ui/vendor, whose README
covers the patches (column-axis expand/collapse, split_by_depth, depth applied
on config update), provenance and rebuild. Bundled through the /inline
entrypoints, never a CDN: the 4.x CDN bundle resolved its server WASM to an
unversioned path and silently pulled whatever was newest.
Layouts — pf.layout
A named ViewConfig owned by an account. Published — everyone on the
forecast lists and can apply it, only owner or admin may change it — or
private. Scope is the version by default, where people enter;
version_id IS NULL applies to every version of the source. is_default (one
per scope, published only) is restored on a first load, version-scoped beating
source-wide — the narrower answer is the one somebody chose for this forecast.
With no default: group_by: ['pf_iter'] over the first value column.
Load order: this version's last-used unnamed config in localStorage
(pf_layout_v{vid}) if any — per-browser continuity, not something anyone names
or shares — else the default. The old named list (pf_layouts_v{vid}) is lifted
into pf.layout as private rows once and cleared.
Permissions are the pf.log rule and can_edit rides on every row so the
Layout ▾ menu greys the rest. Applying is never restricted: the guarantee is
that a published layout cannot be changed under people, not that it cannot be
adapted — Save is withheld on others' layouts, Save as… forks. No territory
clause: layouts are display config and territory restricts rows.
cleanLayout() gates every stored config before restore(), which is
all-or-nothing: it drops unknown columns from the axes and aggregates
(including a multi-arg weight column) entry by entry rather than dropping the
layout, and strips theme, the dead ordering expressions and pf_totals.
The dirty dot is a comparison, not a flag. restore() itself fires
perspective-config-update, so a flag set there would light the moment a layout
was applied. sameConfig() compares against activeConfigRef, ignoring table
(per-load) and theme.
Grand totals ride in the layout as pf_totals: { row, col } — the Total
toggles. 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. Without it: row shown, column hidden.
What this replaced. Named layouts lived only in localStorage, invisible to
others and gone on another machine. The one server-side layout was
pf.source.default_layout, an anonymous blob PUT /sources/:id/default-layout
let any account overwrite for every account — a published layout with no
owner. The route is gone; the column is migrated and unread.
Axis depth
Both axes collapse by a depth in ViewConfig set through restore() —
group_by_depth (applyDepth()) and split_by_depth (applySplitDepth()). In
the config it survives every rebuild, rides into layouts via viewer.save(), and
needs nothing re-applied.
Each axis is a stepper from DepthButtons.jsx — ◂ level ▸, the middle
opening the level list for a jump — three controls whatever the depth. It was one
button per level, growing with the pivot, the current one a blue among greys so
reading it meant scanning; before that a fixed 0 1 2 3 that named nothing and
offered levels a two-field group_by lacks. It shows an em dash until a layout
has been read back, and its middle is sized to the longest level so the toolbar
holds still.
Depths are 1-based counts of levels to show, on the stepper and in the
config, so the value crosses unchanged; server.cpp sets set_depth(n - 1) and
t_traversal::set_depth adds 1 back. The toolbar used to send d + 1 and
subtract it on read-back — self-consistent, and one level off the grid
everywhere. The +1 dodged depth 0, which reaches the engine as
set_depth(-1) on an unsigned t_depth, underflows and clamps to fully
expanded. applyDepth now floors at 1, so the Total slot still shows the first
level, not a bare total — a wart kept rather than traded for the underflow. A
real total needs group_rollup_mode: 'total', unverified on this engine.
This required patching the engine. ViewConfig::apply_update applied ten
fields and neither depth, so a depth through restore() was accepted and
dropped — and Session::update_view_config returns early when nothing changed,
so no view was rebuilt. ui/vendor/0001-apply-depth-fields-on-config-update.patch.
What this replaced:
- Row depth was imperative —
view.set_depth()on an object the viewer discards whenever it re-renders — so restoring it meant guessing when that happened: anobserverShim.jspatchingIntersectionObserverandResizeObserver, focus and visibility listeners, a retry loop forgetView()throwingNo table set, a flag for whether the viewer had "gone away". That produced three visible faults: the tree fully expanding on refocus, snapping on any reflow, and snapping when the settings sidebar opened. - Column collapse restored a truncated
split_by, so discarded levels were remembered separately (splitFull, persisted assplit_full), our collapse had to be told apart from the user rearranging, and the selection was cleared on every collapse.split_fullandexpand_depthare still read from old layouts.
Depth changes rebuild the view — restore() makes the session tear down and
rebuild it, a full traversal, where a manual collapse mutates in place.
Noticeable on a large grain. Detecting a depth-only change and calling
view.set_depth() while still writing the config would fix it, at the risk of the
two drifting.
Per-node expansion is not solved. It is view state with no config form, and
the API is set-only (expand(row_index) / collapse(row_index), no getter), so
it cannot be captured and replayed and is lost on every rebuild. The honest route
is a ViewConfig field of expanded row paths (indices shift as the tree
opens), applied in server.cpp beside the depths — needing node enumeration in
the C++ traversal, a proto field and the apply step. expand_column /
collapse_column share the limitation. And depth is whole-axis: Excel can
collapse one year while the next stays open.
Auto-pause is off
<perspective-viewer> auto-pauses by default — an IntersectionObserver plus
visibilitychange — and pausing deletes the view, so returning to the tab
means restore_and_render(): a fresh traversal of the whole grain, a
multi-second chug per tab switch on a large one, losing per-node expansion.
initViewer() calls viewer.setAutoPause(false) after load(). Nothing updates
the table while hidden — every operation is driven from this page — so the pause
bought nothing.
Toolbar
Two zones. Left, view: Layout ▾ and the Rows and Columns steppers with
their Total chips — all ViewConfig, nothing reaching the server. Right,
forecast: Refresh data, Change log, Bridge, Operations — re-running the load,
opening something over the pivot, or writing.
The distance between them is the separation. Each was once an outlined box too,
the view one tinted too faintly to see and, in dark mode, reading as a sibling;
before that everything was one row of identical hairlines, making a
column-width control and the version's history look like peers. Three weights do
the separating: .pf-section-label names a group and is heavier than its
controls, .pf-group-rule divides groups within a zone, the gap divides zones.
Each axis is one joined control, ◂ | level | ▸, then its Total chip.
Blue is for actions, pressed grey for state. Save and Post are blue; the
Total chips and an open Operations use .pf-pressed. Operations used to relabel
itself "Hide panel" in a blue outline, looking like a second Save.
Fit widths is gone, for three independent reasons: auto-size measures over
the rows in the viewport, so anything below the fold never counted;
draw({invalid_columns}) passes an option DrawOptions lacks, and draw() is
throttled, so a click during a pending draw cleared widths without re-measuring;
and the persistLayout after it saved nothing, since saveColumnSizes() returns
only overrides, which resetAutoSize() had emptied — and this fork discards
column_size_override whenever split_by is set, which here is always.
Recoverable from git if ever worth fixing.
The operation panel docks bottom, right or floats, persisted locally; the toolbar toggle shows the selection count while it is shut.
Calculating indicator
Calculating… shows in the status bar while the pivot rebuilds. Perspective's
beaker renders only while its settings panel is open, vanishing exactly when
nothing else says to wait. A counter — rebuilds overlap — rises in applyDepth,
applySplitDepth and applyLayout, falls after viewer.flush(), and travels as
pf-pivot-busy on the window, like pf-data-changed.
Not driven by perspective-config-update, the obvious hook: on a depth
change over a large grain it arrives after the rebuild, seconds late, and the
flush after it resolves at once. The cost is that sorting or filtering from the
viewer's own chrome shows nothing, since the app is never told they began.
Light and dark
ui/src/theme.jsx holds a context stored as pf_dark, defaulting to
prefers-color-scheme; the status-bar toggle sets .dark on <html>.
index.css defines properties under :root and .dark, with Tailwind overrides
written as .dark .bg-white { … } — no Tailwind dark config. Dark uses
Perspective's Pro Dark palette.
The pivot takes a partial viewer.restore({ theme: 'Pro Dark' | 'Pro Light' })
on load and on toggle. It used to be setAttribute('theme', …), which does
nothing in 5.4.0 — the theme is a ViewerConfig field — so the pivot stayed light
whatever the toggle said. The other half is cleanLayout() dropping theme:
viewer.save() includes it, so every layout reasserted its author's theme. It is
the reader's choice, so a published layout cannot impose one.
Access
Authentication and sessions
Everything under /api but /login, /logout, /me sits behind
requireAuth; the React app mounts only with a session (Gate in main.jsx),
since its effects call the API at once, and auth.jsx wraps fetch so a 401
returns to login.
- Accounts —
pf.app_user, scrypt hashes whose stored form carries its parameters, so they can be raised without invalidating rows../pf.sh add-user | passwd | list-users | disable-user | enable-user; passwords go in on stdin throughnode lib/auth.js hash, never argv or psql. - Sessions —
connect-pg-simpleinpf.session, so restarts keep people signed in and a session is revoked by deleting its row (disable-userdoes). Cookiepf.sid: httpOnly, SameSite=Lax, Secure unlessCOOKIE_SECURE=false, 12h rolling. - Config — the server exits without
SESSION_SECRETrather than using a guessable default.TRUST_PROXY(default 1) keepsreq.ipand secure cookies right behind the TLS proxy. CORS is enabled only byCORS_ORIGIN, by name — a wildcard origin plus a session cookie is CSRF by construction. - Login hardening — ten failures per IP per fifteen minutes (in memory), one message for unknown/wrong/disabled, session id regenerated on success.
Identity is server-side. pf_user, created_by, closed_by come from
sessionUser(req), never the body. The UI used to send a hardcoded
pf_user: 'admin', which any client could set to anything.
Setup and Baseline are admin-only to write
requireAdmin sat exported in lib/auth.js and applied to nothing, so every
non-admin could call every Setup and Baseline endpoint. It now guards source
writes (register, cols, Generate SQL, member refresh, deregister), version writes
(create, update, close, reopen, delete), loads (baseline, reference, segment edit,
clear) and every plan write. is_default is further admin-checked inside
PUT /versions/:id.
Not gated, each for a reason. No read, including the table browser — the
pages stay visible, and gating what a page needs to render turns it into a 403.
Not the forecast operations, which carry territory and ownership rules. Not
PATCH /versions/:id/tags, whose permission is ownership.
The pages go read-only rather than hidden: one <fieldset disabled> per page
instead of a flag threaded through every control — Baseline alone has dozens, and
the one missed would be the one that mattered — plus a ReadOnlyNotice banner.
Setup wraps a real box because the fieldset is its flex row; Baseline uses
display: contents so its layout is untouched. None of this is the
permission; it only stops offering buttons that would answer 403.
A 403 used to blank the whole app. Setup put responses straight into state
and .mapped them; an error body is {error}, and throwing in render unmounts
React. Its list fetches now coerce to arrays, so a bad response costs an empty
panel, not the session. The fragility predated the gating, which only made it
reachable.
Territory scoping
An account sees and changes only its territory: pf.app_user.territory (jsonb
list), is_admin for the unrestricted, and col_meta.is_territory naming which
column of a source the values belong to — flagged, not coded, so another source
can be divided by something other than a sales rep.
Fail closed. No territory and not admin means no rows:
buildTerritoryClause() returns FALSE, not TRUE, for an empty list or an
unflagged source, so a forgotten account sees nothing rather than the book.
Built from the session, never the request — the difference from scope,
which the browser rightly sends as a filter the user chose. A permission cannot
come from the thing it restrains, so the predicate is ANDed on last, in
sliceUnits() for writes and per route for reads. Enforced at:
/data— cursor and theX-Row-Countcount/agg—{{territory_clause}}, before the GROUP BY/versions/:id/table-info— unscoped, it told an account its load was fetching the whole table- every operation (
pf.log.envrecords the territory in force) /sources/:id/values/:col— it reads the source, untouched by any scope- recode's
set— a scoped account cannot set the territory column. That is reassignment, not forecasting, and the row would vanish from the view that would have shown what happened.
Ownership, not territory, governs DELETE /log/:logid, PATCH /log/:logid
and PATCH /versions/:id/tags. The log PATCH looks like a private annotation and
is not: label and bucket name the pivot's columns for everyone, so unguarded
it let any account rename the company's segments, including on loads whose rows
it cannot see.
Known gaps, verified in code:
- Completion now uses unscoped routes. Recode and clone complete from
GET /versions/:id/values/:col(the version's own table, cached in memory keyed on the latest logid), the member list from/sources/:id/dim/:group, sibling autofill from/sources/:id/lookup. None applies the territory clause. - Territory is read onto the session at login, so a change reaches a
signed-in account only when it signs in again. Re-reading it in
requireAuthwould also make disabling immediate.
./pf.sh set-territory | set-admin | orphan-territory; the last lists values in
the data that no account owns — work under one is invisible to all but admins,
which a typo causes easily and nothing in the app reveals.
Bridge and change log
Bridge
A waterfall of how this forecast got from where it started to where it stands,
one step per initiative tag. BridgeView.jsx; the arithmetic is in exported pure
buildSteps() / layoutSteps(), checkable against real data without a browser.
Computed from the table already in the browser, not GET /versions/:id/bridge,
so it reconciles with the pivot. The endpoint remains for API consumers, grouping
by tag over rows outside exclude_iters, with no notion of buckets.
Scope is the selection (the union of its slices, an operation's reach) or the pivot's filter, following the live selection until a scope button is picked. There is no whole-version scope: the filter is part of what the figures mean, and dropping it moves one end of the walk and not the other — rows outside a season filter sat wholly in the forecast loads with no counterpart in the plan, inflating the endpoint without explanation. View filters are AND-only, so selection scope builds a view per slice, each with the pivot's filter.
Membership comes from pf_bucket, not pf_iter: open orders load as
reference and are still forecast, and an iter-keyed bridge dropped them. The
forecast bucket is the version's adjustment_bucket — the value unbucketed
adjustments carry, so the two agree by construction. It used to be the literal
'Forecast'; once buckets carried their sort prefix nothing matched, every row
became comparison, and the basis cancelled itself to zero. With no match it
falls back to whichever bucket holds the adjustments. Selection scope does not
filter on pf_bucket/pf_segment even when a slice names one, since Compare to
needs rows from every bucket.
Compare to sets a bucket as basis — Plan when building a forecast, Prior Year when building the plan — resting on
Forecast − Basis = (Forecast loads − Basis) + adjustments
The opening step is the forecast's loads against the basis and the tagged adjustments explain the rest: no residual, no plug, the bars summing to the endpoint by construction. With no basis it shows the forecast's composition.
Steps group by tag alone, case-insensitively, showing the commonest spelling.
They grouped by label first once, to match pf_segment, but every adjustment
carries the same default label, so no tag ever showed — the label is the segment,
the tag the initiative. Untagged adjustments collapse to one "N untagged
adjustments" step, as in the ledger, rather than a wall of identical bars.
Colour encodes polarity: increase and decrease as two poles of a validated
blue/red diverging pair (green/red is the classic colour-vision failure), neutral
grey anchors. Everything not a bar follows the theme via CSS variables set
through style, where var() is reliable. Every bar is labelled, a table view
gives full precision, and the panel can be dragged.
Known issues:
- Overlapping slices are deduplicated by
pf_id, which grain rows lack, so in grain mode a row matching two selected slices counts twice; it should key onpf_gkey. - Selection scope keeps only dimension and date keys, dropping the
pf_iter/pf_note/pf_op/pf_user/pf_logidkeys operations honour, so it bridges a wider set than the operation would write; a total-cell{}slice is skipped outright. - No drill-down from a step to its entries.
Change log
A modal of the version's entries, newest first — time, operation, slice, tag,
note, rows and totals, author — with undo per row and tag and note edited inline
(Enter saves, Esc cancels). PATCH /log/:logid branches on whether a field was
sent, so "" clears rather than meaning "leave alone" — except label and
bucket, renamed from the Baseline page and never cleared. A saved tag regroups
the ledger and bridge at once, so history can be reclassified. Multi-slice entries
list a line per slice, pulling out shared fields when only one dimension varies
(a dragged region); {} reads "Total within the pivot filter". An entry with
stored SQL opens its debug record — intent, environment, executed statement.
It shows an entry's full impact, not the reader's share: stamped totals are company-wide, so an admin's version-wide scale reads the same everywhere — deliberately, and labelled, rather than re-aggregated per territory.
Everyone / Mine. Everyone's entries are listed, with the reader's own marked
in place — a blue rule down the row and a blue By cell. With several active
accounts, finding your own meant reading By down the list. Marked and
filterable: the mark tells without hiding anybody, the filter is for wanting only
yours. It opens on Mine every time, unpersisted — the log is almost always opened
to check your own recent work, and a stale "Everyone" is the worse default.
isMine() is strict authorship, not canUndo(): an admin can undo everything,
and marking every row as theirs would say nothing. The fetch stays unfiltered,
since the control's counts need the whole set. Rows the reader cannot change are
greyed rather than offering a 403.
?kind=adjustments drops load entries, which own nearly every row, from the
recount join.
Known issue: the log does not show an entry's id, so there is no way to name one when asking about it.
Proposed: additional measures
Status: proposed — not built. Nothing below exists in code yet.
The problem
The app forecasts exactly one dollar measure (role = value) and one quantity
(role = units). col_meta lets a second column be marked value, but only
the loads carry it (generateSQL in lib/sql_generator.js: "the adjustment
operations are single-measure … and use only the first of each"). So a second
measure is right on the baseline and wrong everywhere an adjustment has been
made:
- scale, recode and clone write adjustment rows with that column null
- a recode moves the sales and leaves the cost behind
- in grain mode
/aggsums only the first value and the units column, so it is not in the pivot at all
Extended cost is the first case wanted — margin is sales minus cost — and the same shape recurs for rebates, surcharges, freight and the like.
The model: every extra measure is a rate on a driver
An extra measure is almost never forecast on its own. It is a rate applied to something already being forecast:
| Measure | Driver | Rate held by an ordinary edit |
|---|---|---|
| Extended cost | qty | unit cost |
| Surcharge per unit | qty | surcharge per unit |
| Rebate | sales | rebate as a % of sales |
| Freight (fixed) | none | — carried, not moved by scale |
Declared once, in Setup: col_meta.role = 'measure', plus a new
col_meta.driver of units | value | none. The existing value and
units roles are unchanged and remain the two the ledger edits.
How each operation treats a measure
The rule is that an edit never changes a measure's rate unless the rate is what is being edited — the same rule the scale already applies to price.
| Operation | Measure driven by qty | by sales | none |
|---|---|---|---|
| Load | copied | copied | copied |
| Scale | moves by the qty factor | moves by the dollar factor | untouched |
| Recode | both legs, like the other cols | both legs | both legs |
| Clone | × scale factor | × scale factor | × scale factor |
| Undo | removed with its rows | removed with its rows | removed |
So a price-only edit moves no qty-driven cost (qty did not move) but does move a sales-driven rebate (dollars did). The scale SQL already prorates value and units by one factor per coordinate; each measure is prorated by its driver's factor the same way, per row, so the rate holds per row and not only in total.
Because scale moves a measure at the slice's current rate, a rate set earlier is carried forward by every later edit, which is what makes Set rate (below) stick.
Setting a rate: its own operation, not the ledger
The ledger stays three boxes — value, qty, price — and the measures stay out of it. The ledger is already the hardest part of the panel to read, and a rate is a different question from a price-or-volume edit.
The case that needs it: a part used as a proxy for a new part not yet set up, where the new part's cost is roughly known. The workflow is clone or recode the proxy's volume onto the new part, then set the new part's cost.
Set rate · measure ext_cost · per qty · new rate 0.31
- Writes adjustment rows that change only that measure: the slice's cost goes to rate × its qty; sales and qty do not move. The same shape as a price edit, on a different measure.
- A sales-driven measure takes its rate as a % of sales.
- A
nonemeasure takes a plain target or change amount. - One
pf.logentry,operation = 'rate',paramsrecording the measure, the rate and the resolved change; undoable, listed in the change log, and a line in the bridge like any other adjustment. - Measured against the same basis choice as scale (adjustable rows or the selected total), with the same territory and scope rules.
In the pivot
Each measure is summed like sales and qty — /agg and the /data cursor carry
every measure column. Rates and margins are not stored: they are Perspective
expression columns computed from the sums (unit cost = cost / qty, rebate % =
rebate / sales, margin = sales − cost, margin % = margin / sales), so they are
right at every level of the tree.
What has to change
col_meta:role = 'measure', newdrivercolumn; Setup's editor offers both.lib/sql_generator.js: loads and/aggcarry every measure; scale prorates each by its driver's factor; recode and clone carry them; a newratetemplate.routes/operations.js: arateroute; scale passes the factors through.OperationPanel.jsx: a Set rate form beside Scale / Recode / Clone.- Existing versions: the forecast table needs the measure columns, so a version created before a measure was added needs them added (or recreating) — the same col_meta drift noted under §Open questions.
Open questions
- Should a measure be allowed more than one driver (a surcharge that is part per-unit, part percentage)? Proposed: no — split it into two measures.
- Does Set rate on a slice with no qty (a qty-driven measure) refuse, as the price hold does on a slice with no value? Proposed: yes.
Open questions
- Replay — re-running a version's adjustments against a restated baseline.
Not built: no route takes
replayand nothing returns 501. The hard part is the one cloning a definition declines: a scale's target was sized against the rows that were there, so a replay must choose between the figure and the intent (paramsholds both inputs andresolved).env's template fingerprint exists partly so a replay can tell an entry ran under another template. - Approval workflow — a submission an admin approves before others see it. Deferred.
- Export — CSV, or a push to a reporting table. The bridge's table view is a
partial stand-in; there is no download. Stored names (§Naming) mean a reporting
query needs only a join to
pf.log. - Cross-version comparison — side by side, or a bridge between versions; the isolated tables make it a UNION. Compare to answers the within-version form (against a plan bucket loaded into the same version); across versions is open.
- Bridge drill-down — a step listing its entries, or selecting them back in the pivot.
- Multi-connection support — one database via
.envtoday. The vision: apf.connectiontable (host, port, dbname, user, password as an env-var reference),connection_idonpf.source, per-connection pools;pfstays on a home connection and sources live anywhere, with a Connections panel in Setup. It changes the source schema, so it wants a clean install. - Col_meta / version schema drift — roles changed after a forecast table exists put the generated SQL and the table's columns out of sync, unnoticed. Setup should compare col_meta with each table's columns, warn, and offer to rebuild (recreate the table, keep the version and log). Today: delete and recreate the version. Additional measures would hit this on every version.
- Separating
is_key's meanings — §Data model →pf.col_meta. - A per-source default layout beyond the
pf_iterfallback, built from col_meta when nothing is published. - Dynamic grain — §Display-grain pre-aggregation.
- Per-node expansion that survives a rebuild — §The pivot → Axis depth.
- Tests — SQL generation is token substitution against append-only tables, exercised only by hand.