> ## Documentation Index
> Fetch the complete documentation index at: https://docs.formae.io/llms.txt
> Use this file to discover all available pages before exploring further.

# 02 - Resource schema

> Define the File resource type in PKL with ResourceHint and FieldHint annotations, and validate it.

This section defines the `File` resource type in PKL. The schema describes the properties of files we'll manage on SFTP servers.

Most plugins define multiple resource types. An AWS plugin, for example, might have `EC2::Instance`, `S3::Bucket`, `RDS::Database`, and dozens more. Our SFTP plugin only needs one resource type (`File`), which keeps the tutorial focused. If you're building a plugin with multiple resource types, see [15 - Real-World Plugins](/plugin-development/tutorial/15-real-world-plugins) for patterns on organizing schemas and dispatching operations by resource type.

## Design the resource

Before writing the schema, think about the properties of a file on an SFTP server:

| Property      | Type   | Mutability    | Description                                                  |
| ------------- | ------ | ------------- | ------------------------------------------------------------ |
| `path`        | String | **Immutable** | File path on the server. Changing it means a different file. |
| `content`     | String | Mutable       | Text content of the file                                     |
| `permissions` | String | **Immutable** | Unix permissions (e.g., "0644"). Changing requires replace.  |

The `path` is the **identifier**: it uniquely identifies the file on the server.

Note that we don't include computed values like file size or modification time in the schema. The schema defines what users *declare*. Computed values are returned by your plugin's Read operation but aren't part of the declarative specification.

## Write the schema

Replace the contents of `schema/pkl/sftp.pkl`:

```kotlin theme={"languages":{"custom":["/languages/pkl.json"]}}
module sftp

import "@formae/formae.pkl"

/// A text file on an SFTP server.
@formae.ResourceHint {
    type = "SFTP::Files::File"
    identifier = "$.path"
}
class File extends formae.Resource {
    fixed hidden type: String = "SFTP::Files::File"

    /// Path to the file on the SFTP server.
    /// Cannot be changed after creation - changing the path means a different file.
    @formae.FieldHint { createOnly = true }
    path: String

    /// Text content of the file.
    @formae.FieldHint {}
    content: String

    /// Unix file permissions (e.g., "0644", "0755").
    /// Defaults to "0644" if not specified.
    @formae.FieldHint { createOnly = true }
    permissions: String = "0644"
}
```

## Schema annotations

PKL annotations provide metadata that tells formae how to handle your resources and fields. For complete documentation of all annotation options, see the schema reference.

### @formae.ResourceHint

Every resource class needs a `@formae.ResourceHint` annotation with two required fields:

```kotlin theme={"languages":{"custom":["/languages/pkl.json"]}}
@formae.ResourceHint {
    type = "SFTP::Files::File"
    identifier = "$.path"
}
```

* **type**: the full resource type following the pattern `NAMESPACE::SERVICE::RESOURCE`
* **identifier**: a [JSONPath](https://www.rfc-editor.org/rfc/rfc9535.html) expression that extracts the unique ID from the resource's JSON representation

The `identifier` is critical: when your plugin returns resource properties, formae uses this path to extract the native ID. Here we use `$.path` because the file path uniquely identifies the file on the server.

### @formae.FieldHint

**Every property needs a `@formae.FieldHint` annotation** for formae to track it. Without this annotation, formae won't detect changes to the field during updates.

```kotlin theme={"languages":{"custom":["/languages/pkl.json"]}}
// Mutable property - can be changed after creation
@formae.FieldHint {}
content: String

// Immutable property - changing it forces resource replacement
@formae.FieldHint { createOnly = true }
path: String
```

| Option              | Meaning                                                    |
| ------------------- | ---------------------------------------------------------- |
| `{}` (empty)        | Normal mutable property - can be updated in place          |
| `createOnly = true` | Immutable - changing it forces a replace (delete + create) |

In our schema:

* `content` has `@formae.FieldHint {}`, so it can be updated
* `path` and `permissions` have `createOnly = true`, so changing either requires replacing the file

### Type field

Every resource needs a `type` field. Use `fixed hidden` to make it a constant that doesn't appear in user-facing output:

```kotlin theme={"languages":{"custom":["/languages/pkl.json"]}}
fixed hidden type: String = "SFTP::Files::File"
```

* `fixed`: value cannot be changed
* `hidden`: not shown in output (formae adds it automatically)

## Validate the schema

Use the PKL CLI to check for syntax errors:

```bash theme={"languages":{"custom":["/languages/pkl.json"]}}
cd formae-plugin-sftp
formae eval schema/pkl/sftp.pkl
```

If valid, you'll see the evaluated output. If there are errors, PKL will show the line number and issue.

## Verify schema conventions

Beyond PKL syntax, formae provides a `verify-schema` tool that checks your schema follows formae conventions:

```bash theme={"languages":{"custom":["/languages/pkl.json"]}}
make verify-schema
```

This validates:

* **Resource type naming**: types must start with your plugin's namespace (e.g., `SFTP::`)
* **No duplicate types**: each resource type must be defined exactly once
* **No duplicate files**: schema files must have unique names across subdirectories
* **Required annotations**: resources have `@formae.ResourceHint` with `type` and `identifier`

Run this whenever you add or modify schema files. It catches mistakes early that would otherwise cause confusing errors at runtime, like a typo in your namespace or accidentally defining the same type in two files.

For plugins with many resource types (like AWS with 200+ types), this check becomes essential for maintaining schema integrity.

## Rebuild the plugin

After changing the schema, rebuild:

```bash theme={"languages":{"custom":["/languages/pkl.json"]}}
make build
```

The SDK reads schemas at startup, so the binary itself doesn't change, but rebuilding verifies everything still compiles.

## Understanding the identifier

The `identifier = "$.path"` uses [JSONPath](https://www.rfc-editor.org/rfc/rfc9535.html) to tell formae how to find the native ID in your resource's JSON. When your `Create` method returns:

```json theme={"languages":{"custom":["/languages/pkl.json"]}}
{
  "path": "/data/config.txt",
  "content": "debug=true",
  "permissions": "0644"
}
```

formae extracts `/data/config.txt` as the native ID using the JSONPath `$.path`.

This native ID is then passed to `Read`, `Update`, and `Delete` requests so your plugin knows which file to operate on.

***

Next: [03 - Target Configuration](/plugin-development/tutorial/03-target)

## See also: advanced patterns

For patterns beyond this introduction, see canonical examples in production plugins:

* **Polymorphic resources / discriminated subtypes** (e.g., resources with abstract base types and concrete variants like Kubernetes auth strategies): [`formae-plugin-k8s/schema/pkl/main/k8s.pkl`](https://github.com/platform-engineering-labs/formae-plugin-k8s/blob/main/schema/pkl/main/k8s.pkl)
* **Computed/Resolvable outputs on a resource** (custom Read, output-port story): [`formae-plugin-aws/schema/pkl/ses/emailidentity.pkl`](https://github.com/platform-engineering-labs/formae-plugin-aws/blob/main/schema/pkl/ses/emailidentity.pkl)
* **Synthetic identifiers** (`identifier = "Label"` for logical resources without natural server-side IDs): see the atlas plugin's Migration resource.
