Design: use cases per POV, and the accessor who ticks them off
Audience: contributor · Profile:
pov· Read this when: you are changing the per-POV use-case checklist or who may tick it off.
Six slices, and one note for all of them, because the first had to carry the columns and the vocabulary the rest need — and deciding that twice is how they end up disagreeing. Slice 1 has no section of its own: it is the first half of this note, up to and including Progress: the checklist. The five after it get one each, and each opens by saying where it stands.
All of them are built. One question is still open — Entitle's Ephemeral-mode discriminator, in slice 2b — and nothing depends on the answer, which is why the SE-driven mint path exists.
The problem
services/personas.py ships eight roles and five cards each, and /use-cases renders
them all. Every card resolves its readiness through feature_flags.enabled() — an
instance-wide answer — and every card targets an estate page: /aws#instances, /vsphere,
/k8s, /databases.
On a POV instance those pages are masked or 404. So every card resolves masked, and the
catalog is complete, correct and unusable: a wall of grey saying "Not available on a POV
instance". The page an SE would most want in front of a customer is the one that has
nothing to say there.
The deeper reason is that a POV is not an instance. PovEnvironment carries
pra_tenant_id, ps_tenant_id and entitle_tenant_id as three independent columns
precisely because a Password-Safe-only evaluation, a PRA + Password Safe one and an
all-three one are all normal shapes. Every one of them runs on an instance where
pra_enabled is on. The flag cannot tell them apart, and it is the flag that every card
was asking.
The third axis
| Axis | Module | Power |
|---|---|---|
| Tenancy | feature_flags.install_profile |
Gates. Subtracts, 404s routes |
| Role (demo profile only) | services/personas.py |
Curates. Reorders, surfaces, never hides |
| This POV's products | services/pov_cards.py + services/pov_use_cases.py |
States. Says what this environment can run |
The third one is emphatically not a gate. The catalog is whole for all eight product
mixes and the API serves every group and every card on every one of them (pinned); a mix
decides each card's state, and nothing anywhere subtracts a card, 404s a route or
disables a feature. Set the Entitle tenant on the POV row and its six cards are simply
there, with no migration and nothing to re-enable. That is the property that keeps this
from becoming a second install_profile.
What the pages then draw is a presentation decision on top of that, and all three draw only the in-scope cards — see One format, three surfaces for why, and for what the SE's page used to do instead.
The middle row is the one that moved. The 32 POV cards used to live in eight
Persona.pov_use_cases tuples and render under role headings, which sorted a customer's
remaining work by an axis belonging to the other install profile — and a POV instance has no
role axis at all (personas.applies() is false there; see
Personas). They now live in services/pov_cards, grouped
by the product each one proves, because that is what a POV is bought and scoped by, with
the OT cards pulled out on their own. Five groups, in this order:
| Group | Cards | What it is |
|---|---|---|
pra — Privileged Remote Access |
10 | Reaching a machine without a VPN, an inbound port or a password on screen |
password_safe — Password Safe |
8 + the runbook's 14 | The lab's own accounts under management, and rotated |
entitle — Entitle |
6 | Access that arrives when asked for and leaves on its own |
ot — Operational Technology (OT) |
7 | The plant floor: process-network devices, the HMI, the PLC, the integrator |
environment — Presenter to-do |
5 | Not a customer use case. The POV itself: who can reach it, what runs inside the customer's network, what teardown removes |
A card's group is derived, never authored, with one exception: pov_cards.group_of
returns the first product the card declares, or the environment group when it declares
none. So requires_products is the single field deciding both a card's product group and
its scope, and they cannot disagree. The exception is OT — "this is a plant-floor story"
cannot be read off a card's products, so a card is filed there by being written in
_OT_CARDS. The two-product cards outside OT both lead with pra; a test pins that the
tuple order stays a decision.
The Skytap Password Safe Cloud POC runbook (services/pov_runbooks) is still its own
registry, but its cards are listed under the Password Safe group (Runbook.group), after
the product's own, rather than as a second Password Safe list. A tick on one is filed under
password_safe.
The environment group is the presenter's to-do list, served with presenter: true.
It is never hidden by the Setup tab's choice and never offered in its picker, it is counted
apart (summary.todo_done / todo_total) rather than in the customer's numbers, and it is
left off the customer's page, both Use cases pages and the after-POV summary. It is drawn
in one place: its own section at the very end of the SE's Setup tab, where its items
are ticked like any use case.
Three states, and three words that are not the estate page's
| State | Meaning | Renders |
|---|---|---|
ready |
This POV has the products, and the wire-up has run for them | Links to the POV page tab |
needs_wiring |
The tenant is set, the artifact is not | No link; names what to run, points at the Wired tab |
out_of_scope |
This POV has no tenant for that product at all | Nothing — dropped before it reaches a row |
out_of_scope is deliberately not masked. Masked means this instance's profile refuses
the feature and no operator can change it. Out of scope means this customer's POV was
not wired into that product — a scoping decision, not a fault. Borrowing the word would
make a correctly scoped evaluation read as a misconfiguration, which is exactly the
impression this whole change exists to remove.
Absence beats unwired when a card names two products. A card needing PRA and Password Safe on a POV with no PRA tenant cannot be run at all, so reporting "run the wire-up" would send an operator to a button that will skip the half they came for.
Where the line between the modules falls
services/pov_cards.py may not import database, and neither may services/personas.py
— that one is load-bearing because api/docs_pages imports it deliberately, to survive
without the api layer. So the POV resolvers take a dict of booleans, never a row:
{"pra": bool, "password_safe": bool, "entitle": bool, # does this POV INCLUDE it?
"wired": bool, "onboarded": bool, "entitle_wired": bool} # has the wire-up RUN for it?
services/pov_use_cases.products_for is the one function that turns a PovEnvironment
into that dict, and it reads the counts pov_wireup.describe already computes rather than
re-deriving them. The import direction is one-way and asserted by test — and so is the
other direction: nothing in the POV checklist stack may import services.personas except
for the shared UseCase dataclass. A card is a card; a role is not.
The two halves of the dict are separate on purpose. "We did not include Entitle in this evaluation" and "we have not wired Entitle yet" are different sentences with different next actions, and only one of them is somebody's next click.
Progress: the checklist
PovUseCaseProgress — one row per card somebody touched, keyed (environment_id, card_id).
- A row exists only for a card somebody touched. Absence is "not started", the same
reading
expires_athas everywhere else in this codebase. Adding a card to the registry needs no backfill; enabling this on an existing estate selects zero rows. - The registry is the allowlist. An unknown card id is refused with a 400 rather than stored. A progress table that accepts any string is a free-text store nobody can render, and these rows outlive registry edits — so the mistake would outlive it too.
- A retired card leaves its row behind, un-rendered. Deleting an SE's history on a copy edit is the worse outcome.
stateisdoneorskipped, andskippedis a real answer. "We showed them and it did not land" and "we never got to it" are different things to take into a renewal conversation, and only one is recoverable by running the demo.- Destroy keeps it.
run_env_destroymarks the POV rowdestroyedrather than deleting it, because it is the record of something that existed; these rows are that record's contents. What the destroy owes them is the summary, in the job log, at the moment somebody is closing the evaluation out — which is whatdestroy_notewrites. The accessors of slice 2 are the opposite case: they are credentials, and they are deleted.
note and checked_by_kind ship in slice 1 though only slice 3 writes them, so that slice
needs no migration — the discipline PovEnvironment already follows with its wire-up
columns.
group_key records which group a card was ticked under, denormalised from the registry so
a card that later moves keeps the answer it was ticked under. Nothing reads it — the summary
groups off the live registry — and set_state rewrites it on every save, which is what
healed the rows written while the checklist was grouped by role. It was called persona
until then; database.py's migration list carries the rename.
This is the first thing in the card stack that writes, which is worth naming: a card
navigates and never starts work. A tick is not that — it spends nothing, builds nothing,
reaches no tenant — and it does not live in the registry at all. services/pov_cards stays
a pure read; the write lands in api/pov.py, behind the auth every other POV action
already carries.
Choosing the POV's use cases
A POV's tenants say which products it can reach. They do not say what the evaluation is for. So the first step of a POV's Setup tab is Use cases chosen, and the New POV form asks the same question as POV type:
| POV type | Use cases it tests |
|---|---|
| Password Safe | Password Safe only |
| PRA | PRA only |
| Total PASM (PRA + Password Safe) | both, including the cards that need the two together, such as Password Safe credentials injected into a PRA session |
| Entitle | Entitle only |
| Total PASM + Entitle | all three |
| or tick the products | any other mix |
A card is in the POV when every product it names was chosen, so the combined PRA + Password Safe cards appear on a Total PASM POV and never on a Password-Safe-only one. The cards that name no product are the presenter's to-do list rather than use cases: they are not offered here and are on every POV. Inside the choice you can untick single use cases on the Setup tab, or a whole heading with the box beside it, which is where you leave out a demo — or all of OT — the customer does not care about.
This is the one place the checklist subtracts. A product with no tenant still renders
its cards out_of_scope, because "no tenant picked yet" is somebody's next click. A card
outside the chosen use cases is hidden instead. It is gone from the Use cases tab, from
the customer's page and from the summary, and it is not counted in their totals: the SE
has said this POV is not testing it. A write to a hidden card is refused, which only
happens from a tab opened before the choice changed.
The rest of the Setup tab follows the choice. On a Password-Safe-only POV the PRA Gateway, the wire-up, the PRA/Password Safe link and the Entitle steps all show as skipped ("not in this POV's use cases"), even if those tenants are set.
- Stored as two columns on
PovEnvironmentandPovBlueprint:use_case_products(comma-separated products) anduse_case_excluded(a JSON list of card ids). A preset is only a name for a set of products and is never stored. - NULL means nobody has chosen, which reads as the whole catalog. Every POV from before this step existed keeps the checklist it had; its Setup tab offers Choose, and nothing waits on it.
- Changing the choice deletes nothing. Progress rows stay, so a card taken out and put back gets its tick and its notes back.
- A blueprint carries it, so a "Password Safe POV" recipe builds Password Safe POVs. A request that names its own products wins, and it brings its own unticked list with it rather than mixing in the blueprint's.
PUT /api/pov/managed/{id}/use-case-scope with {products, excluded} sets it, behind the
same owner gate as the Tenants edit. GET …/use-cases?all=1 is the picker's read: it keeps
the hidden cards, flagged in_pov: false. The customer's page never asks for it.
Slice 2 — the ephemeral accessor
Built. The prospect gets a login of their own; slice 3 gives them something to do with it.
Identity
Entitle's create_actor — or an SE pressing a button — mints a real users row, so login,
the sign-in throttle, MFA and the audit trail are the ones already here rather than a
second implementation of each. What makes it an accessor is one column:
accessor_env_id = Column(String(36), nullable=True, index=True) # non-NULL ⇒ accessor
plus a PovAccessor table holding the binding and its provenance: which POV, who minted
it, from where, and when it dies. The environment id is duplicated onto User deliberately
— the guard below runs on every request and must not need a join.
The trap, and why the guard is where it is
User.effective_permissions_dict returns {} for a user none of the three permission
columns says anything about, and require_permission read {} as unrestricted until
empty maps stopped granting anything. When this was written, an accessor created "with no
permissions" held every permission in the dashboard.
So the confinement is a path allowlist in api/auth.get_current_user, the one
dependency every authenticated route resolves through — the same one-reader shape as
feature_flags.enabled. An allowlist and not a denylist: a route added tomorrow is refused
by default, which is the only direction this can fail safely in. Two entries today,
/api/pov/accessor/self and /api/auth/me, and a test pins that it has not grown.
Three surfaces resolve users without going through that dependency, so each is closed where it lives:
| Surface | Why it matters | What it does now |
|---|---|---|
api/entitle_rest |
Grants dashboard permissions up to administrator, and resolves an actor by scanning users and matching a name. An accessor reaching that lookup is a direct escalation. | Every route filters accessor_env_id IS NULL, on the query rather than the response |
api/websocket._authenticate |
Its own resolver, so the path allowlist does not reach it. Job Live Output is not for a prospect. | Refuses an accessor on both the JWT and the PAT branch |
/api/users |
An admin PATCH setting is_admin, or clearing the binding, is two keystrokes to an operator account |
Accessors are hidden from the list, and every mutation route — patch, deactivate, permanent delete, PAT mint — refuses one with a 409 naming the Access tab |
require_admin also refuses an accessor outright. No admin route is in the allowlist, so
that is unreachable today — which is the point: the day one is added by mistake, an
accessor still does not become an administrator.
The client-side half is a convenience, not a control. /api/auth/me reports
accessor_env_id, the Alpine store keeps it, and requireAuth() sends an accessor to
/pov/access rather than leaving them on a dashboard whose every call 403s. It reads
localStorage, which the holder can edit, and it runs in their browser. The gate consults
nothing the client sends.
The Entitle side
api/pov_accessor_rest.py serves Entitle's Ephemeral Accounts route set — the half
api/entitle_rest.py deliberately omits, because dashboard users are permanent accounts
and these are the opposite case:
GET /api/pov/accessor/rest/get_assets live POVs, "pov:<env_id>"
GET /api/pov/accessor/rest/get_all_permissions
POST /api/pov/accessor/rest/create_actor mint; returns the credentials
POST /api/pov/accessor/rest/delete_actor delete
POST /api/pov/accessor/rest/check_config
There is deliberately no give_access: in Ephemeral mode create_actor is the grant.
Four things are copied from functions/fnworkloads/db_grant.py, each learned expensively
there. The identity arrives in provisioning_data, not actor — reading only actor is
what made every real ephemeral grant come back 400. delete_actor refuses any username
without the povguest_ prefix, checked on the name in the request before any lookup, so
a row that disagreed could not talk it into deleting an operator's account.
create_actor returns the credentials, because handing them to the requester is the whole
point of the call — and it returns them nested, under data.login_info, beside a
data.actor block of exactly identifier, name, type and email. That shape is the
whole of Provisioned Actor Data and it allows nothing else: the flat version was
rejected in full, after the accessor had already been minted.
Its secret is its own (pov_accessor_rest_secret), fail-closed in the same shape as the
standing adapter's: 503 when unset, one response for missing and wrong. An endpoint whose
job is minting logins must not authenticate with a credential that already grants
permissions.
The manual path, and why it exists
POST /api/pov/managed/{id}/accessors mints one directly from the POV page's Access tab.
That is not a convenience feature — it is what makes this slice shippable and testable with
no Entitle tenant at all, and it is how the open question below gets answered.
The password is in that response and nowhere else. There is no reveal endpoint, unlike the share link's: that password has to be re-readable because an SE reads it to a customer days later, while an accessor that lost its password is replaced, which is one click and leaves an audit line.
Lifetime
An accessor keeps its own clock, on pov_share's three-step rule — an explicit request,
else the POV's own expires_at, else 14 days — and it is clamped so an accessor can
never outlive the POV it can reach. Entitle owning expiry on its side is not the same as
this instance knowing when to stop trusting a login: an integration that is removed,
misconfigured, or simply never calls back would otherwise leave a working credential behind
forever.
Reaping
pov_accessor_service.teardown runs first in run_env_destroy, taking the position the
share link used to hold. That position belongs to whatever a person outside the account can
be holding, and an accessor is that — but it is a credential into this dashboard rather
than a door into a lab, so its window is the one worth making shortest. Revoking deletes
the user row rather than deactivating it: an inactive row is still a username every actor
lookup in the codebase has to remember to skip, and the one that forgets is an escalation.
expiry_reaper enqueues the identical pov_env_destroy job, so the auto-delete timer is
covered by the same hook with no second code path.
The backstop is a sweep on the POV reconcile pass, outside its platform loop: a fresh
POV instance starts with the auto-delete timer off, and whether a prospect's login should
still work has nothing to do with whether Skytap is reachable. It catches accessors past
their expiry and accessors whose POV already reached destroyed.
The use-case record is not removed with them. That distinction is the point: an accessor is a credential, and the checklist is the account of what the evaluation covered.
Slice 2b — registering the adapter with Entitle
Built. POV page → Access → Register with Entitle creates the REST integration in this POV's own Entitle tenant, so a prospect requests access there and the login is minted, and removed, by the grant itself.
The gap it had to close first, which was two gaps
register_rest took no tenant context. Without one it authenticates with the global
Entitle key and writes the global owner and workflow ids into the HCL — two separate
readers of the singleton, and threading the context through only one of them is worse than
neither: you authenticate as one tenant and name another's ids. So ctx now reaches both
_hcl_fields(ctx) (whose ids the HCL names) and _apply_hcl_sync (whose key applies it),
and a test pins both halves.
This was never a live bug. The one caller was cloud_function_service, behind
cloud_functions_enabled, which is _DEMO_ONLY — on an estate instance the singleton is
the tenant, and that path is unchanged: with no ctx the fallback is exactly what it was.
A lighter tenant context
pov_wireup.entitle_context refused without an agent token and an SSH key. Both are real
prerequisites for the SSH ephemeral-accounts connector, which reaches a private target
from inside the network — and neither applies to a REST adapter, which Entitle calls over
public HTTPS. Reusing it would have blocked accessor registration on prerequisites that do
not exist for it.
So entitle_tenant_ctx is the part every integration in a tenant shares — whose tenant, and
the owner and workflow ids _common_attrs_hcl refuses without — and entitle_context layers
the SSH-specific three (agent token, sudo user, private key) on top rather than duplicating
the resolution.
Everything is checked before anything is created
A registration that half-succeeds leaves an integration in a customer's Entitle tenant that
this dashboard has no state for and therefore cannot remove. So each prerequisite is a
refusal with the remedy in it, reported on the row as accessor_blocker and shown on the
page instead of the button rather than beside a live one:
| Missing | Why it is fatal rather than a warning |
|---|---|
| An Entitle tenant on this POV | There is nowhere to register it |
pov_accessor_rest_secret |
The adapter answers 503 to everything, so the integration would be created and then reject every call Entitle made — which looks like an Entitle fault and is not |
| This instance's public URL | Entitle calls the adapter from its own cloud |
| HTTPS on that URL | Entitle will not call a plaintext endpoint — the same refusal, for the same reason, the broker enrolment makes about the agent endpoint |
Teardown: the tap before the drain
pov_accessor_entitle.teardown runs before pov_accessor_service.teardown, and that
order is the point rather than housekeeping. While the integration is live Entitle can mint
a new accessor, so removing the logins first races a destroy against a grant and can
leave one created after the step that removed them. The destroy order is now: the
integration, the logins it mints, the share link, then the customer's appliance objects.
A failed deregistration keeps the terraform state so a re-run can finish it — the rule the per-VM wire-up teardown already follows. An integration left in a customer's tenant is untidy; one this dashboard can no longer find is worse, and it is standing access.
The open question, still open
Entitle's Ephemeral-mode discriminator is unconfirmed against a live tenant:
register_rest assumes the API infers the mode from allow_creating_accounts and the route
key set. The person who can settle it is the operator who just pressed Register, so the page
asks them — open the integration in Entitle and check its Connection setting says
Ephemeral Accounts. If it says Standing, the discriminator is real and belongs in
register_rest. Until somebody has looked, the SE-driven mint is the path that does not
depend on the answer, and it is why that path exists.
Slice 3 — the accessor writes
Built. GET /pov/access takes no env id in the URL, and neither does anything under
/api/pov/accessor/self/*: every one of them resolves the POV from
current_user.accessor_env_id. "Could an accessor write to somebody else's POV?" is not
guarded here — there is no parameter to guard.
That is also why the write routes needed no change to the allowlist. The allowlisted
prefix is /self, and everything under it is env-from-session by construction; a write
placed anywhere else would have had to widen that list, which is exactly the moment
somebody should stop and think.
A comment is not a verdict
PovUseCaseProgress.state gained a third value, the empty string. Somebody typing
"couldn't get this working" on a card they have not marked is exactly the feedback the
feature exists to capture, and making them claim they covered it first would be a lie the
UI put in their mouth. _summarize counts done and skipped by name, so a note-only row
is neither — which is the truth.
Two writers on one row
set_state's note became tri-state, and this is the sharpest bug the slice could have
shipped:
None leave whatever note is there alone (the default)
"" clear it
text replace it
The SE's tick button sends a state and no note. With note defaulting to "" and written
unconditionally — which is what slice 1 did, correctly, when only one party wrote these
rows — an operator ticking a card the customer had just commented on would silently erase
the comment. That is the one piece of evidence in this feature that cannot be
reconstructed, and it is a regression test rather than a note here.
Every tick records checked_by_kind, the column slice 1 shipped unused, so the operator's
page can say whose it was. "We showed them" and "they did it themselves" are not the same
claim to take into a renewal conversation.
What the accessor may see
Two projections, both written for this audience:
share_view |
The lab link and its expiry. Drops share_id — the publish set's own id, which exists so this dashboard can revoke exactly that one later and means nothing to a customer. No password: revealing it is its own POST, audited under the same action as the operator's own reveal, because it is a second door onto a live credential |
wired_view |
Per VM: what it is, where it is on their own lab network, and which kinds of access exist — a brokered session, a vaulted credential, access on request. Never pra_jump_id, ps_managed_system_id, entitle_integration_id or wiring_error: those are ids inside a customer's own appliance, meaningful only to teardown, and an error message written for an operator |
Naming what goes in, rather than subtracting from api/pov._serialize, is the point.
Subtraction is how one of them comes back on the next edit to the serializer.
The customer's note is rendered back on the operator's page with x-text, never x-html:
it is prose typed by somebody outside the account.
Slice 4 — the page an SE actually clicks
Built, and it is the one that closes the complaint this document opens with.
Slices 1 to 3 built the per-POV checklist and put it a click deeper, on the POV's own page. The nav's Use cases page was left exactly as it was — on purpose, with a test pinning that it must not change — so it went on opening to twenty-six greyed-out cards saying "Not available on a POV instance". The wall survived four merges because the fix had been built somewhere else.
So on a POV instance that page now leads with a POV: pick one from the selector, get its checklist. Measured on the same instance, that is 26 masked cards leading the page before and 25 runnable ones after.
Collapsed turned out not to be enough. The first build of this slice left the instance-wide
catalog underneath the lead, collapsed behind one click. Every group and every card was still
rendered — still in the DOM, still in find-in-page, and still one stray click from being the
wall the lead exists to replace, on the page whose whole job is to stop being that wall. So
on a POV instance the instance-wide catalog is not rendered at all: x-if, not x-show,
because hidden is a claim about CSS and this is a claim about the DOM.
Which axis does that is the part worth reading twice, because it looks like the rule this page
rests on. A persona may reorder and emphasise and never subtract — unchanged, and still
pinned on the estate profile: the groups assignment may not .filter(. This is the
profile choosing which of two catalogs the page is about. The instance-wide one answers "can this instance run it?", which
a POV page has no use for; the per-POV one answers "can I run this on this POV?". An estate
instance renders the first, complete and unfiltered, exactly as it did before; a POV instance
renders the second, and nothing else.
One format, three surfaces
The checklist is rendered in one dense, BeyondTrust-branded format everywhere it appears: sections with an uppercase group header and a right-floated count, and one row per card with the title and its summary on a single line, separated by an em-dash. It replaced a three-column grid of tall cards, which was correct and which nobody scanned mid-call — a POV carries 32 product cards plus up to 14 runbook ones.
| Surface | Who writes | The tick |
|---|---|---|
/use-cases, the POV lead |
nobody | a static ✓ / – mark and the word for it |
/pov/<id>#use-cases |
the SE | a checkbox, and the whole row is a click target |
| the accessor's page | the customer | a checkbox, plus an always-visible note box |
The read-only surface gets a mark, not a checkbox. It performs no writes (pinned), and a checkbox there would be a control that looks live and records nothing — worse than no control, because a tick somebody believed is a card they will not demo again. For the same reason the mark is blank rather than an empty box outline: an outlined square that does nothing when clicked is a control.
out_of_scope cards are not drawn on any of the three, and a group left with nothing in
it renders no heading either. All three go through an inScope() helper with the same name
and the same one-line body, pinned by a test, because the failure mode is one surface being
edited and the others not.
The SE's page used to be the exception: it rendered those cards in a dashed section at the foot of each group, headed not part of this POV, on the reasoning that a scoping decision is worth reading and a card an SE demoed anyway is still a card they demoed. In practice a POV scoped to one product put two whole products' worth of un-tickable rows — most of the page — between the SE and the handful of cards they were there for, under a heading that disagreed with the in-scope denominator printed directly above it. Which products an evaluation includes is now stated once, on the Overview tab, where the tenants that decide it already live.
The stylesheet is templates/_checklist_styles.html
— one definition, reached through a new {% block head %} in base.html by the two surfaces
that extend the shell, and by a direct {% include %} from the accessor page, which does not
extend it and must not start. Three copies of a palette would be three chances for the
customer-facing surface to drift, and two of the three files are never open at the same time.
Every selector is prefixed chk-, and no element carrying one also carries a Tailwind utility.
No backend. The lead reads /api/pov/managed and the same /api/pov/managed/{id}/use-cases
the POV's own page reads, so the two cannot disagree about what a POV can run, and every way
that read can fail — an estate instance 404s the router, an expired session 401s — is a
non-event rather than an error on a page whose main content loaded fine. It performs no
writes: ticking a card off stays on the POV's own page, where every other action on that POV
already lives.
Slice 5 — what a POV leaves behind
Built. The record was never the problem: run_env_destroy marks the row destroyed rather
than deleting it, and pov_use_cases.destroy_note deliberately keeps the checklist as that
record's contents. What was missing is that nothing could reach it. api/pov.list_managed
filters destroyed rows out — right for a list of things you can act on — so a finished
evaluation appeared nowhere and you needed its raw uuid to see it again. Evidence that
survived teardown with no way back to it is the same as losing it at the moment somebody
wants it, which is a renewal conversation weeks later.
Two questions, two shapes
services/pov_summary answers them separately because they are separately asked:
archive(db, limit) |
"Which evaluations have we run?" A light row per POV, and deliberately not _serialize: that builds five describes per row — gateway, resource broker, wire-up, share, accessors — each asking a question about a living environment, every one meaningless and a wasted query for a POV that is gone. Coverage comes from one aggregate over the progress rows rather than resolving the whole catalog per row. It says when it truncated, because a list silently cut is one an SE trusts and should not |
build(db, env) |
"What happened in that one?" The whole account: coverage per group, every card somebody touched, and what they said. Groups with nothing in scope are dropped, so on a scoped POV that breakdown is the product summary — the rows left standing are what the customer bought |
/api/pov/managed/archive is declared before /managed/{env_id} — the third time this
repo has met that trap, and pinned for the third time. Below it, "archive" is captured as an
environment id and the endpoint answers "No such POV environment".
/summary has no status filter, deliberately: it is the endpoint written for after a POV is
over, and filtering the finished ones out would leave it describing only the evaluations
whose story is not finished being told.
The claim the data supports
"They took part" means they ticked something. A login issued and a login used are two
different facts, and only the second is evidence. took_part is computed from progress rows
carrying checked_by_kind='accessor' — never from the existence of a PovAccessor — and the
archive table reports the two separately: "2 ticked by them" against "1 login, unused".
Conflating them would put a claim in a renewal conversation that the data does not support.
Only cards somebody touched are in the summary. A summary listing every untouched card would be the catalog again, and the catalog is not what happened. Roles with nothing in scope are dropped from the breakdown for the same reason — a Password-Safe-only evaluation has several, and "0 of 0" against each is noise in a document read once.
Nothing here writes or reaches the network
A summary that made platform calls would fail for exactly the POVs it exists to describe, whose environment is gone. Products are read off the row — the only thing that can work for a destroyed POV, and the honest question for a live one, since what an evaluation covered was decided when its tenants were chosen.
The take-away is copied, not downloaded: what an SE does with this is paste it into a renewal note or a CRM, and a file on disk is one more step to the same place. The markdown is built in the browser from what the page already loaded.