Build a Harper Incident Hub

Build a local incident API with Harper Resources and automated validation tests.

Introduction

30 Second Summary

During an outage, every incident record shapes what the operations team handles first. Inconsistent severity or ownership can send responders toward the wrong work.

In this project, you will build a local incident-management API with Harper. You will validate its business rules with automated tests before packaging a customer handoff.

What You'll Build

You will run a smoke test against your validated incident API to see bad operational data rejected with clear HTTP evidence.

By the end of this project, you'll have:

  • A working REST endpoint that accepts valid incident records before returning them as JSON.
  • A repeatable smoke test that reproduces the initial data-quality gap before proving each custom rule with PASS evidence.
  • A customer handoff that connects the business outcome to the architecture, runbook, test evidence, known risks, and next steps.
  • Secret Mission: Add a lifecycle rule that blocks resolved incidents without a resolution summary.

Are there any prerequisites?

Basic familiarity with JavaScript, JSON, and terminal commands is enough. You also need Node.js 22+ plus VS Code or Cursor.

The project walks you through the local Harper 5.3.3 setup.

Before We Start

Before the hands-on work begins, define the customer outcome that guides every technical decision. You are building for an enterprise operations team that needs trustworthy incident data for prioritization and reporting.

Set Up Harper Locally

The customer needs delivery claims that another engineer can reproduce. A predictable local runtime gives every later API result a trustworthy starting point.

You already have Node.js available. This step verifies that its version meets the project requirement.

You'll install Harper 5.3.3 locally. A health check will prove that the runtime can accept requests.

In this step, get ready to:
  • Verify the existing Node.js and npm toolchain.
  • Install the pinned Harper runtime.
  • Complete Harper's local setup and verify its health.
Confirm and install the pinned runtime

Harper runs on supported Node.js releases. Your existing Node.js installation must report major version 22 or higher.

The npm command installs the Harper CLI globally. Verifying both tools prevents an incomplete installation from weakening the rest of the delivery.

  • Open an integrated terminal in VS Code or Cursor by pressing Cmd+` on macOS or Ctrl+` on Windows.
  • Check the existing toolchain by running these commands:
node -v
npm -v

What do these checks prove?

  • The first command shows which Node.js release your terminal uses.
  • The second command confirms that npm is available for the global Harper installation.

✔️ Node.js and npm are ready

Your Node.js output shows major version 22 or higher. Your npm output also shows a version number.

Good progress. Your existing toolchain is ready to install the customer runtime.

ⓧ npm does not return a version

Your Node.js installation needs a working copy of npm before Harper can be installed.

  • Open the official Node.js and npm installation guidance.
  • Return to the installer or version manager you used for Node.js.
  • Restore npm through that existing installation path.
  • Close the current terminal.
  • Create a fresh integrated terminal in your editor.
  • Repeat the two toolchain checks above.
  • Install the pinned Harper CLI globally by running this command:
npm install -g harper@5.3.3

What does this command do?

The -g flag makes the CLI available outside one project directory. The exact package reference harper@5.3.3 keeps every learner on the same Harper release.

  • Confirm the installed Harper version by running this command:
harper version

What does the version check show?

The command reads the globally installed Harper CLI version. The expected result for this project is 5.3.3.

✔️ I see version 5.3.3

That's the pinned runtime installed. Your local delivery now uses the same Harper version as the project instructions.

ⓧ I see an older version

Your terminal is resolving an older Harper installation.

  • Repeat the pinned installation command above.
  • Close the current terminal after installation finishes.
  • Create a fresh integrated terminal in your editor.
  • Repeat the Harper version check.

ⓧ Command not found

The global installation has not reached the command search path used by this terminal.

  • Close the current terminal.
  • Create a fresh integrated terminal in your editor.
  • Repeat the Harper version check.
  • Repeat the pinned installation command if the Harper command remains unavailable.
  • Create another fresh terminal after the repeated installation.

Having trouble installing Harper?

A global installation can fail when npm cannot write to its configured global package location. It can also appear missing when the terminal still uses settings loaded before installation.

Help me troubleshoot the Harper installation.

Complete Harper's first-run setup

The first launch creates Harper's local data location and development configuration. The process then keeps Harper running in the foreground.

The next prompts create local administrative credentials. This is a sensitive step, so the values stay out of screenshots and project files.

  • Accept the terms when the first-run prompt asks.
  • Choose a destination inside your home directory when prompted: your Harper data directory.
  • Create a local administrative username.
  • Create a local administrative password.
  • Select dev as the default configuration.
  • Enter localhost as the hostname.
  • Keep this terminal available after the prompts finish.
  • Start Harper's first-run setup by running this command:
harper

What does this command do?

When no local installation exists, harper begins the interactive setup. After setup, the same command runs Harper in the foreground.

After the prompts finish, the first terminal remains occupied by the running Harper process. Keep it active for the health check.

Did the setup stop before Harper started?

  • Confirm that the selected data destination sits inside your home directory.
  • Confirm that you entered both local administrative credentials.
  • Repeat the first-run command after correcting the incomplete prompt.

Help me complete Harper's first-run setup.

Check runtime health

The running process occupies the first terminal. A second terminal can call Harper's local health endpoint without interrupting it.

  • Switch to your editor's terminal panel.
  • Use the panel's new-terminal control to create a second terminal.

Before you run the check, do you think the local health endpoint can respond while Harper occupies the first terminal?

  • Check the active Harper runtime by running this command in the second terminal:
node -e "fetch('http://localhost:9925/health').then((response) => response.text()).then(console.log)"

What does this health check do?

Node.js sends a request to http://localhost:9925/health. The final function prints the response body in your second terminal.

You'll see HarperDB is running. in the second terminal. That response proves the local runtime is active.

Health endpoint not responding?

  • Confirm that the first terminal still shows Harper running.
  • Return to the first terminal if its prompt has returned.
  • Restart Harper with the first-run command above.
  • Confirm that the first-run hostname was localhost.

Help me troubleshoot the local Harper health check.

  • Switch back to the first terminal.
  • Stop the foreground Harper process by pressing Ctrl+C.

Your terminal prompt returns. That's the foundation locked in: Harper ran locally and answered a real health request.

