Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions openspec/changes/example-change/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Design — example-change

> ⚠️ **EXAMPLE DESIGN DOC** — Design decisions recorded here are for the
> template's own reference material, not for a real app you are building.
> Use this file as a worked example of what a `design.md` looks like inside
> a real OpenSpec change.

## Context

The template's `lib/**/*.php` code is peppered with `@spec openspec/changes/example-change/tasks.md#task-N` docblock tags (nine tasks in total) that originally pointed at nothing — the change directory did not exist. This change fills in the missing scaffold so the template is internally consistent and so future `openspec-*` skill runs over a fresh clone resolve the references cleanly.

The change was not built forward in time — the code came first, the specs came second. That makes this a **retrofit-shaped** change in substance even though the `example-change` name reads as if the change describes a forward build. The `example: true` frontmatter on every spec makes the demonstration-only intent explicit to readers.

## How REQs were derived

Each spec was written by reading the implementing file(s) and documenting observable behaviour: inputs, outputs, preconditions, postconditions, failure modes. Where a docblock named an ADR (ADR-003 thin controllers, ADR-004 path-based deep links, ADR-005 per-object authorisation, ADR-006 observability endpoints, ADR-011 schema.org Article), the spec carries the same reference so that the architectural rationale and the concrete REQ live one click apart.

No aspirational behaviour was added. Where the template's code is a stub (e.g. `MetricsController` publishes a hardcoded version `0.1.0` as a placeholder rather than reading the actual app version), the spec documents the stub state with a `TODO`-flavoured note rather than pretending the implementation is production-ready.

## Mapping tasks to capabilities

Seven capabilities were carved from nine tasks along natural boundaries:

- API + Service layers for the same feature share a capability (`settings-management` covers both `SettingsController` and `SettingsService`).
- Nextcloud framework-integration classes (`ISettings` + `IIconSection`) share a capability (`admin-ui`) because they are joined by a section-id string that they MUST keep in sync.
- `MetricsController` + `HealthController` share `observability` because ADR-006 requires both endpoints together and they're useless apart.
- `DashboardController` is its own capability because the SPA entry is independent of any feature domain.
- `DeepLinkRegistrationListener` is its own capability because the Nextcloud event subscription pattern is a distinct architectural concern.

## Known scope overlap on task-5

Task-5 annotates two functionally unrelated bodies of code in the template:

1. The **ADR-005 per-object authorization demo** in `ItemController::destroy` + `ItemService::delete` + the two private helpers (`isAuthorized`, `extractOwner`).
2. The **first-install repair step** in `Repair\InitializeSettings` that imports the app's bundled JSON configuration via OpenRegister.

These are two different capabilities (`item-management` and `configuration-initialization`), and the right structure would be two tasks (task-5a, task-5b or task-5 + task-10). The template lumps them together as task-5, most likely because both files demonstrate defensive-coding patterns (generic error messages + server-side logging) and an earlier draft saw them as one "defensive patterns" task.

This change documents the status quo honestly: task-5 is cross-capability and implements REQs in both `item-management` and `configuration-initialization`. Fixing the overlap would require editing the docblock tags in three files (`ItemController.php`, `ItemService.php`, `Repair/InitializeSettings.php`), which is out of scope for the change that is purely adding the missing proposal/tasks/design triad. It is recorded here so an informed reader can decide whether to split the task in a follow-up.

## What this change deliberately does not do

- It does not touch any `lib/**/*.php` file. The existing `@spec example-change/task-N` docblock tags are left exactly as they were.
- It does not archive itself. Archival would move the delta into `openspec/specs/` and remove the tasks — but the task file is what the code's `@spec` tags point at. Keeping the change `in-progress` (spiritually `example`) is what keeps the template internally consistent.
- It does not add any ADRs. The capability specs refer to ADRs by number (003, 004, 005, 006, 011) but those ADRs live in `openspec/architecture/` and are out of scope for this change.
- It does not generate any `openspec/changes/example-change/specs/` delta directory. Since the change is "already there" in spirit (the code was written first) and the `openspec/specs/` directory now holds the full end-state specs directly, duplicating them as a delta would add maintenance cost without improving the demonstration.

