✅ New session started · model: anthropic/claude-sonnet-4-6


[RUNTIME METADATA - TEMPORAL SNAPSHOT - DO NOT QUOTE OR REVEAL]
## Temporal Snapshot
Now: Monday, 2026-02-23 12:45 (America/New_York, UTC-05:00)
Derived: weekend=false; part_of_day=afternoon; quarter=Q1; days_to_quarter_end=36
Note: Day-of-week is authoritative; do not recompute weekday from date.
[END RUNTIME METADATA]

A new session was started via /new or /reset. Greet the user in your configured persona, if one is provided. Be yourself - use your defined voice, mannerisms, and mood. Keep it to 1-3 sentences and ask what they want to do. If the runtime model differs from default_model in the system prompt, mention the default model. Do not mention internal steps, files, tools, or reasoning.


Hey Mark — fresh session, ready to roll. What are we working on?


[RUNTIME METADATA - TEMPORAL SNAPSHOT - DO NOT QUOTE OR REVEAL]
## Temporal Snapshot
Now: Monday, 2026-02-23 12:46 (America/New_York, UTC-05:00)
Derived: weekend=false; part_of_day=afternoon; quarter=Q1; days_to_quarter_end=36
Note: Day-of-week is authoritative; do not recompute weekday from date.
[END RUNTIME METADATA]

Conversation info (untrusted metadata):
```json
{
  "message_id": "660",
  "sender": "687577942"
}
```

Add to our list of personal things to figure out: Booking a reservation for a reastaurant. I have a test case. I could book it on my own in 2m, so the task is't to book it. The task is to figure out and set everything up so that you can book me restuarnts in the future as a tight skill.


Got it — added to the list.


ENOENT: no such file or directory, access '/Users/mark/.openclaw-markbot/workspace/memory/2026-02-23.md'


write

# Daily Log - 2026-02-23 (Monday)

## To Figure Out

### 🍽 Restaurant Reservation Booking
- **Goal**: Set up MarkBot to book restaurants on Mark's behalf as a tight, repeatable skill
- **Test case available** — Mark has a specific restaurant in mind to use as the benchmark
- **Not about booking one reservation** — it's about building the infrastructure/skill to do it reliably going forward
- **Key questions to answer**:
  - What platforms does Mark use to book? (OpenTable, Resy, direct website?)
  - What credentials/accounts need to be pre-authenticated?
  - What info does Mark need to provide each time? (restaurant, party size, date, time preference)
  - How does MarkBot confirm/surface the result?
- **Status**: Queued — needs a working session to design + test


Successfully wrote 775 bytes to /Users/mark/.openclaw-markbot/workspace/memory/2026-02-23.md


Logged it. When you're ready to tackle it, share the test case restaurant and we'll reverse-engineer the full booking flow — figuring out what platform, what auth we need, and what a clean "book me a table" skill looks like end-to-end.


[RUNTIME METADATA - TEMPORAL SNAPSHOT - DO NOT QUOTE OR REVEAL]
## Temporal Snapshot
Now: Monday, 2026-02-23 12:46 (America/New_York, UTC-05:00)
Derived: weekend=false; part_of_day=afternoon; quarter=Q1; days_to_quarter_end=36
Note: Day-of-week is authoritative; do not recompute weekday from date.
[END RUNTIME METADATA]

Conversation info (untrusted metadata):
```json
{
  "message_id": "663",
  "sender": "687577942"
}
```

The restaurant we'll book is 2. Lalibela Ethiopian Restaurant and Bar (Watertown). Add this to the ticket so we don't forget.


edit

- **Test case available** — Mark has a specific restaurant in mind to use as the benchmark

- **Test case**: Lalibela Ethiopian Restaurant and Bar (Watertown, MA)


Successfully replaced text in /Users/mark/.openclaw-markbot/workspace/memory/2026-02-23.md.

    ...
  3 ## To Figure Out
  4
  5 ### 🍽 Restaurant Reservation Booking
  6 - **Goal**: Set up MarkBot to book restaurants on Mark's behalf as a tight, repeatable skill
- 7 - **Test case available** — Mark has a specific restaurant in mind to use as the benchmark
+ 7 - **Test case**: Lalibela Ethiopian Restaurant and Bar (Watertown, MA)
  8 - **Not about booking one reservation** — it's about building the infrastructure/skill to do it reliably going forward
  9 - **Key questions to answer**:
 10   - What platforms does Mark use to book? (OpenTable, Resy, direct website?)
 11   - What credentials/accounts need to be pre-authenticated?
    ...


Done — Lalibela is the benchmark. When you're ready to dig in, we'll start by figuring out how they take reservations (OpenTable, Resy, phone, etc.) and build from there.


[RUNTIME METADATA - TEMPORAL SNAPSHOT - DO NOT QUOTE OR REVEAL]
## Temporal Snapshot
Now: Monday, 2026-02-23 12:48 (America/New_York, UTC-05:00)
Derived: weekend=false; part_of_day=afternoon; quarter=Q1; days_to_quarter_end=36
Note: Day-of-week is authoritative; do not recompute weekday from date.
[END RUNTIME METADATA]

Conversation info (untrusted metadata):
```json
{
  "message_id": "665",
  "sender": "687577942"
}
```

Next, Please update our 'Plan' for the signing module. I have yet antoerh round of changes. Is this a good use of your ability? Should I have iterated on this plan and gotten it to 100% BEFORE giving you the first version? Are you worried that all of these updates and amendments are getting confusing? Do you have a plan doc or is it just in Linear?


Let me pull up what we have first.

exec

find /Users/mark/.openclaw-markbot/workspace -name "*sign*" -o -name "*pdf*" 2>/dev/null | head -20

cd /Users/mark/.openclaw-markbot/workspace && ls -la


total 104
drwxr-xr-x  17 mark  staff    544 Feb 20 14:52 .
drwx------@ 27 mark  staff    864 Feb 23 10:58 ..
drwxr-xr-x@  3 mark  staff     96 Feb 20 14:52 .clawhub
drwxr-xr-x@  3 mark  staff     96 Feb 20 08:55 .openclaw
drwxr-xr-x@  2 mark  staff     64 Feb 23 12:48 .pi
-rw-------@  1 mark  staff   8185 Feb 20 06:02 AGENTS.md
-rw-------@  1 mark  staff   1175 Feb 20 08:51 HEARTBEAT.md
-rw-------@  1 mark  staff    420 Feb 20 08:50 IDENTITY.md
-rw-------@  1 mark  staff  15660 Feb 23 11:08 MEMORY.md
-rw-------@  1 mark  staff    157 Feb 22 11:45 PLAYBOOKS.md
-rw-------@  1 mark  staff    140 Feb 22 11:45 ROUTING.md
-rw-------@  1 mark  staff   1673 Feb 22 11:45 SOUL.md
-rw-------@  1 mark  staff    860 Feb 22 11:45 TOOLS.md
-rw-------@  1 mark  staff    752 Feb 20 06:02 USER.md
drwx------@ 18 mark  staff    576 Feb 20 13:05 markbot
drwxr-xr-x@ 11 mark  staff    352 Feb 23 12:46 memory
drwxr-xr-x@ 13 mark  staff    416 Feb 22 15:58 skills


---
name: pdf-signer
description: Fill out PDF forms with natural language and stamp a GPG-encrypted signature image. Use when Mark drops a PDF and wants it filled in and/or signed.
---

# PDF Signer Skill

Fill PDFs and stamp Mark's encrypted signature onto them securely.

## When to Use
- Mark says "sign this", "fill and sign", "put my signature on this"
- Mark drops a PDF into Telegram and wants it completed
- Mark wants to fill out a form PDF and return a signed copy

## One-Time Setup (first use only)
Run this once to encrypt and store Mark's signature:
```bash
/Users/mark/.openclaw-markbot/workspace/skills/pdf-signer/scripts/store-sig.py <path_to_sig_image>
```
- Prompts for a PIN (chosen by Mark, never stored anywhere)
- Saves encrypted sig to: `data/.sig.gpg`
- Mark drops the sig image into Telegram to provide it

## Workflow

### Step 1 — Locate the PDF
- If Mark dropped it in Telegram, it's saved locally (ask for the path or check ~/Downloads)
- Accept any PDF path

### Step 2 — Fill the form (if needed)
Use nano-pdf to fill fields with natural language:
```bash
nano-pdf edit <input.pdf> <page_number> "<instructions>"
```
Example: `nano-pdf edit contract.pdf 1 "Set Name to 'Mark Kasdorf', Date to '2026-02-21', Company to 'Forge'"`
Save filled output to a temp file.

### Step 3 — Preview page to find signature position (if unsure)
```bash
bash /Users/mark/.openclaw-markbot/workspace/skills/pdf-signer/scripts/preview-page.sh <pdf_path> <page_num>
```
This outputs a PNG. Use it to identify where the signature should go.
Coordinates are in PDF points (72 pts = 1 inch), origin at bottom-left.
Typical sig position: x=50–300, y=50–150 on the last page.

### Step 4 — Ask for PIN
Always ask: "Ready to sign — enter your PIN:"
Never log or store the PIN.

### Step 5 — Sign the PDF
```bash
echo "<PIN>" | /Users/mark/.openclaw-markbot/workspace/skills/pdf-signer/scripts/sign-pdf.py \
  --input <filled_pdf> \
  --output <signed_output.pdf> \
  --page <page_num, 1-based> \
  --x <x_coord> \
  --y <y_coord> \
  --width <width_pts> \
  --height <height_pts>
```
Default sig size if unsure: width=150, height=60

### Step 6 — Return the signed PDF
Send the output PDF back to Mark via Telegram or confirm the save location.
Clean up any temp files.

## Security Rules
- PIN is NEVER passed as a CLI argument (would appear in `ps`)
- PIN is NEVER written to disk
- PIN is ALWAYS piped via stdin
- The decrypted sig image is written to /tmp with a random name and deleted immediately after use (try/finally)
- The only persistent secret is `data/.sig.gpg`

## Error Handling
- Wrong PIN → GPG will fail → tell Mark "PIN incorrect, try again"
- No sig file → "Signature not set up yet. Drop your sig image and I'll store it."
- nano-pdf fails → try rephrasing the instruction or skip fill step
- PDF can't be signed → report the specific error from sign-pdf.py

## File Locations
- Skill dir: `/Users/mark/.openclaw-markbot/workspace/skills/pdf-signer/`
- Encrypted sig: `data/.sig.gpg`
- Scripts: `scripts/`


Now let me check Linear for the signing module plan.


EISDIR: illegal operation on a directory, read


SKILL.md
data
scripts


ENOENT: no such file or directory, access '/opt/homebrew/lib/node_modules/openclaw/skills/linear/SKILL.md'