Setup checkpoint

  • Harper CLI is installed globally as harper@5.3.3.
  • Your Harper local data directory is your Harper data directory.
  • The local data directory uses the dev configuration.
  • The configured hostname is localhost.
  • Local administrative credentials are configured.
  • The foreground Harper process is stopped.

Your reproducible Harper runtime is ready. Next, you'll create the smallest incident application that delivers a working customer API.

Deliver the Incident API

Your local Harper runtime is healthy. The customer now needs an early working slice that proves the platform can serve incident data.

You will define the incident shape with a schema. Harper will turn that schema into a generated REST collection endpoint without another API framework.

In this step, get ready to:
  • Create the incident application schema.
  • Enable Harper's generated REST interface.
  • Run the application and retrieve JSON from the incident endpoint.
Create the smallest Harper application

A GraphQL schema describes the records Harper stores. Your schema gives every incident the fields needed for intake and reporting.

  • Create the project folder on your Desktop by running these commands:
cd ~/Desktop
mkdir harper-incident-hub
cd harper-incident-hub

What do these commands do?

  • The first command moves your terminal to the Desktop.
  • The second command creates the `harper-incident-hub` directory.
  • The final command moves your terminal into the new directory.

Your terminal is now positioned inside the empty harper-incident-hub project directory.

  • Press Cmd+Space on macOS or the Windows key on Windows to open system search.
  • Type VS Code or Cursor into the search field.
  • Press Enter to open your editor.

You will see your editor's welcome screen. This is where you can select an existing folder as your workspace.

  • Open the harper-incident-hub folder from your Desktop by using the folder picker on the welcome screen.

The editor's file sidebar now shows harper-incident-hub as the open directory.

  • Create schema.graphql inside harper-incident-hub by using the new-file control in the file sidebar.

You will see schema.graphql listed in the file sidebar. The empty file opens in the editor.

  • Define the incident table by pasting this schema into schema.graphql:
type Incident @table @export {
  id: ID @primaryKey
  title: String
  severity: String
  status: String
  owner: String
  customerImpact: String
}

What does this schema do?

  • The Incident type defines the shape of every stored incident.
  • The @table directive tells Harper to create a table for this type.
  • The @export directive lets Harper register the default endpoint for the table.
  • The @primaryKey directive makes id the unique identifier for each incident.
  • Save schema.graphql.

The saved schema contains the incident identifier plus five operational fields. Harper can use this definition to create the local table.

Does the schema show an error?

Check that the opening brace follows @export. Confirm that the closing brace sits below customerImpact.

Make sure the file is named schema.graphql with no extra file extension.

Help me compare my incident schema with the expected structure.

✔️ Awesome, I've got everything!

Your schema.graphql file is saved with the complete incident table definition.

ⓧ I'd like to double check the full code

Compare your saved schema.graphql file with this complete version.

type Incident @table @export {
  id: ID @primaryKey
  title: String
  severity: String
  status: String
  owner: String
  customerImpact: String
}

What should match?

Your file should contain the same directives and six field names. The braces should enclose every field.

Enable the generated REST interface

Harper needs an application configuration that connects the schema to its HTTP runtime. The configuration loads schema.graphql and enables REST access.

  • Create config.yaml inside harper-incident-hub by using the new-file control in the file sidebar.

You will see config.yaml beside schema.graphql in the file sidebar.

  • Connect the schema to Harper's REST interface by pasting this configuration into config.yaml:
graphqlSchema:
  files: 'schema.graphql'
rest: true

What does this configuration do?

  • The graphqlSchema section tells Harper where to load the table definition.
  • The files value points to schema.graphql in the same project directory.
  • The rest: true setting enables Harper's REST interface for exported resources.

Why generated REST instead of another framework?

Harper already owns the schema and data runtime. Its generated REST interface turns the exported table into an HTTP surface without a router or separate database service.

This platform-first approach gives the customer a working slice with fewer components to operate.

  • Save config.yaml.

The project directory now contains both required application files. The configuration points directly to the saved schema.

Is the configuration highlighted as invalid?

Make sure files is indented by two spaces beneath graphqlSchema. Keep rest: true aligned with graphqlSchema.

Confirm that the schema filename is exactly schema.graphql.

Help me fix the structure of my Harper configuration.

✔️ Awesome, I've got everything!

Your config.yaml file is saved beside the incident schema.

ⓧ I'd like to double check the full code

Compare your saved config.yaml file with this complete version.

graphqlSchema:
  files: 'schema.graphql'
rest: true

What should match?

Your file should contain three lines. The files line should be indented beneath graphqlSchema.

Run and verify the incident API

Development mode loads the application from the current directory. It also watches the application files for changes while the process remains active.

  • Return to the terminal that is already inside harper-incident-hub.
  • Start the application runtime by running this command:
harper dev .

What does this command do?

The harper dev . command runs the Harper application in the current directory. The process keeps control of this terminal while it serves the generated endpoint.

You will see startup activity in the terminal. Keep this process active so the endpoint remains available.

Does the application fail to start?

Confirm that the terminal is inside harper-incident-hub. Check that both project files are saved in that directory.

Inspect the startup output for a referenced filename or YAML indentation problem. Compare that file with the complete versions above.

Help me diagnose why my Harper application is not starting.

Before you check the endpoint, do you think the schema and configuration have created a reachable incident collection?

  • Create a second terminal from your editor's terminal panel so the Harper process stays active in the first terminal.
  • Check the generated incident collection by running this command in the second terminal:
node -e "fetch('http://localhost:9926/Incident/').then(async (response) => console.log(response.status, await response.text()))"

What does this check prove?

  • Node.js sends a request to the generated /Incident/ collection endpoint.
  • The callback prints the HTTP status followed by the response body.
  • A successful response proves that Harper loaded the schema and registered the exported resource.

You will see a successful status followed by JSON collection data instead of 404. Your schema and configuration have produced a working HTTP surface.

Still seeing a missing endpoint?

Confirm that the first terminal still has harper dev . running. The request cannot reach the application after that process stops.

Check that @export appears after @table in schema.graphql. Confirm that rest: true appears in config.yaml.

