diff --git a/.claude/skills/doc-review/SKILL.md b/.claude/skills/doc-review/SKILL.md index 563ec8b8..2157f5fb 100644 --- a/.claude/skills/doc-review/SKILL.md +++ b/.claude/skills/doc-review/SKILL.md @@ -1,49 +1,124 @@ --- name: doc-review -description: Review documentation for quality, structure, and convention compliance. Use when the user wants to review docs, audit a page or section, check if content is clear, evaluate information architecture, or assess navigation. Do NOT use when the user wants to write or fix docs — use doc-write for that. Triggers on "review the docs", "audit this page", "is this getting-started page clear", "check the implementation guide section". +description: Review documentation for quality, structure, and convention compliance. Use when the user wants to review docs, audit a page or section, check if content is clear, evaluate information architecture, or assess navigation. Do NOT use when the user wants to write or fix docs — use doc-write for that. For a periodic whole-site navigation and coverage audit that files issues, use doc-structure. Triggers on "review the docs", "audit this page", "is this getting-started page clear", "check the implementation guide section". --- -# Doc Review +# Doc review -Evaluate technical docs against the Diátaxis framework, information-architecture best practices, and the project's CLAUDE.md. +Review changed documentation against this repo's CLAUDE.md, the Diátaxis framework, and the site's information architecture. + +Most of this review's value comes from checks a human reviewer cannot do cheaply: verifying prose against generated reference pages, catching pages the change forgot to update, and questioning where a new page lives. Spend your turns there. + +## What is already checked for you + +Do not re-do this work: + +- **Spelling and Vale rules** — the `Mintlify Validation (kosli) - vale-spellcheck` check runs on every PR and enforces `styles/Kosli/AmericanSpelling.yml`. Never report a spelling finding. Never report "American spelling: pass". +- **PR title format** — the `Validate PR Title` job enforces Conventional Commits. +- **Live-docs script behavior** — the `Test live-docs scripts` job runs `pytest tests/`. + +**Link checking is the exception — verify it yourself.** `Mintlify Validation (kosli) - link-rot` is unreliable: check the PR's own check runs (`gh pr checks`), and if it reports `skipping` or is absent, nothing has validated the links. Treat that as the default and confirm every internal target resolves. ## Before reviewing -1. **Read the project's CLAUDE.md** — it defines all site-specific conventions. Use it as your compliance checklist for writing style, components, link format, and frontmatter. If no CLAUDE.md exists, read 3-4 existing pages to infer conventions. -2. **Read `docs.json`** to understand navigation structure. -3. **Determine review scope** — single page, section, or full site. If unclear, ask the user which scope they want. +1. **Read CLAUDE.md** — the compliance checklist for frontmatter, components, links, writing style, and the don'ts. +2. **Read `config/navigation.json`** for the navigation tree. Navigation lives there, not in `docs.json` — `docs.json` only holds a `$ref` to it. +3. **Read each changed file at the current branch head**, not just the diff. A finding that was already fixed in a later commit must not be reported. +4. **Determine scope** — a single page, a section, or the changed files in a PR. If unclear, ask. + +## Generated pages: report upstream, never inline + +Most of the diff volume in this repo is machine-generated. A finding on a generated page is still worth reporting — but **reporting it as an edit to this repo is wrong**, because the next release silently reverts it and the defect ships again. + +### Two categories, two different failures + +**Deterministically regenerated — a hand-edit is destroyed.** The generator deletes and rewrites these files: + +| Page | Generated by | Fix it here | +|---|---|---| +| `client_reference/kosli*.md` | `kosli docs` from the release binary | **`kosli-dev/cli`** → `cmd/kosli/.go` | +| `helm/k8s_reporter/*.mdx` | `helm-docs` | **`kosli-dev/cli`** → `charts/k8s-reporter/mintlify/.md.gotmpl`, its `_templates.gotmpl` / `_mintlify_templates.gotmpl`, or `values.yaml` | +| `schemas/flow-template/v1.json`, `schemas/policy/v1.json` | `scripts/update_schemas.py` from the API | **`kosli-dev/server`** → the Pydantic models | +| The `kosli *` groups in `config/navigation.json` | `scripts/update-cli-nav.py` | This repo → the script | +| Live-docs sections under `client_reference/` | `scripts/add_livedocs.py` | This repo → the script and `scripts/live_docs_*_data.py` | + +The filename maps to the source: `client_reference/kosli_attest_sonar.md` ← `cmd/kosli/attestSonar.go`; `kosli_create_attestation-type.md` ← `cmd/kosli/createAttestationType.go`; `helm/k8s_reporter/karpenter.mdx` ← `charts/k8s-reporter/mintlify/karpenter.md.gotmpl`. + +**Agent-synced from an upstream source of truth — a hand-edit survives but drifts.** `terraform-reference/` (from `kosli-dev/terraform-provider-kosli`) and `github-action-reference/setup_cli_action.md` (from `kosli-dev/setup-cli-action`'s `README.md` and `action.yml`) are written by the `.mintlify/workflows/` agents. Editing them here works, but if the upstream source disagrees the next sync will undo it. Report both the page fix and the upstream mismatch. + +**Not generated, despite the directory:** `client_reference/overview.md` and `client_reference/output_and_verbosity.md` are hand-authored — the regeneration step only removes `kosli*.md`. Only the version string in `overview.md` is stamped by the workflow. Review these normally. + +### Rules + +- **Do not post an inline comment on a deterministically regenerated page.** The line it anchors to will not exist after the next release. +- **Never emit a "Fix this" link scoped to `repo=kosli-dev/docs` for a generated page.** This has happened: a review correctly wrote *"the durable fix is in the `kosli-dev/cli` generator — a hand-edit here is overwritten by the next release"* and then attached a fix link telling an agent to edit the generated file anyway. If a fix link is warranted, scope it to the upstream repo. +- Report these findings **in the top-level comment**, grouped under a single heading, each naming the upstream repo and file to change. +- Say plainly that the finding does not block the merge. The regeneration PR is a faithful copy of upstream; blocking it does not fix the source and only delays the release. + +### Tell a generator bug apart from a prose typo + +In `kosli-dev/cli`, **`^` is the convention for a backtick** inside a command's long description (`^--jq^`, `^jq^`, `^'NAME=EXPRESSION'^`), and `kosli docs` substitutes it when generating markdown. So a literal `^` surviving into a published page is a **generator escaping bug**, not a typo in the Go string — the fix is in the doc generator's rendering path, not the description. A caret that reached an `` title while the same substitution worked in body prose is exactly this bug. + +Distinguish it from genuine prose defects — British spelling, a contradictory sentence, an ambiguous clause — which do live in the Go string and are fixed by editing it. + +## The checks that matter most + +Run these first. They found the real defects in past reviews. + +### 1. Cross-file consistency + +A change is rarely confined to the files it touches. Grep the rest of the site for what the change contradicts. + +- **Prose vs. generated reference.** Pages under `client_reference/`, `terraform-reference/`, and `helm/` are generated. When prose names a command, flag, resource, or attribute, verify it exists in the generated page. *Precedent: a changelog entry documented `kosli update attestation-type`, a command with no reference page — it did not exist.* +- **Prose vs. generated schema.** `schemas/flow-template/v1.json` and `schemas/policy/v1.json` come from the API. When a page enumerates valid values, diff that list against the schema. *Precedent: `template-reference/flow_template.md` listed an attestation-type set the regenerated schema had already outgrown.* +- **Incomplete sweeps.** When a change removes or renames a concept, grep the whole site for the old term and list every file still using it. *Precedent: an approvals-removal PR updated `glossary.md` and `controls.md` but left `understand_kosli/how_kosli_works.md:22` still calling approvals a built-in attestation type.* + +### 2. Placement and navigation + +- Every new page must appear in `config/navigation.json`. Missing entry is **Critical**. +- **Ask whether the page is in the right tab and group**, not just whether it is listed somewhere. Apply the placement table in the `doc-write` skill. A page whose content is complete factual lookup belongs in the **Reference** tab even when it documents an integration. *Precedent: a GitHub Action reference page was first authored into `integrations/`; a human reviewer had to ask for the move to Reference. That question should come from this review.* +- Flag a new group created to hold a single page, and any page nested more than three levels below its tab. + +### 3. Redirects + +Any PR that renames, moves, or deletes a page needs a `config/redirects.json` entry. This is one of the easiest things to forget, because the PR looks complete without it. A missing redirect for a page that was live is **Critical** — the URL is in customers' bookmarks, in CLI error output, and in the changelog. + +### 4. Anchor stability + +Headings are link targets. Before accepting a renamed heading, grep for its anchor across the repo. Some anchors are referenced from outside the docs — `faq/faq.md#boolean-flags` is emitted in CLI error messages. A renamed heading that breaks an inbound anchor is **Critical**. + +### 5. Page-level quality + +- **Diátaxis fit** — tutorial, how-to, reference, or explanation? Does the content match the form? Report a mismatch only when it would send a reader down the wrong path, not as a taxonomy note. +- **Frontmatter** — `title` and `description` present and accurate. +- **Links** — root-relative (`/getting_started/install`), never relative (`../install`). A relative link is **Critical**. Verify every internal target resolves to a file that exists. +- **Correctness** — commands, flags, output blocks, and screenshots that match the current product. -## Page-level review +## What not to report -1. **Diátaxis classification** — Identify the doc type (tutorial, how-to, reference, explanation). Does the content match? Common issue: tutorials mixed with reference material, or how-to guides that teach instead of solving. -2. **Content quality** — Clear title? Accurate description? Complete for its doc type? Factually correct and current? -3. **Structure** — Logical flow? Appropriate depth? -4. **CLAUDE.md compliance** — Check the page against every rule and convention in the project's CLAUDE.md (frontmatter, components, links, writing style, don'ts). -5. **Link validation** — Internal links resolve to existing files? (Use Glob/Grep to verify.) +The bar is: **would a reader be measurably better off after this change?** If not, leave it out. Specifically, never report: -## Section-level review +- A finding already fixed at the current branch head. +- Rewording that is a matter of taste. Grammar that is merely awkward is not a finding; grammar that is ambiguous or wrong is. +- Whitespace or column alignment inside pasted command output. +- Anything the automated checks above already cover. +- A finding you immediately talk yourself out of. If the recommendation ends in "which it already does" or "no change needed", it was never a finding. +- Sample-data churn in a regenerated page — shifted timestamps, fingerprints, commit SHAs, snapshot indices. Confirm it is only data churn and move on; do not itemise it. +- Any recommendation to hand-edit a deterministically regenerated page. If the only fix is upstream, report it upstream or not at all. +- Praise, "what looks good" sections, and tables of checks that passed. The author knows what they wrote. Reviews are re-rendered into a sticky comment on every push, so recital costs the reader on every read. -1. Read all pages in the section. -2. Check navigation order — logical progression? -3. Identify gaps — missing pages for common user tasks? -4. Check Diátaxis balance — right mix of doc types? -5. Verify cross-linking between related pages. -6. Assess consistency in voice, structure, and components. +Report at most **8 findings**. If more clear the bar, report the 8 that matter most and say how many were left out. -## Site-level review +## Output -1. Is the top-level navigation intuitive? -2. Are tutorials, how-to guides, reference, and explanation clearly separated? -3. Can new users find getting-started content quickly? Can experienced users find reference quickly? -4. Structural issues: orphaned pages, deep nesting (>3 levels), overloaded sections, missing landing pages. +Group findings by file. For each: **Location** (`file:line`), **Issue** (one or two sentences), **Recommendation** (concrete). -## Output format +Categorize: -Categorize findings as: -- **Critical** — Broken functionality, factual errors, missing required content. -- **Improvement** — Clarity issues, structural problems, missing best practices. -- **Suggestion** — Nice-to-have enhancements. +- **Critical** — missing nav entry, relative link, broken internal link or anchor, missing redirect, factually wrong instruction. +- **Improvement** — a reader is likely to be misled, blocked, or sent to the wrong page. +- **Suggestion** — a real but minor gain. If you have no Improvements, question whether the Suggestions are worth posting at all. -For each: **Location** (file:line or section), **Issue**, **Recommendation**. +Put findings on generated pages in their own section, headed **Upstream — does not block this merge**, with the repo and file to change. Never mark one Critical: nothing in the PR under review can fix it. -End with a summary: findings by category, overall assessment, top 3 priorities. +Close with one line: counts by category, and a merge verdict. When nothing clears the bar, say exactly that in one sentence and stop — a short review is a good review. diff --git a/.claude/skills/doc-structure/SKILL.md b/.claude/skills/doc-structure/SKILL.md new file mode 100644 index 00000000..9b98c4b0 --- /dev/null +++ b/.claude/skills/doc-structure/SKILL.md @@ -0,0 +1,114 @@ +--- +name: doc-structure +description: Periodic whole-site audit of documentation navigation, information architecture, and changelog coverage, filing GitHub issues for what it finds. Use for a scheduled or on-demand health check of the docs site as a whole — not for reviewing a single page or a pull request. Triggers on "audit the docs structure", "is the navigation still sensible", "what shipped that we never documented", "run a docs health check", "check changelog coverage". +--- + +# Doc structure audit + +Audit the whole site for navigation and coverage problems, then file one GitHub issue per finding. + +This is the counterpart to `doc-review`, which sees only the files a PR touched. Problems that no single PR causes — a group that grew past its label, a feature three products shipped that no page explains — are invisible to per-PR review and accumulate silently. This skill exists to find those. + +Run it monthly or on demand. It is read-only against the docs and write-only against the issue tracker: **never edit docs pages or navigation here.** The output is issues, so a human decides what to change. + +## Step 1 — Load current state + +```bash +gh issue list --state open --limit 100 --json number,title,labels,body +``` + +Read every open issue before auditing. The backlog already tracks known gaps, and a monthly job that re-files them is worse than one that files nothing. Keep this list in mind through every step below. + +## Step 2 — Mechanical checks + +`scripts/audit_navigation.py` does these deterministically. Run it rather than reimplementing it — it is covered by `tests/test_audit_navigation.py`, so its behavior is pinned, and a hand-rolled shell pipeline is where the subtle bugs live. + +```bash +python3 scripts/audit_navigation.py --json +``` + +It returns three keys: + +- **`orphans`** — a page file with no navigation entry. The page is live but unreachable from the sidebar, violating CLAUDE.md rule 2. Always a finding. +- **`dangling`** — a navigation entry with no file behind it. Always a finding. +- **`shape`** — information-architecture signals, each with a `kind`, a `where` path, and a `detail`: `single-child group`, `deep nesting`, `Title Case label`, `oversized group`, `inconsistent icons`. + +Drop `--json` for a readable report; add `--check` to exit non-zero on integrity findings only. Tune with `--max-nesting` and `--max-group-children`. + +Two things the script already handles, so don't re-litigate them: + +- The `Reference ▸ CLI Reference` subtree is generated by `scripts/update-cli-nav.py` and mirrors the CLI's own command tree, so it is exempt from shape checks. A single-child `kosli allow` group is upstream truth, not a defect. Never file an issue proposing to reshape it. +- `index` is the landing page, configured outside `navigation`, and is never an orphan. + +**Shape findings are signals, not defects.** The script cannot tell a group that should be merged from one deliberately kept separate. Weigh each by how many readers hit it, and use step 3 to decide which are worth an issue. + +**Cross-reference rot** — not scripted; check by hand. Pages nothing links to, and anchors referenced from a page whose heading has since been renamed. + +## Step 3 — Information architecture + +Judgment, not mechanics. Read the group labels and the pages under them and ask: + +- **Does each group's label still describe its contents?** A group named for a doc type that holds a different type sends readers to the wrong place. Check the Diátaxis form of every page in a group against the group's promise. +- **Is one topic split across two homes?** Two groups that each hold half of a subject force the reader to know the org chart. Look for the same noun appearing in two top-level groups. +- **Does a top-level tab deliver what it promises?** A tab is the strongest navigational claim the site makes. A tab holding a handful of stub pages under an ambitious name over-promises. +- **Where would a reader look first?** For the five or six most common tasks, trace the path from the landing page. Count the clicks and the guesses. + +Weigh a finding by how many readers hit it. A mislabeled group at the top of the Documentation tab matters; a nesting quirk four levels into a reference section does not. + +## Step 4 — Changelog coverage + +`changelog/index.mdx` is the record of what shipped. Compare it against what the docs explain. + +**Find the pages the changelog leans on:** + +```bash +grep -o '](/[a-z_/#-]*' changelog/index.mdx | sed 's|](||; s|#.*||' | sort | uniq -c | sort -rn | head -25 +``` + +**Then, for each feature named in an entry from roughly the last quarter, check that the page the entry links to actually explains it.** Take the feature's own keyword — the flag, attribute, or noun the entry is about — and grep the linked page for it. A changelog entry that links to a page which never mentions the thing is a concrete, high-confidence gap. + +This check has caught a real gap. Custom attestation summaries shipped across three products — a CLI `--summary` flag, a Terraform `summary` attribute, and a Platform release rendering them in the UI. All three changelog entries linked to `/getting_started/attestations` to explain the feature. That page contained no occurrence of the word "summary". + +Also flag: + +- A feature named in the changelog with **no** docs page mentioning it anywhere. +- A feature shipped across two or more products with docs for only one of them. Multi-product features are the ones that fall between owners. +- A **breaking change** with no corresponding guidance for readers who need to migrate. + +Skip bug fixes, internal changes, and performance work unless they change something a reader was told to do. + +Weight recent entries more heavily, and treat an entry that is several months old with still no coverage as evidence of a real gap rather than a lag. + +## Step 5 — Deduplicate + +For each candidate finding, search the open issues from step 1 for the same subject. Match on subject, not on wording — "document summary definitions on custom attestation types" and "attestations page missing --summary" are the same issue. + +If an open issue covers the finding: +- Skip it silently when the issue is adequate. +- Add a comment only when this run found something genuinely new about it — another product shipping the same feature, or a second page with the same gap. + +Prefer commenting on an existing issue over opening a near-duplicate. When unsure whether two findings are the same, they are. + +## Step 6 — File issues + +Cap each run at **8 issues**. If more findings survive, file the 8 highest-impact ones and list the rest in the run summary. A backlog nobody can work through is the same as no backlog. + +Follow the repo's conventions: + +- **Title** — `type: imperative description`, matching recent issues (`docs: document summary definitions on custom attestation types`, `automation: add kosli-dev/mcp-server to the update-changelog workflow`). +- **Labels** — `content` on nearly everything. Add `documentation` for a missing or incomplete page, `enhancement` for a structural change, `automation` for a generator or workflow fix. Add `priority: high` only for something actively misleading readers. Confirm against `gh label list` rather than assuming. +- **Body** — state the finding, the evidence that proves it (file paths, line numbers, the grep that found it, the changelog entries involved), and what a fix would look like. Someone should be able to act on the issue without re-running the audit. + +This audit files two kinds of issue, and they need different bodies: + +**Restructure** — a navigation shape change from step 2 or 3. Read `.github/ISSUE_TEMPLATE/docs-restructure.md` and follow its sections. `gh issue create --body` does not apply a template mechanically, so you must reproduce the structure yourself. Its **URL impact** section is the one that matters: a group rename or a collapsed wrapper changes no URLs, but moving a page file does and needs a `config/redirects.json` entry. Get that distinction right or the issue will propose a change that breaks live links. + +**Coverage** — a feature from step 4 with missing or incomplete docs. No template; these match the existing backlog (an issue naming the page to change and the changelog entries that prove the gap). Say which product(s) shipped the feature, which changelog entries cover it, which page should explain it, and what a reader currently cannot find out. + +Never file a restructure issue against generated navigation. The `Reference ▸ CLI Reference` subtree mirrors the CLI's command tree — the audit script already excludes it, so a shape finding there means the exclusion needs fixing, not the navigation. + +Do not assign, milestone, or set priority beyond the labels above. + +## Step 7 — Summarize + +Report: issues filed with numbers and titles, findings skipped as duplicates and which existing issue covers each, findings that cleared the bar but exceeded the cap, and one line on whether site structure improved or degraded since the last run. diff --git a/.claude/skills/doc-write/SKILL.md b/.claude/skills/doc-write/SKILL.md index 2f315da4..0180317c 100644 --- a/.claude/skills/doc-write/SKILL.md +++ b/.claude/skills/doc-write/SKILL.md @@ -3,40 +3,101 @@ name: doc-write description: Create, write, or update documentation pages in a Mintlify-based docs site. Use when the user wants to write docs, create a new page, document a feature, add a guide or tutorial, or update existing documentation. Do NOT use for reviewing or auditing — use doc-review for that. Triggers on "write docs for X", "create a new page", "document this feature", "add a tutorial", "draft a how-to guide". --- -# Doc Write +# Doc write -Author Mintlify docs pages following the Diátaxis framework and the project's CLAUDE.md. +Author Mintlify pages for the Kosli docs site following Diátaxis and this repo's CLAUDE.md. ## Before writing -1. **Read the project's CLAUDE.md** — it defines all site-specific conventions (components, writing style, link format, frontmatter, don'ts). Follow it exactly. If no CLAUDE.md exists, read 3-4 existing pages to infer conventions. -2. **Read `docs.json`** to understand navigation structure. -3. **Read 2-3 similar pages** to match the site's voice, structure, and component usage. -4. **Search for existing content** using Grep and Glob — you may need to update rather than create. +1. **Read CLAUDE.md** — components, writing style, link format, frontmatter, and the don'ts. Follow it exactly. +2. **Read `config/navigation.json`** for the navigation tree. Navigation lives there, not in `docs.json` — `docs.json` only holds a `$ref` to it. +3. **Search for existing content** with Grep and Glob. Updating a page beats adding one; a thin new page next to an existing one splits the topic. +4. **Check open issues** — `gh issue list --state open --label content` — the gap may already be tracked, with context on what the reader needs. +5. **Read 2-3 pages in the destination group** to match voice, structure, and component usage. -## Classify the doc type (Diátaxis) +## Generated pages -Every page must fit one of these four types. Classify before writing: +**Deterministically regenerated — an edit here is deleted on the next release.** Fix the source instead: + +| Path | Fix it in | +|---|---| +| `client_reference/kosli*.md` | **`kosli-dev/cli`** → `cmd/kosli/.go` (e.g. `kosli_attest_sonar.md` ← `attestSonar.go`) | +| `helm/k8s_reporter/*.mdx` | **`kosli-dev/cli`** → `charts/k8s-reporter/mintlify/.md.gotmpl` or `values.yaml` | +| `schemas/` | **`kosli-dev/server`** → the Pydantic models, then `scripts/update_schemas.py` | +| The `kosli *` groups in `config/navigation.json` | This repo → `scripts/update-cli-nav.py` | +| Live-docs sections in `client_reference/` | This repo → `scripts/add_livedocs.py`, `scripts/live_docs_*_data.py` | + +Run `scripts/dev_live_docs.sh` to regenerate locally; it restores `client_reference/` on exit. + +In the CLI's Go long descriptions, **`^` means backtick** (`^--jq^`, `^jq^`) and `kosli docs` substitutes it. Write `^`, not a literal backtick, when editing those strings. + +**Agent-synced from upstream — an edit survives but will drift.** `terraform-reference/` (from `kosli-dev/terraform-provider-kosli`) and `github-action-reference/setup_cli_action.md` (from `kosli-dev/setup-cli-action`'s `README.md` and `action.yml`) are maintained by the `.mintlify/workflows/` agents. Edit the page when it is wrong, but check it against upstream first — if upstream disagrees, the next sync undoes you. + +**Hand-authored despite the directory:** `client_reference/overview.md` and `client_reference/output_and_verbosity.md`. Regeneration only removes `kosli*.md`. Edit these freely. + +To document a command's *usage*, write a how-to that links to the generated reference rather than restating its flags — a restated flag list goes stale silently. + +## Classify the doc type | Type | Purpose | User need | Structure | |------|---------|-----------|-----------| -| **Tutorial** | Learning-oriented | "Teach me X" | Step-by-step guided learning experience with expected outcomes | -| **How-to guide** | Task-oriented | "How do I do X?" | Goal-focused steps, assumes knowledge, handles variations | -| **Reference** | Information-oriented | "What is X?" | Complete, accurate, terse technical description | -| **Explanation** | Understanding-oriented | "Why does X work this way?" | Context, background, trade-offs, alternatives | +| **Tutorial** | Learning-oriented | "Teach me X" | Guided steps with a known outcome | +| **How-to guide** | Task-oriented | "How do I do X?" | Goal-focused steps, assumes knowledge | +| **Reference** | Information-oriented | "What is X?" | Complete, accurate, terse | +| **Explanation** | Understanding-oriented | "Why does X work this way?" | Context, background, trade-offs | + +Tutorials teach through doing; how-to guides solve one problem for someone who already knows the basics. If the type is genuinely ambiguous, ask. + +## Decide where the page goes + +Classification determines placement. Getting this wrong costs a follow-up commit and a reviewer's time, so decide it before writing, not after. + +| The page is… | Tab ▸ group | +|---|---| +| A concept, or the reasoning behind a design | Documentation ▸ Understand Kosli | +| Part of the first-run sequence a new user follows in order | Documentation ▸ Getting started | +| A task an org admin performs (users, roles, auth, org-wide settings) | Documentation ▸ Administration | +| A task a user performs with Kosli | Documentation ▸ Tutorials | +| Setting up Kosli with a third-party product | Documentation ▸ Integrations | +| A specific error message or symptom | Documentation ▸ Troubleshooting | +| Complete factual lookup — CLI, API, Terraform, Helm, schema, policy | **Reference** tab | +| Rollout and adoption guidance for a team standing Kosli up | Implementation Guide | + +The Reference tab wins on content shape, not on subject. A reference page about an integration belongs in Reference — a GitHub Action reference page was once authored into `integrations/` and had to be moved in a follow-up commit. + +A group's label may not describe its contents — read the pages already in your chosen group before writing. Where label and contents disagree, follow the convention the existing pages set; do not create a parallel group alongside it. + +## Navigation rules + +- **Creating a page and adding it to `config/navigation.json` are one task.** A page absent from navigation does not exist on the site. +- Add it to an existing group. Only create a group when you are adding three or more sibling pages — a group wrapping a single page adds a click and gives nothing back. +- Keep pages within three levels of their tab. +- **Sentence case for group labels**, matching CLAUDE.md's heading rule: "Naming conventions", not "Naming Conventions". + +## When moving, renaming, or deleting a page + +1. Add a `config/redirects.json` entry from the old path to the new one. The old URL is in bookmarks, in CLI error output, and in changelog entries. +2. Grep the repo for the old path and update every link. +3. If a heading is being renamed, grep for its anchor first. Some anchors are referenced from outside this repo — the CLI prints `faq/faq.md#boolean-flags` in an error message. Keep the heading, or update the source that links to it. + +## Writing + +1. Classify the doc type and pick the destination from the table above. +2. Outline against the doc type. +3. Write the file. Root-relative links only (`/getting_started/install`). Frontmatter `title` and `description` are required. +4. Add the navigation entry. +5. Add redirects if anything moved. +6. Verify: does the change contradict a generated reference page, a schema, or a changelog entry? Grep and fix what it contradicts. -A common mistake is mixing tutorials with how-to guides — tutorials teach through doing, how-to guides solve specific problems. If the doc type is ambiguous, ask the user. +## Changelog -## Writing process +The changelog is for product changes, not doc changes — a new page describing an existing feature does not get an entry. When documenting a feature that *did* ship, check whether `changelog/index.mdx` already covers it, and make the entry and the page agree. Changelog entries are written by `.mintlify/workflows/update-changelog.md` from release tags; if an entry is wrong, fix the entry too. -1. **Classify** the doc type and confirm with the user if uncertain. -2. **Outline** the page structure based on the doc type. -3. **Write** the MDX file following the project's CLAUDE.md conventions. -4. **Place** the file in the correct directory and update `config/navigation.json` (or `docs.json` navigation, whichever the project uses). +Follow the existing `` format exactly and ask which `tags` value applies (`"CLI"`, `"Platform"`, `"Terraform Provider"`, `"GitHub Action"`) before writing one. -## Output +## Report -Write files directly to disk using Write/Edit tools. Then report: -- File path of the created/updated page. -- Navigation entry added (path in `config/navigation.json` or `docs.json`). -- Related pages that should cross-link to this one. +- File path created or updated. +- Navigation entry added, and which tab and group — say why that placement. +- Redirects added, if any. +- Pages that should now cross-link to this one. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..c3754ff6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,3 @@ +# Templates are a starting point, not a gate. Most docs issues are a sentence +# or two and should stay that way. +blank_issues_enabled: true diff --git a/.github/ISSUE_TEMPLATE/docs-restructure.md b/.github/ISSUE_TEMPLATE/docs-restructure.md new file mode 100644 index 00000000..00898d4d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/docs-restructure.md @@ -0,0 +1,109 @@ +--- +name: Docs restructure +about: Propose a change to navigation shape — grouping, nesting, labels, or where a page lives +title: 'docs: ' +labels: ['content', 'enhancement'] +--- + + + +## Current structure + + + +``` +Documentation > Tutorials > Evaluation +``` + +**Pages affected:** + + + +- `tutorials/evaluate_trails_with_opa` + +## What's wrong + + + +## Why it matters + + + +## Proposed structure + +``` +Documentation > Tutorials + └─ tutorials/evaluate_trails_with_opa +``` + +## URL impact + + + +- [ ] No page files move — nav-only change, no redirects needed +- [ ] Page files move — redirects required, listed below + + + +## Out of scope + + + +## Evidence + + + +``` +$ python3 scripts/audit_navigation.py --json +``` + +## Verification + +- [ ] `python3 scripts/audit_navigation.py --check` — integrity still clean +- [ ] `python3 -m pytest tests/` — navigation integrity test passes +- [ ] `mint broken-links` — no new broken links +- [ ] Redirects added for every moved page, and the old URLs resolve diff --git a/.github/workflows/doc-review.yml b/.github/workflows/doc-review.yml index d4f8bf67..eabcce14 100644 --- a/.github/workflows/doc-review.yml +++ b/.github/workflows/doc-review.yml @@ -67,17 +67,45 @@ jobs: the `doc-review` skill (defined in `.claude/skills/doc-review/SKILL.md`) and the rules in this repo's CLAUDE.md. - Scope: only the docs files (.md, .mdx, config/navigation.json, + Scope: the docs files (.md, .mdx, config/navigation.json, docs.json) changed in this PR. Use `gh pr diff` to discover them. + You may read any file in the repo to check the change against it - + cross-file consistency is the most valuable thing you can do here. Constraints: - - Read each changed file and CLAUDE.md before commenting. - - If a new page was added, verify it is also listed in - `config/navigation.json`. Flag as Critical if missing. + - Read each changed file AT THE CURRENT BRANCH HEAD, not just the + diff. Never report something a later commit already fixed. + - Never report spelling. The `vale-spellcheck` check enforces + `styles/Kosli/AmericanSpelling.yml` on every PR. Do not restate + checks that passed, and do not include a "what looks good" + section - the sticky comment re-renders on every push. + - Prioritise the cross-file checks in the skill: prose against + generated reference pages and schemas, and incomplete sweeps + when a concept is renamed or removed. + - If a new page was added, verify it is listed in + `config/navigation.json` (Critical if missing) AND question + whether its tab and group are right for its Diátaxis type. + - If a page was moved, renamed, or deleted, verify + `config/redirects.json` has an entry. Critical if missing. - Flag relative links (e.g. `../foo`) as Critical - they must be root-relative. - - Be concise. Group findings by file. Use the doc-reviewer agent's - Critical / Improvement / Suggestion categories. + - GENERATED PAGES: this workflow reviews the automated + `docs: update CLI and helm reference for vX.Y.Z` PRs, whose + diffs are almost entirely machine-generated. Follow the skill's + "Generated pages" section. Do NOT post inline comments on + `client_reference/kosli*.md`, `helm/k8s_reporter/*.mdx` or + `schemas/` - those lines are deleted on the next release. Do NOT + emit a "Fix this" link scoped to this repo for such a page. + Report the finding in the top-level comment under an + "Upstream - does not block this merge" heading, naming the + upstream repo and file (usually `kosli-dev/cli`, + `cmd/kosli/.go` or + `charts/k8s-reporter/mintlify/.md.gotmpl`). + - Do not itemise regenerated sample-data churn (timestamps, + fingerprints, commit SHAs). Confirm it is only churn, say so in + one line, and move on. + - Apply the skill's "What not to report" bar and its 8-finding + cap. If nothing clears the bar, say so in one sentence. - This review is advisory: do not request changes or approve. Note: The PR branch is already checked out in the current working diff --git a/.github/workflows/doc-structure.yml b/.github/workflows/doc-structure.yml new file mode 100644 index 00000000..0268f014 --- /dev/null +++ b/.github/workflows/doc-structure.yml @@ -0,0 +1,96 @@ +name: Doc Structure Audit + +on: + schedule: + # 09:00 UTC on the 1st of each month. + - cron: '0 9 1 * *' + workflow_dispatch: + inputs: + focus: + description: 'Optional: limit the audit to one area (e.g. "navigation", "changelog coverage", "Tutorials group")' + required: false + type: string + +# Never let two audits run at once — both would file the same issues, since +# neither can see the other's output when it deduplicates. +concurrency: + group: doc-structure + cancel-in-progress: false + +jobs: + doc-structure: + name: Docs navigation and coverage audit + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + issues: write + id-token: write # required: used to fetch the GitHub OIDC token + steps: + - name: Harden Runner + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + with: + egress-policy: audit + + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Run docs structure audit + uses: anthropics/claude-code-action@a874e9ecd7bb36efdad65429c6b35815f5a08f10 # v1.0.210 + env: + GH_TOKEN: ${{ github.token }} + with: + anthropic_federation_rule_id: ${{ vars.ANTHROPIC_FEDERATION_RULE_ID }} + anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }} + anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }} + # The `doc-structure` skill is defined locally in + # `.claude/skills/doc-structure/SKILL.md` and is picked up + # automatically from the checked-out repo. + # --allowedTools is a whitelist - the agent can ONLY use these tools + # plus the implicit Read/Grep/Glob set. It has no write access to the + # working tree: this audit files issues, it does not change docs. + # The mechanical checks live in scripts/audit_navigation.py so this + # can allow that one command rather than `Bash(python3:*)`, which + # would be arbitrary code execution. + claude_args: | + --max-turns 60 + --model claude-opus-5 + --allowedTools "Bash(python3 scripts/audit_navigation.py:*),Bash(grep:*),Bash(sed:*),Bash(sort:*),Bash(uniq:*),Bash(head:*),Bash(gh issue list:*),Bash(gh issue view:*),Bash(gh issue create:*),Bash(gh issue comment:*),Bash(gh label list:*)" + prompt: | + REPO: ${{ github.repository }} + + Run a full documentation structure audit of this repository. + Follow the `doc-structure` skill (defined in + `.claude/skills/doc-structure/SKILL.md`) and the rules in this + repo's CLAUDE.md. Work through its steps in order. + + ${{ github.event.inputs.focus && format('Focus this run on: {0}. Skip audit areas outside that focus.', github.event.inputs.focus) || 'Audit all areas: navigation integrity, information architecture, and changelog coverage.' }} + + Constraints: + - Read every open issue BEFORE filing anything. Most findings are + already tracked. Filing a duplicate is worse than filing nothing. + - This audit is read-only against the docs. Do not edit any page, + `config/navigation.json`, or `docs.json`. Your only writes are + GitHub issues. + - Cap the run at 8 new issues. If more findings clear the bar, + file the 8 highest-impact and list the rest in your summary. + - Every issue body must carry the evidence that proves the + finding — file paths, line numbers, the command that found it — + so it can be acted on without re-running the audit. + - For a navigation restructure, read + `.github/ISSUE_TEMPLATE/docs-restructure.md` and reproduce its + sections in the issue body. `gh issue create --body` does not + apply templates mechanically, so you must follow it yourself. + Fill in its URL impact section correctly: a group rename or a + collapsed wrapper changes no URLs, but a moved page file needs a + `config/redirects.json` entry. + - Cluster before filing. Many shape findings share one underlying + cause and belong in one issue, not one issue per line. + - If nothing clears the bar, file nothing and say so. A quiet run + is a valid result. + + Note: the repository is already checked out in the current working + directory. + + End with a summary of issues filed, findings skipped as duplicates + (naming the issue that already covers each), and findings that + exceeded the cap. diff --git a/CLAUDE.md b/CLAUDE.md index bec3e26f..78b2b558 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,6 +29,21 @@ mint a11y # Check color contrast and accessibility Requires Node.js v19+. +## Automated PR checks + +Reported by the Mintlify GitHub app, alongside this repo's own workflows: + +| Check | Enforces | Source | +|---|---|---| +| `Mintlify Validation (kosli) - vale-spellcheck` | `.vale.ini` + `styles/Kosli/AmericanSpelling.yml` | Mintlify app | +| `Mintlify Validation (kosli) - link-rot` | Link targets — unreliable, frequently reports `skipping`. **Don't rely on it**; run `mint broken-links` locally (core rule 5) and check `gh pr checks` before assuming links were validated. | Mintlify app | +| `Mintlify Deployment` | Preview build | Mintlify app | +| `Doc quality review` | The `doc-review` skill | `doc-review.yml` | +| `Validate PR Title` | Conventional Commits | `pr-quality.yml` | +| `Test live-docs scripts` | `pytest tests/` — including navigation integrity, so **core rule 2 is enforced**: a page file with no `config/navigation.json` entry fails the build | `pr-quality.yml` | + +Because spelling is already enforced, review agents should not spend turns hand-checking it. + ## Architecture - **`docs.json`** — Central config: theme, API settings, logos. Uses `$ref` to compose from files in `config/`. @@ -38,18 +53,52 @@ Requires Node.js v19+. - **`style.css`** — Custom CSS overrides applied on top of the Mintlify theme - **`scripts/`** — Python scripts that generate "live docs" (mostly under `client_reference/`) and update navigation. See [Live docs](#live-docs) below. - **`tests/`** — pytest suite for the live-docs scripts. -- **`.github/workflows/`** — `doc-review.yml` (Claude-powered PR review), `pr-quality.yml` (link/title checks), `update-cli-docs.yml`, `update-schemas.yml`. +- **`.github/workflows/`** — `doc-review.yml` (Claude-powered PR review), `doc-structure.yml` (monthly navigation and coverage audit that files issues), `pr-quality.yml` (PR title + live-docs tests), `update-cli-docs.yml`, `update-schemas.yml`. - **`schemas/`** — Generated JSON Schema assets. See [Schemas](#schemas) below. ## Live docs `client_reference/` content is partly generated by scripts in `scripts/`. Run `scripts/dev_live_docs.sh` to regenerate locally; it restores -`client_reference/` on exit. Don't hand-edit generated pages — find the -source script first. +`client_reference/` on exit. + +**Don't hand-edit generated pages** — `update-cli-docs.yml` deletes and +rewrites them on every CLI release, so an edit here is silently reverted and +the defect ships again. Fix the source: + +| Page | Fix it in | +|---|---| +| `client_reference/kosli*.md` | `kosli-dev/cli` → `cmd/kosli/.go` (`kosli_attest_sonar.md` ← `attestSonar.go`) | +| `helm/k8s_reporter/*.mdx` | `kosli-dev/cli` → `charts/k8s-reporter/mintlify/.md.gotmpl`, or `values.yaml` | +| `kosli *` nav groups in `config/navigation.json` | `scripts/update-cli-nav.py` | +| Live-docs sections | `scripts/add_livedocs.py`, `scripts/live_docs_*_data.py` | + +In the CLI's Go long descriptions, `^` means backtick (`^--jq^`) and +`kosli docs` substitutes it — so a literal `^` on a published page is a +generator escaping bug, not a typo in the description. + +`client_reference/overview.md` and `client_reference/output_and_verbosity.md` +are hand-authored; regeneration only removes `kosli*.md`. Tests for the generators live in `tests/` and run with `pytest`. +## Auditing navigation + +```bash +python scripts/audit_navigation.py # readable report +python scripts/audit_navigation.py --check # exit 1 on integrity findings +python scripts/audit_navigation.py --json # for the doc-structure skill +``` + +**Integrity** — orphaned pages (a file with no `navigation` entry, core rule 2) +and dangling entries (an entry with no file). Enforced on every PR by +`pytest tests/`. + +**Shape** — advisory information-architecture signals: single-child groups, +deep nesting, Title Case labels, oversized groups, inconsistent icons. Never +fails a build. The `Reference ▸ CLI Reference` subtree is exempt because +`update-cli-nav.py` generates it from the CLI's command tree. + ## Schemas `schemas/flow-template/v1.json` and `schemas/policy/v1.json` are the static @@ -118,8 +167,11 @@ description: One sentence describing the page purpose. When available, prefer skills over ad-hoc approaches: +- **Writing or updating a page** — use the `doc-write` skill (`.claude/skills/doc-write/`). +- **Reviewing a page or a PR** — use the `doc-review` skill (`.claude/skills/doc-review/`). +- **Auditing site navigation and changelog coverage** — use the `doc-structure` skill (`.claude/skills/doc-structure/`). It files issues; it never edits docs. - **PR creation** — use the `pr-creator` skill if available. -- **Changelog entries** — use the `changelog-creator` skill if available. Follow the existing `` format in `changelog/index.mdx` exactly: +- **Changelog entries** — most entries are generated by `.mintlify/workflows/update-changelog.md` from release tags. When writing one by hand, follow the existing `` format in `changelog/index.mdx` exactly: ```mdx diff --git a/administration/kosli_capture.md b/administration/kosli_capture.md new file mode 100644 index 00000000..5cfd3be8 --- /dev/null +++ b/administration/kosli_capture.md @@ -0,0 +1,68 @@ +--- +title: "Kosli Capture Managed Service" +sidebarTitle: "Kosli Capture" +description: "Learn how the Kosli Capture Managed Service snapshots your cloud environments from Kosli's infrastructure, with no software to install." +tag: "ALPHA" +--- + + +Kosli Capture is still in active development. Its capabilities and configuration format may change, and onboarding is done together with Kosli's Customer Success team. + + +Kosli Capture is a managed service that runs on Kosli's infrastructure and connects to your cloud platform to observe the resources deployed there. You grant Kosli Capture a set of permissions, and it uses them to run a `kosli snapshot` every few minutes against the infrastructure you have allowed it to scan. + +Kosli also supports reporting from your own cloud accounts by running the Kosli CLI on a schedule. Kosli Capture inverts this, with Kosli running the regular [snapshots](/getting_started/environments) so there is no software for you to install. + +## Overview + +Kosli Capture connects to your cloud accounts using permissions that you manage. You configure Kosli Capture by providing a few details describing what you want to be in scope, and Kosli Capture uses the permissions to regularly reach into your estate and record snapshots, sending the data into your Kosli organization. Kosli Capture is architected to be driven by your tagging scheme; it examines the tags on your infrastructure and uses them to determine how to structure the snapshots, and how to build the environments within Kosli. + +## Security + +The security of your cloud infrastructure is the primary driver behind the internal architecture of the Kosli Capture managed service. + +* Each customer has their own dedicated instance of Kosli Capture +* The service connects to your cloud using IAM permissions that you manage, secured using a shared-secret. +* Snapshots are written to your Kosli organizations using an API key that you are able to revoke + +Within Kosli's AWS accounts, every customer has a dedicated instance of Kosli Capture, and its permissions are limited to reading the shared-secret and API key for your organization only. By running isolated instances of the managed service per customer, Kosli guarantees that each customer's data remains separate. + +## Hands-off operation + +Kosli Capture has been designed to operate with no on-going support from you. Once the initial security permissions have been created, Kosli capture will continue to operate without needing additional support. Monitoring, maintenance and rotation of API keys is all handed automatically. As your cloud infrastructure changes over time, Kosli capture will continue to find resources according to your tagging scheme without you needing to do anything; your application teams do not need to take any action in order to onboard their products and services into Kosli. + +## Setup + +Getting started with Kosli Capture involves three stages: + + + + Create an IAM role in your AWS account specifically for Kosli Capture. Kosli provides a CloudFormation template to simplify this process. The template requires a shared secret, which Kosli provides to you during onboarding. + + + Working with Kosli's Customer Success team, author a configuration document that shows how your cloud resources should be mapped to Kosli environments. This configuration document is loaded into Kosli. + + + Kosli enables Kosli Capture for your Kosli org, and the regular snapshots appear in Kosli. + + + +## Finding resources + +Kosli Capture finds all supported resources within your AWS accounts, and examines the tags on those resources to determine which Kosli environment should hold the snapshots. Kosli Capture will create physical environments for you. + +Kosli Capture can filter out resources based on your tags. + +As your cloud environment evolves, such as the addition of new ECS clusters or the retirement of existing Lambdas, Kosli Capture automatically detects the changes. Because Kosli Capture creates physical environments as needed, when your infrastructure changes, Kosli will keep up. No changes to the configuration created during the initial setup are required. + +## Multiple AWS accounts + +Kosli Capture can operate across multiple AWS accounts, allowing you to snapshot development, QA, pre-production, and production workloads with the same configuration document. + +## IAM permissions + +For Kosli Capture to snapshot your environment, you must grant a set of read-only permissions. Kosli's CloudFormation template lists these. The permissions are typically "Describe" or "List" permissions. The [Kosli Capture Security](./kosli_capture_security) page provides a deep-diver into the structure of the permissions needed. + +The IAM role created in your environment includes a trust policy that allows Kosli Capture to assume the role. The trust policy limits access to the AWS account in which Kosli Capture is running. Furthermore, the trust policy includes an external ID that acts as a shared secret between Kosli and you, so that only access from Kosli Capture is permitted. + +The external ID (shared secret) is securely stored with Kosli Capture. Kosli's internal IAM permissions ensure that the secret can only be accessed by the specific instance of Kosli Capture that has been configured for you. diff --git a/administration/kosli_capture_security.md b/administration/kosli_capture_security.md new file mode 100644 index 00000000..36d4b950 --- /dev/null +++ b/administration/kosli_capture_security.md @@ -0,0 +1,134 @@ +--- +title: Kosli Capture - Security +sidebarTitle: Security +description: "Learn about the security of Kosli Capture" +tag: "ALPHA" +--- + + +Kosli Capture is still in active development. Its capabilities and configuration format may change, and onboarding is done together with Kosli's Customer Success team. + + +## Kosli capture permissions + +The Kosli Capture managed service uses the public AWS, GCP and Azure APIs to extract information about your cloud environments. In order to do this, you need to provide Kosli with an IAM role that allows access to these APIs. The role is created and owned by you. Kosli publishes a CloudFormation template, for use in AWS, showing the permissions needed. The template is publicly accessible and can be used directly within an `aws cloudformation create-stack` call. + +### Assume role + +The IAM role defined within the CloudFormation template includes an "assume role" policy granting permission from Kosli. This appears as: + +``` + KosliCaptureAccessRole: + Type: AWS::IAM::Role + Properties: + RoleName: !Ref RoleName + Description: >- + Read-only access for Kosli Capture SDLC compliance evidence collection. + Managed by CloudFormation; do not edit in place. + MaxSessionDuration: 3600 + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Sid: AllowKosliToAssumeWithExternalId + Effect: Allow + Principal: + AWS: !Ref TrustedPrincipalArn + Action: sts:AssumeRole + Condition: + StringEquals: + sts:ExternalId: !Ref ExternalId +``` + +### All permissions needed + +The IAM role defined within the Cloudformation template includes a number of IAM policy statements, granting read-only access to some AWS APIs. The statements are: + +``` +Statement: + + # How Capture finds what to snapshot. Discovery lists the ECS + # clusters in the account and reads each cluster's tags from the + # same DescribeClusters call; those tags are what decide which + # Kosli environment a cluster is reported into. Without + # ListClusters and DescribeClusters a role created from this + # template cannot run discovery at all. + # + # Worth knowing for a security review: these are inventory calls + # and none of them returns application data. DescribeTaskDefinition + # is the widest - a task definition holds the container image, the + # command, and any environment variables written into the + # definition itself in plain text. Values injected from Secrets + # Manager or Parameter Store are named there rather than resolved, + # so what comes back is the reference and not the secret. + - Sid: EcsInventory + Effect: Allow + Action: + - ecs:DescribeCapacityProviders + - ecs:DescribeClusters + - ecs:DescribeContainerInstances + - ecs:DescribeServices + - ecs:DescribeTaskDefinition + - ecs:DescribeTasks + - ecs:ListClusters + - ecs:ListContainerInstances + - ecs:ListServices + - ecs:ListTagsForResource + - ecs:ListTaskDefinitionFamilies + - ecs:ListTaskDefinitions + - ecs:ListTasks + Resource: "*" + + - Sid: LambdaInventory + Effect: Allow + Action: + - lambda:GetFunctionConfiguration + - lambda:GetPolicy + - lambda:ListAliases + - lambda:ListFunctions + - lambda:ListTags + - lambda:ListVersionsByFunction + Resource: "*" + + - Sid: S3BucketMetadataOnly + Effect: Allow + Action: + - s3:GetBucketLocation + - s3:GetBucketLogging + - s3:GetBucketPolicyStatus + - s3:GetBucketPublicAccessBlock + - s3:GetBucketTagging + - s3:GetBucketVersioning + - s3:GetEncryptionConfiguration + - s3:ListAllMyBuckets + Resource: "*" + + # The explicit denies below are redundant given the allow-list + # above, but they are here so that a reviewer can verify the + # boundary without having to reason about IAM defaults, and so + # that any future widening of this policy cannot accidentally + # grant data-plane access. + - Sid: NeverReadObjectData + Effect: Deny + Action: + - s3:GetObject + - s3:GetObjectAcl + - s3:GetObjectAttributes + - s3:GetObjectTagging + - s3:GetObjectTorrent + - s3:GetObjectVersion + - s3:GetObjectVersionAcl + - s3:GetObjectVersionAttributes + - s3:GetObjectVersionTagging + - s3:ListMultipartUploadParts + Resource: "*" + + # lambda:GetFunction returns a pre-signed URL to the deployment + # package. That is source-code access, so it is denied outright. + - Sid: NeverDownloadFunctionCode + Effect: Deny + Action: + - lambda:GetFunction + - lambda:GetLayerVersion + Resource: "*" +```` + diff --git a/administration/managing_environments/overview.md b/administration/managing_environments/overview.md index 5727f0b6..1d56bacd 100644 --- a/administration/managing_environments/overview.md +++ b/administration/managing_environments/overview.md @@ -64,6 +64,10 @@ terraform import kosli_environment.my_environment production The `type` in your Terraform configuration must exactly match the type of the existing environment in Kosli. A mismatch will cause import errors or misconfiguration. +### Automatically creating physical environments + +The [Kosli Capture Managed Service](/administration/kosli_capture) will automatically snapshot your infrastructure according to rules you define. Kosli Capture will create physical environments as needed. + ## Managing logical environments Logical environments group physical environments into a combined view — useful for representing a full production tier across multiple runtimes. diff --git a/config/navigation.json b/config/navigation.json index e680d778..1f390a37 100644 --- a/config/navigation.json +++ b/config/navigation.json @@ -69,7 +69,14 @@ "administration/managing_custom_attestation_types/overview" ] }, - "administration/managing_tags" + "administration/managing_tags", + { + "group": "Kosli Capture", + "pages": [ + "administration/kosli_capture", + "administration/kosli_capture_security" + ] + } ] }, { diff --git a/getting_started/attestations.md b/getting_started/attestations.md index 34ef73a1..ac490758 100644 --- a/getting_started/attestations.md +++ b/getting_started/attestations.md @@ -203,6 +203,10 @@ Use **`--attestation-data`** on `kosli attest custom` to provide the JSON data t attestation type's jq expression evaluates. This is what determines the compliance status of the attestation. See the [Custom](#custom) attestation type below for details. +The `--attestation-data` JSON payload sent to Kosli is limited to 1 MB; larger payloads fail with a +400 error. For larger reports, distill the data you need into a summary and attach the full document +separately — see [Attesting large documents](/tutorials/attest_large_documents). + ```shell kosli attest custom \ --type coverage-metrics \ diff --git a/getting_started/environments.md b/getting_started/environments.md index ed8723ff..dfa1c242 100644 --- a/getting_started/environments.md +++ b/getting_started/environments.md @@ -55,7 +55,7 @@ Currently, the following environment types are supported: - Azure Web Apps and Function Apps - Google Cloud Run (services and jobs) -You can report environment snapshots manually using the `kosli snapshot [...]` commands for testing. For production use, however, you would configure the reporting to happen automatically on regular intervals, e.g. via a cron job or scheduled CI job, or on certain events. +You can report environment snapshots manually using the `kosli snapshot [...]` commands for testing. For production use, however, you would configure the reporting to happen automatically on regular intervals, e.g. via a cron job or scheduled CI job, or on certain events. Kosli can also report these snapshots for you, using the [Kosli Capture Managed Service](/administration/kosli_capture). You can follow one of the tutorials below to setup automatic snapshot reporting for your environment: - [Kubernetes environment reporting](/tutorials/report_k8s_envs) diff --git a/scripts/audit_navigation.py b/scripts/audit_navigation.py new file mode 100644 index 00000000..3feb0744 --- /dev/null +++ b/scripts/audit_navigation.py @@ -0,0 +1,322 @@ +#!/usr/bin/env python3 +"""Audit config/navigation.json against the page files on disk. + +Two kinds of finding, deliberately separated: + +**Integrity** — objectively broken, and the only thing `--check` fails on: + +| Finding | Meaning | +|----------|----------------------------------------------------------------| +| orphan | A page file exists but no navigation entry points at it, so it | +| | is live on the site and unreachable from the sidebar. This is | +| | CLAUDE.md rule 2 ("never create a page file without also | +| | adding it to navigation"). | +| dangling | A navigation entry names a page with no file behind it. | + +**Shape** — information-architecture signals. Heuristics, reported but never +fatal: single-child groups, over-deep nesting, Title Case labels (CLAUDE.md +mandates sentence case), inconsistent icons among sibling groups, and +oversized groups. + +Usage: + python scripts/audit_navigation.py # human-readable report + python scripts/audit_navigation.py --check # exit 1 on integrity findings only + python scripts/audit_navigation.py --json # machine-readable, for the doc-structure skill +""" +import argparse +import json +import re +import sys +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parent.parent + +# Pages live at the repo root in topic directories. Everything here is either +# not a site page or not routed through navigation. +_EXCLUDED_DIRS = {".github", ".mintlify", ".claude", "node_modules", "snippets", "styles", "tests"} +_EXCLUDED_NAMES = {"CLAUDE.md", "README.md"} + +# The site landing page is configured outside `navigation`, so it is never an orphan. +_NOT_ROUTED = {"index"} + +# Keys whose values hold nested navigation structure. +_CONTAINER_KEYS = ("tabs", "groups", "menu", "pages") + +# Words allowed to be capitalised mid-label: proper nouns, acronyms, and +# command names. Extend this rather than loosening the sentence-case check. +_PROPER_NOUNS = { + "API", "APIs", "AWS", "Azure", "Bitbucket", "CLI", "CTRF", "Docker", "ECS", + "FAQ", "GitHub", "GitLab", "Helm", "Jira", "JSON", "JUnit", "K8S", "Karpenter", + "Kosli", "Kubernetes", "Lambda", "LaunchDarkly", "MCP", "OPA", "Rego", "S3", + "SAML", "SCIM", "SSO", "Slack", "Snyk", "Sonar", "TLS", "Terraform", +} + +# Multi-word product names, where a word that would otherwise look like Title +# Case is part of the name. Matched and removed before the word check, so +# "Actions" is allowed in "GitHub Actions" but still flagged on its own. +_PROPER_PHRASES = ( + "GitHub Actions", + "GitHub Action", + "Cloud Run", + "Evidence Vault", + "Audit Log", +) + +_MAX_NESTING_DEFAULT = 2 +_MAX_GROUP_CHILDREN_DEFAULT = 12 + +# Navigation subtrees written by a generator. Their shape mirrors an upstream +# structure (the CLI's own command tree), so a single-child `kosli allow` group +# is correct rather than a defect, and reshaping it here would be reverted on +# the next release. Integrity still applies - a dangling generated entry is a +# real bug. Keyed by the label that roots the subtree. +_GENERATED_SUBTREES = { + "CLI Reference": "scripts/update-cli-nav.py", +} + + +def load_nav(nav_file): + """Return the parsed navigation config.""" + return json.loads(Path(nav_file).read_text(encoding="utf-8")) + + +def nav_pages(nav): + """Return the set of page paths referenced anywhere in nav.""" + found = set() + + def walk(node): + if isinstance(node, str): + found.add(node) + elif isinstance(node, list): + for item in node: + walk(item) + elif isinstance(node, dict): + for key, value in node.items(): + if key in _CONTAINER_KEYS: + walk(value) + + walk(nav) + return found + + +def page_files(root): + """Return the set of extensionless page paths on disk, relative to root.""" + root = Path(root) + found = set() + for path in root.rglob("*"): + if path.suffix not in (".md", ".mdx") or not path.is_file(): + continue + rel = path.relative_to(root) + if rel.parts[0] in _EXCLUDED_DIRS or rel.name in _EXCLUDED_NAMES: + continue + found.add(rel.with_suffix("").as_posix()) + return found + + +def integrity_findings(nav, root): + """Return (orphans, dangling) as sorted lists.""" + on_disk = page_files(root) + in_nav = nav_pages(nav) + orphans = sorted(on_disk - in_nav - _NOT_ROUTED) + dangling = sorted(in_nav - on_disk) + return orphans, dangling + + +def _label(node): + """Return a node's display label, or None if it is not a labelled container.""" + for key in ("tab", "item", "group"): + if key in node: + return node[key] + return None + + +def _children(node): + for key in ("groups", "menu", "pages"): + if key in node: + return node[key] + return [] + + +def is_title_case(label): + """True if a word after the first is capitalised without being a known proper noun.""" + # Drop known multi-word product names so their internal capitals don't count, + # keeping a placeholder so the remaining words keep their position. + stripped = label + for phrase in _PROPER_PHRASES: + stripped = stripped.replace(phrase, "x" if stripped.startswith(phrase) else "x x") + + words = re.findall(r"[\w'&/-]+", stripped) + for word in words[1:]: + bare = word.strip("&/-") + if not bare or not bare[0].isupper(): + continue + if bare in _PROPER_NOUNS or bare.rstrip("s") in _PROPER_NOUNS: + continue + if bare.isupper() and len(bare) <= 4: # unlisted short acronym + continue + return True + return False + + +def walk_containers(nav): + """Yield one record per labelled container: label, path, depth, and child counts. + + `depth` counts labelled containers between the tab and this one, so a group + sitting directly under a tab has depth 1. + """ + records = [] + + def walk(node, trail): + if isinstance(node, list): + for item in node: + walk(item, trail) + return + if not isinstance(node, dict): + return + label = _label(node) + if label is None: + return + children = _children(node) + path = trail + [label] + records.append( + { + "label": label, + "path": path, + "depth": len(trail), + "children": len(children), + "pages": sum(1 for c in children if isinstance(c, str)), + "icon": bool(node.get("icon")), + # Tabs and menu items are top-level section names, not headings, + # so the sentence-case rule does not apply to them. + "is_section": "tab" in node or "item" in node, + "generated_by": next( + (_GENERATED_SUBTREES[p] for p in path if p in _GENERATED_SUBTREES), None + ), + } + ) + walk(children, path) + + walk(nav.get("tabs", []), []) + return records + + +def shape_findings(nav, max_nesting=_MAX_NESTING_DEFAULT, + max_children=_MAX_GROUP_CHILDREN_DEFAULT): + """Return a list of information-architecture findings. Never fatal.""" + records = walk_containers(nav) + findings = [] + + for record in records: + where = " > ".join(record["path"]) + + # A generated subtree mirrors an upstream structure. Reshaping it here + # would be reverted, so its shape is not this audit's business. + if record["generated_by"]: + continue + + if not record["is_section"] and record["children"] == 1: + findings.append({ + "kind": "single-child group", + "where": where, + "detail": "a group wrapping one entry costs a click and returns nothing", + }) + + if record["depth"] > max_nesting: + findings.append({ + "kind": "deep nesting", + "where": where, + "detail": f"{record['depth']} containers below its tab (limit {max_nesting})", + }) + + if not record["is_section"] and is_title_case(record["label"]): + findings.append({ + "kind": "Title Case label", + "where": where, + "detail": "CLAUDE.md mandates sentence case; nav labels are the most-read headings", + }) + + if record["children"] > max_children: + findings.append({ + "kind": "oversized group", + "where": where, + "detail": f"{record['children']} children with no internal grouping", + }) + + # Icon consistency is a property of a sibling set, not of one container. + by_parent = {} + for record in records: + if record["generated_by"]: + continue + by_parent.setdefault(" > ".join(record["path"][:-1]), []).append(record) + for parent, siblings in by_parent.items(): + if len(siblings) < 2: + continue + with_icon = [s["label"] for s in siblings if s["icon"]] + without = [s["label"] for s in siblings if not s["icon"]] + if with_icon and without: + findings.append({ + "kind": "inconsistent icons", + "where": parent or "(top level)", + "detail": f"has icons: {', '.join(with_icon)}; missing: {', '.join(without)}", + }) + + return findings + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--nav-file", default=str(_REPO_ROOT / "config" / "navigation.json"), + help="Path to navigation.json.") + parser.add_argument("--root", default=str(_REPO_ROOT), + help="Repo root to scan for page files.") + parser.add_argument("--check", action="store_true", + help="Exit 1 if there are integrity findings. Shape findings never fail.") + parser.add_argument("--json", action="store_true", dest="as_json", + help="Emit findings as JSON.") + parser.add_argument("--max-nesting", type=int, default=_MAX_NESTING_DEFAULT, + help=f"Containers allowed below a tab (default {_MAX_NESTING_DEFAULT}).") + parser.add_argument("--max-group-children", type=int, default=_MAX_GROUP_CHILDREN_DEFAULT, + help=f"Children before a group is oversized (default {_MAX_GROUP_CHILDREN_DEFAULT}).") + args = parser.parse_args() + + nav = load_nav(args.nav_file) + orphans, dangling = integrity_findings(nav, args.root) + shape = shape_findings(nav, args.max_nesting, args.max_group_children) + + if args.as_json: + json.dump({"orphans": orphans, "dangling": dangling, "shape": shape}, + sys.stdout, indent=2) + sys.stdout.write("\n") + else: + print(f"{len(nav_pages(nav))} pages in navigation, " + f"{len(page_files(args.root))} page files on disk\n") + + if orphans: + print(f"ORPHANS ({len(orphans)}) - live but unreachable from the sidebar:") + for page in orphans: + print(f" {page}") + if dangling: + print(f"\nDANGLING ({len(dangling)}) - navigation entry with no file:") + for page in dangling: + print(f" {page}") + if not orphans and not dangling: + print("integrity: ok") + + if shape: + print(f"\nSHAPE ({len(shape)}) - advisory, never fails the build:") + for finding in shape: + print(f" [{finding['kind']}] {finding['where']}") + print(f" {finding['detail']}") + + if args.check and (orphans or dangling): + print( + f"\n{len(orphans)} orphaned page(s) and {len(dangling)} dangling entry(s). " + "Every page file must be listed in navigation (CLAUDE.md rule 2).", + file=sys.stderr, + ) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tests/test_audit_navigation.py b/tests/test_audit_navigation.py new file mode 100644 index 00000000..c8c01c06 --- /dev/null +++ b/tests/test_audit_navigation.py @@ -0,0 +1,224 @@ +import json +import subprocess +import sys +from pathlib import Path + +import audit_navigation as nav_audit + +_SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "audit_navigation.py" + + +def _write_site(tmp_path, nav, pages): + """Build a throwaway docs tree: nav config plus page files.""" + nav_file = tmp_path / "config" / "navigation.json" + nav_file.parent.mkdir(parents=True) + nav_file.write_text(json.dumps(nav), encoding="utf-8") + for page in pages: + target = tmp_path / page + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text("---\ntitle: x\n---\n", encoding="utf-8") + return nav_file + + +# --- page discovery --------------------------------------------------------- + +def test_page_files_strips_both_extensions(tmp_path): + """Regression: a broken extension strip made every page look orphaned.""" + _write_site(tmp_path, {"tabs": []}, ["a/one.md", "a/two.mdx"]) + assert nav_audit.page_files(tmp_path) == {"a/one", "a/two"} + + +def test_page_files_skips_excluded_dirs_and_names(tmp_path): + _write_site(tmp_path, {"tabs": []}, [ + "real/page.md", + "snippets/frag.mdx", + ".claude/skills/doc-write/SKILL.md", + ".github/notes.md", + "CLAUDE.md", + "README.md", + ]) + assert nav_audit.page_files(tmp_path) == {"real/page"} + + +# --- navigation traversal --------------------------------------------------- + +def test_nav_pages_walks_tabs_groups_menu_and_nested_pages(): + nav = { + "tabs": [ + {"tab": "Docs", "groups": [ + {"group": "A", "pages": [ + "a/one", + {"group": "B", "pages": ["a/b/two"]}, + ]}, + ]}, + {"tab": "Ref", "menu": [ + {"item": "CLI", "groups": [{"group": "C", "pages": ["ref/three"]}]}, + {"item": "API", "openapi": "https://example.test/openapi.json"}, + ]}, + ] + } + assert nav_audit.nav_pages(nav) == {"a/one", "a/b/two", "ref/three"} + + +# --- integrity -------------------------------------------------------------- + +def test_orphan_is_reported(tmp_path): + nav = {"tabs": [{"tab": "Docs", "groups": [{"group": "A", "pages": ["a/one"]}]}]} + nav_file = _write_site(tmp_path, nav, ["a/one.md", "a/lonely.md"]) + orphans, dangling = nav_audit.integrity_findings(nav_audit.load_nav(nav_file), tmp_path) + assert orphans == ["a/lonely"] + assert dangling == [] + + +def test_dangling_entry_is_reported(tmp_path): + nav = {"tabs": [{"tab": "Docs", "groups": [ + {"group": "A", "pages": ["a/one", "a/ghost"]}]}]} + nav_file = _write_site(tmp_path, nav, ["a/one.md"]) + orphans, dangling = nav_audit.integrity_findings(nav_audit.load_nav(nav_file), tmp_path) + assert orphans == [] + assert dangling == ["a/ghost"] + + +def test_index_is_never_an_orphan(tmp_path): + """The landing page is configured outside `navigation`.""" + nav_file = _write_site(tmp_path, {"tabs": []}, ["index.mdx"]) + orphans, _ = nav_audit.integrity_findings(nav_audit.load_nav(nav_file), tmp_path) + assert orphans == [] + + +def test_clean_site_has_no_integrity_findings(tmp_path): + nav = {"tabs": [{"tab": "Docs", "groups": [{"group": "A", "pages": ["a/one"]}]}]} + nav_file = _write_site(tmp_path, nav, ["a/one.md", "index.mdx"]) + assert nav_audit.integrity_findings(nav_audit.load_nav(nav_file), tmp_path) == ([], []) + + +# --- sentence case ---------------------------------------------------------- + +def test_sentence_case_labels_pass(): + for label in ["Getting started", "Naming conventions", "Users & roles", + "Multi-flow workflows", "Understand Kosli", "kosli attest"]: + assert not nav_audit.is_title_case(label), label + + +def test_title_case_labels_are_flagged(): + for label in ["Naming Conventions", "Data Sources", "Helm Charts", + "Managing Environments", "Roles & Responsibilities"]: + assert nav_audit.is_title_case(label), label + + +def test_proper_nouns_and_acronyms_are_allowed_mid_label(): + for label in ["Report AWS environments", "The Kosli CLI", "Using GitHub Actions", + "Attest with Snyk", "Kubernetes and Terraform"]: + assert not nav_audit.is_title_case(label), label + + +# --- shape ------------------------------------------------------------------ + +def test_single_child_group_is_flagged(): + nav = {"tabs": [{"tab": "Docs", "groups": [{"group": "Lonely", "pages": ["a/one"]}]}]} + kinds = {f["kind"] for f in nav_audit.shape_findings(nav)} + assert "single-child group" in kinds + + +def test_deep_nesting_is_flagged_against_the_limit(): + nav = {"tabs": [{"tab": "Docs", "groups": [ + {"group": "One", "pages": [ + {"group": "Two", "pages": [ + {"group": "Three", "pages": ["a/deep"]}]}]}]}]} + deep = [f for f in nav_audit.shape_findings(nav, max_nesting=2) + if f["kind"] == "deep nesting"] + assert [f["where"] for f in deep] == ["Docs > One > Two > Three"] + assert not [f for f in nav_audit.shape_findings(nav, max_nesting=5) + if f["kind"] == "deep nesting"] + + +def test_tabs_and_menu_items_are_exempt_from_sentence_case(): + """Tabs and menu items are section names, not headings.""" + nav = {"tabs": [{"tab": "Implementation Guide", "menu": [ + {"item": "CLI Reference", "groups": [{"group": "General", "pages": ["a/one", "a/two"]}]}]}]} + assert not [f for f in nav_audit.shape_findings(nav) if f["kind"] == "Title Case label"] + + +def test_generated_subtree_is_exempt_from_shape_checks(): + """`kosli allow` having one subcommand is upstream truth, not a defect.""" + nav = {"tabs": [{"tab": "Reference", "menu": [ + {"item": "CLI Reference", "groups": [ + {"group": "kosli allow", "pages": ["client_reference/kosli_allow_artifact"]}]}]}]} + assert nav_audit.shape_findings(nav) == [] + + +def test_non_generated_subtree_is_still_checked(): + nav = {"tabs": [{"tab": "Reference", "menu": [ + {"item": "Template Reference", "groups": [ + {"group": "Templates", "pages": ["template-reference/flow_template"]}]}]}]} + kinds = {f["kind"] for f in nav_audit.shape_findings(nav)} + assert "single-child group" in kinds + + +def test_oversized_group_is_flagged(): + nav = {"tabs": [{"tab": "Docs", "groups": [ + {"group": "Big", "pages": [f"a/p{n}" for n in range(20)]}]}]} + kinds = {f["kind"] for f in nav_audit.shape_findings(nav, max_children=12)} + assert "oversized group" in kinds + + +def test_inconsistent_icons_reported_once_per_sibling_set(): + nav = {"tabs": [{"tab": "Docs", "groups": [ + {"group": "A", "icon": "book", "pages": ["a/one", "a/two"]}, + {"group": "B", "pages": ["b/one", "b/two"]}, + ]}]} + icons = [f for f in nav_audit.shape_findings(nav) if f["kind"] == "inconsistent icons"] + assert len(icons) == 1 + assert "A" in icons[0]["detail"] and "B" in icons[0]["detail"] + + +# --- CLI contract ----------------------------------------------------------- + +def _run(nav_file, root, *args): + return subprocess.run( + [sys.executable, str(_SCRIPT), "--nav-file", str(nav_file), "--root", str(root), *args], + capture_output=True, text=True, + ) + + +def test_check_exits_nonzero_on_orphan(tmp_path): + nav = {"tabs": [{"tab": "Docs", "groups": [{"group": "A", "pages": ["a/one"]}]}]} + nav_file = _write_site(tmp_path, nav, ["a/one.md", "a/lonely.md"]) + result = _run(nav_file, tmp_path, "--check") + assert result.returncode == 1 + assert "a/lonely" in result.stdout + + +def test_check_passes_on_clean_site(tmp_path): + nav = {"tabs": [{"tab": "Docs", "groups": [{"group": "A", "pages": ["a/one", "a/two"]}]}]} + nav_file = _write_site(tmp_path, nav, ["a/one.md", "a/two.md"]) + result = _run(nav_file, tmp_path, "--check") + assert result.returncode == 0, result.stderr + + +def test_check_does_not_fail_on_shape_findings_alone(tmp_path): + """Shape is advisory. A single-child group must never block a PR.""" + nav = {"tabs": [{"tab": "Docs", "groups": [{"group": "Lonely", "pages": ["a/one"]}]}]} + nav_file = _write_site(tmp_path, nav, ["a/one.md"]) + result = _run(nav_file, tmp_path, "--check") + assert result.returncode == 0, result.stderr + assert "single-child group" in result.stdout + + +def test_json_output_is_machine_readable(tmp_path): + nav = {"tabs": [{"tab": "Docs", "groups": [{"group": "Lonely", "pages": ["a/one"]}]}]} + nav_file = _write_site(tmp_path, nav, ["a/one.md", "a/lonely.md"]) + payload = json.loads(_run(nav_file, tmp_path, "--json").stdout) + assert payload["orphans"] == ["a/lonely"] + assert payload["dangling"] == [] + assert any(f["kind"] == "single-child group" for f in payload["shape"]) + + +def test_real_repo_navigation_has_integrity(tmp_path): + """The committed navigation must route every page. CLAUDE.md rule 2.""" + repo = Path(__file__).resolve().parent.parent + orphans, dangling = nav_audit.integrity_findings( + nav_audit.load_nav(repo / "config" / "navigation.json"), repo + ) + assert orphans == [], f"orphaned pages: {orphans}" + assert dangling == [], f"dangling nav entries: {dangling}" diff --git a/tutorials/attest_large_documents.md b/tutorials/attest_large_documents.md index e445fa2c..513837f3 100644 --- a/tutorials/attest_large_documents.md +++ b/tutorials/attest_large_documents.md @@ -8,6 +8,10 @@ By the end, you will have a Kosli attestation that captures the key facts from y This two-part approach keeps attestation payloads focused on what compliance rules need to evaluate, while ensuring the raw evidence remains available. + +The `--attestation-data` JSON payload sent by `kosli attest custom` is limited to 1 MB — exceeding it returns a 400 error. Distill larger reports into a summary and attach the full document with `--attachments`, as described below. + + ## Prerequisites * [Install Kosli CLI](/getting_started/install).