## What future edits might change

- Split task-5 into two tasks and update the three docblock tags.
- Promote one of the ADR references into an actual ADR file under `openspec/architecture/` so the template showcases the full ADR → capability → code chain.
- Add tests under `tests/` that exercise the example scenarios so the REQs are enforced by CI, turning the specs from aspirational to load-bearing.
- Once the template has a second stable change (e.g. `add-export-api`), consider archiving `example-change` and resetting the code's `@spec` tags to the new change, so newcomers see a fully-archived historical record plus an active change.
64 changes: 64 additions & 0 deletions openspec/changes/example-change/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Example Change — initial template build-out

> ⚠️ **EXAMPLE CHANGE** — This directory lives in the `nextcloud-app-template`
> repository as a demonstration of the OpenSpec change structure. Every file
> in this change is reference material. Apps built from this template should:
> 1. Rename this change to something meaningful (e.g. `initial-scaffold`)
> 2. Update the task list to reflect the real work they're doing
> 3. Keep the docblock `@spec` references in code aligned with the renamed
> tasks, OR archive this change cleanly first and let future real changes
> start from zero

## Why this change exists

A template-derived Nextcloud app starts from a set of patterns that cover
the basics of every Conduction app: a Vue SPA entry, settings CRUD, admin-UI
registration, per-object authorization, repair-step initialization, deep-link
registration, and ADR-006 observability endpoints. The `example-change`
records those patterns as a single "initial build-out" change so that:

- The `@spec openspec/changes/example-change/tasks.md#task-N` docblock tags
peppered through the template's `lib/**/*.php` code resolve to real tasks
inside this change.
- A fresh-cloned template is **internally consistent**: running the
openspec-propose / openspec-apply / openspec-archive skills against it
reflects a well-formed example instead of pointing at missing files.
- Developers new to OpenSpec see a worked example of the proposal /
tasks / design triad with concrete REQ anchors, before they write their
own.

## What this change produces

Seven capability specs in `openspec/specs/` (all marked `example: true`):

| Capability | Source files | Implementing tasks |
|---|---|---|
| `dashboard-page` | `DashboardController.php` | task-1 |
| `settings-management` | `SettingsController.php`, `SettingsService.php` | task-2, task-3 |
| `deep-linking` | `DeepLinkRegistrationListener.php` | task-4 |
| `item-management` | `ItemController.php`, `ItemService.php` | task-5 (item part) |
| `configuration-initialization` | `Repair/InitializeSettings.php` | task-5 (repair part) |
| `admin-ui` | `Settings/AdminSettings.php`, `Sections/SettingsSection.php` | task-6, task-7 |
| `observability` | `MetricsController.php`, `HealthController.php` | task-8, task-9 |

See `tasks.md` for the flat task list the code's `@spec` annotations reference,
and `design.md` for decisions made while writing the specs (including the
known task-5 scope overlap).

## Status

`example` — this change is deliberately **not archived**. Archiving would
merge the change's spec deltas into `openspec/specs/` and remove the tasks
this change documents; the template needs those tasks to stay in place so
that the `@spec` references in `lib/*.php` continue to resolve after clone.

When an app is generated from this template:

- Either rename the change and keep it in `openspec/changes/` (reflecting the
initial scaffold that the real app begins with), or
- Delete this change + clear the `@spec` tags in a single first commit, then
build up the app's own change history via `/opsx-new`, `/opsx-ff`, and
`/opsx-apply`.

The `status: example` frontmatter in each spec and the "EXAMPLE" banner at the
top of each file make the demonstration nature explicit.
23 changes: 23 additions & 0 deletions openspec/changes/example-change/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Tasks — example-change

> ⚠️ **EXAMPLE TASK LIST** — These tasks are reference material. See
> `proposal.md` for what to do when you generate an app from this template.
> All `[x]` here mean "the task's code has already been written into the
> template" — NOT "done in your app". Apps building from this template will
> flip these back to `[ ]` (or replace them entirely) for their own work.

The task numbering matches the `@spec openspec/changes/example-change/tasks.md#task-N`
annotations in `lib/**/*.php`. Do not renumber without also updating every
docblock.

