PortfolioPlane

Proof, not status.

Security

How one customer’s data is kept from another

Written for the person doing the third-party risk review, not for the person doing the demo. Every figure below was measured rather than written, and carries the date it was measured on. Every claim carries its standing — implemented, proposed, or not held — because the product refuses to let a customer blur those three, and it would be a poor advertisement for that if this page did.

Nothing here is described as certified. We claim no attestation we do not hold, and the section immediately below is the list of the ones we do not.

What we do not hold

Not held

This is a hard procurement stop for some buyers and it belongs at the top of the page rather than in the eleventh answer of a questionnaire.

  • No SOC 2 Type II. Type I would not clear a health plan’s review either — what they want is the observation window, and the window starts when we decide, not when we sell.
  • No HITRUST, i1 or r2.
  • No BAA history. If a buyer’s counsel concludes we are a business associate, we sign one. The position below on clinical data is a way to make a review shorter, not a position to defend past the point where it costs a deal.
  • No FedRAMP authorization.
  • No penetration test summary dated within twelve months, and no completed SIG questionnaire.

We cannot compete on paper with a platform whose business associate agreement is already executed at the parent-company level. What follows is the part that is checkable rather than certified: the mechanism, and the evidence that it holds.

Tenant isolation is enforced by the database

Live

Most multi-tenant products filter by customer in application code — a where org_id = … that a developer must remember to write on every query. That works until the one query where somebody forgets, and the failure is silent: the query returns rows, the page renders, and nothing anywhere says the rows belonged to somebody else.

Here the boundary is Row Level Security, which lives in Postgres rather than in the application. A policy is attached to the table itself, so it is applied to every query that ever touches that table — including one written next year by somebody who has never read this page, and including one issued by hand through a database client.

57tables in the application schema56 carry an organization id; the remaining one is the organization table itself
57have Row Level Security enabledevery table, with no exceptions and no opt-out list
57have it FORCEDsee below — this is the half that most schemas leave off
184policies written against them0 tables have none
0policies that permit everythinga policy whose condition is literally “true” would make every other number here decoration
0permissions held by the unauthenticated rolea signed-out request can reach nothing in the application schema

What “forced” means

Enabling Row Level Security applies the policies to ordinary users. It does not apply them to the table’s owner, which is the role most application schemas are created by. A schema can therefore be fully policied and still hand every tenant’s rows to the one connection most likely to be used for a migration, a support query or a background job.

Forcing it removes that exemption: the policies apply to the owner too. All 57 tables here are forced, and a database trigger applies the same setting to any table added in future, so the guarantee does not depend on the next person remembering it.

A cross-tenant reference is a constraint violation, not a bug

A requirement belongs to a project. If that reference were declared the ordinary way — requirement points at project by its id — then a requirement in one customer’s organization could point at a project in another’s, and the database would accept it. It would look like a working join. It would only be noticed when somebody read a row and did not recognize it.

So 81 of the 198 references in the schema are declared on the pair (organization, id) instead. A row pointing across organizations does not satisfy the constraint, so it cannot be written at all — the database refuses the insert rather than storing something that has to be found later.

257 further constraints hold the rules that are not about tenancy: that an acceptance names a real person, that the person who publishes a revision is not the person who submitted it, that a monitoring check can never be recorded as if it had blocked something.

Two ways a boundary like this is bypassed

Both are counted above rather than asserted, because both are silent when they go wrong.

  • A view. A view has no policies of its own, and by default it runs with the privileges of whoever created it — so a view over a tenant-scoped table returns every tenant’s rows while every policy underneath it still reads as correct. This schema has 3, and 3 of them is declared to run as the caller instead.
  • A privileged function. A function can be declared to run as its owner, which is necessary for a few things and is a way to escalate privilege if the function does not pin where its names resolve. There are 95 such functions, and 95 of them pin it.

Measured on 14 August 2026, 0 days ago, by reading the production database’s own catalogue. This page is built from that measurement rather than from a live query, and deliberately: reading the policy catalogue needs an operator credential that bypasses the boundary entirely, and putting that credential on the request path of a public page to make a figure an hour fresher would be a worse fact than the staleness it fixed.

