Design a multi-tenant data analysis platform, in the spirit of Palantir's own Foundry: tenants ingest datasets, run transforms that derive new datasets from them, and analysts query the results. The users are government and enterprise customers, which changes what the interview is about.
This is not the throughput-and-sharding design you may have drilled elsewhere. The scoping is explicit and it reshapes the whole conversation:
The single sentence that should organize your whole answer is the interviewer's: assume mistakes will happen, so how do we detect and recover? Every major component below earns its place by answering that question. If a design decision does not help you catch a mistake or undo one, it is probably not what this interview is testing. [Source: darkinterview.com]
This walkthrough follows the Interview Framework and is sized for a 45 to 60 minute round.
The priorities here are unusual, so name them deliberately:
Do not open with capacity math and a sharding plan. The prompt explicitly de-emphasizes scale, and government/enterprise data volumes are large but not consumer-internet large. Leading with throughput signals that you did not hear the actual question, which is about permissions, audit, lineage, and recovery. [Source: darkinterview.com]
Keep this to a sentence or two, only to justify a simple shape. Assume thousands of tenants, each with thousands to millions of rows per dataset and a modest number of analysts. Metadata (datasets, versions, permissions, lineage edges) fits comfortably in a relational database. Dataset bytes live in object storage. Query volume is human-driven and small. The design effort goes into the control plane, not the data plane.
Tenant(
tenant_id UUID PK,
name TEXT
)
Principal( -- a user or a service account
principal_id UUID PK,
tenant_id UUID FK,
type ENUM('user','service'),
email TEXT
)
Group( -- roles / need-to-know groups
group_id UUID PK,
tenant_id UUID FK,
name TEXT
)
GroupMembership( -- which principals belong to which groups
group_id UUID FK,
principal_id UUID FK
)
Dataset(
dataset_id UUID PK,
tenant_id UUID FK,
name TEXT,
current_version_id UUID, -- pointer to the latest immutable version
created_by UUID
)
DatasetVersion( -- immutable snapshot; datasets are append-only in time
version_id UUID PK,
dataset_id UUID FK,
version_number INT,
storage_uri TEXT, -- object-storage location of this snapshot
markings UUID[], -- dataset-level classification labels
column_schema JSONB, -- column definitions and the markings on each column
produced_by_transform_run UUID, -- NULL for a raw ingested version
content_hash CHAR(64), -- integrity + dedup
created_at TIMESTAMP
)
Transform(
transform_id UUID PK,
tenant_id UUID FK,
name TEXT,
code_ref TEXT, -- versioned reference to the transform logic
code_version TEXT
)
TransformRun(
run_id UUID PK,
transform_id UUID FK,
code_version TEXT,
input_version_ids UUID[], -- exact versions consumed
output_version_id UUID, -- exact version produced
status ENUM('running','succeeded','failed'),
started_at TIMESTAMP,
finished_at TIMESTAMP
)
LineageEdge( -- explicit graph: which version derived from which
edge_id UUID PK,
upstream_version_id UUID FK,
downstream_version_id UUID FK,
transform_run_id UUID FK
)
Marking( -- a classification label, e.g. a clearance level or handling caveat
marking_id UUID PK,
tenant_id UUID FK,
name TEXT
)
MarkingAssignment( -- which principals or groups are cleared for which markings
marking_id UUID FK,
principal_type ENUM(,),
principal_id UUID
)
AccessGrant(
grant_id UUID PK,
tenant_id UUID FK,
dataset_id UUID FK,
principal_type ENUM(,),
principal_id UUID,
action ENUM(,,,),
allowed_columns TEXT[] ,
row_predicate TEXT ,
granted_by UUID,
created_at ,
expires_at
)
AuditEvent(
event_id UUID PK,
tenant_id UUID FK,
actor_principal_id UUID,
action TEXT,
resource_type TEXT,
resource_id UUID,
resource_version_id UUID ,
decision ENUM(,),
context JSONB,
prev_hash (),
event_hash (),
created_at
)
Datasets are immutable and versioned. A transform never overwrites a dataset in place. It produces a new DatasetVersion, and the Dataset row simply advances its pointer. This is the backbone of recovery: rolling back a bad transform is repointing to the previous version, and the old state is never lost. It is also what makes lineage exact rather than approximate, because every edge references specific versions.
Lineage is a first-class graph, captured at execution time. Every TransformRun records the exact input versions it read and the output version it wrote, and materializes LineageEdge rows. You do not reconstruct lineage by parsing code later; you record it as a byproduct of running the transform, which is the only way to keep it complete. [Source: darkinterview.com]
Permissions and markings are two orthogonal layers. An AccessGrant is an ACL entry: it lets a principal (a user or a group) perform an action on a dataset, optionally narrowed to an allowed_columns set and a row_predicate. Markings are a separate, data-carried classification: columns and whole datasets are labeled, principals are cleared for those labels through MarkingAssignment, and you may only see data whose markings you hold. Access is the intersection of the two. This split is how you model government-style need-to-know: a grant can let the analytics group read a dataset, while markings still filter each analyst to the rows and columns their clearance permits. Neither layer alone is sufficient.
Keeping every row scoped by tenant_id, including grants, lineage edges, and audit events, is what makes cross-tenant isolation enforceable at the query layer rather than trusted to application logic. Isolation you can express as a WHERE tenant_id = ? predicate is isolation you can prove.
REST over HTTPS fits the control plane cleanly; this is metadata management plus a policy-checked query path. Dataset bytes are read through the query engine, which applies permissions, not fetched directly from storage by clients. [Source: darkinterview.com]
POST /v1/datasets -- register a dataset, ingest first version
POST /v1/transforms/{id}:run -- run a transform; records a TransformRun + lineage
GET /v1/datasets/{id}/versions -- list immutable versions
POST /v1/datasets/{id}:query
Body: { "columns": ["region","amount"], "filter": "amount > 1000", "version_id": "..." }
The query service resolves the caller's effective permission, injects the row predicate and column mask from any matching grant, checks the caller's markings against the version's column markings, and only then reads bytes. Every call emits an audit event with the columns actually touched.
POST /v1/grants -- create an AccessGrant
DELETE /v1/grants/{id} -- revoke; takes effect on the next check
GET /v1/datasets/{id}/access-review -- who can currently read this, and via which grant
GET /v1/versions/{id}/lineage?direction=downstream&depth=all
Response: { "nodes": [ ...dataset versions... ], "edges": [ ...transform runs... ] }
GET /v1/audit?resource_id=...&actor=...&start=...&end=... -- query the append-only log
POST /v1/datasets/{id}:rollback -- repoint to a prior version
POST /v1/transforms/{id}:replay -- re-run from a chosen input version
Make the audit event a product of the query path, not a courtesy log line. The query service is the single choke point where a read happens, so it is the only place that can record which columns and rows were actually returned. If audit lives anywhere else, it will drift from reality.
The system splits into a control plane (what is allowed, where data came from, what happened) and a thin data plane (running transforms, serving reads). The interesting logic is almost entirely in the control plane. [Source: darkinterview.com]
TransformRun and the LineageEdge rows connecting input versions to the output version.Answering "can principal P read version V, and what may they see" means:
principal_id, the groups they belong to (GroupMembership), and the markings they hold (MarkingAssignment).AccessGrant rows on V's dataset with the read action that match P or any of P's groups. No matching grant means deny.allowed_columns, then restricted to columns whose markings P holds; the effective row filter is the disjunction of their row_predicates. Read V, apply both, and return only what remains.Cache decisions per (principal, version, query shape) and invalidate on any grant or marking change touching that principal or resource. Because grants and markings are tenant-scoped, invalidation stays local to one tenant.
Enforce permissions in the query service (the policy enforcement point), never only at the storage boundary. If clients can reach object storage directly, row and column masking evaporate and your carefully designed grants become decorative. [Source: darkinterview.com]
This is where the interview actually lives. Reframe the usual "scaling" phase around the interviewer's refrain: mistakes will happen, so detect and recover.
The audit log is append-only and each event stores the hash of the previous event, forming a hash chain per tenant. Periodically the head hash is anchored somewhere write-once (a separate append-only store or a signed checkpoint). To verify integrity, recompute the chain and compare against the anchors. Any deletion or edit of a past event breaks the chain from that point forward and is detectable.
"We write to an audit table" is not a compliance answer. What makes it tamper-evident is the chain plus the anchor: given the anchors, no one can remove or alter a past event without the verification failing. State that mechanism explicitly; it is the point of the question. [Source: darkinterview.com]
Lineage is not documentation; it is how you compute a blast radius. When something goes wrong with a dataset version, walk the lineage graph downstream to find every version derived from it, transitively, along with the transform runs on each edge. That set is exactly what a mistake could have contaminated.
Have a crisp answer for each mistake the interviewer will raise:
| Mistake | Detect | Recover |
|---|---|---|
| A transform produced wrong output | scheduled data checks fail; or a user reports it, and lineage scopes the impact | roll the dataset pointer back to the previous immutable version; fix the transform; replay downstream runs from known-good inputs |
| Someone was granted too much access | access-review query surfaces the grant; audit shows an unexpected GRANT_ACCESS | revoke the grant (effective on next check); use audit to list exactly what they read while it was live |
| A dataset was shared with the wrong group | access review; anomalous read pattern in audit | revoke; notify; assess exposure via audit + lineage |
| Cross-tenant isolation was breached | denied-then-allowed anomaly, or chain verification during an incident review | contain via revocation; the version immutability means no data was destroyed, only over-exposed, so recovery is about access and disclosure, not reconstruction |
| Audit records look altered | hash-chain verification against anchors fails | isolate the window between good anchors; treat as a security incident |
Because every dataset version is immutable, recovery almost never means reconstructing lost data. It means repointing to a good version and, where access was the problem, revoking and measuring exposure. That is the property immutability buys you, and it is worth saying plainly. [Source: darkinterview.com]
When the interviewer says "assume mistakes will happen," resist the urge to promise you will prevent them all. The stronger answer is that the platform is built so mistakes are bounded and reversible: immutable versions bound data mistakes, the audit chain bounds and exposes tampering, and lineage bounds the blast radius of both.
Government and enterprise access rules layer several mechanisms, and a strong answer keeps them distinct:
Deny wins on conflict, unmatched means denied, and grants expire. Column markings travel with the schema through transforms, so a derived dataset does not silently launder a restricted column into an unmarked one; propagating markings along lineage edges is what prevents that. [Source: darkinterview.com]
If pushed, give the growth path in one breath and move on: the metadata database can take read replicas and, far later, partition by tenant_id since tenants never query across each other; object storage absorbs dataset bytes natively; the audit store is append-only and partitions by tenant and time. None of this is the focus, and saying so is the right instinct here.
Designing for scale the prompt explicitly set aside. Opening with QPS math, Kafka, and sharding spends the interview on the one axis you were told does not matter and skips permissions, audit, and lineage, which do.
Treating audit as a plain log table. Without a tamper-evident chain and periodic anchoring, an operator can alter history and the compliance story fails. Compliance-grade audit must be provably complete. [Source: darkinterview.com]
Mutating datasets in place. In-place updates destroy the state you need for rollback and make lineage approximate. Immutable versioning is what makes recovery a pointer change instead of a reconstruction.
Reconstructing lineage after the fact. Parsing transform code to guess derivations is incomplete and brittle. Capture lineage at execution time from the exact input and output versions.
Enforcing permissions only at storage. If masking and row filtering do not happen in the query service, direct storage access bypasses them entirely and cross-tenant isolation cannot be guaranteed. [Source: darkinterview.com]
Requirements
Data Model
API Design [Source: darkinterview.com]
High-Level Design
Detection & Recovery
| Concern | Decision |
|---|---|
| Design posture | Correctness, isolation, and recoverability over throughput; scale is explicitly not the driver |
| Datasets | Immutable, versioned snapshots; the dataset holds a pointer to its current version |
| Permissions | Two orthogonal layers enforced in the query service: ACL grants (role, row predicate, allowed columns) and data markings the principal must be cleared for |
| Isolation | Every row tenant-scoped so isolation is a query predicate, not trusted application logic |
| Audit | Append-only, per-tenant hash chain with periodic anchoring; tamper is detectable |
| Lineage | Captured at execution time as explicit version-to-version edges; used to compute blast radius |
| Recovery | Rollback by repointing to a prior version, revoke bad grants, replay downstream transforms |
| Guiding principle | Mistakes are made bounded and reversible rather than assumed away |