Help me troubleshoot why my generated incident endpoint is unavailable.

That is your first customer-visible slice complete. The Incident collection now responds with JSON from a local Harper application. Next, you will test whether this fast generated API also protects the customer's operational rules.

Reproduce the Data-Quality Risk

Your generated Harper REST endpoint already proves that the customer can create and retrieve incident data through a working API.

A schema-valid value can still violate the customer's operating rules. You'll prove that gap with a repeatable Node.js probe before adding business-rule enforcement.

In this step, get ready to:
  • Define the customer's accepted incident values.
  • Build a baseline probe for an invalid severity value.
  • Capture evidence that the generated endpoint stores the invalid incident.
Define the customer's operating rules

A schema defines the fields that an incident can carry. The customer still decides which values have operational meaning.

  • Treat P1, P2, P3, and P4 as the accepted severity values.
  • Require a non-empty title for every incident.
  • Restrict status to open, investigating, monitoring, or resolved.
  • Require an owner for every P1 incident.

Where do these rules come from?

These are the fictional operations team's acceptance criteria. They give the probe a customer outcome to measure against.

Harper provides the generated data interface. The customer supplies the workflow-specific meaning behind each value.

Build a baseline probe

A baseline smoke test captures the endpoint's current behaviour in a script you can rerun. This probe submits urgent even though that value falls outside the customer's accepted severity list.

  • In the Explorer sidebar, click the New File icon beside harper-incident-hub.
  • Name the file smoke-test.mjs.

You'll see smoke-test.mjs appear in the Explorer sidebar.

  • Add the baseline probe by pasting this code into smoke-test.mjs:
const BASE_URL = 'http://localhost:9926/Incident';

const runId = Date.now();
const incidentId = `INC-BAD-SEVERITY-${runId}`;

const createResponse = await fetch(`${BASE_URL}/`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    id: incidentId,
    title: 'Customer checkout failures',
    severity: 'urgent',
    owner: 'on-call-engineer',
  }),
});

if (createResponse.status === 201) {
  const storedResponse = await fetch(`${BASE_URL}/${incidentId}`);
  console.log(`Baseline risk reproduced: invalid incident was accepted with ${createResponse.status}.`);
  console.log(await storedResponse.text());
} else {
  throw new Error(`Expected invalid incident to be accepted with 201, received ${createResponse.status}`);
}

What does this probe do?

  • The BASE_URL constant points to the generated incident endpoint.
  • The runId value gives each test incident a unique identifier.
  • The POST request sends a JSON incident whose severity is urgent.
  • The successful branch retrieves the stored incident through its unique incidentId.
  • The final branch throws an error if the endpoint no longer demonstrates the expected baseline behaviour.
  • Save smoke-test.mjs.
Capture the designed failure

Before you run the probe, pause on one prediction: whether the generated endpoint will judge urgent against the customer's severity list.

  • Switch back to the second terminal from the earlier endpoint check.
  • Test the generated endpoint by running this command:
node smoke-test.mjs

What does this command do?

Node.js executes the probe against the Harper process that remains active in the other terminal. The script creates the invalid incident before retrieving the stored record as evidence.

You'll see Baseline risk reproduced: invalid incident was accepted with 201. first. The next line shows the stored incident with its severity set to urgent.

You have now reproduced the customer's data-quality risk with an observable response and a stored record.

In customer terms, inconsistent severity data can disrupt prioritization and reporting. The generated endpoint needs customer-specific rules before this workflow is ready for operational use.

Probe did not reproduce the risk?

  • Return to the terminal running Harper.
  • Confirm the Harper development process is still active.
  • Check that BASE_URL matches the generated incident endpoint.
  • Save smoke-test.mjs before running the probe again.

Help me diagnose why my baseline incident probe is not producing the expected response.

✔️ Awesome, I've got everything!

Great work. Your saved probe now reproduces the invalid-severity risk and retrieves the stored evidence.

ⓧ I'd like to double check the full code

Compare your complete smoke-test.mjs file with this reference:

const BASE_URL = 'http://localhost:9926/Incident';

const runId = Date.now();
const incidentId = `INC-BAD-SEVERITY-${runId}`;

const createResponse = await fetch(`${BASE_URL}/`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    id: incidentId,
    title: 'Customer checkout failures',
    severity: 'urgent',
    owner: 'on-call-engineer',
  }),
});

if (createResponse.status === 201) {
  const storedResponse = await fetch(`${BASE_URL}/${incidentId}`);
  console.log(`Baseline risk reproduced: invalid incident was accepted with ${createResponse.status}.`);
  console.log(await storedResponse.text());
} else {
  throw new Error(`Expected invalid incident to be accepted with 201, received ${createResponse.status}`);
}

The gap is now measurable instead of theoretical. Next, you'll enforce the operating rules with a custom Harper Resource and turn this failing baseline into delivery evidence.

Enforce Business Rules

Your baseline probe proved that the generated Harper API accepts the customer-invalid severity urgent. The customer now has evidence of a data-quality risk that can disrupt prioritization.

A custom Resource can enforce those rules through the existing REST interface. This keeps the table storage plus HTTP behavior inside one Harper application.

In this step, get ready to:
  • Implement one validation path for every incident write.
  • Transfer the Incident route to the custom Resource.
  • Produce repeatable smoke-test evidence for the customer.
Implement the validated Resource

The Resource needs one source of truth for title checks plus workflow rules. Centralizing that logic in normalizeAndValidateIncident() keeps creates plus updates consistent.

Why use a Harper Resource?

The Resource extends Harper's existing table behavior. Valid requests still use the platform's storage plus HTTP capabilities.

This platform-first approach avoids adding another API framework. The customization stays focused on the customer's business rules.

  • Return to the editor with the harper-incident-hub directory open.
  • Create resources.js inside harper-incident-hub by using the new-file control in the file sidebar.
  • Add the Harper import plus accepted workflow values by pasting this code into resources.js:
import { tables } from 'harper';

const ALLOWED_SEVERITIES = new Set(['P1', 'P2', 'P3', 'P4']);
const ALLOWED_STATUSES = new Set(['open', 'investigating', 'monitoring', 'resolved']);