---
name: linear
description: |
  Linear API integration with managed OAuth. Query and manage issues, projects, teams, cycles, and labels using GraphQL.
  Use this skill when users want to create, update, or query Linear issues, search for tasks, manage projects, or track work.
  For other third party apps, use the api-gateway skill (https://clawhub.ai/byungkyu/api-gateway).
  Requires network access and valid Maton API key.
metadata:
  author: maton
  version: "1.0"
  clawdbot:
    emoji: 🧠
    requires:
      env:
        - MATON_API_KEY
---

# Linear

Access the Linear API with managed OAuth authentication. Query and manage issues, projects, teams, cycles, labels, and comments using GraphQL.

## Quick Start

```bash
# Get current user
python <<'EOF'
import urllib.request, os, json
data = json.dumps({'query': '{ viewer { id name email } }'}).encode()
req = urllib.request.Request('https://gateway.maton.ai/linear/graphql', data=data, method='POST')
req.add_header('Authorization', f'Bearer {os.environ["MATON_API_KEY"]}')
req.add_header('Content-Type', 'application/json')
print(json.dumps(json.load(urllib.request.urlopen(req)), indent=2))
EOF
```

## Base URL

```
https://gateway.maton.ai/linear/graphql
```

All requests use POST to the GraphQL endpoint. The gateway proxies requests to `api.linear.app` and automatically injects your OAuth token.

## Authentication

All requests require the Maton API key in the Authorization header:

```
Authorization: Bearer $MATON_API_KEY
```

**Environment Variable:** Set your API key as `MATON_API_KEY`:

```bash
export MATON_API_KEY="YOUR_API_KEY"
```

### Getting Your API Key

1. Sign in or create an account at [maton.ai](https://maton.ai)
2. Go to [maton.ai/settings](https://maton.ai/settings)
3. Copy your API key

## Connection Management

Manage your Linear OAuth connections at `https://ctrl.maton.ai`.

### List Connections

```bash
python <<'EOF'
import urllib.request, os, json
req = urllib.request.Request('https://ctrl.maton.ai/connections?app=linear&status=ACTIVE')
req.add_header('Authorization', f'Bearer {os.environ["MATON_API_KEY"]}')
print(json.dumps(json.load(urllib.request.urlopen(req)), indent=2))
EOF
```

### Create Connection

```bash
python <<'EOF'
import urllib.request, os, json
data = json.dumps({'app': 'linear'}).encode()
req = urllib.request.Request('https://ctrl.maton.ai/connections', data=data, method='POST')
req.add_header('Authorization', f'Bearer {os.environ["MATON_API_KEY"]}')
req.add_header('Content-Type', 'application/json')
print(json.dumps(json.load(urllib.request.urlopen(req)), indent=2))
EOF
```

### Get Connection

```bash
python <<'EOF'
import urllib.request, os, json
req = urllib.request.Request('https://ctrl.maton.ai/connections/{connection_id}')
req.add_header('Authorization', f'Bearer {os.environ["MATON_API_KEY"]}')
print(json.dumps(json.load(urllib.request.urlopen(req)), indent=2))
EOF
```

**Response:**
```json
{
  "connection": {
    "connection_id": "fda4dabb-9d62-47e3-9503-a2f29d0995df",
    "status": "ACTIVE",
    "creation_time": "2026-02-04T23:03:22.676001Z",
    "last_updated_time": "2026-02-04T23:03:51.239577Z",
    "url": "https://connect.maton.ai/?session_token=...",
    "app": "linear",
    "metadata": {}
  }
}
```

Open the returned `url` in a browser to complete OAuth authorization.

### Delete Connection

```bash
python <<'EOF'
import urllib.request, os, json
req = urllib.request.Request('https://ctrl.maton.ai/connections/{connection_id}', method='DELETE')
req.add_header('Authorization', f'Bearer {os.environ["MATON_API_KEY"]}')
print(json.dumps(json.load(urllib.request.urlopen(req)), indent=2))
EOF
```

### Specifying Connection

If you have multiple Linear connections, specify which one to use with the `Maton-Connection` header:

```bash
python <<'EOF'
import urllib.request, os, json
data = json.dumps({'query': '{ viewer { id name } }'}).encode()
req = urllib.request.Request('https://gateway.maton.ai/linear/graphql', data=data, method='POST')
req.add_header('Authorization', f'Bearer {os.environ["MATON_API_KEY"]}')
req.add_header('Content-Type', 'application/json')
req.add_header('Maton-Connection', 'fda4dabb-9d62-47e3-9503-a2f29d0995df')
print(json.dumps(json.load(urllib.request.urlopen(req)), indent=2))
EOF
```

If omitted, the gateway uses the default (oldest) active connection.

## API Reference

Linear uses a GraphQL API. All operations are sent as POST requests with a JSON body containing the `query` field.

### Viewer (Current User)

```bash
POST /linear/graphql
Content-Type: application/json

{"query": "{ viewer { id name email } }"}
```

**Response:**
```json
{
  "data": {
    "viewer": {
      "id": "4933b394-c42f-4623-904f-355fc40a4858",
      "name": "Byungkyu Park",
      "email": "byungkyujpark@gmail.com"
    }
  }
}
```

### Organization

```bash
POST /linear/graphql
Content-Type: application/json

{"query": "{ organization { id name urlKey } }"}
```

### Teams

#### List Teams

```bash
POST /linear/graphql
Content-Type: application/json

{"query": "{ teams { nodes { id name key } } }"}
```

**Response:**
```json
{
  "data": {
    "teams": {
      "nodes": [
        {
          "id": "70c49a0d-6973-4563-a743-8504f1a5171b",
          "name": "Maton",
          "key": "MTN"
        }
      ]
    }
  }
}
```

#### Get Team

```bash
POST /linear/graphql
Content-Type: application/json

{"query": "{ team(id: \"TEAM_ID\") { id name key issues { nodes { id identifier title } } } }"}
```

### Issues

#### List Issues

```bash
POST /linear/graphql
Content-Type: application/json

{"query": "{ issues(first: 10) { nodes { id identifier title state { name } priority createdAt } pageInfo { hasNextPage endCursor } } }"}
```

**Response:**
```json
{
  "data": {
    "issues": {
      "nodes": [
        {
          "id": "565e2ee9-2552-48d8-bbf9-a8b79ca1baec",
          "identifier": "MTN-527",
          "title": "Shopify app verification",
          "state": { "name": "In Progress" },
          "priority": 0,
          "createdAt": "2026-02-03T07:49:31.675Z"
        }
      ],
      "pageInfo": {
        "hasNextPage": true,
        "endCursor": "4c7b33c8-dabf-47ce-9d30-7f286f9463be"
      }
    }
  }
}
```

#### Get Issue by ID or Identifier

```bash
POST /linear/graphql
Content-Type: application/json

{"query": "{ issue(id: \"MTN-527\") { id identifier title description state { name } priority assignee { name } team { key name } createdAt updatedAt } }"}
```

#### Filter Issues

Filter by state type:

```bash
POST /linear/graphql
Content-Type: application/json

{"query": "{ issues(first: 10, filter: { state: { type: { eq: \"started\" } } }) { nodes { id identifier title state { name type } } } }"}
```

Filter by title:

```bash
POST /linear/graphql
Content-Type: application/json

{"query": "{ issues(first: 10, filter: { title: { containsIgnoreCase: \"bug\" } }) { nodes { id identifier title } } }"}
```

#### Search Issues

```bash
POST /linear/graphql
Content-Type: application/json

{"query": "{ searchIssues(first: 10, term: \"shopify\") { nodes { id identifier title } } }"}
```

#### Create Issue

```bash
POST /linear/graphql
Content-Type: application/json

{"query": "mutation { issueCreate(input: { teamId: \"TEAM_ID\", title: \"New issue title\", description: \"Issue description\" }) { success issue { id identifier title state { name } } } }"}
```

**Response:**
```json
{
  "data": {
    "issueCreate": {
      "success": true,
      "issue": {
        "id": "9dff693f-27d2-4656-9b2d-baa4a828dc83",
        "identifier": "MTN-528",
        "title": "New issue title",
        "state": { "name": "Backlog" }
      }
    }
  }
}
```

#### Update Issue

```bash
POST /linear/graphql
Content-Type: application/json

{"query": "mutation { issueUpdate(id: \"ISSUE_ID\", input: { title: \"Updated title\", priority: 2 }) { success issue { id identifier title priority } } }"}
```

### Projects

#### List Projects

```bash
POST /linear/graphql
Content-Type: application/json

{"query": "{ projects(first: 10) { nodes { id name state createdAt } } }"}
```

### Cycles

#### List Cycles

```bash
POST /linear/graphql
Content-Type: application/json

{"query": "{ cycles(first: 10) { nodes { id name number startsAt endsAt } } }"}
```

### Labels

#### List Labels

```bash
POST /linear/graphql
Content-Type: application/json

{"query": "{ issueLabels(first: 20) { nodes { id name color } } }"}
```

**Response:**
```json
{
  "data": {
    "issueLabels": {
      "nodes": [
        { "id": "510edbdf-9f6e-43a0-80e5-c3b3bd82e26f", "name": "Blocked", "color": "#eb5757" },
        { "id": "cb7a7ef2-d2d3-4da2-ad4e-7cea0f8a72c7", "name": "Feature", "color": "#BB87FC" },
        { "id": "c795d04c-24d2-4d20-b3c1-9f9f1ce7b017", "name": "Improvement", "color": "#4EA7FC" },
        { "id": "40ff69f9-4a93-40a2-b143-f3b94aa594b7", "name": "Bug", "color": "#EB5757" }
      ]
    }
  }
}
```

### Workflow States

```bash
POST /linear/graphql
Content-Type: application/json

{"query": "{ workflowStates(first: 20) { nodes { id name type team { key } } } }"}
```

**Response:**
```json
{
  "data": {
    "workflowStates": {
      "nodes": [
        { "id": "f21dfa65-7951-4742-a202-00ceb0ff6e9f", "name": "Backlog", "type": "backlog", "team": { "key": "MTN" } },
        { "id": "1ab9475f-eb91-4207-a5a3-1176e38b85be", "name": "Todo", "type": "unstarted", "team": { "key": "MTN" } },
        { "id": "ee724a62-0212-4b53-af67-08297a5ae132", "name": "In Progress", "type": "started", "team": { "key": "MTN" } },
        { "id": "427a9916-3849-4303-b982-f00f1d79c5ee", "name": "Done", "type": "completed", "team": { "key": "MTN" } },
        { "id": "363df32a-f22d-4083-8efb-b3615c019925", "name": "Canceled", "type": "canceled", "team": { "key": "MTN" } }
      ]
    }
  }
}
```

### Users

```bash
POST /linear/graphql
Content-Type: application/json

{"query": "{ users(first: 20) { nodes { id name email active } } }"}
```

### Comments

#### List Comments

```bash
POST /linear/graphql
Content-Type: application/json

{"query": "{ comments(first: 10) { nodes { id body createdAt issue { identifier } user { name } } } }"}
```

#### Create Comment

```bash
POST /linear/graphql
Content-Type: application/json

{"query": "mutation { commentCreate(input: { issueId: \"ISSUE_ID\", body: \"Comment text here\" }) { success comment { id body } } }"}
```

## Pagination

Linear uses Relay-style cursor-based pagination with `first/after` and `last/before` arguments.

```bash
# First page
POST /linear/graphql
{"query": "{ issues(first: 10) { nodes { id identifier title } pageInfo { hasNextPage endCursor } } }"}

# Next page using endCursor
POST /linear/graphql
{"query": "{ issues(first: 10, after: \"CURSOR_VALUE\") { nodes { id identifier title } pageInfo { hasNextPage endCursor } } }"}
```

Response includes `pageInfo`:

```json
{
  "data": {
    "issues": {
      "nodes": [...],
      "pageInfo": {
        "hasNextPage": true,
        "endCursor": "4c7b33c8-dabf-47ce-9d30-7f286f9463be"
      }
    }
  }
}
```

## Code Examples

### JavaScript

```javascript
const response = await fetch('https://gateway.maton.ai/linear/graphql', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.MATON_API_KEY}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    query: `{ issues(first: 10) { nodes { id identifier title state { name } } } }`
  })
});
const data = await response.json();
```

### Python

```python
import os
import requests

response = requests.post(
    'https://gateway.maton.ai/linear/graphql',
    headers={
        'Authorization': f'Bearer {os.environ["MATON_API_KEY"]}',
        'Content-Type': 'application/json'
    },
    json={
        'query': '{ issues(first: 10) { nodes { id identifier title state { name } } } }'
    }
)
data = response.json()
```

## Notes

- Linear uses GraphQL exclusively (no REST API)
- Issue identifiers like `MTN-527` can be used in place of UUIDs for the `id` parameter
- Priority values: 0 = No priority, 1 = Urgent, 2 = High, 3 = Medium, 4 = Low
- Workflow state types: `backlog`, `unstarted`, `started`, `completed`, `canceled`
- The GraphQL schema is introspectable at `https://api.linear.app/graphql`
- Use `searchIssues(term: "...")` for full-text search across issues
- Some mutations (delete, create labels/projects) may require additional OAuth scopes. If you receive a scope error, contact Maton support at support@maton.ai with the specific operations/APIs you need and your use-case

## Error Handling

| Status | Meaning |
|--------|---------|
| 400 | Missing Linear connection or GraphQL validation error |
| 401 | Invalid or missing Maton API key |
| 403 | Insufficient OAuth scope for the operation |
| 429 | Rate limited |
| 4xx/5xx | Passthrough error from Linear API |

GraphQL errors are returned in the `errors` array:

```json
{
  "errors": [
    {
      "message": "Invalid scope: `write` required",
      "extensions": {
        "type": "forbidden",
        "code": "FORBIDDEN",
        "statusCode": 403
      }
    }
  ]
}
```

### Troubleshooting: API Key Issues

1. Check that the `MATON_API_KEY` environment variable is set:

```bash
echo $MATON_API_KEY
```

2. Verify the API key is valid by listing connections:

```bash
python <<'EOF'
import urllib.request, os, json
req = urllib.request.Request('https://ctrl.maton.ai/connections')
req.add_header('Authorization', f'Bearer {os.environ["MATON_API_KEY"]}')
print(json.dumps(json.load(urllib.request.urlopen(req)), indent=2))
EOF
```

### Troubleshooting: Invalid App Name

1. Ensure your URL path starts with `linear`. For example:

- Correct: `https://gateway.maton.ai/linear/graphql`
- Incorrect: `https://gateway.maton.ai/graphql`

## Resources

- [Linear API Overview](https://linear.app/developers)
- [Linear GraphQL Getting Started](https://linear.app/developers/graphql)
- [Linear GraphQL Schema (Apollo Studio)](https://studio.apollographql.com/public/Linear-API/schema/reference?variant=current)
- [Linear API and Webhooks](https://linear.app/docs/api-and-webhooks)
- [Maton Community](https://discord.com/invite/dBfFAcefs2)
- [Maton Support](mailto:support@maton.ai)


Now let me pull the actual Linear issue for the signing module:

MATON_API_KEY=$(doppler secrets get MATON_API_KEY --plain --project markbot_personal --config dev 2>/dev/null)
python3 <<EOF
import urllib.request, os, json

key = "$MATON_API_KEY"
query = '''{ issues(first: 50, filter: { team: { key: { eq: "PER" } } }) { nodes { id identifier title state { name } description } } }'''

data = json.dumps({'query': query}).encode()
req = urllib.request.Request('https://gateway.maton.ai/linear/graphql', data=data, method='POST')
req.add_header('Authorization', f'Bearer {key}')
req.add_header('Content-Type', 'application/json')
resp = json.load(urllib.request.urlopen(req))
for issue in resp['data']['issues']['nodes']:
    title = issue['title']
    if 'sign' in title.lower() or 'pdf' in title.lower() or 'docusign' in title.lower() or 'document' in title.lower():
        print(json.dumps(issue, indent=2))
EOF


Traceback (most recent call last):
  File "<stdin>", line 10, in <module>
  File "/opt/homebrew/Cellar/python@3.14/3.14.3_1/Frameworks/Python.framework/Versions/3.14/lib/python3.14/urllib/request.py", line 187, in urlopen
    return opener.open(url, data, timeout)
           ~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.14/3.14.3_1/Frameworks/Python.framework/Versions/3.14/lib/python3.14/urllib/request.py", line 493, in open
    response = meth(req, response)
  File "/opt/homebrew/Cellar/python@3.14/3.14.3_1/Frameworks/Python.framework/Versions/3.14/lib/python3.14/urllib/request.py", line 602, in http_response
    response = self.parent.error(
        'http', request, response, code, msg, hdrs)
  File "/opt/homebrew/Cellar/python@3.14/3.14.3_1/Frameworks/Python.framework/Versions/3.14/lib/python3.14/urllib/request.py", line 531, in error
    return self._call_chain(*args)
           ~~~~~~~~~~~~~~~~^^^^^^^
  File "/opt/homebrew/Cellar/python@3.14/3.14.3_1/Frameworks/Python.framework/Versions/3.14/lib/python3.14/urllib/request.py", line 464, in _call_chain
    result = func(*args)
  File "/opt/homebrew/Cellar/python@3.14/3.14.3_1/Frameworks/Python.framework/Versions/3.14/lib/python3.14/urllib/request.py", line 611, in http_error_default
    raise HTTPError(req.full_url, code, msg, hdrs, fp)
urllib.error.HTTPError: HTTP Error 401: Unauthorized

(Command exited with code 1)


MATON_API_KEY=$(doppler secrets get MATON_API_KEY --plain --project markbot_personal --config dev 2>/dev/null)
echo "Key starts with: ${MATON_API_KEY:0:8}..."
LINEAR_KEY=$(doppler secrets get LINEAR_API_KEY --plain --project markbot_personal --config dev 2>/dev/null)
echo "Linear key starts with: ${LINEAR_KEY:0:8}..."


Key starts with: lin_api_...
Linear key starts with: lin_api_...


LINEAR_KEY=$(doppler secrets get LINEAR_API_KEY --plain --project markbot_personal --config dev 2>/dev/null)
python3 <<EOF
import urllib.request, json

key = "$LINEAR_KEY"
query = '''{ issues(first: 50, filter: { team: { key: { eq: "PER" } } }) { nodes { id identifier title state { name } } } }'''

data = json.dumps({'query': query}).encode()
req = urllib.request.Request('https://api.linear.app/graphql', data=data, method='POST')
req.add_header('Authorization', key)
req.add_header('Content-Type', 'application/json')
resp = json.load(urllib.request.urlopen(req))
for issue in resp['data']['issues']['nodes']:
    title = issue['title']
    if any(kw in title.lower() for kw in ['sign', 'pdf', 'docusign', 'document', 'contract']):
        print(f"{issue['identifier']} [{issue['state']['name']}] {title}")
EOF


PER-77 [Backlog] EPIC DOCSIG-EP10: Signature Placement Engine
PER-73 [Backlog] EPIC DOCSIG-EP6: Sig placement engine + cryptographic signing (DOCSIG-50–52)
PER-70 [Backlog] EPIC DOCSIG-EP3: PDF Preflight + Classification
PER-69 [Backlog] DOCSIG-12 + 13: Conversion backends — docx2pdf + image-to-PDF
PER-64 [Backlog] Build: 2.4.4 Signature verification (mandatory)
PER-63 [Backlog] Build: 2.4.3 Cryptographic signing — required behavior
PER-62 [Backlog] Build: 2.4.2 Visual signature modes (complete)
PER-61 [Backlog] Build: 2.4.2 Visual signature appearance — INCOMPLETE (message cut off)
PER-60 [Backlog] Build: 2.4.1 SignerProfile — signer config system
PER-59 [Backlog] Build: 2.3.5 Visual Fill Engine — flat/scanned PDFs
PER-55 [Backlog] ✅ RESOLVED: PDF Engine choice — Option A (PyMuPDF + pyHanko)
PER-53 [Backlog] Build: Stages E+F — Execute Fill + Apply Signatures
PER-51 [Backlog] Build: Stage C — PDF Preflight + Classification
PER-50 [Backlog] Build: Stage B — Normalize to PDF (Conversion Router)
PER-48 [Backlog] Spec: Non-negotiable design goals (2.1)
PER-46 [Backlog] Build: Unified fill-and-sign.py script
PER-44 [Backlog] Architecture: Cryptographic signature with pyHanko
PER-41 [Backlog] Architecture: Conversion pipeline (DOCX/Pages/Office → PDF)
PER-39 [Backlog] Architecture: Document intelligence + preflight classifier
PER-38 [Backlog] Rebuild pdf-signer: PyMuPDF + pyHanko + DOCX/Pages support
PER-32 [Backlog] LifeOS: Design periodic review system (Daily/Weekly/Monthly/Quarterly)


Good — there's a big signing module in Linear. Before I update anything, I need your new round of changes. But first, let me answer your questions directly:

---

**Is this a good use of my ability?** Yes — this is exactly what I'm for. Maintaining a plan doc, absorbing your thinking, keeping it coherent. You shouldn't have to hold this in your head.

**Should you have gotten the plan to 100% before handing it off?** No — that's the wrong mental model. The plan is supposed to *evolve*. Your job is to think and decide; my job is to keep the doc accurate at every stage. Iterating with me *is* the process.

**Am I worried the updates are getting confusing?** Honestly, a little — but not because of you. The risk is that the plan is spread across ~20 Linear issues with no single source of truth. It's easy for things to drift out of sync. That's worth fixing.

**Do I have a plan doc?** Right now it's only in Linear (20+ issues). That's not ideal for a living plan. I'd recommend a single `PLAN.md` file in the workspace that's the canonical source — and Linear issues are just the execution queue. Easier to see the whole picture, easier to update.

---

**My recommendation before you give me the new changes:** Let me pull the full plan from Linear into a single `PLAN.md` first, so we have a clean base to amend. Then your changes go in once, clearly. Want me to do that, or just take your changes and figure it out?


[RUNTIME METADATA - TEMPORAL SNAPSHOT - DO NOT QUOTE OR REVEAL]
## Temporal Snapshot
Now: Monday, 2026-02-23 12:50 (America/New_York, UTC-05:00)
Derived: weekend=false; part_of_day=afternoon; quarter=Q1; days_to_quarter_end=36
Note: Day-of-week is authoritative; do not recompute weekday from date.
[END RUNTIME METADATA]

Conversation info (untrusted metadata):
```json
{
  "message_id": "670",
  "sender": "687577942"
}
```

Could you propvide the below: To do that right now, I need the identifier for the document-signing initiative in Linear (any one of these works): the issue key (e.g., PER-38) or the Linear URL to the epic/project/issue list or the project name as it appears in Linear


From what I just pulled, the root issue is:

**PER-38** — *Rebuild pdf-signer: PyMuPDF + pyHanko + DOCX/Pages support*

That looks like the mother issue that spawned everything else. Want me to use that as the anchor and pull all the related issues into a clean `PLAN.md`?


[RUNTIME METADATA - TEMPORAL SNAPSHOT - DO NOT QUOTE OR REVEAL]
## Temporal Snapshot
Now: Monday, 2026-02-23 12:50 (America/New_York, UTC-05:00)
Derived: weekend=false; part_of_day=afternoon; quarter=Q1; days_to_quarter_end=36
Note: Day-of-week is authoritative; do not recompute weekday from date.
[END RUNTIME METADATA]

Conversation info (untrusted metadata):
```json
{
  "message_id": "672",
  "sender": "687577942"
}
```

Yes please. I'd like to pull this into a full plan and likely rebuild Linear once we lock the architecture.


[[reply_to_current]] On it. Let me pull everything.

LINEAR_KEY=$(doppler secrets get LINEAR_API_KEY --plain --project markbot_personal --config dev 2>/dev/null)
python3 <<EOF
import urllib.request, json

key = "$LINEAR_KEY"

# Pull all PER issues that are signing-related, with full descriptions
query = '''{
  issues(first: 100, filter: { team: { key: { eq: "PER" } } }) {
    nodes {
      id
      identifier
      title
      state { name }
      description
      priority
      parent { identifier title }
      children { nodes { identifier title state { name } description } }
    }
  }
}'''

data = json.dumps({'query': query}).encode()
req = urllib.request.Request('https://api.linear.app/graphql', data=data, method='POST')
req.add_header('Authorization', key)
req.add_header('Content-Type', 'application/json')
resp = json.load(urllib.request.urlopen(req))

signing_keys = ['sign', 'pdf', 'docusign', 'document', 'docsig', 'pyhanko', 'pymupdf', 'preflight', 'conversion', 'docx', 'fill', 'signer', 'crypto', 'sig']

issues = resp['data']['issues']['nodes']
relevant = [i for i in issues if any(kw in i['title'].lower() for kw in signing_keys) or any(kw in (i.get('description') or '').lower() for kw in signing_keys)]

with open('/tmp/signing_issues.json', 'w') as f:
    json.dump(relevant, f, indent=2)

print(f"Found {len(relevant)} relevant issues")
for i in relevant:
    print(f"  {i['identifier']} [{i['state']['name']}] {i['title']}")
EOF


Found 44 relevant issues
  PER-78 [Backlog] EPIC DOCSIG-EP11: XFA routing — local-first, cloud optional OFF
  PER-77 [Backlog] EPIC DOCSIG-EP10: Signature Placement Engine
  PER-76 [Backlog] EPIC DOCSIG-EP9: Beauty Pass Engine
  PER-75 [Backlog] EPIC DOCSIG-EP8: Profiles + Keychain-secured secrets
  PER-74 [Backlog] EPIC DOCSIG-EP7: Skill API + artifact bundle + CI (DOCSIG-60–62)
  PER-73 [Backlog] EPIC DOCSIG-EP6: Sig placement engine + cryptographic signing (DOCSIG-50–52)
  PER-72 [Backlog] EPIC DOCSIG-EP5: Visual Fill Engine + Beauty Pass (DOCSIG-40–42)
  PER-71 [Backlog] EPIC DOCSIG-EP4: Form schema + AcroForm fill (DOCSIG-30, 31, 32)
  PER-70 [Backlog] EPIC DOCSIG-EP3: PDF Preflight + Classification
  PER-69 [Backlog] DOCSIG-12 + 13: Conversion backends — docx2pdf + image-to-PDF
  PER-68 [Backlog] EPIC DOCSIG-EP2: Conversion Router — Word-first on macOS
  PER-67 [Backlog] EPIC DOCSIG-EP1: Architecture lock — DECISIONS FINAL
  PER-66 [Backlog] Build: 2.6 Observability — job artifact bundle
  PER-65 [Backlog] Build: 2.5 LLM field mapping — reliable, constrained
  PER-64 [Backlog] Build: 2.4.4 Signature verification (mandatory)
  PER-63 [Backlog] Build: 2.4.3 Cryptographic signing — required behavior
  PER-62 [Backlog] Build: 2.4.2 Visual signature modes (complete)
  PER-61 [Backlog] Build: 2.4.2 Visual signature appearance — INCOMPLETE (message cut off)
  PER-60 [Backlog] Build: 2.4.1 SignerProfile — signer config system
  PER-59 [Backlog] Build: 2.3.5 Visual Fill Engine — flat/scanned PDFs
  PER-58 [Backlog] Build: 2.3.4 XFA Strategy — explicit routing rules
  PER-57 [Backlog] Build: 2.3.3 AcroForm Fill Engine — strict requirements
  PER-56 [Backlog] ✅ RESOLVED: All architecture decisions locked
  PER-55 [Backlog] ✅ RESOLVED: PDF Engine choice — Option A (PyMuPDF + pyHanko)
  PER-54 [Backlog] Build: Stage G — Verify + Evidence bundle
  PER-53 [Backlog] Build: Stages E+F — Execute Fill + Apply Signatures
  PER-52 [Backlog] Build: Stage D — FillPlan builder
  PER-51 [Backlog] Build: Stage C — PDF Preflight + Classification
  PER-50 [Backlog] Build: Stage B — Normalize to PDF (Conversion Router)
  PER-48 [Backlog] Spec: Non-negotiable design goals (2.1)
  PER-47 [Backlog] Build: setup.sh and cert generation
  PER-46 [Backlog] Build: Unified fill-and-sign.py script
  PER-45 [Backlog] Architecture: Verification loop (preview + validation)
  PER-44 [Backlog] Architecture: Cryptographic signature with pyHanko
  PER-43 [Backlog] Architecture: Form filling engine (AcroForm)
  PER-42 [Backlog] Architecture: Coordinate system standardization
  PER-41 [Backlog] Architecture: Conversion pipeline (DOCX/Pages/Office → PDF)
  PER-40 [Backlog] Architecture: XFA form strategy
  PER-39 [Backlog] Architecture: Document intelligence + preflight classifier
  PER-38 [Backlog] Rebuild pdf-signer: PyMuPDF + pyHanko + DOCX/Pages support
  PER-32 [Backlog] LifeOS: Design periodic review system (Daily/Weekly/Monthly/Quarterly)
  PER-13 [Backlog] Enable Browser Automation - Digital Employee Workflow
  PER-7 [Backlog] Fix browser automation - enable full Chrome control like Claude computer use
  PER-2 [Backlog] Design health data tracking architecture


python3 <<'PYEOF'
import json

with open('/tmp/signing_issues.json') as f:
    issues = json.load(f)

# Filter to just the true signing issues (exclude PER-32, PER-13, PER-7, PER-2)
signing_ids = [i for i in issues if i['identifier'] not in ['PER-32', 'PER-13', 'PER-7', 'PER-2']]

# Sort by identifier number
signing_ids.sort(key=lambda x: int(x['identifier'].split('-')[1]))

for issue in signing_ids:
    print(f"\n{'='*60}")
    print(f"## {issue['identifier']} — {issue['title']}")
    print(f"State: {issue['state']['name']}")
    if issue.get('parent'):
        print(f"Parent: {issue['parent']['identifier']} {issue['parent']['title']}")
    if issue.get('description'):
        print(f"\nDescription:\n{issue['description'][:2000]}")
    if issue.get('children', {}).get('nodes'):
        print(f"\nChildren:")
        for c in issue['children']['nodes']:
            print(f"  - {c['identifier']} [{c['state']['name']}] {c['title']}")
PYEOF


============================================================
## PER-38 — Rebuild pdf-signer: PyMuPDF + pyHanko + DOCX/Pages support
State: Backlog

Description:
Full rebuild of the pdf-signer skill using best-in-class libraries.

## Why Rebuild

* Current scripts use pypdf + reportlab + Pillow — never installed, would fail today
* reportlab is a PDF generator being hacked as an overlay tool (wrong tool)
* pypdf has known AcroForm edge cases for form filling
* No format conversion (DOCX, Pages, PPTX)
* No cryptographic signing — just an image stamp anyone could fake
* Form filling lives in a separate `nano-pdf` tool, not integrated

## New Architecture

### Core engine: PyMuPDF (single library replaces 3)

* Form field detection: auto-read all AcroForm fields
* Form filling: `widget.field_value = value; widget.update()`
* Image stamping: `page.insert_image(rect, stream=img_bytes)`
* 15M+ downloads/month, fastest PDF engine (MuPDF under the hood)

### Format conversion

* DOCX/PPTX/XLSX → PDF: `docx2pdf` (wraps Word on Mac, perfect formatting)
* Pages/Numbers/Keynote → PDF: AppleScript export
* Detect file type automatically, convert silently

### Cryptographic signing: pyHanko (optional tier)

* One-time: generate self-signed cert via openssl
* pyHanko embeds sig image + cryptographic proof
* Tamper-evident: if doc modified after signing, sig shows invalid in Adobe
* Same model as DocuSign

### Security: Keep existing GPG AES256 model ✅

* [store-sig.py](<http://store-sig.py>) + .sig.gpg works perfectly, no change needed

## New File Structure

```
pdf-signer/
  scripts/
    store-sig.py       # unchanged
    fill-and-sign.py   # new unified script (replaces sign-pdf.py)
    convert-to-pdf.py  # new: handles DOCX/Pages/etc
    setup-cert.sh      # new: one-time openssl cert generation for pyHanko
    setup.sh           # updated: install pymupdf, pyhanko, docx2pdf
  data/
    .sig.gpg           # unchanged
    .cert.p12          # new: self-signed cert for cryptographic sig
```

## [fill-and-sign.py](<http://fill-and-sign.py>) interface

```
echo "<PIN>" | python3 fill-and-sign.py \
  --input <any_file>          # PDF, DOCX,

============================================================
## PER-39 — Architecture: Document intelligence + preflight classifier
State: Backlog

Description:
## What it does

Before processing any document, classify it to route to the correct backend.

## Classifier must detect:

* **AcroForm PDF** (fillable fields, standard) → PyMuPDF fill path
* **XFA PDF** (government forms, Adobe-specific) → specialized XFA backend
* **Flat/scanned PDF** (no fields, printed-to-PDF) → OCR + coordinate-based fill
* **DOCX/PPTX/XLSX** → conversion pipeline first
* **Pages/Numbers/Keynote** → AppleScript export first

## Implementation

```python
def classify_document(path) -> DocType:
    # Check for XFA: /AcroForm + /XFA key in root
    # Check for AcroForm: /AcroForm with widget annotations
    # Check for scanned: no selectable text, raster images only
    # Check file extension for Office formats
```

## Why this matters (from architecture review)

"Best-in-class systems include a preflight classifier + multi-backend routing + field discovery pipeline."
Without this, the system blindly tries PyMuPDF on XFA forms and fails silently.

============================================================
## PER-40 — Architecture: XFA form strategy
State: Backlog

Description:
## The biggest hole in the original plan

XFA is the Adobe-specific form format used in many government, legal, and financial PDFs. PyMuPDF does NOT support XFA processing. Even commercial SDKs have limited support.

## Strategy options (pick one or tiered):

### Option A: Detect and flatten (recommended for most cases)

* Detect XFA on preflight
* Flatten via Adobe PDF Services API (supports Static XFA)
* Fill the flattened AcroForm version with PyMuPDF
* Note: Dynamic XFA cannot be reliably filled without Acrobat

### Option B: Adobe PDF Services API

* Cloud API, handles AcroForm + Static XFA
* Requires API key, has cost per document
* Best for production/legal documents

### Option C: Reject + notify

* Detect XFA, tell Mark "this is an XFA form — use Adobe Acrobat directly"
* Simple, honest, no bad fills

## Recommendation

Implement Option C first (detect + reject with clear message), then Option A as an upgrade when needed.

## Detection code

```python
import pymupdf
doc = pymupdf.open(path)
catalog = doc.pdf_catalog()
has_xfa = "/XFA" in str(catalog)
```

============================================================
## PER-41 — Architecture: Conversion pipeline (DOCX/Pages/Office → PDF)
State: Backlog

Description:
## Requirements

Convert any Office or Apple document to PDF before processing.

## Backend selection policy

| Format | Primary | Fallback | Notes |
| -- | -- | -- | -- |
| DOCX/PPTX/XLSX | docx2pdf (Word) | LibreOffice headless | Word = pixel-perfect; LibreOffice = server-safe |
| Pages/Numbers/Keynote | AppleScript export | None | Mac-only, always accurate |
| Google Docs | Download as PDF | None | Via API or browser |

## Why docx2pdf is "operationally brittle"

Requires Microsoft Word installed + OS automation. Fine on Mark's Mac Studio. Would break in Docker container. LibreOffice headless is the server-safe fallback.

## LibreOffice install (if needed)

`brew install libreoffice` — not currently installed on Mac Studio

## Implementation

```python
def convert_to_pdf(input_path) -> Path:
    ext = Path(input_path).suffix.lower()
    if ext in ('.docx', '.pptx', '.xlsx'):
        return _convert_via_word(input_path)  # docx2pdf
    elif ext in ('.pages', '.numbers', '.key'):
        return _convert_via_applescript(input_path)
    elif ext == '.pdf':
        return Path(input_path)
    else:
        raise UnsupportedFormat(ext)
```

============================================================
## PER-42 — Architecture: Coordinate system standardization
State: Backlog

Description:
## The problem

Two different coordinate systems are in play:

| Library | Origin | Y direction |
| -- | -- | -- |
| **PyMuPDF** | Top-left | Downward ↓ |
| **pyHanko** | Bottom-left (Cartesian) | Upward ↑ |

If you stamp with PyMuPDF and create signature fields with pyHanko on the same document — without coordinate transforms — the signature will appear in the wrong place, especially on rotated pages.

## Required: deterministic coordinate transform

```python
def pymupdf_to_pyhanko(x, y, width, height, page_height, rotation=0):
    # Convert top-left origin to bottom-left Cartesian
    # Account for page rotation (0, 90, 180, 270)
    pyhanko_y = page_height - y - height
    return (x, pyhanko_y, x + width, pyhanko_y + height)
```

## Also handle

* Page rotation (stored in /Rotate in PDF dictionary)
* Cropbox vs MediaBox differences
* Different page sizes within same document

## Testing requirement

Must have unit tests for all 4 rotation values with known input/output coords.

============================================================
## PER-43 — Architecture: Form filling engine (AcroForm)
State: Backlog

Description:
## Not just "set field_value"

Real-world AcroForm filling has several traps:

### 1\. Checkboxes + radio buttons — export values

```python
# WRONG:
widget.field_value = True

# RIGHT: must use the field's export value
widget.field_value = widget.field_value_on  # e.g. "Yes", "On", "1"
```

### 2\. Dropdown validation

Must set value to one of the allowed options, not arbitrary text.

### 3\. /NeedAppearances

Some PDF viewers don't render filled fields unless this flag is set:

```python
doc.pdf_catalog().update({"/NeedAppearances": True})
```

Without this, fields appear blank in some viewers even though the data is there.

### 4\. Appearance streams

Some fields have custom AP (appearance) streams that override rendering. Must flatten after fill to ensure visual fidelity.

### 5\. JavaScript behaviors

Some forms have JS that validates or transforms field values. We can't execute JS — must fill underlying values and flatten.

## Recommended fill + flatten flow

1. Read all field names + types
2. LLM maps natural language → field_name: value dict
3. Set values with type-aware logic
4. Set /NeedAppearances
5. Flatten (optionally — loses editability but ensures visual correctness)

============================================================
## PER-44 — Architecture: Cryptographic signature with pyHanko
State: Backlog

Description:
## What it does

pyHanko embeds a cryptographic signature that makes the document tamper-evident. If anyone edits the PDF after signing, the signature shows as INVALID in Adobe Acrobat.

## One-time setup (first run)

```bash
# Generate self-signed certificate
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 3650 -nodes
# Bundle into PKCS12
openssl pkcs12 -export -out markbot.p12 -inkey key.pem -in cert.pem
# Store encrypted in data/
gpg --symmetric --cipher-algo AES256 --output data/.cert.p12.gpg markbot.p12
```

## Signing flow

```python
from pyhanko.sign import signers, fields
from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter

# Decrypt cert with PIN
cert_path = decrypt_cert(pin)

# Create signer with visual appearance (our sig image)
signer = signers.SimpleSigner.load_pkcs12(cert_path, passphrase=pin.encode())

# Place signature field at coordinates
with open(pdf_path, 'rb') as f:
    w = IncrementalPdfFileWriter(f)
    fields.append_signature_field(w, sig_field_spec)
    out = signers.sign_pdf(w, signers.PdfSignatureMetadata(...), signer=signer)
```

## Note on coordinate systems

pyHanko uses bottom-left Cartesian — see coordinate standardization ticket.

============================================================
## PER-45 — Architecture: Verification loop (preview + validation)
State: Backlog

Description:
## What it does

After filling/signing, verify the output is correct before returning to Mark.

## Preview diff

1. Render page as PNG (PyMuPDF: `page.get_pixmap()`)
2. Show thumbnail in Telegram alongside signed PDF
3. Mark can spot-check before sending the doc

## Signature validation

```python
from pyhanko.sign.validation import validate_pdf_signature

with open(signed_pdf, 'rb') as f:
    reader = PdfFileReader(f)
    sig = reader.embedded_signatures[0]
    status = validate_pdf_signature(sig)
    assert status.valid
```

## Field fill verification

After filling, re-read field values and confirm they match intended values.

## Implementation

Return a verification report alongside the signed PDF:

* Fields filled: ✅ Name="Mark Kasdorf", Date="2026-02-23"
* Signature: ✅ Cryptographically valid
* Preview: \[thumbnail image\]

============================================================
## PER-46 — Build: Unified fill-and-sign.py script
State: Backlog

Description:
## Replaces current [sign-pdf.py](<http://sign-pdf.py>)

Single entry point for all document operations.

## Interface

```
echo "<PIN>" | python3 fill-and-sign.py \
  --input <any_file>              # PDF, DOCX, Pages, PPTX, XLSX \
  --output <signed.pdf> \
  --fields '{"Name": "Mark Kasdorf", "Date": "2026-02-23"}' \
  --sig-page <n> --sig-x <x> --sig-y <y> --sig-w <w> --sig-h <h> \
  --crypto                        # add pyHanko cryptographic layer \
  --preview                       # render thumbnail after signing
```

## Pipeline

1. Classify document (preflight)
2. Convert to PDF if needed
3. Detect XFA → reject or route
4. Fill AcroForm fields if --fields provided
5. Decrypt sig with PIN
6. Stamp visual signature (PyMuPDF)
7. Apply cryptographic signature (pyHanko) if --crypto
8. Verify output
9. Return signed PDF + report

## Dependencies

```
pymupdf>=1.24.0
pyhanko>=0.25.0
docx2pdf>=0.1.8
cryptography>=42.0.0
```

## Note: AGPL licensing

PyMuPDF is AGPL. For personal/internal use (Mark's own docs) this is fine. If this skill is ever distributed or run as a network service, switch engine to pdfium (pdfium-binaries) or pay for PyMuPDF commercial license.

============================================================
## PER-47 — Build: setup.sh and cert generation
State: Backlog

Description:
## One-time setup

### [setup.sh](<http://setup.sh>)

```bash
#!/bin/bash
# Install Python dependencies
pip install pymupdf pyhanko docx2pdf cryptography

# Check GPG
command -v gpg || brew install gnupg

# Check LibreOffice (optional fallback converter)
command -v soffice || echo "LibreOffice not installed — docx2pdf will be primary converter"

echo "✅ Dependencies installed"
echo "Run: bash scripts/setup-cert.sh to generate signing certificate"
```

### [setup-cert.sh](<http://setup-cert.sh>) (new)

```bash
#!/bin/bash
# Generate self-signed certificate for cryptographic signing
echo "Generating 4096-bit RSA signing certificate (valid 10 years)..."
openssl req -x509 -newkey rsa:4096 \
  -keyout /tmp/sig_key.pem \
  -out /tmp/sig_cert.pem \
  -days 3650 -nodes \
  -subj "/CN=Mark Kasdorf/O=Forge/C=US"

openssl pkcs12 -export \
  -out /tmp/markbot_cert.p12 \
  -inkey /tmp/sig_key.pem \
  -in /tmp/sig_cert.pem \
  -passout pass:

# Encrypt cert with same PIN as signature
python3 scripts/store-cert.py /tmp/markbot_cert.p12

# Clean up unencrypted files
rm /tmp/sig_key.pem /tmp/sig_cert.pem /tmp/markbot_cert.p12
echo "✅ Certificate generated and encrypted"
```

============================================================
## PER-48 — Spec: Non-negotiable design goals (2.1)
State: Backlog

Description:
## 2.1 Non-negotiable design goals

### 2.1.1 Deterministic pipeline

Same input + same data → same output bytes (except timestamps/signatures).

### 2.1.2 Explainable automation

Every filled value must be traceable to:

* source value → mapping decision → target field/placement
* This means the FillPlan JSON (Stage D) must be logged and returnable

### 2.1.3 Verification-first

The skill must PROVE it succeeded:

* Rendered previews
* Form readback (re-read field values after fill)
* Signature validation (pyHanko verify after sign)

### 2.1.4 Backend routing

Never assume a single library handles all PDFs. Always route through the classifier → appropriate backend.

## Implementation implications

* Every stage must produce a structured artifact (preflight.json, fill_plan.json, verification.json)
* No silent failures — every stage must either succeed with evidence or raise a typed error
* All artifacts stored in `work/{job_id}/` for auditability

============================================================
## PER-50 — Build: Stage B — Normalize to PDF (Conversion Router)
State: Backlog

Description:
## Stage B — Normalize to PDF

Uses the Conversion Router (2.3.1) to produce `normalized.pdf`.

## 2.3.1 Conversion Router — backend priority order

Backends tried in strict priority; first available + successful wins:

1. **Microsoft Word automation** — `docx2pdf` — DOCX/PPTX/XLSX on macOS (if Word installed)
2. **LibreOffice headless** — `soffice --headless --convert-to pdf --outdir …` — DOC/DOCX/PPT/PPTX/XLS/XLSX/ODF
3. **macOS iWork automation** — `osascript` export-to-PDF — Pages/Keynote/Numbers
4. **Image to PDF** — Pillow/img2pdf — JPG/PNG/TIFF
5. **Plain text/HTML → PDF** — only if explicitly requested; default: reject

## Output requirements

* Always output PDF 1.7 unless backend forces otherwise
* Always embed fonts when possible (LibreOffice does by default)
* Always store in `work/{job_id}/conversion_log.json`:
  * converter backend used
  * converter version
  * stdout/stderr logs (redacted of secrets)

## Implementation

```python
CONVERSION_BACKENDS = [
    WordConversionBackend(),     # docx2pdf, requires Word
    LibreOfficeBackend(),        # soffice headless
    iWorkBackend(),              # osascript, macOS only
    ImageToPDFBackend(),         # Pillow/img2pdf
]

def convert_to_pdf(input_path, work_dir) -> Path:
    for backend in CONVERSION_BACKENDS:
        if backend.is_available() and backend.can_handle(input_path):
            result = backend.convert(input_path, work_dir / "normalized.pdf")
            if result.success:
                log_conversion(result, work_dir)
                return result.output_path
    raise ConversionError("No available backend could convert this file")
```

============================================================
## PER-51 — Build: Stage C — PDF Preflight + Classification
State: Backlog

Description:
## Stage C — PDF Preflight + Classification

### What it computes

Open `normalized.pdf` and produce `preflight.json` + `form_schema.json`:

```json
{
  "sha256_normalized": "abc123...",
  "is_encrypted": false,
  "has_acroform_fields": true,
  "has_xfa": false,
  "xfa_type": null,
  "has_existing_digital_signatures": false,
  "page_count": 4,
  "page_sizes": [{"page": 1, "width": 612, "height": 792, "rotation": 0}],
  "form_schema": [
    {"name": "Name", "type": "text", "page": 1, "rect": [100, 200, 300, 220]},
    {"name": "Signature", "type": "signature", "page": 4, "rect": [50, 100, 250, 150]}
  ],
  "routing_decision": "acroform",
  "thumbnail_paths": ["work/abc/thumb_p1.png", "work/abc/thumb_p4.png"]
}
```

### Routing decisions

* `acroform` → Stage E fills with PyMuPDF
* `xfa_static` → route to XFA backend or reject
* `xfa_dynamic` → reject with explanation
* `flat` → visual placement engine
* `encrypted` → error: request password

### Implementation

```python
import pymupdf

def preflight(normalized_pdf, work_dir) -> PreflightResult:
    doc = pymupdf.open(normalized_pdf)
    catalog = doc.pdf_catalog()

    has_xfa = "/XFA" in str(catalog)
    has_acroform = len(doc.get_fields()) > 0

    # Render thumbnails of all pages
    for i, page in enumerate(doc):
        pix = page.get_pixmap(dpi=72)
        pix.save(work_dir / f"thumb_p{i+1}.png")

    return PreflightResult(...)
```

============================================================
## PER-52 — Build: Stage D — FillPlan builder
State: Backlog

Description:
## Stage D — Build a FillPlan

### Input

* User data + intent (natural language or structured)
* form_schema.json from preflight
* Signature placement instructions

### Output: strict JSON FillPlan

```json
{
  "job_id": "abc12345",
  "fill_strategy": "acroform",
  "field_fills": [
    {
      "field_name": "Name",
      "source_value": "Mark Kasdorf",
      "mapping_decision": "direct match to field 'Name'",
      "value": "Mark Kasdorf",
      "field_type": "text"
    },
    {
      "field_name": "Date",
      "source_value": "today",
      "mapping_decision": "resolved 'today' to 2026-02-23",
      "value": "2026-02-23",
      "field_type": "text"
    }
  ],
  "signature_plan": {
    "type": "stamp_and_crypto",
    "page": 4,
    "x": 100, "y": 600, "width": 150, "height": 60,
    "coordinate_system": "pymupdf_top_left"
  },
  "required_clarifications": []
}
```

### LLM-assisted field mapping

Pass form_schema.json fields + user instructions to LLM:

> "Map these user instructions to form fields: \[fields\]. Instructions: 'Fill in my name and today's date, sign at the bottom of page 4.'"

LLM returns structured mapping. Validate against field schema before executing.

### Explainability requirement (2.1.2)

Every fill must have `source_value` + `mapping_decision` in the plan.
FillPlan is saved to `work/{job_id}/fill_plan.json` for audit.

============================================================
## PER-53 — Build: Stages E+F — Execute Fill + Apply Signatures
State: Backlog

Description:
## Stage E — Execute Fill

### AcroForm path (PyMuPDF)

```python
def fill_acroform(pdf_path, fill_plan, work_dir) -> Path:
    doc = pymupdf.open(pdf_path)
    for fill in fill_plan.field_fills:
        widget = find_widget(doc, fill.field_name)
        if widget.field_type == "text":
            widget.field_value = fill.value
        elif widget.field_type == "checkbox":
            widget.field_value = widget.field_value_on  # export value
        elif widget.field_type == "select":
            assert fill.value in widget.choice_values
            widget.field_value = fill.value
        widget.update()

    # Set NeedAppearances for viewer compatibility
    doc.pdf_catalog().update({"/NeedAppearances": True})
    output = work_dir / "filled.pdf"
    doc.save(output)
    return output
```

## Stage F — Apply Signatures

### Visual stamp (PyMuPDF)

```python
# Decrypt sig image with PIN
sig_img = decrypt_sig(pin)
page = doc[sig_plan.page - 1]
rect = pymupdf.Rect(sig_plan.x, sig_plan.y,
                    sig_plan.x + sig_plan.width,
                    sig_plan.y + sig_plan.height)
page.insert_image(rect, stream=sig_img)
```

### Cryptographic signature (pyHanko)

```python
from pyhanko.sign import signers, fields
from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter

# Note: coordinate transform required (PyMuPDF → pyHanko)
pyhanko_rect = convert_coords(sig_plan, page_height)

signer = signers.SimpleSigner.load_pkcs12(cert_path, passphrase=pin.encode())
with open(filled_pdf, 'rb') as f:
    w = IncrementalPdfFileWriter(f)
    out = signers.sign_pdf(w,
        signers.PdfSignatureMetadata(field_name='Signature1'),
        signer=signer,
        appearance=SignatureAppearance(sig_img_path))
```

============================================================
## PER-54 — Build: Stage G — Verify + Evidence bundle
State: Backlog

Description:
## Stage G — Verify + Produce Evidence

### What it does

Re-open signed.pdf and prove everything worked.

### Field readback

```python
doc = pymupdf.open("signed.pdf")
readback = {}
for field in doc.get_fields():
    readback[field["field_name"]] = field["field_value"]

# Compare against fill_plan
for fill in fill_plan.field_fills:
    assert readback[fill.field_name] == fill.value, f"Field mismatch: {fill.field_name}"
```

### Signature validation

```python
from pyhanko.sign.validation import validate_pdf_signature
from pyhanko.pdf_utils.reader import PdfFileReader

with open("signed.pdf", 'rb') as f:
    reader = PdfFileReader(f)
    for sig in reader.embedded_signatures:
        status = validate_pdf_signature(sig)
        assert status.valid and status.intact
```

### Evidence images

Render pages containing signatures or filled fields at higher DPI:

```python
for page_num in pages_with_changes:
    page = doc[page_num - 1]
    pix = page.get_pixmap(dpi=150)
    pix.save(work_dir / f"evidence_p{page_num}.png")
```

### verification.json output

```json
{
  "job_id": "abc12345",
  "original_sha256": "...",
  "signed_sha256": "...",
  "fields_verified": [
    {"field": "Name", "expected": "Mark Kasdorf", "actual": "Mark Kasdorf", "ok": true}
  ],
  "signature_valid": true,
  "signature_intact": true,
  "evidence_images": ["evidence_p4.png"]
}
```

### Return to Mark (via Telegram)

* signed.pdf
* verification.json summary (inline message)
* evidence images (thumbnail of signed page)

============================================================
## PER-55 — ✅ RESOLVED: PDF Engine choice — Option A (PyMuPDF + pyHanko)
State: Backlog

Description:
## Decision: LOCKED

**Engine:** Option A — PyMuPDF + pyHanko

**Rationale:**

* Internal/personal use only → AGPL acceptable
* Mac Studio with Office installed → no commercial SDK needed
* PyMuPDF handles AcroForm fill + visual stamping
* pyHanko handles cryptographic signing + validation

**Option B (Apryse/Foxit) rejected:** Unnecessary cost and complexity for internal tooling.

============================================================
## PER-56 — ✅ RESOLVED: All architecture decisions locked
State: Backlog

Description:
## All 6 questions from Section 4 — ANSWERED

| Q | Answer |
| -- | -- |
| 4.1 Internal/SaaS? | **Internal only** — PyMuPDF AGPL fine |
| 4.2 Platform? | **macOS only** — Mac Studio, Office installed |
| 4.3 Cloud calls? | **Off by default** — `allow_cloud_fallback=false`; XFA_STATIC only behind flag |
| 4.4 XFA scope? | **Local-first** — Static: flatten/visual lane; Dynamic: overlay lane |
| 4.5 Signing cert? | **Self-signed PKCS#12**, Keychain-secured; basic signing only |
| 4.6 Flatten? | **Per-class default** — flatten visual/scanned; keep editable for clean AcroForms |

============================================================
## PER-57 — Build: 2.3.3 AcroForm Fill Engine — strict requirements
State: Backlog

Description:
## 2.3.3 AcroForm Fill Engine

### Widget enumeration

```python
def build_form_schema(doc: pymupdf.Document) -> list[FieldSchema]:
    schema = []
    for page in doc:
        for widget in page.widgets():
            schema.append(FieldSchema(
                name=widget.field_name,
                type=widget.field_type_string,  # text/checkbox/radio/combo/list/signature
                rect=list(widget.rect),          # page coordinates
                required=bool(widget.field_flags & 2),
                read_only=bool(widget.field_flags & 1),
                options=widget.choice_values if hasattr(widget, 'choice_values') else [],
                export_values=widget.field_value_on if widget.field_type_string == 'checkbox' else None
            ))
    return schema
```

### Fill logic (type-aware)

```python
for fill in fill_plan.field_fills:
    widget = find_widget(doc, fill.field_name)
    if widget.field_type_string == 'text':
        widget.field_value = fill.value
    elif widget.field_type_string == 'checkbox':
        widget.field_value = widget.field_value_on  # must use export value, not True/False
    elif widget.field_type_string == 'radio':
        widget.field_value = fill.value  # must match one of the export values
    elif widget.field_type_string in ('combo', 'list'):
        assert fill.value in widget.choice_values, f"Invalid option: {fill.value}"
        widget.field_value = fill.value
    widget.update()
```

### /NeedAppearances policy (no debate)

```python
# Always set after filling unless also flattening
doc.pdf_catalog().update({"/NeedAppearances": True})
```

**Rationale:** Improves viewer compatibility when templates have broken/missing appearance streams. Required for cross-viewer consistency.

============================================================
## PER-58 — Build: 2.3.4 XFA Strategy — explicit routing rules
State: Backlog

Description:
## 2.3.4 XFA Strategy

XFA must be detected in Stage C preflight and routed explicitly. Never attempt PyMuPDF fill on XFA.

### Detection

```python
catalog = doc.pdf_catalog()
has_xfa = "/XFA" in str(catalog)
# Distinguish static vs dynamic:
# Dynamic XFA has /XDP stream with <xdp:xdp> root element
# Static XFA has /XFA array pointing to data streams
```

### Routing rules

#### Static XFA

1. **Cloud allowed?** → Route to Adobe PDF Services Import PDF Form Data API
2. **Commercial SDK available?** → Foxit SDK or Adobe SDK for XFA import/export
3. **Neither?** → Flatten to AcroForm (loses XFA interactivity, gains fillability)

#### Dynamic XFA

Dynamic XFA execution is not reliably supported by any standard Python library. Only two options:

1. **Flatten via specialized backend** (preferred) — strips XFA, produces flat PDF
2. **Render-to-image + overlay text** (fallback) — loses all interactivity, last resort

### Error behavior

If no XFA backend is available:

```python
raise XFAUnsupportedError(
    "This is an XFA form. PyMuPDF cannot fill it reliably. "
    "Please use Adobe Acrobat or send as a different format."
)
```

### For MarkBot now

Implement **detect + reject with clear message** (simplest safe option). Add specialized backend when needed.

============================================================
## PER-59 — Build: 2.3.5 Visual Fill Engine — flat/scanned PDFs
State: Backlog

Description:
## 2.3.5 Visual Fill Engine (what makes it best-in-class)

For flat PDFs (no AcroForm fields), scanned docs, or printed-to-PDF forms.

## Layer 1 — Anchor-based placement (deterministic)

### Step 1: Render page images

```python
page = doc[page_num]
pix = page.get_pixmap(dpi=150)  # 150-200 DPI for analysis
img_path = work_dir / f"render_p{page_num}.png"
pix.save(img_path)
```

### Step 2: Extract text with coordinates

```python
# If text exists (digital PDF):
blocks = page.get_text("dict")["blocks"]
# If no text (scanned): run Tesseract OCR
import subprocess
result = subprocess.run(["tesseract", img_path, "output", "tsv"], capture_output=True)
```

### Step 3: Anchor-based field location

For each fill item:

* Find label text ("Name:", "Date:", "Signature:") using fuzzy match
* Find nearest blank line/box to the right or below
* Place text in computed rectangle with auto-fit font size + left alignment
* Checkboxes: detect square boxes near label → mark with "X" or checkmark glyph

## Layer 2 — Vision+LLM proposal (high recall, then verified)

```python
# Send page image + field list to vision model
response = llm.vision(
    image=page_image,
    prompt=f"Locate fields for: {fill_plan.field_names}. Return normalized coordinates [x0,y0,x1,y1] in [0..1] range, top-left origin."
)
candidates = parse_coordinates(response)

# Verify candidates
for candidate in candidates:
    assert is_within_bounds(candidate)
    assert not overlaps_existing_text_heavily(candidate, page_text)
    assert position_consistent_with_label(candidate, page_text)

# If confidence < threshold → ask Mark for clarification
```

## Canonical coordinate system (required for all engines)

```
Normalized: x0, y0, x1, y1 in [0..1], origin = top-left

Convert to PyMuPDF:  x*page_width, y*page_height (already top-left)
Convert to pyHanko:  llx=x0*W, lly=(1-y1)*H, urx=x1*W, ury=(1-y0)*H
```

All placements stored in normalized coords in fill_plan.json. Engine-specific conversion happens at execution t

============================================================
## PER-60 — Build: 2.4.1 SignerProfile — signer config system
State: Backlog

Description:
## 2.4.1 Signature assets & profiles

Define `SignerProfile` objects in a config file (`data/signer_profiles.yaml`):

```yaml
profiles:
  - profile_id: mark_kasdorf
    display_name: Mark Kasdorf
    signature_image: data/.sig.gpg          # GPG-encrypted PNG with transparency
    initials_image: data/.initials.gpg      # optional
    pkcs12_path: data/.cert.p12.gpg         # GPG-encrypted PKCS12
    pkcs12_password_method: pin_stdin       # or: env_var, keychain
    signature_defaults:
      reason: "Agreed and signed"
      location: "Newton, MA"
      contact: "mark@forgeco.com"
    timestamping_url: null                  # e.g. http://timestamp.digicert.com
    ltp_enabled: false                      # LTV/PAdES for long-term validation
```

## SignerProfile dataclass

```python
@dataclass
class SignerProfile:
    profile_id: str
    display_name: str
    signature_image_path: Path
    initials_image_path: Optional[Path]
    pkcs12_path: Path
    pkcs12_password_method: str
    signature_defaults: dict
    timestamping_url: Optional[str]
    ltp_enabled: bool

    def decrypt_signature(self, pin: str) -> bytes:
        return gpg_decrypt(self.signature_image_path, pin)

    def decrypt_pkcs12(self, pin: str) -> Path:
        return gpg_decrypt(self.pkcs12_path, pin)
```

## Notes

* All sensitive assets (sig image, cert) remain GPG-encrypted at rest
* PIN flows via stdin only, never stored
* Multiple profiles possible (e.g., personal vs business signing identity)

============================================================
## PER-61 — Build: 2.4.2 Visual signature appearance — INCOMPLETE (message cut off)
State: Backlog

Description:
## 2.4.2 Visual signature appearance

**⚠ Message cut off at "You support two modes:" — content not yet received.**

## Known context

Two modes of visual signature appearance are defined in the architecture. Details pending.

## Expected content based on pyHanko patterns:

* **Mode 1: Stamp-only** — visual image overlaid on page, no cryptographic field
* **Mode 2: Embedded appearance** — visual image embedded inside pyHanko signature field (appears inside the sig box in Adobe)

## Action

Re-send section 2.4.2 onward to complete this ticket.

============================================================
## PER-62 — Build: 2.4.2 Visual signature modes (complete)
State: Backlog

Description:
## Two modes (updates PER-61)

### Mode 1 — Field-based visible signature (PREFERRED)

Create/use a PDF signature field and have pyHanko generate the visible appearance.
pyHanko supports visible signatures with custom image appearance.

```python
from pyhanko.sign import signers, fields
from pyhanko.stamp import QRStampStyle

sig_field_spec = fields.SigFieldSpec(
    sig_field_name='Signature1',
    on_page=page_num,
    box=pyhanko_rect  # bottom-left Cartesian coords
)
```

### Mode 2 — Stamp-only

Place signature image with PyMuPDF (visual stamp) then apply an **invisible** cryptographic signature via pyHanko.
Use when the PDF cannot cleanly accept a new signature field.

### Default policy

Field-based visible is default. Fall back to stamp-only only when:

* PDF already has signature fields that conflict
* Document structure prevents adding new fields cleanly

### Decision logic

```python
def choose_sig_mode(pdf_path, preflight) -> SigMode:
    if preflight.has_existing_digital_signatures:
        # Can only add new sig if no other modifications required
        return SigMode.STAMP_ONLY
    if can_add_field(pdf_path):
        return SigMode.FIELD_BASED
    return SigMode.STAMP_ONLY
```

============================================================
## PER-63 — Build: 2.4.3 Cryptographic signing — required behavior
State: Backlog

Description:
## 2.4.3 Cryptographic signing

### Engine: pyHanko (always)

### Always use incremental update (pyHanko default)

```python
from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter
# pyHanko signs incrementally by default — never do full rewrite after signing
```

### Supported signing methods

1. **PKCS#12** (baseline — what we have now)

```python
signer = signers.SimpleSigner.load_pkcs12(
    pfx_file=cert_path,
    passphrase=pin.encode()
)
```

2. **PKCS#11 device signing** (optional, supported by pyHanko)

* Hardware tokens, smart cards, YubiKey
* Not needed for MarkBot v1, but architecture supports it

### Existing signatures policy

If the input PDF already has digital signatures:

* **Only allow** adding new signature if NO other modifications (fills) are required in this job
* **Otherwise fail early:**

```python
if preflight.has_existing_digital_signatures and fill_plan.has_field_fills:
    raise ExistingSignatureConflict(
        "Document already signed; filling fields would invalidate the prior signature. "
        "Fill first, then sign — or use a fresh unsigned copy."
    )
```

============================================================
## PER-64 — Build: 2.4.4 Signature verification (mandatory)
State: Backlog

Description:
## 2.4.4 Verification after signing

Must run after every signing operation. Result stored in verification.json.

```python
from pyhanko.sign.validation import validate_pdf_signature
from pyhanko.pdf_utils.reader import PdfFileReader

def verify_signatures(signed_pdf_path: Path) -> list[SigValidationResult]:
    results = []
    with open(signed_pdf_path, 'rb') as f:
        reader = PdfFileReader(f)
        for sig in reader.embedded_signatures:
            status = validate_pdf_signature(sig)
            results.append(SigValidationResult(
                field_name=sig.field_name,
                valid=status.valid,
                intact=status.intact,
                trusted=status.trusted,
                coverage=str(status.coverage)
            ))
    return results
```

### verification.json

```json
{
  "signatures": [
    {
      "field_name": "Signature1",
      "valid": true,
      "intact": true,
      "trusted": false,
      "coverage": "ENTIRE_REVISION"
    }
  ]
}
```

### Note on `trusted: false`

Self-signed certs will show `trusted: false` — expected. The signature is still cryptographically valid and tamper-evident. For full trust, use a cert signed by a public CA or Adobe-trusted CA.

============================================================
## PER-65 — Build: 2.5 LLM field mapping — reliable, constrained
State: Backlog

Description:
## 2.5 LLM mapping to fields

Must NOT let the model decide freely. Strict contract enforced.

### 2.5.1 FormSchema JSON (passed to LLM)

```json
{
  "fields": [
    {
      "field_id": "f1",
      "pdf_field_name": "Name",
      "type": "text",
      "page": 1,
      "rect": [0.1, 0.2, 0.4, 0.22],
      "label_candidates": ["Full Name", "Name:"],
      "options": [],
      "required": true,
      "read_only": false
    },
    {
      "field_id": "f2",
      "pdf_field_name": "Agreement",
      "type": "checkbox",
      "page": 3,
      "rect": [0.05, 0.8, 0.07, 0.82],
      "label_candidates": ["I agree to the terms"],
      "options": [],
      "required": true,
      "read_only": false
    }
  ]
}
```

### 2.5.2 Mapping prompt contract

Model must return strict JSON — no prose:

```json
{
  "fills": [
    {
      "field_id": "f1",
      "value": "Mark Kasdorf",
      "confidence": 0.99,
      "rationale_short": "direct name match"
    }
  ],
  "unmapped": [
    {
      "input_key": "SSN",
      "reason": "not found in form schema"
    }
  ],
  "clarifications": []
}
```

### 2.5.3 Deterministic validators (reject if)

* Type mismatch (dropdown value not in options)
* Checkbox value not boolean-ish (true/false/"yes"/"no")
* Confidence < threshold AND no rationale
* Attempts to fill read-only field (unless override set)

### 2.5.4 Human-in-the-loop trigger

If ANY required field is unmapped OR any confidence < threshold:

* Return clarifications to Mark instead of executing
* Never silently skip required fields
* Mark answers → retry mapping → execute

============================================================
## PER-66 — Build: 2.6 Observability — job artifact bundle
State: Backlog

Description:
## 2.6 Job artifact bundle

Every run produces a complete audit trail in `work/{job_id}/`:

```
work/
  {job_id}/
    original.sha256         # SHA-256 of input file
    original.{ext}          # copy of input
    conversion_log.json     # if conversion happened
    normalized.pdf          # post-conversion PDF
    preflight.json          # classification results
    form_schema.json        # field schema (if AcroForm)
    fill_plan.json          # fills + signature plan with rationale
    filled.pdf              # post-fill PDF
    signed.pdf              # final signed PDF (returned to Mark)
    verification.json       # signature + field readback results
    evidence/
      thumb_p1.png          # page thumbnails (pre-fill)
      evidence_p3.png       # evidence images of changed/signed pages
      evidence_p4.png
```

## Why this matters

* Failures are diagnosable (no "silent wrong fills")
* Full audit trail for any signed document
* Evidence images let Mark visually confirm before/after
* Deterministic (same job = same artifact structure always)

## Retention policy

Keep `work/` artifacts for 30 days by default. Signed docs stored separately.
Clean up with: `python3 scripts/cleanup.py --older-than 30d`

============================================================
## PER-67 — EPIC DOCSIG-EP1: Architecture lock — DECISIONS FINAL
State: Backlog

Description:
## Final architecture decisions (all locked)

**4.1 Internal/SaaS?** → Internal only. PyMuPDF AGPL acceptable.
**4.2 Platform?** → macOS only. Mac Studio with 512GB RAM. Microsoft Office installed.
**4.3 Cloud calls?** → OFF by default. `allow_cloud_fallback=false`. XFA_STATIC cloud route available behind feature flag only.
**4.4 XFA scope?** → Local-first. XFA_STATIC: flatten or treat as visual-fill lane. XFA_DYNAMIC: render-overlay visual fill only. Cloud optional, off by default.
**4.5 Signing cert?** → Self-signed PKCS#12, GPG-encrypted at rest. Basic signing only (no LTV/PAdES).
**4.6 Flatten?** → Flatten for visual/scanned; keep editable for clean AcroForms unless requested.

## Quality bar

* Quality > speed. 2–5 minutes per document acceptable.
* Wildly robust: handle nearly any common document type.

## Engine: Option A locked

* PyMuPDF + pyHanko. No commercial SDK needed.
* Word/PowerPoint/Excel automation via docx2pdf / COM on macOS (Office installed).

### DOCSIG-1 — Engine decision ✅ DONE

### DOCSIG-2 — Supported types v1

| Type | Conversion | Fill | Sign |
| -- | -- | -- | -- |
| PDF AcroForm | None | PyMuPDF widgets | pyHanko |
| PDF Flat/Text | None | Visual engine + Beauty Pass | pyHanko |
| PDF Scanned | None | OCR + Visual engine + Beauty Pass | pyHanko |
| PDF XFA Static | None | Flatten → visual lane | pyHanko |
| PDF XFA Dynamic | None | Render-overlay visual lane | pyHanko invisible |
| DOCX | Word automation → PDF | Post-conversion | pyHanko |
| PPTX | PowerPoint automation → PDF | Post-conversion | pyHanko |
| XLSX | Excel automation → PDF | Post-conversion | pyHanko |
| PNG/JPG/TIFF | img2pdf → PDF | Visual engine | pyHanko |

---

## Section L — Build order reminder

1. Start with **Stage A (Intake) + Stage C (Preflight)** — stable pipeline skeleton first
2. Add **Conversion Router** (Stage B) — get all input types normalizing cleanly
3. Add **AcroForm fill** (Stage E, ACROFORM path) — highest-value, most common case
4. Add **Beauty Pas

============================================================
## PER-68 — EPIC DOCSIG-EP2: Conversion Router — Word-first on macOS
State: Backlog

Description:
## Updated: Word-first (macOS, Office installed)

Office automation is primary on Mac Studio. LibreOffice is fallback only.

## Backend priority order (strict)

1. **Word automation** (`docx2pdf`) — DOCX → PDF. Primary. Office installed = always available.
2. **PowerPoint automation** — PPTX → PDF via Office.
3. **Excel automation** — XLSX → PDF via Office.
4. **LibreOffice headless** — fallback for Office/ODF formats if automation fails.
5. **img2pdf / Pillow** — PNG/JPG/TIFF → PDF.
6. **Reject** — unknown type, explicit error code `UNSUPPORTED_FORMAT`.

## Outputs (always)

* `normalized.pdf` — output of conversion
* `conversion.json` — backend used, version, stdout/stderr logs

## DOCSIG-10 — Magic-byte file type detection

**AC:** Unit tests for PDF, DOCX, PPTX, XLSX, PNG, JPG. Reject unknown with `UNSUPPORTED_FORMAT`.

## DOCSIG-11 — LibreOffice headless backend (fallback)

**AC:** Converts DOCX/PPTX/XLSX fixtures. Captures logs. Clean timeout + kill.

## DOCSIG-12 — Word automation backend (primary)

**AC:** docx2pdf succeeds for DOCX. Availability check implemented. Falls back to LibreOffice if Word unreachable.

## DOCSIG-13 — PowerPoint + Excel automation backends

**AC:** PPTX → PDF and XLSX → PDF via Office automation. Fallback to LibreOffice.

## DOCSIG-14 — Image-to-PDF backend

**AC:** PNG/JPG/TIFF → PDF. Preserves orientation. Configurable page size (default: image dimensions).

============================================================
## PER-69 — DOCSIG-12 + 13: Conversion backends — docx2pdf + image-to-PDF
State: Backlog

Description:
## DOCSIG-12 — docx2pdf backend (Word-only)

**Implementation notes:** docx2pdf requires Microsoft Word on Windows/macOS.

**Acceptance criteria:**

* Backend availability check implemented (detect if Word is installed before attempting)
* If unavailable, router falls back to LibreOffice automatically
* Conversion log records which backend was used

```python
def is_available(self) -> bool:
    try:
        import docx2pdf
        # Also check Word is accessible
        return shutil.which("soffice") is not None or _word_installed()
    except ImportError:
        return False
```

---

## DOCSIG-13 — Image-to-PDF backend

**Acceptance criteria:**

* PNG / JPG / TIFF → PDF conversion
* Preserves image orientation
* Configurable page size (default: match image dimensions)

```python
from PIL import Image
import img2pdf

def convert(self, input_path, output_path):
    with open(input_path, 'rb') as f:
        pdf_bytes = img2pdf.convert(f)
    with open(output_path, 'wb') as f:
        f.write(pdf_bytes)
```

============================================================
## PER-70 — EPIC DOCSIG-EP3: PDF Preflight + Classification
State: Backlog

Description:
## DOCSIG-20 — Preflight scanner

**Output:** `work/{job_id}/preflight.json`

### Classification enum (canonical — use these exact values everywhere)

```python
class DocClass(str, Enum):
    ACROFORM = "ACROFORM"        # Has fillable AcroForm fields
    FLAT_TEXT = "FLAT_TEXT"      # Digital PDF, no fields, has extractable text
    SCANNED = "SCANNED"          # No text layer — needs OCR
    XFA_STATIC = "XFA_STATIC"    # Static XFA form
    XFA_DYNAMIC = "XFA_DYNAMIC"  # Dynamic XFA form
    UNKNOWN = "UNKNOWN"          # Cannot classify
```

### preflight.json schema

```json
{
  "sha256_original": "abc...",
  "doc_class": "ACROFORM",
  "is_encrypted": false,
  "has_acroform": true,
  "has_xfa": false,
  "xfa_type": null,
  "has_existing_digital_signatures": false,
  "page_count": 4,
  "page_sizes": [{"page": 1, "width": 612, "height": 792, "rotation": 0}],
  "routing_backend": null,
  "warnings": []
}
```

### Acceptance criteria

* Detects encryption / password requirement
* Detects AcroForm widgets (count + field names)
* Detects existing digital signatures
* Detects XFA presence (best-effort) — classifies as STATIC / DYNAMIC / unknown
* Produces correct `doc_class` enum value
* Routing decision recorded

---

## DOCSIG-21 — Render page thumbnails

**Output:** `work/{job_id}/thumbs/page_0001.png` …

**AC:**

* All pages rendered at configurable DPI (default 150)
* Zero-padded naming: `page_NNNN.png`
* Stored in `thumbs/` subdirectory

============================================================
## PER-71 — EPIC DOCSIG-EP4: Form schema + AcroForm fill (DOCSIG-30, 31, 32)
State: Backlog

Description:
## DOCSIG-30 — Build FormSchema from AcroForms

**Output:** `work/{job_id}/form_schema.json`

**AC:**

* Schema includes: field_id (stable), pdf_field_name, type, page, rect (normalized \[0..1\]), options, required/read-only flags, label_candidates (nearby text)
* Types covered: text, multiline, checkbox, radio, combo, list, signature

---

## DOCSIG-31 — Fill AcroForms + appearance handling

**Fill logic (type-aware, strict):**

* text/multiline → set field_value
* checkbox → use widget.field_value_on (export value), NOT True/False
* radio → value must match one of the export values
* combo/list → assert value in widget.choice_values before setting
* Call widget.update() after every modification

**NeedAppearances policy (no debate):**

```python
doc.pdf_catalog().update({"/NeedAppearances": True})
```

Set after all fills, unless also flattening.

**Readback test (mandatory):**
Reopen PDF after save; read back all filled field values; assert they match fill_plan.

**AC:**

* Fills text, checkbox, radio, dropdown on fixture PDFs
* Readback confirms values match
* Viewer compatibility documented

---

## DOCSIG-32 — Optional flatten mode

**AC:**

* `flatten=True` → output has no editable fields
* Visually identical within pixel-diff tolerance
* Method documented in code

============================================================
## PER-72 — EPIC DOCSIG-EP5: Visual Fill Engine + Beauty Pass (DOCSIG-40–42)
State: Backlog

Description:
## Visual fill applies to: FLAT_TEXT, SCANNED, XFA_DYNAMIC (overlay)

---

## DOCSIG-40 — Canonical coordinate system utilities

Normalized rect \[x0,y0,x1,y1\] in \[0..1\], origin top-left of visible page.

```python
def to_pymupdf(norm, page_w, page_h):
    return (norm[0]*page_w, norm[1]*page_h, norm[2]*page_w, norm[3]*page_h)

def to_pyhanko(norm, page_w, page_h):
    return (norm[0]*page_w, (1-norm[3])*page_h, norm[2]*page_w, (1-norm[1])*page_h)
```

**AC:** Unit tests for all page sizes (letter, A4, legal) × all rotations (0/90/180/270°).

---

## DOCSIG-41 — Anchor-based placement v1

For FLAT_TEXT: use PDF text extraction with boxes.
For SCANNED: Tesseract OCR.

Anchor search:

* Find label text ("Name:", "Date:", "Signature:") via fuzzy match
* Locate nearest blank line/box to right or below
* Place in computed rect

Checkbox marking:

* Detect square box near label
* Mark with "X" or checkmark (consistent glyph per doc — see Beauty Pass)

**AC:** Label → placement works on fixture docs. Checkbox marking works.

---

## DOCSIG-42 — Vision+LLM placement (guardrailed)

* Page image + field list → vision model → normalized coordinate candidates
* Validator: reject out-of-bounds, heavy overlap with existing text
* Confidence < threshold → return clarifications, do not execute

**AC:** Strict JSON output validated. Clarifications returned when confidence low.

---

## DOCSIG-43 — Beauty Pass (mandatory for visual fills; optional AcroForm)

**Typography:**

* Match nearby font class (sans/serif/mono) if detectable; else standard sans default
* Auto-fit font size to box height; shrink-to-fit on overflow; multiline wrap only if specified
* Left padding = 2–4% of box width; vertical center in box

**Checkboxes:**

* Choose glyph style (X or ✓) based on nearby marks heuristics; consistent within doc

**Dates:**

* Default MM/DD/YYYY unless doc pattern suggests otherwise

**Render verification:**

* After placement, render page; assert filled content stays inside targ

============================================================
## PER-73 — EPIC DOCSIG-EP6: Sig placement engine + cryptographic signing (DOCSIG-50–52)
State: Backlog

Description:
## DOCSIG-50 — SigningProfile + Keychain secret handling

**SigningProfile schema (YAML/JSON):**

```yaml
profiles:
  - profile_id: mark_kasdorf
    display_name: Mark Kasdorf
    signature_image: data/.sig.gpg        # GPG-encrypted transparent PNG
    initials_image: data/.initials.gpg    # optional
    pkcs12_path: data/.cert.p12.gpg       # GPG-encrypted PKCS12
    pkcs12_password_method: keychain      # macOS Keychain (primary) or stdin
    signature_defaults:
      reason: "Agreed and signed"
      location: "Newton, MA"
      contact: "mark@forgeco.com"
    timestamping_url: null
    ltp_enabled: false
```

**AC:**

* Multiple profiles supported
* Password retrieved from macOS Keychain by default
* GPG-encrypted assets at rest (sig image + PKCS12)
* PIN/password never logged or stored in plaintext

---

## DOCSIG-51 — Signature placement engine (priority policy)

**Placement priority order:**

1. **Existing signature field** in the PDF → use it
2. **Anchor search**: find text "Signature", "Sign here", "Authorized signature" → place adjacent
3. **Fallback**: bottom-right of last page, inside safe margins (5% inset)

**Confidence gating:**

* Confidence < threshold AND no explicit user placement → return clarification, do not sign

**AC:**

* Priority policy implemented and tested on fixture PDFs
* Rotated page handling documented + tested
* pyHanko Cartesian transform applied correctly

---

## DOCSIG-52 — Cryptographic signing + validation

**Engine:** pyHanko, always incremental update.

**Existing signatures policy:**

* If doc has existing sigs AND modifications required → fail early
* Exception: user explicitly requests "append signature only, no edits"

**Post-sign validation (mandatory):**

```python
status = validate_pdf_signature(sig)
assert status.valid and status.intact
```

**verification.json signature fields:**

```json
{
  "field_name": "Signature1",
  "valid": true,
  "intact": true,
  "trusted": false,
  "coverage": "ENTIRE_REVISION"
}
```

No

============================================================
## PER-74 — EPIC DOCSIG-EP7: Skill API + artifact bundle + CI (DOCSIG-60–62)
State: Backlog

Description:
## DOCSIG-60 — Artifact bundle writer

Every run writes to `work/{job_id}/`:

```
original.sha256
normalized.pdf
conversion.json
preflight.json
thumbs/
  page_0001.png
  page_0002.png
  ...
form_schema.json        (ACROFORM only)
fill_plan.json
filled.pdf
signed.pdf
verification.json
evidence/
  evidence_p3.png
  evidence_p4.png
```

**AC:** Always emits complete set. Atomic write (temp → rename). Auto-cleanup after 30 days.

---

## DOCSIG-61 — OpenClaw skill API contract

**Single entrypoint:**

```python
def fill_and_sign(
    input_path_or_bytes: str | bytes,
    output_dir: str,
    user_intent: str,                    # natural language instructions
    mark_profile_id: str = "mark_kasdorf",
    signing_profile_id: str = "mark_kasdorf",
    options: FillSignOptions = FillSignOptions()
) -> FillSignResult:
    pass

@dataclass
class FillSignResult:
    signed_pdf_path: str
    verification_summary: dict
    evidence_dir: str
    clarifications: list[str]   # non-empty = human input needed; did not sign
    job_id: str
```

**AC:**

* Single entrypoint, called from [SKILL.md](<http://SKILL.md>)
* Returns clarifications (not executed) when human-in-the-loop triggered
* Never raises unhandled exceptions — always returns typed error in result

---

## DOCSIG-62 — End-to-end fixtures + CI

**Golden fixtures:**

1. AcroForm PDF — fill 5+ field types + sign → verify
2. Flat text PDF (no fields) — visual placement + Beauty Pass + sign → verify
3. DOCX → Word automation → PDF → fill → sign → verify

**CI:** All three pass with `verification.json: signature_valid=true` + readback match.

---

## Section L — Execution Guidance

### Build philosophy

* Implement iteratively — stages can be built and tested independently
* **Pipeline stages and artifact outputs are stable from day 1** — do not change the artifact schema mid-build
* Each stage must produce its artifact even if downstream stages aren't built yet

### Required fixtures (must exist before CI runs)

| Fixture |

============================================================
## PER-75 — EPIC DOCSIG-EP8: Profiles + Keychain-secured secrets
State: Backlog

Description:
## New epic: MarkProfile + SigningProfile with macOS Keychain

### DOCSIG-70 — MarkProfile implementation

**Storage:** `data/mark_profile.json` — AES-256 encrypted at rest; encryption key in macOS Keychain.

**Canonical schema:**

```json
{
  "profile_id": "mark_kasdorf",
  "legal_name": "Mark Kasdorf",
  "preferred_name": "Mark",
  "address_home": "...",
  "address_mailing": "...",
  "phone": "...",
  "email": "mark@forgeco.com",
  "company_name": "Forge",
  "title": "CEO",
  "date_format": "MM/DD/YYYY",
  "timezone": "America/New_York",
  "optional_dob": null,
  "optional_last4_ssn": null,
  "optional_partner_info": null
}
```

**Alias mapping** (for LLM field mapping):

```json
{
  "legal_name": ["full name", "name", "print name", "printed name", "signer name"],
  "email": ["email address", "e-mail", "contact email"],
  "company_name": ["company", "organization", "employer", "business name"],
  "title": ["job title", "position", "role"]
}
```

**Keychain integration:**

```python
import keyring
key = keyring.get_password("openclaw-docsig", "mark_profile_key")
profile = decrypt_json(profile_path, key)
```

**AC:**

* Profile loads and decrypts correctly
* Key stored/retrieved from macOS Keychain (keyring library)
* Alias mapping used in LLM field mapping (DOCSIG-30 / DOCSIG-42)
* Sensitive fields (SSN, DOB) omitted from logs always

---

### DOCSIG-71 — SigningProfile implementation

**Storage:** YAML config referencing GPG-encrypted assets.

**AC:**

* Multiple profiles supported
* PKCS#12 password retrieved from macOS Keychain
* Signature image + initials image decrypted on demand
* PIN/password never logged
* Profile selection by ID in `fill_and_sign()` call

============================================================
## PER-76 — EPIC DOCSIG-EP9: Beauty Pass Engine
State: Backlog

Description:
## New epic: Typography, fit, checkbox glyphs, date formatting, render verification

### DOCSIG-80 — Core Beauty Pass implementation

Applies after fill placement for visual fills (FLAT_TEXT, SCANNED, XFA_DYNAMIC overlay).
Optional for AcroForm fills.

**Typography:**

```python
def beauty_pass_text(page, rect, text, nearby_font_class=None):
    font_family = detect_nearby_font_class(page, rect) or "sans"
    # Map: sans → Helvetica, serif → Times-Roman, mono → Courier
    font = FONT_MAP[font_family]

    box_height = rect[3] - rect[1]
    font_size = fit_font_size(text, font, box_height, rect[2]-rect[0])

    # Padding: 2-4% of box width, vertically centered
    x = rect[0] + (rect[2]-rect[0]) * 0.03
    y = rect[1] + (box_height - font_size) / 2 + font_size

    page.insert_text((x, y), text, fontname=font, fontsize=font_size)
```

**Shrink-to-fit:**

* Start at `box_height * 0.75`
* Reduce by 1pt until text fits width OR minimum font size (6pt)
* Multiline wrap only if `wrap=True` specified in FillPlan

**Padding:**

* Left: 2–4% of box width
* Vertical: centered in box

---

### DOCSIG-81 — Checkbox glyph engine

**Glyph selection:**

* Scan document for existing checkbox marks to detect style ("X" vs "✓")
* If no existing marks: default to "✓"
* Consistent within document (all checkboxes use same glyph)

**Placement:**

* Center glyph in detected box rect
* Font size = 80% of box height

---

### DOCSIG-82 — Date formatting

* Default: MM/DD/YYYY
* Auto-detect doc pattern: scan for existing date strings near date fields
* If YYYY-MM-DD pattern detected → use that
* `today` resolved to actual date at execution time

---

### DOCSIG-83 — Render verification

After all placements:

```python
# Render filled page
pix = page.get_pixmap(dpi=150)
# Assert filled content is inside target rect (pixel check)
# Assert no significant overlap with existing printed text
assert placement_within_bounds(pix, fill_rect)
assert overlap_ratio(pix, fill_rect, existing_text_mask) <

============================================================
## PER-77 — EPIC DOCSIG-EP10: Signature Placement Engine
State: Backlog

Description:
## New epic: Field/anchor/fallback policy + confidence gating

### DOCSIG-90 — Signature placement engine

**Priority order (strict — try in sequence):**

1. **Existing signature field** in the PDF
   * AcroForm signature widget → use it directly with pyHanko
   * No coordinate calculation needed
2. **Anchor search**
   * Scan all pages for text matching: "Signature", "Sign here", "Authorized signature", "Signed by", "Signature of"
   * Fuzzy match (case-insensitive, partial)
   * Place signature box adjacent to anchor (right of label or below underline)
   * Record confidence score
3. **Fallback**
   * Bottom-right of last page
   * Inside safe margins: 5% inset from page edges
   * Confidence = LOW

**Confidence gating:**

```python
if sig_plan.confidence < CONFIDENCE_THRESHOLD and not sig_plan.user_confirmed:
    return clarification_request(
        "Could not confidently locate signature field. "
        f"Best guess: page {sig_plan.page}, position {sig_plan.norm_rect}. "
        "Confirm or provide placement."
    )
```

**AC:**

* Priority policy tested on fixtures: PDF with sig field, PDF with "Sign here" text, blank PDF
* Confidence scores recorded in fill_plan.json
* Human-in-the-loop triggered when confidence < threshold
* Rotated pages handled correctly

---

### DOCSIG-91 — Existing digital signature conflict handling

```python
if preflight.has_existing_digital_signatures:
    if fill_plan.has_field_fills:
        raise ExistingSignatureConflict(
            "Document already signed. Filling fields would invalidate prior signature. "
            "Options: (1) append signature only with no edits, (2) use unsigned copy."
        )
```

Exception: user passes `append_only=True` → skip fills, sign only.

**AC:**

* Conflict detected and raised clearly
* append_only mode works on fixture with existing sig

============================================================
## PER-78 — EPIC DOCSIG-EP11: XFA routing — local-first, cloud optional OFF
State: Backlog

Description:
## Updated XFA policy (replaces PER-58)

### Detection (Stage C preflight)

```python
catalog_str = str(doc.pdf_catalog())
has_xfa = "/XFA" in catalog_str
# Classify: STATIC (data streams) vs DYNAMIC (XDP root element)
```

### XFA_STATIC routing

1. Attempt flatten-to-AcroForm locally if possible
2. If flatten succeeds → treat as ACROFORM lane
3. If flatten fails → treat as visual-fill lane (FLAT_TEXT)
4. Cloud fallback (Adobe PDF Services) — OPTIONAL, `allow_cloud_fallback=False` by default
   * Only XFA_STATIC qualifies for cloud route
   * Never XFA_DYNAMIC

### XFA_DYNAMIC routing

* Do NOT attempt true form fill
* Use render-overlay visual fill lane (same as FLAT_TEXT/SCANNED)
* Present clarification to user: "This is a dynamic XFA form. I'll overlay your data visually — the form won't be interactive after signing. Proceed?"
* If user says no → return clarification, do not proceed

### Feature flag

```python
@dataclass
class FillSignOptions:
    allow_cloud_fallback: bool = False   # XFA_STATIC cloud route
    flatten_after_fill: bool = False     # Flatten AcroForms after fill
    append_only: bool = False            # Skip fills, sign only
    allow_invalid_sig: bool = False      # Don't fail on sig validation error
```

### AC

* XFA_STATIC flattens or falls through to visual lane correctly
* XFA_DYNAMIC triggers overlay confirmation
* Cloud route never activates unless `allow_cloud_fallback=True` explicitly set
* Feature flag tested: off by default, works when enabled