# Hub API Guide

*The canonical reference for what the Hub API can do and how to use it — for human developers, AI knowledge systems, and software tooling alike.*

## Give This to Your AI {#give-this-to-your-ai}

Hub is designed to work with AI assistants, and this guide is the canonical reference describing how Hub's API actually works. If you use an AI assistant — ChatGPT, Claude, Gemini, a custom GPT, or another AI that can read documentation — giving it this guide helps it work with Hub correctly instead of guessing.

Providing this documentation to an AI helps it:

- Understand what Hub can do.
- Discover which endpoints actually exist.
- Use the correct routes and parameters instead of inventing plausible-looking ones.
- Prefer [Search](#search) when discovering a record it doesn't already have an ID for.
- Avoid guessing at undocumented capabilities.
- Produce more reliable, correctly-shaped API calls.

**This guide does not, by itself, do any of the following:**

- It does not authenticate an AI.
- It does not provide API credentials.
- It does not grant access to Hub.

Authorization is still controlled entirely by API keys, sessions, permissions, company (tenant) isolation, and module entitlements — reading this guide is harmless on its own, and teaches an AI *how Hub works*, not *what it's allowed to do*. What it's allowed to do is determined by whatever credentials and scope it's actually given.

## Choose the Format That Fits Your AI {#choose-your-format}

This guide is published in three forms, generated from one canonical source so they can't drift apart. Pick whichever matches how your AI (or tooling) actually consumes documentation.

### Humans

Read this HTML guide: [https://justwhatsneeded.com/api-guide.html](https://justwhatsneeded.com/api-guide.html). Formatted for browsers, with the same content as the other two formats plus page styling and navigation.

### AI Knowledge

A plain-Markdown version of this exact guide is published at:

[https://justwhatsneeded.com/api-guide.md](https://justwhatsneeded.com/api-guide.md)

This is the form to use for:

- ChatGPT Custom GPT Knowledge
- Claude Projects and Claude knowledge uploads
- Gemini Gems
- Microsoft Copilot knowledge systems
- Any other AI system that accepts Markdown or plain-text documentation uploads

### Software / Tools

The detailed machine contract — every field, schema, and response shape — is the OpenAPI specification:

[https://hub.justwhatsneeded.com/docs/openapi.yaml](https://hub.justwhatsneeded.com/docs/openapi.yaml)

This is the form to use for:

- API tooling
- SDK generation
- MCP servers
- API clients
- Automated integrations

## Example Instructions for Your AI {#example-instructions}

Two copy-ready examples — pick whichever matches how you're giving your AI this guide.

**If your AI can retrieve public web pages:**

```text
Use the Hub API Guide at https://justwhatsneeded.com/api-guide.html as the canonical reference for Hub API capabilities, routing conventions, authentication requirements, parameters, responses, and usage guidance. Consult it before assuming an endpoint exists.
```

**If you're uploading this guide as knowledge (Custom GPT, Claude Project, Gemini Gem, etc.):**

```text
Use the Hub API Guide at https://justwhatsneeded.com/api-guide.md as your primary knowledge source for Hub API behavior. Prefer documented search capabilities when locating unknown records and direct resource endpoints once IDs are known.
```

Not every AI can open a URL on its own — it needs browsing, web retrieval, a connector, or a configured action to do that. If yours can't, use the second example and actually upload or paste the Markdown version into its knowledge configuration instead of just handing it the link.

## Document Metadata {#metadata}

| | |
|---|---|
| **Title** | Hub API Guide |
| **Status** | Canonical |
| **Audience** | Human developers, AI knowledge systems, and software/tooling |
| **API Guide version** | `3.0` |
| **Hub API / OpenAPI version** | `1.2.0` |
| **Last updated** | `2026-07-20` |
| **Canonical URL (HTML)** | [https://justwhatsneeded.com/api-guide.html](https://justwhatsneeded.com/api-guide.html) |
| **Markdown / AI knowledge URL** | [https://justwhatsneeded.com/api-guide.md](https://justwhatsneeded.com/api-guide.md) |
| **OpenAPI specification** | [https://hub.justwhatsneeded.com/docs/openapi.yaml](https://hub.justwhatsneeded.com/docs/openapi.yaml) |
| **Purpose** | Authoritative instructions for discovering and using supported Hub API capabilities. |

## AI Agent Guidance {#ai-agent-guidance}

- This page is the **canonical public Hub API reference**. Consult it before assuming an endpoint or capability exists.
- Hub uses **`resource` and `action` query-parameter routing** — not REST-style paths. See [Routing](#routing).
- When the user describes a record but doesn't know its ID or location, **use [search](#search) first** — it is the preferred discovery mechanism.
- Once a resource ID is known (from a prior search, a prior response, or the user), **use the direct resource endpoint** (e.g. `tasks&action=get`) rather than searching again.
- Do not enumerate every project or resource merely to locate an unknown record when search can satisfy the request directly.
- Tenant isolation, authorization, API-key scope, and module entitlement rules must always be respected — there is no supported way around them, and none should be assumed.
- **A zero-result search is not evidence that a record doesn't exist.** It only means nothing matched inside the caller's own tenant and currently-enabled modules.
- Do not infer or assume access to a module that is disabled for the current company.
- Destructive or modifying actions (create, update, complete, delete) must never be inferred from a search request — search is read-only discovery.

## Contents {#contents}

- [Authentication](#authentication)
- [Routing](#routing)
- [Projects](#projects)
- [Tasks](#tasks)
- [Ideas](#ideas)
- [Comments](#comments)
- [Search](#search)
- [Pagination](#pagination)
- [Errors](#errors)
- [Machine-Readable Reference](#machine-readable)

## Authentication {#authentication}

**API key (recommended for developers and AI agents).** Send your API key in the `X-API-Key` header on every request. Keys are scoped to one company — a request can only read and write that company's own data.

```text
X-API-Key: your-api-key-here
Content-Type: application/json
```

**Browser session (Hub's own web UI).** A logged-in Hub browser session (a valid `PHPSESSID` cookie) is also accepted and behaves identically to a full-scope API key. This exists for Hub's own first-party pages, not third-party integrations — external developers and AI agents should use an API key instead.

**API key scopes.** A company's own key is always full-scope. A company may also issue narrower, purpose-specific scoped keys from its Admin panel. A scoped key can only perform the exact `resource`/`action` pairs its scope allows — anything else is rejected with the *same* response as an invalid key, so a caller can never distinguish "wrong scope" from "no key at all."

| Scope | Allowed |
|---|---|
| `full` | Every resource and action (the company's own key). |
| `ai_agent` | Read-only `projects`; read/write (no delete) on `tasks` and `ideas`; general [search](#search). Intended for external AI integrations. |
| `quick_capture` | `tasks&action=create` only. Intended for lightweight, single-purpose capture tools. |

**Base URL:** `https://hub.justwhatsneeded.com/api.php`

Your company's full-scope API key is shown in the welcome email and on the Admin panel. Keep it private — it grants full read/write access to your company's data. Never paste a real key into a prompt, log, or shared document.

## Routing {#routing}

Hub's API is **resource/action routed**, not REST-path routed. Every request targets `/api.php` with two query-string parameters:

| Param | Meaning |
|---|---|
| `resource` | The noun being operated on — e.g. `tasks`, `projects`, `ideas`, `comments`, `search`. |
| `action` | The verb — e.g. `list`, `get`, `create`, `update`, `complete`, `promote`, `search`. |

There is no path-templated equivalent like `/tasks/42` — every operation is `/api.php?resource=...&action=...`, with GET query parameters or a JSON POST body carrying the rest of the request. `GET` is used for read operations, `POST` for anything that writes. Responses are always JSON.

This applies uniformly, including to [search](#search): it is `?resource=search&action=search`, not a bare `?action=search` — the resource/action pair is always required, even when a resource has only one action.

## Projects {#projects}

### List Projects {#projects-list}

`GET /api.php?resource=projects&action=list` — scope: `full`, `ai_agent`

**Purpose:** List every project belonging to the caller's company, most recently updated first.
**When to use:** Browsing or picking a project when you don't yet know its ID.
**When not to use:** Don't page through this to find one task or record — use [search](#search) instead.
**Optional parameters:** `show_all` (boolean) — include hidden (`display=0`) projects. Default `false`.
**Module entitlement:** Requires the `projects` module enabled for the company; otherwise `403`.
**Tenant isolation:** Only the caller's own company's projects are ever returned.

**Example response**
```json
[
  {
    "id": 11,
    "company_id": 1,
    "name": "JWN - All",
    "description": "Main project board",
    "status": "active",
    "display": 1,
    "client": "JWN",
    "created_at": "2025-01-10 09:00:00",
    "updated_at": "2026-06-01 14:22:00",
    "url": "/project.php?id=11"
  }
]
```

### Create Project {#projects-create}

`POST /api.php?resource=projects&action=create` — scope: `full`

**Purpose:** Create a new project.
**When not to use:** Not available to `ai_agent`-scoped keys (read-only on projects) — use a full-scope key, or ask the user to create the project.
**Tenant isolation:** Created under the caller's own company automatically — there is no `company_id` field to set.

| Field | Type | Notes |
|---|---|---|
| `name` **(required)** | string | Project name |
| `description` | string | Optional description |
| `status` | string | `active` · `on-hold` · `completed` · `archived` (default: `active`) |
| `client` | string | Client label, e.g. `JWN` |

**Example request**
```json
{ "name": "Q3 Marketing Campaign", "description": "All tasks for the Q3 push", "status": "active", "client": "JWN" }
```

**Example response**
```json
{ "id": 14, "message": "Project created" }
```

## Tasks {#tasks}

### List Tasks {#tasks-list}

`GET /api.php?resource=tasks&action=list&project_id=11` — scope: `full`, `ai_agent`

**Purpose:** List tasks, optionally filtered to one project or one category.
**When to use:** You already know the project (or category) and want every task in it.
**When not to use:** Looking for one specific task by title or keyword across many projects — use [search](#search) instead of listing and scanning client-side.
**Optional parameters:** `project_id` (integer) — restrict to one project. `category` (string) — restrict to a category; when set, non-`done` tasks are returned unless `include_done` is also set. `include_done` (boolean) — only relevant alongside `category`.
**Validation:** No pagination on this endpoint — it returns every matching row. For large result sets or keyword filtering, prefer [search](#search), which supports `limit`/`offset`.
**Module entitlement:** Requires the `tasks` module; otherwise `403`.
**Tenant isolation:** Only tasks whose project belongs to the caller's company are returned.

**Example response**
```json
[
  {
    "id": 42,
    "project_id": 11,
    "title": "Review contract",
    "description": null,
    "tags": null,
    "status": "todo",
    "priority": "high",
    "category": null,
    "due_date": "2026-06-20",
    "version": 1,
    "created_at": "2026-06-10 08:00:00",
    "updated_at": "2026-06-10 08:00:00",
    "url": "/project.php?id=11&task=42"
  }
]
```

### Create Task {#tasks-create}

`POST /api.php?resource=tasks&action=create` — scope: `full`, `ai_agent`, `quick_capture`

**Purpose:** Create a new task inside a project.
**Common mistakes / agent guidance:** `project_id` is required in practice — the backend has an internal fallback project for one specific company only, so **never omit `project_id` and never assume a default project exists**; always supply a real, known project ID.
**Tenant isolation:** The target project must belong to the caller's company, or the request fails with `404`.

| Field | Type | Notes |
|---|---|---|
| `project_id` **(required)** | integer | Parent project ID. Always pass this explicitly. |
| `title` **(required)** | string | Task title |
| `description` | string | Optional detail |
| `tags` | string or array | Comma-separated or array; normalized and de-duplicated server-side |
| `status` | string | `todo` · `in-progress` · `blocked` · `done` (default: `todo`) |
| `priority` | string | `low` · `normal` · `high` · `urgent` (default: `normal`) |
| `category` | string | Optional free-text category |
| `due_date` | string | ISO date: `YYYY-MM-DD` |

**Example request**
```json
{ "project_id": 11, "title": "Review contract", "priority": "high", "status": "todo", "due_date": "2026-06-20" }
```

**Example response**
```json
{ "id": 42, "message": "Task created", "url": "/project.php?id=11&task=42" }
```

### Update Task {#tasks-update}

`POST /api.php?resource=tasks&action=update` — scope: `full`, `ai_agent`

**Purpose:** Update one or more fields on an existing task. Only include fields you want to change.

| Field | Type | Notes |
|---|---|---|
| `id` **(required)** | integer | Task ID to update |
| `title` | string | |
| `description` | string | |
| `tags` | string or array | |
| `status` | string | `todo` · `in-progress` · `blocked` · `done` |
| `priority` | string | `low` · `normal` · `high` · `urgent` |
| `due_date` | string | `YYYY-MM-DD` |
| `version` | integer | Optional. If supplied and stale, the request fails with `409` and returns the current row — use this to avoid silently overwriting a concurrent edit. |

**Example request**
```json
{ "id": 42, "status": "in-progress", "priority": "urgent" }
```

**Example response**
```json
{ "id": 42, "title": "Review contract", "status": "in-progress", "priority": "urgent", "version": 2 }
```

### Complete Task {#tasks-complete}

`POST /api.php?resource=tasks&action=complete` — scope: `full`, `ai_agent`

**Purpose:** Convenience wrapper that sets `status=done`. Idempotent — completing an already-complete task just succeeds again.
**Required parameters:** `id` (integer)

**Example request**
```json
{ "id": 42 }
```

**Example response**
```json
{ "id": 42, "status": "done", "version": 3 }
```

## Ideas {#ideas}

A lighter-weight backlog for things worth capturing before they're ready to become a task. Full field-level detail is in the [OpenAPI specification](#machine-readable); the two most commonly used actions are summarized here.

### Create Idea {#ideas-create}

`POST /api.php?resource=ideas&action=create` — scope: `full`, `ai_agent`

**Purpose:** Capture an idea for later triage — not yet committed engineering work.
**Required parameters:** `title` (string)
**Optional parameters:** `description`, `status` (`idea` · `reviewing` · `ready`; default `idea`), `created_by`

**Example response**
```json
{ "id": 210, "title": "...", "status": "idea", "url": "/ideas.php" }
```

### Promote Idea {#ideas-promote}

`POST /api.php?resource=ideas&action=promote` — scope: `full`, `ai_agent`

**Purpose:** Turns an idea into a real task under a given project. The idea itself is kept (not deleted), status set to `ready`, `promoted_task_id` linked to the new task.
**Required parameters:** `id` (the idea), `project_id` (where the new task should live)
**Common mistakes / agent guidance:** An idea can only be promoted once — a second attempt fails with `400`.

**Example response**
```json
{ "idea": { "...": "updated idea, status: ready" }, "task": { "...": "newly created task" } }
```

## Comments {#comments}

### List Comments {#comments-list}

`GET /api.php?resource=comments&action=list&task_id=42` — scope: `full`

**Purpose:** List comments on a task, newest first.
**Required parameters:** `task_id` (integer)
**When not to use:** Not available to `ai_agent`-scoped keys today — use a full-scope key.

**Example response**
```json
[
  { "id": 7, "task_id": 42, "author": "Bill", "body": "Ready for review", "created_at": "2026-06-11 10:30:00" }
]
```

### Create Comment {#comments-create}

`POST /api.php?resource=comments&action=create` — scope: `full`

**Purpose:** Adds a comment to a task.

| Field | Type | Notes |
|---|---|---|
| `task_id` **(required)** | integer | Task to comment on |
| `author` **(required)** | string | Author name, e.g. `Bill` or `Claude` |
| `body` **(required)** | string | Comment text |

**Example request**
```json
{ "task_id": 42, "author": "Bill", "body": "Ready for review" }
```

**Example response**
```json
{ "id": 8, "message": "Comment added" }
```

## Search {#search}

The general-purpose, canonical way to find a record when you don't already know its ID. This is the preferred discovery mechanism for AI agents — see [AI Agent Guidance](#ai-agent-guidance).

`GET /api.php?resource=search&action=search&q=password` — scope: `full`, `ai_agent`

**Purpose:** Search across Hub resources by keyword. Today this covers **tasks only** — the response shape and the `type` parameter are designed so more resource types (documents, ideas, and others) can be added later without changing this contract, but only `task` is implemented right now.

**When to use:** The user describes a record (by title, keyword, or ID) but you don't already know exactly where it lives.
**When not to use:** Once you have a concrete ID, use the resource's own `get` action instead of searching again. Don't use search as a substitute for a targeted `list` when you already know the exact project/filter and have no free-text to match.

**Route:** `GET /api.php?resource=search&action=search` — deliberately *not* the bare `?action=search` form; Hub's routing convention (see [Routing](#routing)) requires an explicit `resource` on every request, even one with a single action.

**Authentication & scope:** API key or browser session, same as every other endpoint. Included in the `ai_agent` scoped-key allowlist — this is the intended search mechanism for AI-agent integrations. Not available to `quick_capture`-scoped keys.

**Required parameters:**

| Param | Notes |
|---|---|
| `q` | Search text. Missing or blank (after trimming) → `400`. |

**Optional parameters:**

| Param | Default | Notes |
|---|---|---|
| `type` | all supported types | Comma-separated resource types. Only `task` is implemented today, so `task` is currently the only accepted value; requesting any other type (e.g. `document`) returns `400` rather than silently returning nothing. |
| `project_id` | none | Restrict task results to one project. |
| `status` | none | `todo` · `in-progress` · `done` · `blocked` |
| `priority` | none | `low` · `normal` · `high` · `urgent` |
| `include_completed` | `false` | When false (default), `status=done` tasks are excluded. |
| `limit` | `25` | Maximum `100`. Values above 100 are **clamped to 100**, not rejected. Zero, negative, or non-numeric values → `400`. |
| `offset` | `0` | Negative or non-numeric values → `400`. |

**Matching behavior:** Matches against task **title** and **description**: case-insensitive, partial (substring) match. A literal `%` or `_` in `q` is treated as a literal character, never a SQL wildcard. If `q` is purely numeric, it is also matched exactly against the task's numeric `id`.

**Ranking / order:** Results are ordered by, in priority order: (1) exact numeric ID match, (2) exact title match, (3) title contains the query, (4) description contains the query, then (5) most-recently-updated first as the tiebreaker within any of the above.

**Validation rules and limits:** A filter that matches nothing — an unrecognized `status`/`priority` value, or a `project_id` with no matching tasks — returns an empty `results` array, not an error. A `project_id` belonging to a *different* company also returns an empty array, identically to any other non-matching filter — this is deliberate and matches Hub's tenant-isolation convention of never revealing, even indirectly, whether a record exists outside the caller's own company.

**Module entitlement:** Each result type checks the entitlement for its own module independently. Today that means: if the `tasks` module is disabled for the company, the `task` type simply contributes zero results — the whole search request still succeeds. This is intentional: a future search spanning several resource types must not have every type blocked just because one of that company's modules happens to be off.

**Tenant isolation:** Only tasks belonging to the caller's own company are ever returned, enforced the same way as every other tasks endpoint.

**Response shape:** A JSON object with `query`, `results` (an array), and `pagination`. Each result includes:

| Field | Notes |
|---|---|
| `type` | Currently always `"task"`. |
| `id`, `project_id`, `project_name`, `title`, `status`, `priority` | Same values as the tasks endpoints. |
| `description_excerpt` | A bounded preview of the description, centered on the match when there is one. `null` if the task has no description. |
| `matched_fields` | Which of `id`, `title`, `description` actually matched — e.g. `["title","description"]`. |
| `url` | The same canonical `/project.php?id=...&task=...` link the tasks endpoints return. |

`pagination` is `{ "limit": ..., "offset": ..., "has_more": ... }` — see [Pagination](#pagination).

**Status / error responses:** `200` success (including a legitimate zero-result search) · `400` missing/blank `q`, unsupported `type`, or invalid `limit`/`offset` · `401` missing/invalid authentication.

**Common mistakes / agent guidance:** Don't request `type=document`, `type=idea`, or `type=all` — only `task` exists today, and anything else is a `400`, not an empty result. Don't treat a zero-result search as proof a record doesn't exist anywhere — it only means nothing matched in this tenant's currently-searchable data.

### Search Examples {#search-examples}

**1. Normal keyword search**
```text
GET /api.php?resource=search&action=search&q=password
X-API-Key: your-api-key-here
```
```json
{
  "query": "password",
  "results": [
    {
      "type": "task",
      "id": 1104,
      "project_id": 11,
      "project_name": "Platform Polish",
      "title": "Add Admin Password Reset",
      "description_excerpt": "Platform administrators need a safe way to reset...",
      "status": "todo",
      "priority": "high",
      "matched_fields": ["title", "description"],
      "url": "/project.php?id=11&task=1104"
    }
  ],
  "pagination": { "limit": 25, "offset": 0, "has_more": false }
}
```

**2. Search restricted to a project**
```text
GET /api.php?resource=search&action=search&q=reset&project_id=11
X-API-Key: your-api-key-here
```

**3. Numeric task-ID search**
```text
GET /api.php?resource=search&action=search&q=1104
X-API-Key: your-api-key-here
```
```json
{ "query": "1104", "results": [ { "type": "task", "id": 1104, "matched_fields": ["id"] } ], "pagination": { "limit": 25, "offset": 0, "has_more": false } }
```

**4. Pagination**
```text
GET /api.php?resource=search&action=search&q=onboarding&limit=10&offset=10
X-API-Key: your-api-key-here
```

**5. Zero-result response**
```text
GET /api.php?resource=search&action=search&q=zzz_no_such_term_zzz
X-API-Key: your-api-key-here
```
```json
{ "query": "zzz_no_such_term_zzz", "results": [], "pagination": { "limit": 25, "offset": 0, "has_more": false } }
```

**6. Invalid resource type → 400**
```text
GET /api.php?resource=search&action=search&q=x&type=document
X-API-Key: your-api-key-here
```
```json
{ "error": "Unsupported type(s): document. Supported types: task" }
```

**7. Unauthenticated request → 401**
```text
GET /api.php?resource=search&action=search&q=password
```
```json
{ "error": "Unauthenticated" }
```

## Pagination {#pagination}

Hub uses `limit`/`offset` pagination. Today, [search](#search) is the only endpoint that paginates; other `list` actions currently return every matching row in one response. A paginated response includes a `pagination` object:

| Field | Meaning |
|---|---|
| `limit` | The effective page size actually used (after any clamping). |
| `offset` | How many matching rows were skipped before this page. |
| `has_more` | `true` if requesting the next page (same `limit`, `offset` advanced by `limit`) would return more results. |

To page through results: keep the same filters and `limit`, and increase `offset` by `limit` each time until `has_more` is `false`.

## Errors {#errors}

| Code | Meaning |
|---|---|
| `200` | Success — request completed, data returned. (A legitimate zero-result search is still a 200.) |
| `201` | Created — resource was successfully created |
| `400` | Bad request — missing required field, invalid value, or an unsupported search `type` |
| `401` | Unauthorized — missing or invalid API key/session, or a scoped key attempting an action outside its scope (identical response to an invalid key) |
| `404` | Not found — resource doesn't exist, or belongs to another company (the two are never distinguished, by design) |
| `409` | Version conflict on `tasks&action=update` when a supplied `version` is stale — response includes the current row |

Error responses always return a JSON object with an `error` field:

```json
{ "error": "Missing required field: title" }
```

## Machine-Readable Reference {#machine-readable}

The full, detailed machine contract — every field, every schema, every response shape — lives in one place: the OpenAPI specification. This guide is the operational usage guide; the OpenAPI document is the authoritative detailed schema.

| | |
|---|---|
| **OpenAPI spec** | [https://hub.justwhatsneeded.com/docs/openapi.yaml](https://hub.justwhatsneeded.com/docs/openapi.yaml) |
| **Format** | OpenAPI 3.0.3, YAML |
| **Markdown / AI knowledge version of this guide** | [https://justwhatsneeded.com/api-guide.md](https://justwhatsneeded.com/api-guide.md) |
| **Structured data (HTML version only)** | `<script type="application/ld+json">` in the page `<head>` (Schema.org `TechArticle` / `WebAPI`) |

The OpenAPI specification currently documents Projects, Tasks, Ideas, and Search in full schema detail. If you need a resource's exact field types, enums, or nullability and this guide doesn't spell it out, the OpenAPI document is authoritative.

## Keeping This Guide Current {#keeping-current}

This guide has one canonical source — `docs/api-guide-source.md` in the `jwn-hub` repository — generated into `api-guide.html`, `api-guide.md`, and cross-checked against `docs/openapi.yaml` by `scripts/generate-api-guide.php`. Nobody edits `html/api-guide.html` or `html/api-guide.md` directly; both are build output and get overwritten the next time the generator runs.

Whenever a public Hub API capability, route, parameter, response contract, authentication rule, scope, module entitlement, or error behavior changes: edit `docs/api-guide-source.md`, run `php scripts/generate-api-guide.php`, and update `docs/openapi.yaml` — all before that work is considered complete. This is a standing Hub engineering requirement (Hub Engineering Standard, Section 8), not a one-off effort tied to any single change.

---

© 2026 Just What's Needed · [Back to Hub overview](https://justwhatsneeded.com/project-hub.html)