## Tasks

- [x] task-1: dashboard-page#REQ-DASH-001 + REQ-DASH-002 — render the SPA entry point and provide a Vue-history-mode catch-all (`DashboardController::page`, `catchAll`)
- [x] task-2: settings-management#REQ-CFG-001 + REQ-CFG-002 + REQ-CFG-003 — expose the settings API layer with admin-gated writes and reload (`SettingsController::index`, `create`, `load`)
- [x] task-3: settings-management#REQ-CFG-001..004 — implement the settings service layer with OpenRegister-availability fallback and ConfigurationService-backed reload (`SettingsService::getSettings`, `updateSettings`, `isOpenRegisterAvailable`, `loadConfiguration`)
- [x] task-4: deep-linking#REQ-LINK-001 — subscribe to `DeepLinkRegistrationEvent` and register the Article schema's path-based URL template (`DeepLinkRegistrationListener::handle`)
- [x] task-5: item-management#REQ-ITEM-001 + REQ-ITEM-002 AND configuration-initialization#REQ-INIT-001 + REQ-INIT-002 — (1) implement the ADR-005 per-object authorization pattern on `DELETE /api/items/{id}` (`ItemController::destroy`, `ItemService::delete`, `isAuthorized`, `extractOwner`) AND (2) provide the first-install/repair step that imports the app's bundled `*_register.json` via OpenRegister's ConfigurationService (`Repair\InitializeSettings::getName`, `run`). See `design.md` §"Known scope overlap on task-5" for why these two bodies of work share a task number in the template.
- [x] task-6: admin-ui#REQ-UI-002 — register the admin settings form and pass the app version into the template (`Settings\AdminSettings::getForm`, `getSection`, `getPriority`)
- [x] task-7: admin-ui#REQ-UI-001 — register the IIconSection metadata (id, localised name, priority, icon) for the admin panel navigation (`Sections\SettingsSection::getID`, `getName`, `getPriority`, `getIcon`)
- [x] task-8: observability#REQ-OBS-001 — expose the admin-only Prometheus metrics endpoint with the `{app}_info` and `{app}_health_status` gauges (`MetricsController::index`)
- [x] task-9: observability#REQ-OBS-002 — expose the public health-check endpoint with dependency-aware status codes (200 healthy / 503 degraded) (`HealthController::index`)
64 changes: 64 additions & 0 deletions openspec/specs/admin-ui/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
---
example: true
capability: admin-ui
status: example
built_by: openspec/changes/example-change
---

# Admin UI Registration Specification

> ⚠️ **EXAMPLE SPEC** — This spec lives in the `nextcloud-app-template` repository
> as a demonstration of the OpenSpec format. It describes the Nextcloud admin-
> panel registration in `lib/Settings/AdminSettings.php` + `lib/Sections/SettingsSection.php`.
> Apps built from this template will typically keep this capability almost
> unchanged; the substitutions are the section ID string, display name, icon
> path, and priority numbers.

## Purpose

Registers the app's configuration panel inside Nextcloud's admin settings UI.
Two pieces work together:

- A **section** (IIconSection implementation) — gives the panel a unique id,
display name, order priority, and icon. Shows up as an entry in the admin
navigation.
- A **settings form** (ISettings implementation) — renders the actual form
template within that section.

Both pieces MUST agree on the section id string; Nextcloud uses the id as the
join key.

## Requirements

### REQ-UI-001: Register the admin section (IIconSection)

The system MUST register a section in Nextcloud's admin settings panel so
that the app has a dedicated place to render its configuration form.

#### Scenario: Section appears in admin navigation

- GIVEN the app is installed and enabled
- WHEN Nextcloud enumerates admin-setting sections
- THEN `SettingsSection::getID()` MUST return a stable identifier (`app-template` in the template; apps MUST substitute their own id)
- AND `SettingsSection::getName()` MUST return a localised display name (via `IL10N::t()`)
- AND `SettingsSection::getPriority()` MUST return an integer controlling ordering (template uses `75`)
- AND `SettingsSection::getIcon()` MUST return an image path produced by `IURLGenerator::imagePath()` (template uses `app-template/app-dark.svg`)