function badRequest(message) {
  const error = new Error(message);
  error.statusCode = 400;
  return error;
}

What Does This Code Do?

  • The tables import exposes the existing Incident table to the Resource.
  • The first set stores the four accepted severity values.
  • The second set stores the four accepted status values.
  • The badRequest() helper creates validation errors with an HTTP status of 400.
  • Save resources.js.
  • Confirm that resources.js appears beside schema.graphql in the file sidebar.

Can't See resources.js?

Check that the file sits directly inside harper-incident-hub. Check that its name uses the same lowercase spelling shown above.

Ask for help with the file location: Why can't my Harper project find resources.js?

Validation begins by combining stored data with the incoming request. This merge preserves fields during a later partial update.

  • Place your cursor below badRequest() in resources.js.
  • Begin normalizeAndValidateIncident() by pasting this opening section:
function normalizeAndValidateIncident(input, existing = {}) {
  if (!input || typeof input !== 'object' || Array.isArray(input)) {
    throw badRequest('A JSON incident object is required');
  }

  const incident = { ...existing, ...input };

  if (typeof incident.title !== 'string' || !incident.title.trim()) {
    throw badRequest('title is required');
  }

  if (!ALLOWED_SEVERITIES.has(incident.severity)) {
    throw badRequest('severity must be P1, P2, P3, or P4');
  }

  const normalized = {
    ...incident,
    title: incident.title.trim(),
    status: incident.status ?? 'open',
  };

How Does Validation Begin?

  • The first condition requires request data to be a JSON object.
  • The object spread places incoming fields over stored incident data.
  • The next checks require a non-empty title plus an accepted severity.
  • The normalized record trims its title.
  • The nullish fallback supplies open when the caller omits a status.
  • Complete normalizeAndValidateIncident() by pasting this section directly below the normalized object:
  if (typeof normalized.owner === 'string') {
    normalized.owner = normalized.owner.trim();
  }

  if (!ALLOWED_STATUSES.has(normalized.status)) {
    throw badRequest('status must be open, investigating, monitoring, or resolved');
  }

  if (normalized.severity === 'P1' && !normalized.owner) {
    throw badRequest('owner is required for P1 incidents');
  }

  return normalized;
}

How Does Validation Finish?

  • Owner values are trimmed before the ownership rule runs.
  • The status check limits incidents to the customer's four workflow states.
  • The ownership check rejects a P1 incident without an owner.
  • Valid normalized data returns to the calling Resource method.
  • Save resources.js.
  • Confirm that the function closes immediately after return normalized;.

Does the Function Look Incomplete?

Check that the opening section ends with the normalized object. Check that the closing section begins with the owner check.

Ask for help comparing the two sections: Help me find the missing part of normalizeAndValidateIncident().

The validation helper defines the rules. The exported class now applies those rules to create plus update requests.

  • Append the Incident class to the bottom of resources.js by pasting this code:
export class Incident extends tables.Incident {
  static async post(target, data) {
    const incident = normalizeAndValidateIncident(await data);
    return super.post(target, incident);
  }

  static async put(target, data) {
    const incident = normalizeAndValidateIncident(await data);
    return super.put(target, incident);
  }

  static async patch(target, data) {
    const existing = await super.get(target);
    const incident = normalizeAndValidateIncident(await data, existing);
    return super.patch(target, incident);
  }
}

How Does the Resource Handle Writes?

  • The class extends tables.Incident so valid requests keep Harper's table behavior.
  • The post() method validates creates before delegation.
  • The put() method validates full replacements before delegation.
  • The patch() method retrieves the stored record before validating a partial update.
  • Save resources.js.
  • Confirm that the file ends with the closing brace for the exported Incident class.

Seeing a JavaScript Syntax Problem?

Check that each method closes before the next one begins. Check that the class has its own final closing brace.

Ask for help locating the syntax issue: Help me check the structure of my Incident Resource class.

Transfer endpoint ownership

Only one Resource should own the Incident route. The schema keeps the table while the JavaScript class takes responsibility for HTTP requests.

  • Switch back to schema.graphql in the editor.
  • Remove @export by replacing the file contents with this code:
type Incident @table {
  id: ID @primaryKey
  title: String
  severity: String
  status: String
  owner: String
  customerImpact: String
}

Why Remove @export?

The @table directive keeps the Incident table in the default database. Removing @export releases the generated Resource route.

The exported JavaScript class can now own the same Incident URL without a route conflict.

  • Save schema.graphql.
  • Switch back to config.yaml in the editor.
  • Load the custom Resource by replacing the file contents with this code:
graphqlSchema:
  files: 'schema.graphql'
rest: true
jsResource:
  files: 'resources.js'

What Does This Configuration Do?

  • The graphqlSchema section continues loading the Incident table definition.
  • The rest setting keeps the HTTP interface enabled.
  • The jsResource section loads the exported class from resources.js.
  • Save config.yaml.
  • Switch to the terminal where harper dev . remains active.
  • Confirm that Harper reports fresh activity after reloading the project files.

Does Harper Report a Reload Problem?

  • Check that resources.js sits beside config.yaml.
  • Check that the indentation below jsResource matches the indentation below graphqlSchema.
  • Check every closing brace in resources.js.

Ask for help with the reload details: Help me diagnose why Harper cannot load my Incident Resource.

Before you run the old probe, predict whether its original acceptance check still matches the API.

  • Test the new route ownership from the second terminal by running this command:
node smoke-test.mjs

What Does This Check Prove?

The old probe still expects the invalid request to return 201. The custom Resource returns 400 instead.

The probe throws because its old definition of success is now outdated. The severity gap has been closed.

That intended failure marks the turning point. The API now rejects the same operational risk that Harper accepted before the custom Resource took ownership.

Turn the probe into repeatable evidence

The baseline probe only treats acceptance as evidence. The final smoke test needs clear assertions for rejected data plus successful data.

  • Switch back to smoke-test.mjs in the editor.
  • Replace its entire contents with the request helpers below:
const BASE_URL = 'http://localhost:9926/Incident';

function assert(condition, message) {
  if (!condition) {
    throw new Error(message);
  }
}

