Two rulings from Pouya, 2026-09-02.
(1) The section stays generic — "it over-explains technical mechanics that
belong in the evidence file, not in front of an inquirer." Deleted: the
measurement paragraph, the root-credential sentence, the SSO/federated-login
enumeration, the resource-policy clause, the "company that runs a database"
aside, the deploy-credential sentence and the three-copies summary. All of it
stays true and stays measured in AGENTS.md §7 and the evidence file, which now
maps each shipped sentence to what it rests on.
(2) The consent string names the corporation: "I consent to SML Company Ltd
storing and using the information in this form…". docs/05 §Consent text moves
with it, proven byte-identical. Two new §4 rows carry the attestations the copy
rests on.
(3) The §Who can see it approval closes via the page read-through, which is now
blocker 2 in docs/06's callout rather than a checklist line.
Q64 closes MOOT — the paragraph it was about was deleted, so it gates nothing.
The underlying gap is unchanged: §7 records root as held by Pouya, not held only
by Pouya, and nothing about root custody may be published without asking again.
Two sentences were added back under review: the shared-account disclosure, to
§Where it is stored (a storage disclosure, never named in the ruling — without
it no page said the intake sits in a shared account), and one naming SML Company
Ltd in the policy, because a consent naming a company the linked policy never
mentions is an accountability gap.
adversarial-reviewer, two rounds, 14 findings, all resolved, none declined;
nine of round 2's ten were defects in round 1's own repairs. claims-auditor
correctly deferred to cutover per D20.
Gates, exit status read: check 0 · build 0 (23 pages) · check:claims 0
(12 patterns, 33 approved strings) · check:intake 0 · og:proof 0 · lint 0 ·
lighthouse 0, worst of 23 99/100/100/100. Tripwire proven both ways — exit 0 on
the revised page, exit 1 with 5 matches on the bd282aa bytes. Regex untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
240 lines
9.5 KiB
TypeScript
240 lines
9.5 KiB
TypeScript
/**
|
||
* The intake form's fields. Spec: docs/05-backend-spec.md §Form fields.
|
||
*
|
||
* ⚠️ **THE LAMBDA HAS ITS OWN COPY OF THIS TABLE, AND THAT DUPLICATION IS
|
||
* DELIBERATE — IT IS NOT THE SES-DKIM SHAPE.** `docs/05` is explicit: *"Client
|
||
* side validation is a convenience. The Lambda re-validates everything."* A
|
||
* server that validates against a list the client shipped it is not validating;
|
||
* it is asking the attacker what the rules are. So `backend/intake/handler.mjs`
|
||
* carries an independent table and trusts nothing from here.
|
||
*
|
||
* What stops the two drifting is a check rather than a shared import:
|
||
* **`npm run check:intake`** asserts that the two tables agree on every field
|
||
* name, on which are required, and on every length cap — and fails the build
|
||
* script if they do not. Independent validation, mechanically cross-checked. If
|
||
* you add a field here, add it there, and the check will tell you if you didn't.
|
||
*
|
||
* WHAT THIS DATA IS, because it changes how the form is built (`docs/05`): in a
|
||
* live legal dispute this collects the inquirer's identity, **the names of
|
||
* opposing parties and their counsel**, and the nature of the dispute. That is
|
||
* personal information about identifiable third parties who have not consented
|
||
* and do not know the submission happened. Hence: no dollar amounts, no
|
||
* uploads, an explicit unchecked consent box, and a matter summary whose hint
|
||
* tells the writer not to put privileged detail in it.
|
||
*/
|
||
|
||
export type IntakeField = {
|
||
name: string;
|
||
label: string;
|
||
/** `select` and `radio` carry `options`; everything else does not. */
|
||
type: 'text' | 'email' | 'tel' | 'select' | 'radio' | 'textarea' | 'checkbox';
|
||
required: boolean;
|
||
/** Maximum characters. The Lambda REJECTS over this rather than truncating —
|
||
* a silently truncated matter summary is a misread file. */
|
||
max?: number;
|
||
options?: readonly string[];
|
||
/** Rendered under the field. */
|
||
hint?: string;
|
||
/** `autocomplete` token, where one genuinely applies. Omitted rather than
|
||
* guessed: a wrong token makes a browser fill the wrong value. */
|
||
autocomplete?: string;
|
||
};
|
||
|
||
/**
|
||
* ⚠️ **DO NOT ADD A DOLLAR-AMOUNT FIELD.** `docs/05`: *"Do not collect dollar
|
||
* amounts, document uploads, or anything the inquirer might reasonably treat as
|
||
* privileged. The intake call is for that."* The old site invented matter values;
|
||
* this form is the one place a real one could arrive and then need storing.
|
||
*/
|
||
export const INTAKE_FIELDS: readonly IntakeField[] = [
|
||
{
|
||
name: 'name',
|
||
label: 'Your name',
|
||
type: 'text',
|
||
required: true,
|
||
max: 120,
|
||
autocomplete: 'name',
|
||
},
|
||
{
|
||
name: 'email',
|
||
label: 'Email',
|
||
type: 'email',
|
||
required: true,
|
||
max: 254, // RFC 5321 maximum path length; not a round number by choice.
|
||
autocomplete: 'email',
|
||
},
|
||
{
|
||
name: 'phone',
|
||
label: 'Phone',
|
||
type: 'tel',
|
||
required: false,
|
||
max: 40,
|
||
autocomplete: 'tel',
|
||
hint: 'Optional.',
|
||
},
|
||
{
|
||
name: 'role',
|
||
label: 'Your role',
|
||
type: 'select',
|
||
required: true,
|
||
options: ['Counsel', 'In-house', 'Party', 'Institution', 'Other'],
|
||
},
|
||
{
|
||
name: 'organisation',
|
||
label: 'Firm or organisation',
|
||
type: 'text',
|
||
required: false,
|
||
max: 160,
|
||
autocomplete: 'organization',
|
||
},
|
||
{
|
||
name: 'process',
|
||
label: 'Process sought',
|
||
type: 'select',
|
||
required: true,
|
||
/* The five from docs/05. "ENE" is expanded here because this is a form label
|
||
read by a party as well as by counsel, and §11's glossary authority is
|
||
about site copy rather than about abbreviating in a select. */
|
||
options: [
|
||
'Mediation',
|
||
'Arbitration',
|
||
'Med-Arb',
|
||
'Early neutral evaluation',
|
||
'Not sure',
|
||
],
|
||
},
|
||
{
|
||
name: 'practiceArea',
|
||
label: 'Subject matter',
|
||
type: 'select',
|
||
required: true,
|
||
/* THE SIX AREAS PLUS OTHER. Deliberately the short display names rather
|
||
than `PRACTICE_AREAS[].name`: those carry the full "Construction &
|
||
Infrastructure" form for a card heading, and a select is not a card. The
|
||
cross-check in `scripts/check-intake.mjs` compares these against the
|
||
handler's list, and `PRACTICE_SLUGS` remains the site's own source for
|
||
which areas exist. */
|
||
options: [
|
||
'Construction',
|
||
'Technology',
|
||
'Energy',
|
||
'Insurance',
|
||
'Shareholder',
|
||
'Cross-border',
|
||
'Other',
|
||
],
|
||
},
|
||
{
|
||
name: 'otherParties',
|
||
label: 'Other parties',
|
||
type: 'text',
|
||
required: false,
|
||
max: 300,
|
||
hint: 'Needed to run a conflicts check. Names only.',
|
||
},
|
||
{
|
||
name: 'opposingCounsel',
|
||
label: 'Opposing counsel',
|
||
type: 'text',
|
||
required: false,
|
||
max: 300,
|
||
hint: 'Also for the conflicts check.',
|
||
},
|
||
{
|
||
name: 'summary',
|
||
label: 'What the dispute is about',
|
||
type: 'textarea',
|
||
required: true,
|
||
max: 2000,
|
||
hint: 'A few sentences is enough. Please do not include privileged or confidential detail — that is what the intake call is for.',
|
||
},
|
||
{
|
||
name: 'timing',
|
||
label: 'Timing',
|
||
type: 'select',
|
||
required: false,
|
||
options: ['Urgent', 'Within 30 days', 'Within 90 days', 'Exploring'],
|
||
},
|
||
{
|
||
name: 'preferredContact',
|
||
label: 'Preferred reply',
|
||
type: 'radio',
|
||
required: false,
|
||
options: ['Email', 'Phone'],
|
||
},
|
||
];
|
||
|
||
/**
|
||
* THE CONSENT TEXT, VERBATIM FROM `docs/05` §Consent text. It is a legal notice
|
||
* the inquirer agrees to, so it is rendered from here and never retyped or
|
||
* reworded to fit a layout. Note that it says the same three things
|
||
* `NO_RETAINER_NOTICE` says — that constant is the site-wide statement and this
|
||
* is the one the inquirer ticks; both ship on `/contact/`, which is deliberate:
|
||
* `docs/01` requires the page to carry the notice, and `docs/05` requires the
|
||
* checkbox to carry it too.
|
||
*
|
||
* ⚠️ **IT NAMES SML COMPANY LTD — Pouya's ruling, 2026-09-02 — AND THREE
|
||
* CONSTRAINTS RIDE ON THAT.** **Name only, no terminal period**, and never
|
||
* beside the licence-status row (`AGENTS.md` §4). **`docs/05` §Consent text is a
|
||
* byte-identical second copy with no `check:` script over it**, so it moves with
|
||
* this string. And **`/legal/privacy/` must keep naming the same party** — it
|
||
* does, under §Why it is collected; a consent naming a company the linked policy
|
||
* never mentions is an accountability gap, not a matter of voice.
|
||
*/
|
||
export const CONSENT_TEXT =
|
||
'I consent to SML Company Ltd storing and using the information in this form ' +
|
||
'to respond to my inquiry and to run a conflicts check. I understand that ' +
|
||
'submitting this form does not create a retainer, does not appoint a neutral, ' +
|
||
'and does not itself establish a mediator–party relationship.';
|
||
|
||
/**
|
||
* The honeypot. `docs/05`: *"hidden from sighted and screen-reader users, must
|
||
* be empty"*.
|
||
*
|
||
* ⚠️ **`display: none` PLUS `tabindex="-1"` PLUS `aria-hidden`, AND THE NAME
|
||
* MATTERS.** A honeypot named `honeypot` is skipped by any bot worth stopping;
|
||
* one named like a real field is filled. `company_website` is a plausible field
|
||
* on a professional intake form and is not one this form has. It must never be
|
||
* reachable by keyboard or announced by a screen reader — a honeypot that traps
|
||
* a screen-reader user is an accessibility defect that also loses a real inquiry.
|
||
*/
|
||
export const HONEYPOT_FIELD = 'company_website';
|
||
|
||
/**
|
||
* WHERE THE FORM POSTS — AND IT IS A SAME-ORIGIN PATH, NOT THE API GATEWAY
|
||
* HOSTNAME. This is a design decision with four consequences, taken at step 8
|
||
* and recorded because the obvious implementation is the other one.
|
||
*
|
||
* The obvious version posts to the execute-api hostname `AGENTS.md` §7 records.
|
||
* Posting to `/api/intake` instead, with a CloudFront behaviour routing `/api/*`
|
||
* to that origin:
|
||
*
|
||
* 1. **`Content-Security-Policy: form-action 'self'`** — `docs/05` specifies
|
||
* `form-action 'self' <api-endpoint>`; with a same-origin post the second
|
||
* term is unnecessary, so the policy is strictly tighter.
|
||
* 2. **No cross-origin POST at all**, so no CORS question for the form. (CORS
|
||
* never governed it anyway — a form POST is a top-level navigation, not an
|
||
* XHR, so it is exempt from preflight. `docs/05`'s CORS line protects the
|
||
* endpoint against scripted calls from other origins, which is a different
|
||
* control, and the handler's `Origin` check is what covers the form.)
|
||
* 3. **The endpoint id stays out of the HTML.** ⚠️ It is NOT true that §7 is
|
||
* the only place it lives, and this bullet said so: `.env.example` still
|
||
* sets `PUBLIC_INTAKE_ENDPOINT` to the full execute-api hostname. That
|
||
* variable is now read by nothing, so the line is dead as well as
|
||
* duplicative. It is not edited here because this environment denies read
|
||
* access to `.env.example`, and nothing may edit a file it cannot read —
|
||
* it is in the batched list for Pouya instead. Found by
|
||
* `adversarial-reviewer` round 2, against an unscoped sweep.
|
||
* 4. **Submitting locally does nothing.** `astro dev` has no `/api/` route, so
|
||
* a POST 404s. Under the alternative, clicking Submit on a laptop would
|
||
* write a real DynamoDB record and send two real emails.
|
||
*
|
||
* ⚠️ **THE COST, STATED RATHER THAN LEFT TO BE DISCOVERED: THE FORM DOES NOT
|
||
* WORK UNTIL THAT CLOUDFRONT BEHAVIOUR EXISTS AND THE HANDLER IS DEPLOYED.**
|
||
* Neither has been done — nothing on this project deploys before cutover (D11),
|
||
* and both are checklist items in `docs/06`. Until then the page is complete and
|
||
* the pipe behind it is not, which is why `/contact/` also publishes the email
|
||
* address rather than treating the form as the only way in.
|
||
*/
|
||
export const INTAKE_ACTION = '/api/intake';
|