OWASP ASVS L2 — Scope Statement: Forms Portal Approval Workflow¶
Classification: CONFIDENTIAL — Internal Use Only Document:
security/asvs-scope-forms-approval.md· v1.1 · 2026-08-18 · GPUS-IT Status: SCOPE ONLY. This is not an assessment, not a gap analysis, and not a remediation plan. It defines the boundary those documents will work inside.
1. What this document is, and why it exists now¶
This scope statement is written before three of its four endpoints exist. That is deliberate, and it is the point of the document rather than a caveat on it.
The travel approval workflow introduces the first place in the forms portal where one member of staff reads another's submitted personal data, and the first place where a recorded decision carries financial weight. Those two properties are what pull the workflow into ASVS L2 territory. Writing the boundary now means the endpoints are built against a stated security contract instead of being assessed against one afterwards, when the cost of a structural change is a migration rather than an edit.
It also exists to declare known architectural facts up front. §4 states seven of them plainly. Each is true today, most are estate-wide and predate this workflow, and every one of them would otherwise be discovered by an assessor and written up as a finding. A fact an assessor discovers is a finding; a fact the system owner declares with its consequence is a scope boundary. The difference is not cosmetic — it determines whether the assessment spends its effort on the decisions that were actually made, or on rediscovering the context those decisions were made in.
The prior art here is direct. VLN-011 documented a control that returned success while doing nothing for 37 submissions, and the reason nobody caught it was that every check an operator would think to run came back green. Several of the facts in §4 describe conditions with that same shape: a mechanism that is present, that looks like a control, and that is not one. Naming them here is the cheapest available mitigation.
2. In scope — four endpoints¶
The assessment covers four API endpoints.
Status, 2026-08-19. §2.1, §2.2, §2.6 (the approver list) and §2.7 (the submitter status view) are BUILT. §2.3 is SUPERSEDED by §2.7 and will not be built. §2.4 remains unbuilt and undesigned, which is what still blocks any edit-or-resubmit path — §2.7 is read-only for that reason. The prose in each subsection below states what the endpoint must be; where an endpoint now exists, it also names the module that implements it, and the as-built behaviour should be read against §4's facts rather than assumed from this text.
Neither built endpoint is deployed — the code is committed but not pushed, so no Cloud Build trigger has run. Assessment against the live service must wait for a deploy.
2.1 GET /api/submissions/<id>/approval-view — BUILT 2026-08-19¶
The approver's read of a submission awaiting their decision. It is the first non-submitter decrypt of submission PII anywhere in the portal, returning all thirteen fields of the travel request together with the decisions and comments recorded by prior steps.
Its authorization model is the security-critical property. Access is granted by
comparing the caller's Okta identity against
approval_steps.resolved_okta_email — the snapshot, frozen at dispatch —
and not against live membership in approval_role_members. The snapshot is
the authorization target by design; see fact D in §4 for the consequence, which
is that this endpoint does not honour a mid-flight membership change.
The snapshot is not, however, created at dispatch, and an assessment that
assumes it is will mis-read the control. resolved_* is written for every
step of the chain at persistence and merely re-written for the one step being
dispatched; a step at status PENDING already carries its approver's address.
A populated resolved_okta_email therefore means assigned, never may read.
What separates the two is status = 'DISPATCHED', ANDed with the address
comparison — see fact D and the corrected header of
014_approval_workflow.sql.
This endpoint decrypts. Everything the assessment says about key handling, audit, and least disclosure applies here first.
As built: routes_phase2.approval_view, authorized by
approval_access.resolve_approver_access — the single shared check, which is
the whole of the access control given fact A. Every call is logged and audited,
granted or denied. All authorization failures return one indistinguishable 403,
including for a submission that does not exist: the id identifies without
authorizing (fact C), so a 404/403 split would make the endpoint an oracle for
which UUIDs are live travel requests.
2.2 POST /api/submissions/<id>/approval-decision — BUILT 2026-08-19¶
The approver's decision: approve, return for revision, or decline. It writes
decided_at, decided_by_email and comment to the approval_steps row.
Non-repudiation is the product of this endpoint, not a property of it. A travel approval is a financial control; the record of who approved what, and when, is the entire reason the workflow exists rather than an email thread. That places the integrity and completeness of the write above its availability in the assessment's priority ordering.
As built: routes_phase2.approval_decision + approval_decide. The
decision, the voiding of remaining steps, the round transition and the audit
row are ONE transaction; the step UPDATE is guarded WHERE status =
'DISPATCHED', a compare-and-swap rather than a check-then-act, so two
concurrent decisions cannot both land. dispatch_step runs after that commit,
never inside it. The audit row is written in the same transaction precisely
because of fact G — audit_log is the immutable half of the record and a
decision must not commit without it.
Known gap, deliberate: RETURN and DECLINE do not notify the submitter.
There is no submitter-facing path to notify them into while §2.3 is unbuilt,
and the response says so explicitly (submitter_notified: false) rather than
letting the SPA imply otherwise. Second gap: the audit row records that a
comment of a given length was recorded, not its text — audit_log is shipped
to Wazuh and an approver's free text may describe the trip, so the text stays
in approval_steps, which is mutable. Whether the comment text needs the same
immutability as the fact of the decision is an open audit-scope question.
decided_by_email is stored separately from resolved_okta_email on purpose,
and the two must be independently queryable. Divergence between them means
somebody other than the assigned approver recorded a decision — an
authorization defect — and the schema is shaped so that this remains detectable
after the fact rather than being collapsed into a single field at write time.
2.3 GET /api/submissions/<id>/revision-view — SUPERSEDED by §2.7¶
Not built, and will not be. This endpoint was specified as the submitter's
read of their own submission while the approval record is in
RETURNED_FOR_REVISION. What was built instead is §2.7, the same read with the
state gate removed and a wider purpose: where the request is, at any state.
§2.7 is a strict superset — every call §2.3 would have authorized, §2.7 authorizes. The state gate is the whole of the difference, and B-revised in §4 records why it was dropped. The original text is kept below because the reasoning it contains about the state gate is what B-revised argues against, and an assessor should be able to see both.
The submitter's read of their own submission, available only while the
approval record is in state RETURNED_FOR_REVISION. It is a decrypt, but of
the caller's own data, which makes it a materially lighter disclosure question
than §2.1. Its sensitivity is in the state gate: the same call outside that
state must fail, because the no-read-after-submit policy (fact B) otherwise
stands.
Inventory registration
The notification transport in scope here is gpus_forms_approval_notify
(topic gpus-forms-approval-notify) in inventory.yaml under
cloud_services, with gpus_forms_approval_notify_sub,
gpus_forms_approval_notify_dlq and gpus_forms_approval_notify_dlq_sub.
Registered 2026-08-26. The pointer-only payload rule below is the reason
those entries record what the message may not carry.
2.4 POST /api/submissions/<id>/revise — NOT BUILT, PROVISIONAL¶
Creates a new submission linked to the returned one, forming a revision chain.
This endpoint's design is not settled and is explicitly provisional in this
scope. The open questions are consequential enough that assessing it now
would assess a guess: whether a revision is a new submissions row or a new
version on the existing approval record; whether prior steps are re-dispatched
or preserved; how the retention clock (retention_days: 2555) applies across a
chain; and what the durable identifier is for a request that exists as three
rows. Until those are answered, the assessment should record it as in-scope but
undesigned rather than assess it against assumptions this document does not
make.
SUPERSEDED 2026-08-26 — the questions are answered and the path is BUILT
Kept in place rather than rewritten, per this document's own convention for §2.5. Everything above was true when written and every open question it lists has since been settled by migration 022 (2026-08-25):
| Open question above | Answer |
|---|---|
New submissions row, or a new version on the existing round? |
A new row, carrying submissions.parent_submission_id (Option A). Every submission still has one round at version 1. |
| Are prior steps re-dispatched or preserved? | The revision gets a fresh chain that restarts at step 1; the parent's steps are preserved on the parent, which stays RETURNED_FOR_REVISION. |
| How does the retention clock apply across a chain? | Each row is its own submission with its own clock. No chain-level retention concept was introduced. |
| What is the durable identifier for a request that exists as three rows? | There isn't one, by decision. Each row is durably identified by its own submission_id; supersession is derived from the existence of a child row, with no stored flag. |
The endpoint was never built in the shape named in the heading. There is
no POST /api/submissions/<id>/revise. The path is
GET /api/submissions/<id>/revision-source for the prefill, and the
ordinary POST /api/submissions carrying parent_submission_id, with
revision_access.resolve_revision_access run on both — owner, round
state RETURNED_FOR_REVISION, and not-already-revised.
This heading's staleness has already cost something. It is cited by
SubmissionStatus.tsx as the reason the portal offered no revise control,
and that reasoning outlived it by a day: the page went on telling returned
travellers the request "cannot be edited" while the path was live and
unreachable. See
VLN-047 — the surfaces that cite a provisional
section are part of what has to be revisited when it stops being
provisional.
2.5 Supporting components — BUILT and verified¶
Three components exist and are verified against the live system. None is wired into any request path. They are in scope because the endpoints above will depend on them, and because their properties constrain what those endpoints can do.
| Component | State |
|---|---|
| Migrations 014 + 015 | Applied. Four tables, RLS enabled on all four, forms_app grants, composite FK from approval_steps to its parent round. Verified by information_schema / pg_constraint readback, not by the apply succeeding. |
approval_resolver.py |
470198c, amended 653da8e. 13/13 tests. Resolves the chain and writes nothing. |
approval_dispatch.py |
49d0f49. 15/15 tests. Dispatches one step and sends its notification. |
Superseded 2026-08-19. The paragraph that stood here said the resolver and
dispatcher were unreferenced by any route and that no execution path ran from
an HTTP request to either module. That is no longer true: finalize_submission
calls _run_travel_approval, which persists the chain and dispatches its first
step, and approval_decision calls dispatch_step after each APPROVE. Both
modules are now on live request paths.
2.6 GET /api/approvals — BUILT 2026-08-19¶
Not in the original four. It is the approver's queue: which chains are waiting, on whom, and for how long. It is in scope because it is a new read surface with its own scope rule, not because it discloses content — it returns chain state only, no field values and no decrypt, and the module that builds it imports no crypto and names no ciphertext column (asserted by test, mirroring the routing worker's renderer).
Two scope rules, and the second is the one to assess. Every authenticated
caller sees chains with a step dispatched to them plus chains they have decided
a step on. An active member of role_key = 'travel_coordinator'
(approval_role_members, seeded by migration 017) additionally sees every
IN_APPROVAL chain.
Coordinator scope is deliberately not an exemption from §2.1. A coordinator
sees that a chain exists and where it is stuck; opening any one of them runs
resolve_approver_access with no coordinator branch and returns the same
generic 403 to a coordinator who is not the assigned approver. The asymmetry —
seeing that a request exists is not seeing what it says — is what makes the
wider list scope acceptable, and it is the property to test.
The role is deliberately not FORMS_ADMIN_EMAILS, which grants the global
admin role across every form in the estate plus the audit log.
travel_coordinator appears in no chain configuration, so no step can ever
resolve to it: read scope only, structurally.
2.7 GET /api/submissions/<id>/status — BUILT 2026-08-19¶
The submitter's read of their own request: the round state, every step with its label, approver display name, status and decision time, the comment on returned and declined steps, and the request's own field values. Read only — there is no edit and no resubmit, because the revision path (§2.4) is still undesigned.
Replaces §2.3. It is the same read with the state gate removed; see B-revised in §4 for why, and for the fact that this is the portal's second decrypt path.
Its authorization is a THIRD rule, and that is the property to assess.
approval_access.resolve_submitter_access lives beside
resolve_approver_access in the same module but is a separate function with a
separate return type. The two answer different questions — "is this caller the
subject of this record" versus "may this caller act on this step" — and were
deliberately not folded together, because one function returning either a
submission or a step, with a reason vocabulary half of which is meaningless to
each caller, is how a caller ends up acting on the wrong half.
The rule is two conditions and no more: a non-empty token email, and
submissions.submitter_email equal to it after case and whitespace
normalization on both sides. No state condition, no admin bypass, no role
check. An admin reading someone else's submission is a different act with a
different justification and belongs at /api/admin/submissions.
Comment visibility is narrower than the step list suggests: only RETURNED and
DECLINED steps surface their comment. An approve-with-a-note is an aside
between approvers, and the submitter-outcome email makes the same distinction,
so the page and the mail cannot tell the traveller different stories.
The uniform 403 applies unchanged — "not yours" and "no such submission" are
indistinguishable, on the same enumeration-oracle reasoning as §2.1, which does
not weaken because the reader is a submitter. purged answers 410 rather than
403: the caller is authorized and the content has passed retention, and
hiding that behind an authorization error would tell them they had lost access
when they had not.
3. Out of scope¶
The travel request form itself. Live and verified against the database on
2026-08-18: thirteen fields, one email_template action, five pulldowns
resolving, template travelrequest001 bound. Its field-level classification —
in particular which fields are searchable and therefore held in plaintext in
submissions.searchable_values — is settled and assessed elsewhere. The
approval workflow consumes that classification; it does not set it.
The twenty-four other forms in the estate. They share the schema, the RLS posture and the routing worker, so facts A, E and F in §4 apply to them equally. They have no approval workflow, and nothing in this assessment changes their behaviour.
Okta configuration and application provisioning. The dedicated forms
application (0oazce9jd5inTjtrn417, cut over 2026-08-05) is administered
outside this repository. Its group claims, redirect URI registration and
lifecycle are the identity provider's assessment, not this one. This scope
depends on Okta correctly asserting an identity; it does not assess whether it
does.
HappyFox. The ticketing system is a downstream consumer reached by email ingest. Fact F states the consequence for this workflow.
The MAPLE Postfix relay and the Gmail path beyond it. Notification mail is
handed to localhost:25 and leaves the assessment boundary there. Fact E
states the consequence.
Reporting (R1–R4). Not yet built. Approval-state reporting will need its own scope once designed, because it reads across submissions and approvers and therefore has a disclosure profile unlike any of the four endpoints above.
4. Declared facts¶
Each of the following is true of the system as it stands. They are stated here as facts with their consequences, not as findings, because they are known, deliberate where noted, and in most cases pre-existing and estate-wide.
A. Row-level security is not a control layer here¶
relforcerowsecurity is f on every table in the forms schema, including the
four added by migration 014. In PostgreSQL, that means the table owner bypasses
row-level security entirely. The owner of every table in public is
maple-agent@gpus-infra.iam — which is also the identity the 2.5(c) routing
worker runs under.
The policies created by 002_rls.sql and by 014 are scoped TO forms_app.
They constrain the application role and nothing else.
Consequence: application-layer authorization is the sole access control on
all four endpoints in §2. RLS provides defence in depth for the forms_app
path and provides nothing against the owner. An assessment that credits RLS as
a second layer for these endpoints would be crediting a control that is not
engaged. This condition is estate-wide, predates the approval workflow, and is
not introduced by it — the four new tables were deliberately built to match the
existing posture rather than diverge from it.
B. Two documented exceptions to the no-read-after-submit policy¶
forms-api-contract.md:271 states that submitters cannot list, view, edit or
delete submissions after submitting — fire-and-forget. That rule is load-bearing
and is referenced in code at routes_phase2.py:559-561, where a previous
design decision was scoped out specifically because relaxing the rule would
have been an authorization change.
This workflow introduces exactly two exceptions:
- An approver may read a submission on which they hold a
DISPATCHEDstep (§2.1). State-gated, on the step's status and the round's. - A submitter may read their own submission (§2.7). Not state-gated — see the revision below.
Consequence: the policy otherwise stands unchanged, and these two are the complete set. Any third read path discovered during assessment is a defect, not an undocumented feature.
B-revised, 2026-08-19 — exception 2 is no longer state-gated¶
This section previously read that exception 2 was "a submitter may read their
own submission while its approval record is in RETURNED_FOR_REVISION",
and concluded that "both exceptions are state-gated". That gate was designed
and then deliberately dropped when the endpoint was built as §2.7. The change
is recorded here rather than left for an assessor to find as a discrepancy
between this document and the code.
What changed: approval_access.resolve_submitter_access compares
submissions.submitter_email against the token email and stops. There is no
state condition. A submitter reads their own request in any state — in
approval, approved, returned, declined, blocked.
Why. The state gate was protecting the wrong thing. The no-read-after-
submit policy exists to stop submitters browsing other people's submissions
and editing their own after the fact; a read of one's own record does neither.
Gating it on RETURNED_FOR_REVISION meant the only person who could not find
out where a request had got to was the person who submitted it — which is the
condition that produced the operational failure this endpoint was built to end
(a submitter waiting on a chain stalled at step 2, with no way to see it and no
notification path). The gate bought no confidentiality and cost the subject of
the record all visibility of it.
Consequence for the assessment. Exception 1 remains state-gated and a state machine bug there is still an access control bug. Exception 2 is now gated on identity alone, which makes it a simpler control with a smaller failure surface — one string comparison, no dependence on the state machine — and moves its risk entirely onto the correctness of that comparison. Assess it there.
This is the portal's second decrypt path. §2.1 was the first. Both are
audited (submission_viewed + decrypt_success), both log every call granted
or denied, and both return one uniform 403 for every authorization failure. The
disclosure question is materially lighter here — the caller authored every
value — but the key-handling question is identical.
C. The approval link is not a bearer credential¶
Notification email contains https://forms.greenpeace.us/approvals/<submission_id>.
There is no token, no query parameter and no secret in that URL. The submission
id is an opaque UUID that identifies the record; it does not authorize access
to it.
The page is gated by Okta (application 0oazce9jd5inTjtrn417). A forwarded
link presents the recipient with an authentication challenge, and on
authenticating as someone other than the assigned approver, fails the identity
comparison described in §2.1.
This is structural, not compensating. The link cannot be redeemed by possession because possession confers nothing; the authorization decision is made server-side against the caller's asserted identity every time.
This is stated explicitly because an approval link arriving by email reads as a
finding on sight, and because the estate contains a genuine counter-example
that invites the comparison: the signed GCS attachment URLs appended by
routing_worker.py:472-476 are bearer capabilities, where the URL is the
credential and possession is authorization. The approval link is a different
mechanism with a different property, and the distinction should be recorded
before it is mistaken.
D. Snapshot authorization does not revoke¶
Authorization for §2.1 and §2.2 compares against approval_steps.resolved_okta_email,
frozen at dispatch. Removing or deactivating a member in
approval_role_members does not revoke access to a step that has already
been dispatched to them.
Precisely, the column has two write points and then freezes:
approval_persist.insert_round_and_steps writes it for every step when the
chain is persisted, approval_dispatch.write_snapshot_dispatched re-writes it
for the dispatched step from membership as it stands at that moment, and
nothing rewrites it afterwards. The freeze is therefore per step, not per
chain: a membership change lands on steps not yet dispatched and misses the
one that is. It also means a PENDING step already carries an address, so the
address alone can never be the authorization test — status is the other half,
and approval_access.resolve_access_core requires both.
This is deliberate. The alternative — authorizing against live membership — silently strands every in-flight approval the moment a role changes, and does so without any signal that it has happened. Stranded approvals are invisible; a revoked one is not.
Consequence: revocation is an administrative re-dispatch, which voids the outstanding step and issues a new one, and is a recorded event. The assessment should evaluate whether that path exists and is auditable, rather than evaluating the snapshot itself as a defect.
E. Notification delivery is not observable¶
approval_steps.notification_sent_at records that smtplib returned without
raising after handing a message to localhost:25 on MAPLE. It means the local
relay accepted the message. It does not mean the message was delivered, and it
does not mean it was read.
Bounces, rejections downstream of the relay, and silent drops are not detectable from the portal. There is no delivery receipt path back into the database.
Consequence: a stamped notification_sent_at is evidence of handoff, not of
notification. Any control that depends on an approver having been told —
an escalation timer, a stale-approval alert — must not be built on this column
alone. This is the same class of gap VLN-011 documents at §11.1 for HappyFox
ticket ids, and the same reasoning applies: absence of an error is not evidence
of delivery.
F. The portal never learns the HappyFox ticket number¶
On the email-ingest path, submissions.happyfox_ticket_id remains NULL
permanently. HappyFox mints a ticket number after ingest, in its own system,
and never reports it back down the SMTP path. No database query can answer
whether a given submission opened a ticket.
Consequence: the portal's own record id is the durable identifier for a travel request and for its approval chain. Cross-referencing to a HappyFox ticket is a manual, out-of-band operation. Any assessment finding, audit trail or user-facing reference must key on the submission UUID.
G. Approval decisions carry no immutability constraint¶
approval_steps holds the decision record — decided_by_email, decided_at,
comment and status. The table carries no immutability constraint of any
kind, and forms_app holds UPDATE on it.
audit_log is the deliberate contrast. It is protected twice: 001_init.sql:293
revokes UPDATE, DELETE from PUBLIC, and 002_rls.sql:74 revokes them again
from forms_app, forms_admin, forms_viewer and forms_readonly.
014_approval_workflow.sql grants SELECT, INSERT, UPDATE on approval_steps
to forms_app and issues no corresponding revoke; neither does
015_approval_steps_parent_fk.sql. No REVOKE statement naming
approval_steps exists anywhere in forms-backend/schema/.
Verified by live grant read against gpus_forms on 2026-08-18, via
information_schema.table_privileges and has_table_privilege():
| Table | forms_app privileges |
UPDATE |
DELETE |
|---|---|---|---|
approval_steps |
SELECT, INSERT, UPDATE | t |
f |
audit_log |
SELECT, INSERT | f |
f |
Two points of precision the migration text alone would obscure. The asymmetry
is on UPDATE specifically — an approval decision can be rewritten, not
erased. And DELETE is absent from both tables for different reasons: on
audit_log by explicit revoke, on approval_steps merely because it was never
granted. The protections are not equivalent even where the effective privilege
matches today.
Consequence: §2.2 asserts non-repudiation as the endpoint's product, and that
property does not currently rest on the storage layer. It rests on
application discipline — the decision endpoint being the only code that writes
these columns — and on the access log that the approval-view path will produce.
Any identity holding forms_app can alter a recorded approval decision after
the fact, and the row itself will carry no evidence that it was altered. The
application role is the identity the Cloud Run backend runs as, so this is not
a hypothetical privilege held only by an operator.
This is unresolved, and remediation is undecided and out of scope for this
document. It is worth recording why the obvious fix is not obvious:
approval_steps has legitimate post-decision writes. notification_sent_at is
stamped in a separate transaction after dispatched_at, by design, so that a
send failure remains visible (fact E); and administrative re-dispatch, which is
how revocation happens at all (fact D), writes to rows that already carry a
status. A blanket REVOKE UPDATE would break both. The design question is
which columns are append-only and which remain mutable, and that question has
not been answered.
5. Applicable ASVS chapters¶
The following chapters are named as applicable, with the reason each applies to this workflow specifically. This section does not assess them. Verification requirement selection and evaluation belong to the assessment document that follows this one.
V1 — Architecture, Design and Threat Modelling. Applies because three of four endpoints do not exist. This is the chapter that governs whether a security contract was stated before implementation, and this document is the artefact V1 asks for. Facts A and D in §4 are architectural decisions with security consequences and belong to V1's evidence.
V3 — Session Management. Applies because authorization for every endpoint derives from an Okta OIDC session, and because fact C makes the session the only thing standing between a forwarded link and a submission's full contents. Token lifetime, storage and renewal are properties this workflow inherits rather than sets, but it inherits them into a higher-consequence context than any prior portal surface.
V4 — Access Control. The centre of gravity for this assessment. Fact A establishes that application-layer authorization is the sole control; §2.1 establishes that it is snapshot-based; fact B establishes that two of the controls are state-gated rather than role-gated. V4 is where the interaction of those three is evaluated, including the horizontal-access question that §2.1 raises for the first time in this portal: one authenticated user reading another's data.
V7 — Error Handling and Logging. Applies because §2.2's product is
non-repudiation. The decision record must be complete, attributable and
tamper-evident, and audit_log already carries REVOKE UPDATE, DELETE for
every application role — though fact G records that approval_steps, where
the decision itself lives, carries no such protection. Fact E belongs
here too: a log field that records less
than it appears to is a logging defect regardless of the transport underneath.
V8 — Data Protection. Applies because §2.1 and §2.3 decrypt. The portal's
AES-256-GCM envelope with per-submission KMS-wrapped keys is the mechanism, and
the questions V8 raises are about disclosure minimisation rather than
cryptography: which fields an approver needs, whether all thirteen are
justified, and how the plaintext mirror in submissions.searchable_values
interacts with a decrypt path. The notification email's deliberately minimal
body — which omits traveller name and destination precisely because they are
not available without a decrypt — is a V8-relevant design decision already
taken.
V13 — API and Web Service. Applies because all four are JSON APIs consumed by a single-page application and authorized by bearer token. The chapter covers the transport-level properties the other five depend on: verification of the token, its audience and issuer, and consistent enforcement across every route rather than on the ones that were remembered.
6. What must exist before assessment can proceed¶
The assessment cannot begin against three endpoints that have not been written. This scope is publishable now and useful now — it constrains the build — but the sequence is:
- This scope statement, agreed.
- §2.1–§2.3 implemented against it. §2.4 designed, then implemented or formally deferred.
- The approval workflow wired into a request path; today nothing is.
- Assessment against selected ASVS L2 verification requirements.
Facts A through G do not change at any step and should be carried forward verbatim into the assessment document.
7. References¶
- VLN-011 — HappyFox dispatch has never run — §11.1 for fact F, and the failure spine facts A and E share
- Forms API contract — §271, the no-read-after-submit policy that fact B narrows
- Forms field audit — §4 queue table, §5.12 the email-ingest standing constraint
- Forms Phase 2.5(c) design — routing worker as-built, signed-URL bearer capabilities (contrast for fact C)
forms-backend/schema/014_approval_workflow.sql,forms-backend/schema/015_approval_steps_parent_fk.sql— the schema and its applied/verified record- Forms DB access runbook — ownership and privilege model underlying fact A