async function call(path, options = {}) {
  const response = await fetch(`${BASE_URL}${path}`, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      ...(options.headers ?? {}),
    },
  });

  const text = await response.text();
  let body = null;

  if (text) {
    try {
      body = JSON.parse(text);
    } catch {
      body = text;
    }
  }

  return { response, body };
}

What Do the Helpers Do?

  • The assert() helper stops the test when an expected condition is false.
  • The call() helper sends requests to BASE_URL.
  • The response parser keeps JSON values available for later assertions.
  • The fallback preserves a text response when JSON parsing is unavailable.
  • Save smoke-test.mjs.
  • Confirm that the file currently ends with return { response, body };.

Did the Old Probe Remain in the File?

Check that incidentId plus createResponse no longer appear. The next chunks replace those baseline variables with assertion-based tests.

Ask for help checking the replacement: Help me remove the old baseline probe safely.

  • Append the invalid-severity test to smoke-test.mjs by pasting this code:
const runId = Date.now();

const invalidSeverity = await call('/', {
  method: 'POST',
  body: JSON.stringify({
    id: `INC-BAD-SEVERITY-${runId}`,
    title: 'Customer checkout failures',
    severity: 'urgent',
    owner: 'on-call-engineer',
  }),
});
assert(invalidSeverity.response.status === 400, 'Invalid severity should return 400');
console.log(`PASS invalid severity rejected: ${invalidSeverity.response.status}`);

How Does the Severity Test Work?

The request repeats the urgent severity from the baseline probe. The assertion recognizes 400 as evidence that the invalid value was rejected.

  • Save smoke-test.mjs.
  • Run the updated severity test from the second terminal by using this command:
node smoke-test.mjs

What Should This Test Report?

You should see PASS invalid severity rejected: 400. This converts the original customer risk into repeatable rejection evidence.

Good progress. The severity rule now has a passing automated check that another team can reproduce.

Missing the Severity PASS Line?

Check the Harper terminal for a Resource reload problem. Check that @export is absent from schema.graphql.

Ask for help with the first assertion: Why is my invalid severity test failing?

  • Append the missing-owner test to the bottom of smoke-test.mjs by pasting this code:
const missingOwner = await call('/', {
  method: 'POST',
  body: JSON.stringify({
    id: `INC-BAD-OWNER-${runId}`,
    title: 'Customer checkout failures',
    severity: 'P1',
  }),
});
assert(missingOwner.response.status === 400, 'A P1 without an owner should return 400');
console.log(`PASS owner rule enforced: ${missingOwner.response.status}`);

What Does the Ownership Test Prove?

This request uses the accepted severity P1. The missing owner should produce 400 because the Resource evaluates both fields together.

  • Save smoke-test.mjs.
  • Run both rejection checks from the second terminal by using this command:
node smoke-test.mjs

What Should the Two Checks Report?

You should see one PASS line for invalid severity. You should see another PASS line for the missing P1 owner.

Missing the Owner PASS Line?

Check that the request uses P1 without an owner field. Check that the Resource tests normalized.owner after trimming it.

Ask for help with the cross-field rule: Why does my P1 owner assertion fail?

Rejection tests protect bad input. The customer also needs proof that valid incidents still reach the table with the correct default status.

  • Append the successful creation plus retrieval checks by pasting this code at the bottom of smoke-test.mjs:
const validId = `INC-VALID-${runId}`;
const validIncident = await call('/', {
  method: 'POST',
  body: JSON.stringify({
    id: validId,
    title: 'Customer checkout failures',
    severity: 'P1',
    owner: 'on-call-engineer',
    customerImpact: 'Customers cannot complete checkout',
  }),
});
assert(validIncident.response.status === 201, 'A valid incident should return 201');
console.log(`PASS valid incident created: ${validIncident.response.status}`);

const storedIncident = await call(`/${validId}`);
assert(storedIncident.response.status === 200, 'The created incident should be retrievable');
assert(storedIncident.body.status === 'open', 'The default status should be open');
console.log(`PASS default status applied: ${storedIncident.body.status}`);

What Do the Success Checks Prove?

  • The valid request supplies an accepted severity plus the required P1 owner.
  • The 201 assertion proves that validation permits correct incident creation.
  • The retrieval assertion proves that the new incident remains available through the Resource.
  • The final assertion proves that an omitted status becomes open.
  • Save smoke-test.mjs.

Before the final run, predict which four results the smoke test should report.

  • Run the complete smoke test from the second terminal by using this command:
node smoke-test.mjs

What Does the Final Run Verify?

You should see four PASS lines. They show 400 for invalid severity plus 400 for missing P1 ownership.

They also show 201 for valid creation plus open for the stored default status.

You have closed the customer's data-quality gap. The Incident route now rejects invalid operational data while preserving valid writes plus retrieval.

Missing One of the Four PASS Lines?

  • Check that @export no longer appears in schema.graphql.
  • Check that config.yaml loads resources.js through jsResource.
  • Check the active Harper terminal for a reload problem.

Ask for help with the failed assertion: Help me diagnose which Harper business-rule test is failing.

✔️ Awesome, I've got everything!

Your custom Resource owns the Incident route. Double check that all four files are saved before moving on.

ⓧ I'd like to double check the full code

Compare your saved schema.graphql with this complete version.

type Incident @table {
  id: ID @primaryKey
  title: String
  severity: String
  status: String
  owner: String
  customerImpact: String
}

What Should the Schema Contain?

The schema keeps the Incident table plus its six fields. The missing @export directive leaves route ownership to the JavaScript Resource.

Compare your saved config.yaml with this complete version.

graphqlSchema:
  files: 'schema.graphql'
rest: true
jsResource:
  files: 'resources.js'

What Should the Configuration Contain?

The configuration loads the schema plus resources.js. It also keeps the REST interface enabled.

Compare your saved resources.js with this complete version.

import { tables } from 'harper';

const ALLOWED_SEVERITIES = new Set(['P1', 'P2', 'P3', 'P4']);
const ALLOWED_STATUSES = new Set(['open', 'investigating', 'monitoring', 'resolved']);

function badRequest(message) {
  const error = new Error(message);
  error.statusCode = 400;
  return error;
}