Somebody tries it, on a schedule

Live

Counting policies establishes that a boundary is declared. It does not establish that it holds. So a signed-in session belonging to one organization attempts to read and to write every other organization’s rows, on every tenant-scoped table, through the same interface the browser uses — no test hook, no injected header, no privileged connection. Every attempt must be refused.

455read and write attempts madeacross 56 tenant-scoped tables
7organizations the identity does not belong towith none of these the run would prove nothing, so it refuses to pass
0attempts that were not refusedthe only number on this page that must be zero

The run enumerates the tables from the catalogue rather than from a list, so a table added tomorrow is covered tomorrow. A table added with no organization id on it is reported by name instead of being skipped quietly.

Last run on 14 August 2026, 0 days ago. Outcome: no foreign row was readable and no foreign write was accepted. This is the record of a run at a moment, not a live check, and an administrator of a tenancy can see the same record inside their own workspace.

Encryption

Live

Both of these are properties of the managed platforms this product runs on rather than things we implemented, and that distinction is worth stating plainly: what we can commit to is that we have not turned either of them off. Core application data is stored by Supabase and delivered by Netlify. If the optional voice assistant is enabled, its Row Level Security-scoped workspace brief and the live conversation are sent to Google Gemini as described in the subprocessor table below.

  • At rest. The database and its backups are stored encrypted with AES-256 by the managed Postgres platform (Supabase).
  • In transit. Every connection — browser to application, application to database — is TLS 1.2 or above, terminated by the application host (Netlify) and by the database platform. There is no unencrypted listener to reach.

What we do not offer: customer-managed encryption keys, field-level or column-level encryption, and a data residency commitment. If a buyer requires any of the three, the answer today is no.

Clinical, payment and personal data

Proposed

A portfolio governance register does not need protected health information to do its job. Requirements, citations, commits, test outcomes, findings and dispositions are not clinical data. Nothing in this product asks for a member, a claim, a diagnosis or a card number; there is no field for one, no import path that expects one, and no feature that would work better with one.

That is a design position, and the honest word for a design position is not “guarantee”. A classification is not a control, and whether we are a business associate is a factual question about what we actually receive — not one our architecture settles. Three separate commitments would close it, they fail separately, and none of the three is built yet:

  1. A contractual prohibition the customer acknowledges. Protected health information is out of scope by agreement, acknowledged at onboarding by the person who founds the organization, and restated on every screen that accepts a file or a pasted register. This is the commitment that actually holds, because it binds the party who controls the input. Proposed
  2. A named ingestion boundary. Seven tables accept customer free text today — a requirement’s statement and citation, a finding’s quoted text and reviewer note, an project’s title and summary, a test’s identifier and failure message, an uploaded file’s own metadata, an environment’s label and address. A boundary that does not list the fields it covers is a boundary nobody can check, so the list is written down rather than implied. Proposed
  3. Screening at the two import paths, at write. The register import and the test-results import are the two places arbitrary strings arrive in bulk from a customer’s estate and nobody reads them on the way in — a test named member 123-45-6789 renews is a plausible accident. On those two paths a match on an identifier pattern should refuse the row and name it, rather than storing it and raising a finding afterwards. Refusal is available there precisely because an import is a batch somebody is already watching; the same control on a live typed field would be a false-positive machine. Proposed

So the accurate sentence today is: the product does not ask for clinical, payment or personal data, and does not depend on any — and the three commitments that would turn that from a design intention into something a reviewer can hold us to are written and not yet shipped. A business associate agreement is executed on request rather than argued away.

The decision trail, retention and deletion

Live

Every act that changes what the register claims is written to a trail that refuses deletion to everybody — to an organization’s owner, to us, and to the database superuser. That is not a policy somebody could change in an admin screen; it is enforced in the database and verified by attempting the delete, which is the only way to know. The actor on each entry is taken from the authenticated session by the database rather than supplied by the client, so a request naming somebody else is refused.