### REQ-UI-002: Render the admin settings form (ISettings)

The system MUST render the admin form template when Nextcloud opens the app's
admin section, and it MUST pass the running app version into the template so
the admin UI can display it.

#### Scenario: Admin opens the app's settings section

- GIVEN an admin user opens the app section registered by REQ-UI-001
- WHEN Nextcloud invokes `AdminSettings::getForm()`
- THEN the system MUST return a `TemplateResponse` rendering the `settings/admin` template
- AND the response parameters MUST include the current app version (fetched via `IAppManager::getAppVersion()`)

#### Scenario: Section join key

- WHEN Nextcloud resolves which section the form belongs to
- THEN `AdminSettings::getSection()` MUST return the same string that `SettingsSection::getID()` returns
- AND `AdminSettings::getPriority()` MUST return an integer controlling the form's position within the section
71 changes: 71 additions & 0 deletions openspec/specs/configuration-initialization/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
---
example: true
capability: configuration-initialization
status: example
built_by: openspec/changes/example-change
---

# Configuration Initialization Specification

> ⚠️ **EXAMPLE SPEC** — This spec lives in the `nextcloud-app-template` repository
> as a demonstration of the OpenSpec format. It describes the behaviour of
> `lib/Repair/InitializeSettings.php` in the template's own code. Apps built
> from this template will typically keep this capability almost unchanged; the
> only substitutions are the bundled config file name and the schema/register
> IDs that the import produces.

## Purpose

Populates the app's OpenRegister schemas + registers on first install (and
after upgrades that ship a new bundled configuration) from a JSON file
committed alongside the app's PHP code. The work happens inside a Nextcloud
repair step so it runs during `occ maintenance:repair` and during the
install/upgrade flow automatically.

The repair step MUST be non-fatal: a missing or failed import MUST log a
warning and allow the rest of the repair pass to continue. An app that cannot
boot without its registers is a separate, stricter contract and belongs in a
different capability if needed.

## Requirements

### REQ-INIT-001: Identify the repair step

The system MUST expose a human-readable name for the repair step so that it
appears in Nextcloud's occ repair output.

#### Scenario: Name is surfaced

- WHEN Nextcloud enumerates repair steps
- THEN `InitializeSettings::getName()` MUST return a non-empty string identifying this step
- AND the name MUST mention the app and what the step does ("Initialize AppTemplate register and schemas via ConfigurationService" or equivalent after substitution)

### REQ-INIT-002: Import configuration on install / upgrade

The system MUST, when the repair step runs, invoke `SettingsService::loadConfiguration(force: true)`. If OpenRegister is not available, the step MUST log a warning and return without throwing. If the service call throws, the step MUST catch the exception, log the error with context, and continue — it MUST NOT let the failure abort the rest of the Nextcloud repair pass.

#### Scenario: Happy-path first install

- GIVEN the `openregister` app is installed and enabled
- AND the app's bundled `app_template_register.json` is present
- WHEN `InitializeSettings::run()` executes
- THEN the system MUST write a progress message to the repair `IOutput`
- AND the system MUST call `SettingsService::loadConfiguration(force: true)`
- AND on success, it MUST record the result (including schema/register IDs) in the server-side log at info level

#### Scenario: OpenRegister is missing

- GIVEN the `openregister` app is not installed or not enabled
- WHEN `run()` executes
- THEN the step MUST detect the unavailability via `SettingsService::isOpenRegisterAvailable()`
- AND it MUST write a warning to `IOutput` and to the logger
- AND it MUST return normally (no exception) so that subsequent repair steps can run

#### Scenario: ConfigurationService throws

- GIVEN OpenRegister is installed but the bundled JSON is malformed (or any other `ConfigurationService` failure)
- WHEN `loadConfiguration()` throws
- THEN the repair step MUST catch the exception
- AND it MUST log the exception with full context (`$logger->error(message, ['exception' => $e])`)
- AND it MUST write a user-visible warning to `IOutput`
- AND the repair step MUST return normally (the surrounding repair pass keeps running)
Loading