function normalizeAndValidateIncident(input, existing = {}) {
  if (!input || typeof input !== 'object' || Array.isArray(input)) {
    throw badRequest('A JSON incident object is required');
  }

  const incident = { ...existing, ...input };

  if (typeof incident.title !== 'string' || !incident.title.trim()) {
    throw badRequest('title is required');
  }

  if (!ALLOWED_SEVERITIES.has(incident.severity)) {
    throw badRequest('severity must be P1, P2, P3, or P4');
  }

  const normalized = {
    ...incident,
    title: incident.title.trim(),
    status: incident.status ?? 'open',
  };

  if (typeof normalized.owner === 'string') {
    normalized.owner = normalized.owner.trim();
  }

  if (!ALLOWED_STATUSES.has(normalized.status)) {
    throw badRequest('status must be open, investigating, monitoring, or resolved');
  }

  if (normalized.severity === 'P1' && !normalized.owner) {
    throw badRequest('owner is required for P1 incidents');
  }

  return normalized;
}

export class Incident extends tables.Incident {
  static async post(target, data) {
    const incident = normalizeAndValidateIncident(await data);
    return super.post(target, incident);
  }

  static async put(target, data) {
    const incident = normalizeAndValidateIncident(await data);
    return super.put(target, incident);
  }

  static async patch(target, data) {
    const existing = await super.get(target);
    const incident = normalizeAndValidateIncident(await data, existing);
    return super.patch(target, incident);
  }
}

What Should the Resource Contain?

The Resource centralizes title plus severity validation. It also handles status defaults plus P1 ownership.

The exported class applies those rules before delegating valid writes to the Incident table.

Compare your saved smoke-test.mjs with this complete version.

const BASE_URL = 'http://localhost:9926/Incident';

function assert(condition, message) {
  if (!condition) {
    throw new Error(message);
  }
}

async function call(path, options = {}) {
  const response = await fetch(`${BASE_URL}${path}`, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      ...(options.headers ?? {}),
    },
  });

  const text = await response.text();
  let body = null;

  if (text) {
    try {
      body = JSON.parse(text);
    } catch {
      body = text;
    }
  }

  return { response, body };
}

const runId = Date.now();

const invalidSeverity = await call('/', {
  method: 'POST',
  body: JSON.stringify({
    id: `INC-BAD-SEVERITY-${runId}`,
    title: 'Customer checkout failures',
    severity: 'urgent',
    owner: 'on-call-engineer',
  }),
});
assert(invalidSeverity.response.status === 400, 'Invalid severity should return 400');
console.log(`PASS invalid severity rejected: ${invalidSeverity.response.status}`);

const missingOwner = await call('/', {
  method: 'POST',
  body: JSON.stringify({
    id: `INC-BAD-OWNER-${runId}`,
    title: 'Customer checkout failures',
    severity: 'P1',
  }),
});
assert(missingOwner.response.status === 400, 'A P1 without an owner should return 400');
console.log(`PASS owner rule enforced: ${missingOwner.response.status}`);

const validId = `INC-VALID-${runId}`;
const validIncident = await call('/', {
  method: 'POST',
  body: JSON.stringify({
    id: validId,
    title: 'Customer checkout failures',
    severity: 'P1',
    owner: 'on-call-engineer',
    customerImpact: 'Customers cannot complete checkout',
  }),
});
assert(validIncident.response.status === 201, 'A valid incident should return 201');
console.log(`PASS valid incident created: ${validIncident.response.status}`);

const storedIncident = await call(`/${validId}`);
assert(storedIncident.response.status === 200, 'The created incident should be retrievable');
assert(storedIncident.body.status === 'open', 'The default status should be open');
console.log(`PASS default status applied: ${storedIncident.body.status}`);

What Should the Smoke Test Contain?

The smoke test checks two rejected requests plus one accepted request. It retrieves the accepted incident to verify the default status.

Your Incident API now protects the customer's workflow with testable business rules. Next, you will package the outcome plus evidence into a handoff another team can operate.

Package the Customer Handoff

Your custom Harper Resource now protects the incident workflow with customer-specific rules. The passing smoke test gives you evidence that those rules work.

A Forward Deployed Engineer also owns adoption. This step turns your implementation into a customer handoff that another team can operate without relying on its original author.

In this step, get ready to:
  • Translate the validated incident workflow into a customer outcome.
  • Document the runbook with its production-readiness risks.
  • Rehearse a two-minute customer delivery story.
Translate the implementation into outcomes

A useful handoff starts with the operational change the customer receives. The architecture then explains how your implementation creates that outcome.

  • Create CUSTOMER_HANDOFF.md inside the open harper-incident-hub folder using your editor's Explorer sidebar.
  • Add the handoff title with its business outcome by pasting this content:
# Customer Incident Hub Handoff

## Business outcome

The operations team now has one incident intake endpoint that rejects inconsistent severity, status, title, and ownership data before it reaches reporting workflows.

What does this section establish?

The opening names the operations team as the customer. It connects validation to consistent reporting workflows.

  • Save CUSTOMER_HANDOFF.md.
  • Switch CUSTOMER_HANDOFF.md to your editor's Markdown preview.

You should see the document title above a Business outcome section that describes the protected incident workflow.

Does the preview show plain text?

Confirm that the file ends with .md. Check that each heading starts with the same number of hash characters shown above.

Help me fix the Markdown preview for my customer handoff.

  • Return CUSTOMER_HANDOFF.md to text view.
  • Place your cursor after the business outcome sentence.
  • Append the architecture section by pasting this content:


## Architecture

A client calls Harper's REST interface. The exported `Incident` Resource applies customer-specific rules and delegates valid writes to the built-in `Incident` table. The schema, API, business logic, and data runtime remain in one Harper application.

Why this architecture?

The custom Resource extends Harper's generated REST capabilities. The customer gets business-rule validation without another API framework or data service.

  • Save CUSTOMER_HANDOFF.md.
  • Switch the file back to Markdown preview.

You should see a separate Architecture section beneath the business outcome. It should describe one Harper application containing the schema, API, business logic, and data runtime.

Is the architecture heading missing?