It is retained for the life of the tenancy and does not roll off. For comparison, the organization-level audit log of the market leader in this category is fixed at 180 days and the limit cannot be changed; another major platform deletes at 90. A health plan’s own documentation retention obligation is measured in years.

  • Writes are recorded. Reads are not. Proposed Most tools in this category record writes only and we are one of them. An audit-controls standard cares about access, so this is a real gap and it is ours.
  • Scheduled machine-readable export of the trail. Proposed “We keep it for the life of the tenancy” is a strong line until somebody asks what happens on the day the contract ends. Retention by us only closes a customer’s obligation while the relationship lasts. The export that would let them close it themselves is not built.
  • Full tenant export, certified deletion on termination, and litigation hold. Proposed Deleting a tenancy removes the tenancy; it does not selectively edit history inside a living one.

Incident response, and the clock

Proposed

A payer’s third-party risk function negotiates a breach notification window, commonly 24 to 72 hours from vendor to plan. Leaving the number off a page like this reads either as an oversight or as a figure being avoided, so: 72 hours from confirmed compromise, to a named contact per tenancy, with a shorter clock negotiable per contract. Seventy-two is chosen because it is a window a small team can meet while still investigating — committing to 24 and missing it is worse than committing to 72 and beating it.

What does not exist today: a documented incident-response runbook, an on-call rotation, or a named security contact. A buyer will ask for all three and the answer is currently no. Written here rather than discovered.

What our own staff can reach

Not held

The first question a payer’s risk function asks a small vendor is whether the vendor’s own people can read the customer’s data. The isolation above is a boundary between customers. It is not a boundary between a customer and us, and a reader who takes the first claim seriously is owed the second.

QuestionAnswer today
Can platform staff read tenant data?Yes. The operator holds a management credential that executes arbitrary SQL as the database owner, which bypasses the policies above entirely.
Is that access just-in-time?No. It is standing.
Is it separately logged?No. The database logs the statement; nothing correlates it to a named operator or to a reason.
Is it visible to the customer?No. Nothing surfaces it in the product.

That is literally how the refusal proof above is run, so it is not a theoretical capability. The four requirements that close it — break-glass instead of standing access, logged distinctly in a store the operator cannot edit, visible to the customer in their own workspace, and a named system actor so that “the platform did this” and “nobody did this” stop being the same record — are written and not built. Proposed

Availability, backup and known advisories

Proposed
  • No uptime commitment. There is no availability target today and no measurement that could support one. A buyer asking for 99.9% should be told the number is not committed, and what would have to be true first: measurement, an on-call rotation, and a status page a customer can read without asking us.
  • Backups: daily, point-in-time, never exercised. The managed platform takes them. Nobody here has performed a restore, and a backup nobody has restored from is a belief rather than a control — which is the exact distinction this product sells. Until a restore has been executed and timed, no recovery objective is stated.
  • Two open dependency advisories, and why they are not reachable. A security review runs the audit command and finds them, so the answer belongs here. Both are the same shape: the slide-deck writer depends on an image-dimensions library whose parsers for three image formats can be driven into a loop by a malformed file. There is no patched version. The answer is reachability — the deck writer builds text, shapes and tables and embeds no images, so no byte a customer controls reaches those parsers. That analysis is one line of code from being false, so it is enforced by a test that fails the build if an image is ever embedded, rather than asserted in a paragraph like this one.

Subprocessors

Live

Published rather than assembled when asked. Notice and the right to object are commercial terms.

SubprocessorWhat it receives
SupabaseThe database, authentication identities and file storage.
NetlifyRequest metadata, and the rendered HTML of every page.
ResendThe email address of anyone invited to a workspace.
StripeBilling contact and payment details for a paid plan. Card numbers are entered on Stripe’s own page and never reach this application.
Google Gemini (optional voice assistant)Only when the assistant is enabled: a Row Level Security-scoped brief including the workspace name and slug, selected project keys, titles and states, aggregate register counts and standings, plus the live conversation’s audio and transcriptions. It receives nothing from this application when the assistant is not enabled.

If something here is missing

A reviewer with a question this page does not answer should assume the answer is one we have not written down yet rather than one we are withholding — and ask. An administrator of an existing workspace can read their own tenancy’s isolation facts, including the refusal proof above, from inside the product under Settings.

How the product works, in detail