Check that the architecture content starts on a new line after the business outcome. Confirm that ## Architecture begins at the start of its line.

Help me fix the missing Architecture section in my Markdown handoff.

Document operation and ownership

The runbook gives the receiving team a repeatable path from starting the local service to evaluating its test evidence. The remaining sections make the current ownership boundaries explicit.

  • Return CUSTOMER_HANDOFF.md to text view.
  • Place your cursor after the architecture paragraph.
  • Append the runbook by pasting this content:


## Runbook

1. From the project directory, run `harper dev .`.
2. Confirm the collection endpoint responds at `http://localhost:9926/Incident/`.
3. In a second terminal, run `node smoke-test.mjs`.
4. Treat every `PASS` line as delivery evidence. Investigate any thrown assertion before handoff.

What does the runbook provide?

The runbook records the exact local start command. It also connects the collection endpoint to the automated test evidence.

  • Save CUSTOMER_HANDOFF.md.
  • Switch the file to Markdown preview.

You should see a numbered Runbook with four steps. The commands and local collection endpoint should render as inline code.

Does the runbook render as one paragraph?

Place each numbered item on its own line. Keep the numbers followed by a full stop so the Markdown preview recognizes the ordered list.

Help me fix the numbered runbook in my Markdown handoff.

  • Return CUSTOMER_HANDOFF.md to text view.
  • Place your cursor after the fourth runbook item.
  • Append the business rules by pasting this content:


## Business rules

- Severity must be `P1`, `P2`, `P3`, or `P4`.
- Status defaults to `open` and must remain one of `open`, `investigating`, `monitoring`, or `resolved`.
- Every incident needs a non-empty title.
- Every `P1` incident needs an owner.

Why record the business rules?

These rules define the customer-specific contract enforced by the Resource. Recording them gives process owners a concrete list to confirm before production use.

  • Save CUSTOMER_HANDOFF.md.
  • Switch the file to Markdown preview.

You should see four bullets under Business rules. They should cover severity, status, title, and P1 ownership.

Are the business rules merged together?

Check that every rule begins on a new line with a hyphen. Keep a space between each hyphen and its rule text.

Help me repair the business-rule list in CUSTOMER_HANDOFF.md.

  • Return CUSTOMER_HANDOFF.md to text view.
  • Place your cursor after the P1 ownership rule.
  • Append the test evidence section by pasting this content:


## Test evidence

The smoke test proves that invalid severity and missing P1 ownership return HTTP 400, a valid incident returns HTTP 201, and the stored incident receives the default `open` status.

What does the evidence prove?

The smoke test covers two rejected writes. It also proves successful creation with the expected default status.

  • Save CUSTOMER_HANDOFF.md.
  • Switch the file to Markdown preview.

You should see a Test evidence section that connects the smoke test to HTTP 400, HTTP 201, and the default open status.

Is the test evidence incomplete?

Compare the paragraph with the four checks in smoke-test.mjs. Confirm that the paragraph includes both rejected cases plus the successful creation and default status.

Help me compare the handoff evidence with my smoke test.

  • Return CUSTOMER_HANDOFF.md to text view.
  • Place your cursor after the test evidence paragraph.
  • Append the known risks by pasting this content:


## Known risks

- The service is running locally in development mode.
- Production authentication and authorization have not been designed.
- Production monitoring, backup, recovery, capacity, and availability requirements have not been assessed.
- The fictional business rules need confirmation from the real process owner before production use.

Why name the risks?

The risk list separates demonstrated local behavior from production readiness. It gives security, operations, and process owners clear decisions to make next.

  • Save CUSTOMER_HANDOFF.md.
  • Switch the file to Markdown preview.

You should see four Known risks covering local development, production access control, operational requirements, and rule confirmation.

Is a production risk missing?

Check the list against the four bullets above. Keep each risk on its own line so ownership gaps remain visible.

Help me check the production-readiness risks in my customer handoff.

  • Return CUSTOMER_HANDOFF.md to text view.
  • Place your cursor after the final known-risk bullet.
  • Append the next steps by pasting this content:


## Next steps

Confirm production requirements with security and operations stakeholders, choose the target deployment model, add authorization, define service objectives, and create an operational test plan before go-live.

What happens after handoff?

The next steps convert the risk list into production decisions. They give the receiving team a path from a validated local service to a production plan.

  • Save CUSTOMER_HANDOFF.md.
  • Switch the file to Markdown preview.

You should see Next steps as the final section. It should end with an operational test plan before go-live.

Does the handoff end at known risks?

Check that the next-steps heading appears after the fourth risk. Confirm that its paragraph sits below the heading.

Help me restore the final Next steps section in CUSTOMER_HANDOFF.md.

✔️ Awesome, I've got everything!

Your customer handoff now covers the outcome, architecture, runbook, rules, evidence, risks, and next steps.

ⓧ I'd like to double check the full code

Compare your complete CUSTOMER_HANDOFF.md with this reference.

# Customer Incident Hub Handoff

## Business outcome

The operations team now has one incident intake endpoint that rejects inconsistent severity, status, title, and ownership data before it reaches reporting workflows.

## Architecture

A client calls Harper's REST interface. The exported `Incident` Resource applies customer-specific rules and delegates valid writes to the built-in `Incident` table. The schema, API, business logic, and data runtime remain in one Harper application.

## Runbook

1. From the project directory, run `harper dev .`.
2. Confirm the collection endpoint responds at `http://localhost:9926/Incident/`.
3. In a second terminal, run `node smoke-test.mjs`.
4. Treat every `PASS` line as delivery evidence. Investigate any thrown assertion before handoff.

## Business rules

- Severity must be `P1`, `P2`, `P3`, or `P4`.
- Status defaults to `open` and must remain one of `open`, `investigating`, `monitoring`, or `resolved`.
- Every incident needs a non-empty title.
- Every `P1` incident needs an owner.

## Test evidence

The smoke test proves that invalid severity and missing P1 ownership return HTTP 400, a valid incident returns HTTP 201, and the stored incident receives the default `open` status.

## Known risks

- The service is running locally in development mode.
- Production authentication and authorization have not been designed.
- Production monitoring, backup, recovery, capacity, and availability requirements have not been assessed.
- The fictional business rules need confirmation from the real process owner before production use.

## Next steps

Confirm production requirements with security and operations stakeholders, choose the target deployment model, add authorization, define service objectives, and create an operational test plan before go-live.

What should match?

This reference is the exact completed handoff. Your headings, lists, commands, endpoint, risks, and next steps should match it.

Rehearse the delivery story

A delivery walkthrough connects the customer's problem to the evidence in front of them. The sequence shows why the custom Resource exists before discussing future production work.

Before you run the final check, do you expect all four business-rule tests to pass with Harper still running?

  • Switch back to the test terminal from the previous step.
  • Run the final smoke test from the open harper-incident-hub directory by running this command:
node smoke-test.mjs

What should the evidence show?

You should see four PASS lines. They confirm an invalid severity returns 400. They also confirm a missing P1 owner returns 400.

The remaining lines confirm valid creation returns 201. They also confirm the stored status is open.

  • Capture a screenshot that clearly shows all four PASS lines.
  • Return to the Markdown preview for CUSTOMER_HANDOFF.md.

You should see Business outcome, Architecture, Runbook, Test evidence, Known risks, and Next steps in the finished document.

Did the final smoke test stop early?

Confirm that the original Harper terminal still shows the running development process. Check the first thrown assertion in the test terminal because it identifies the rule that failed.

Help me diagnose the failed final smoke test before customer handoff.

  • Set a timer for two minutes.
  • Open your walkthrough with the operations team's inconsistent incident-data problem.
  • Describe the early API win produced by the schema and configuration.
  • Recount the accepted urgent severity that exposed the customer risk.
  • Explain how the custom Harper Resource fixed the risk with platform-first validation.
  • Show the four PASS lines as test evidence.
  • Name production requirements as the next customer decision.
  • Close with the measurable outcome that inconsistent incident data is rejected before it reaches reporting workflows.

That completes the delivery package. You can now demonstrate the validated service with test evidence and hand its operating decisions to another team.

Secret mission

Protect Incident Resolution Quality

A resolved incident should explain how the customer impact ended. Add a cross-field lifecycle rule that rejects incomplete resolutions. Prove both outcomes through repeatable update tests.

Clean Up Your Resources

Clean Up Your Resources

Your local Harper project creates no ongoing charges. Choose the cleanup option that matches what you want to do next.

Resources you used:

  • Foreground Harper development process serving the incident API.
  • The harper-incident-hub directory containing the project files and customer handoff.
  • The Harper data destination containing the local Incident table and its stored records.

Keep everything running

No action needed. Choose this if you want the incident API ready for more testing or demonstrations.

  • Leave the foreground Harper process running so the API stays available on port 9926.
  • Keep the harper-incident-hub directory with its source files and customer handoff.
  • Keep the Harper data destination with its local incident records.

Pause - I'll come back to this later

Stop the active process to free its local resources. Your project files and stored records remain available for your next session.

  • Switch back to the terminal running Harper.
  • Press Ctrl+C.

You'll see your shell prompt return. Requests to port 9926 stop responding.

  • Keep the harper-incident-hub directory for your next session.
  • Keep the Harper data destination so its incident records remain available.
  • Return to the runbook in CUSTOMER_HANDOFF.md when you want to restart the service.

Delete - I don't want to use this again

Deleting these folders is permanent. The checks below protect any other Harper work in the same data destination.

  • Switch back to the terminal running Harper.
  • Press Ctrl+C.

You'll see your shell prompt return. The incident API is now stopped.

Finding the exact Harper data destination is a little fiddly because you selected it during first-run setup.

  • Record the Harper data destination you chose during setup: path to your Harper data directory.
  • Confirm that directory contains no other Harper work.
  • Back up every file you need from that directory.

macOS

  • Navigate to the parent directory that contains harper-incident-hub in your file manager.
  • Select the harper-incident-hub directory.
  • Press Cmd+Delete to move the selected directory to Trash.
  • Confirm that harper-incident-hub no longer appears in its parent directory.
  • Navigate to the parent directory of path to your Harper data directory.
  • Select the Harper data destination only after confirming that it contains no other Harper work.
  • Press Cmd+Delete to move the selected directory to Trash.
  • Empty Trash to complete the removal.

Windows

  • Navigate to the parent directory that contains harper-incident-hub in your file manager.
  • Select the harper-incident-hub directory.
  • Press Shift+Delete to remove the selected directory permanently.
  • Confirm that harper-incident-hub no longer appears in its parent directory.
  • Navigate to the parent directory of path to your Harper data directory.
  • Select the Harper data destination only after confirming that it contains no other Harper work.
  • Press Shift+Delete to remove the selected directory permanently.
  • Confirm that the Harper data destination no longer appears in its parent directory.

Linux

  • Navigate to the parent directory that contains harper-incident-hub in your file manager.
  • Select the harper-incident-hub directory.
  • Press Delete to move the selected directory to Trash.
  • Confirm that harper-incident-hub no longer appears in its parent directory.
  • Navigate to the parent directory of path to your Harper data directory.
  • Select the Harper data destination only after confirming that it contains no other Harper work.
  • Press Delete to move the selected directory to Trash.
  • Empty Trash to complete the removal.

Your global Harper 5.3.3 installation remains available for future local projects.

Nice Work!

Nice Work!

You did it! You built a validated incident-management service on Harper that turns customer requirements into enforceable API behavior. The finished delivery includes repeatable test evidence plus a customer-facing handoff.

What you learned:

  • Use a schema to deliver a generated REST API. Extend a custom Harper Resource to reject business-invalid incident data.
  • Reproduce a customer data-quality failure before applying the fix. Prove each business rule with automated Node.js smoke tests. Use HTTP status evidence to make every result observable.
  • Translate the implementation into a customer-facing handoff. Explain the business outcome. Document the architecture. Provide the runbook. Surface the known risks. Set out the next steps.
  • Secret Mission: Protect incident closure with a resolution-summary lifecycle rule. Reject incomplete resolution updates. Accept summary-backed resolutions. Retrieve the final record as test evidence.

Ready to quiz yourself?