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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,11 @@ All notable changes to ForgeLM are documented here.
`good first issue`s with their size and area) from the README and CONTRIBUTING, and record in
`CONTRIBUTING.md`, `CLAUDE.md` and `AGENTS.md` that the front door is chosen with labels and
views, never by closing issues.
- `forgelm purge --check-policy --config` accepts a file whose only top-level key is `retention:`, so a scheduled
retention report no longer needs a full training config next to it. A file with any other top-level key is still
validated as a full config, so a misspelt `retension:` or a partial training config exits 1 instead of being ignored.
`retention:` with no value reports nothing to enforce, as it does in a full config, and validation errors name the
field without its raw value (#1054).

### Removed

Expand Down
2 changes: 1 addition & 1 deletion docs/guides/gdpr_erasure-tr.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ $ forgelm purge --run-id fg-abc123def456 --kind artefacts --output-dir ./outputs
$ forgelm purge --check-policy --config configs/run.yaml --output-dir ./outputs
```

Salt-okunur tarama. Output dizinini gezer, her artefact'ın yaşını (canonical: `final_model.staging.<run_id>/` dizini için koşumun ilk audit event'i, yoksa audit-log genesis `timestamp`'i; fallback: filesystem `mtime` + `age_source=mtime` flag'i ile) yüklü config'in `retention:` horizon'larına karşı kıyaslar ve structured violation list yayar.
Salt-okunur tarama. Output dizinini gezer, her artefact'ın yaşını (canonical: `final_model.staging.<run_id>/` dizini için koşumun ilk audit event'i, yoksa audit-log genesis `timestamp`'i; fallback: filesystem `mtime` + `age_source=mtime` flag'i ile) yüklü config'in `retention:` horizon'larına karşı kıyaslar ve structured violation list yayar. `--config`, eğitim config'i ya da yalnızca `retention:` bloğunu içeren bir dosya olabilir; başka bir üst düzey anahtar içeren dosya tam config olarak doğrulanır.

**Başarılı policy raporları 0 ile çıkar** (rapor-değil-gate semantiği — design §10 Q5). Config-load başarısızlıkları `EXIT_CONFIG_ERROR` (sıfır olmayan) ile çıkar: eksik / okunamayan / Pydantic validation'ı geçemeyen explicit `--config`, "loader failed" durumunu "ihlal yok" sanmamak için `EXIT_CONFIG_ERROR` olarak yüzeye çıkar. CI gate isteyen operatörler `--output-format json` kullanır ve raporu kendileri sınar, örneğin `set -o pipefail` altında `jq -e '.success == true and has("count") and .count == 0'`: `count`, hiçbir `retention:` bloğu yüklenmediğinde yoktur; böylece gate hiç kontrol edilmemiş bir dizinde geçmek yerine başarısız olur, ve `--output-dir` gerçek eğitim çıktı dizini olmalıdır (olmayan bir dizin `count: 0` raporlar). Bu, public exit-code kontratını her ForgeLM subcommand'ında tutarlı tutar.

Expand Down
2 changes: 1 addition & 1 deletion docs/guides/gdpr_erasure.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ $ forgelm purge --run-id fg-abc123def456 --kind artefacts --output-dir ./outputs
$ forgelm purge --check-policy --config configs/run.yaml --output-dir ./outputs
```

Read-only scan. Walks the output directory, compares each artefact's age (canonical: the run's first audit event for a `final_model.staging.<run_id>/` directory, else the audit log's genesis `timestamp`; fallback: filesystem `mtime` with `age_source=mtime` flag) against the loaded config's `retention:` horizons, and emits a structured violation list.
Read-only scan. Walks the output directory, compares each artefact's age (canonical: the run's first audit event for a `final_model.staging.<run_id>/` directory, else the audit log's genesis `timestamp`; fallback: filesystem `mtime` with `age_source=mtime` flag) against the loaded config's `retention:` horizons, and emits a structured violation list. `--config` can be the training config or a file that holds only the `retention:` block; a file with any other top-level key is validated as a full config.

**Successful policy reports exit 0** (report-not-gate semantic per design §10 Q5). Config-load failures exit with `EXIT_CONFIG_ERROR` (non-zero): an explicit `--config` that is missing, unreadable, or fails Pydantic validation surfaces as `EXIT_CONFIG_ERROR` so the operator does not mistake "loader failed" for "no violations". Operators wiring a CI gate use `--output-format json` and test the report themselves, for example `jq -e '.success == true and has("count") and .count == 0'` under `set -o pipefail`: `count` is absent when no `retention:` block was loaded, so the gate fails instead of passing on a directory that was never checked, and `--output-dir` must be the real training output directory (a missing one reports `count: 0`). This keeps the public exit-code contract consistent across every ForgeLM subcommand.

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/purge_subcommand-tr.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ JSONL eğitim corpus'undan `id` (veya `row_id`) field'ı ile tanımlanan tek bir

### Retention-policy raporu (`--check-policy`)

Salt-okunur tarama. `<output_dir>` üzerinde dolaşır, her artefact'ın yaşını (kanonik: `final_model.staging.<run_id>/` dizini için koşumun ilk audit event'i, yoksa audit-log genesis `timestamp`'i; fallback: dosya sistemi `mtime`'ı, `age_source=mtime` flag'iyle) yüklü config'in `retention:` horizon'larıyla karşılaştırır ve yapısal bir ihlal listesi yayar.
Salt-okunur tarama. `<output_dir>` üzerinde dolaşır, her artefact'ın yaşını (kanonik: `final_model.staging.<run_id>/` dizini için koşumun ilk audit event'i, yoksa audit-log genesis `timestamp`'i; fallback: dosya sistemi `mtime`'ı, `age_source=mtime` flag'iyle) yüklü config'in `retention:` horizon'larıyla karşılaştırır ve yapısal bir ihlal listesi yayar. Config, tam bir eğitim config'i ya da tek üst düzey anahtarı `retention:` olan bir YAML dosyası olabilir; başka bir üst düzey anahtar içeren dosya tam config olarak doğrulanır.

**Paylaşılan çıktı dizinleri.** `compliance/`, `data_audit_report.json` ve ham-belge dizinlerinin yaşı, dosyanın en son yazıldığı andan değil, audit log'un *ilk* event'inden sayılır. Birçok koşum için yeniden kullanılan bir dizinde bugün yazılmış bir paket de ufku aşmış olarak raporlanabilir: raporun listelediği bir şeyi silmeden önce dosyanın kendi tarihine bakın.

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/purge_subcommand.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ Erases a single row identified by its `id` (or `row_id`) field from a JSONL trai

### Retention-policy report (`--check-policy`)

Read-only scan. Walks `<output_dir>`, compares each artefact's age (canonical: the run's first audit event for a `final_model.staging.<run_id>/` directory, else the audit log's genesis `timestamp`; fallback: filesystem `mtime` with `age_source=mtime`) against the loaded config's `retention:` horizons, and emits a structured violation list.
Read-only scan. Walks `<output_dir>`, compares each artefact's age (canonical: the run's first audit event for a `final_model.staging.<run_id>/` directory, else the audit log's genesis `timestamp`; fallback: filesystem `mtime` with `age_source=mtime`) against the loaded config's `retention:` horizons, and emits a structured violation list. The config can be a full training config or a YAML file whose only top-level key is `retention:`; a file with any other top-level key is validated as a full config.

**Shared output directories.** `compliance/`, `data_audit_report.json` and the raw-document directories are aged from the audit log's *first* event, not from when the file was last written. In a directory reused for many runs, a bundle written today can be reported as past its horizon: check the file's own date before deleting anything the report lists.

Expand Down
2 changes: 1 addition & 1 deletion docs/usermanuals/en/compliance/gdpr.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ retention:
ephemeral_artefact_retention_days: 365
```

`forgelm purge --check-policy --config config.yaml --output-dir <training.output_dir>` lists the artefacts in that directory that are past their horizon and exits 0 however many it finds (without `--output-dir` it scans the current directory, not `training.output_dir`). Nothing is deleted automatically and nothing is written to the audit log for it; deleting what is overdue is your storage layer's job. (`retention.enforce` is reserved: no command acts on it yet.) ForgeLM does not create `raw_documents/` or `ingestion_output/`, so `raw_documents_retention_days` has an effect only if you keep source documents under `<training.output_dir>/raw_documents/` or `ingestion_output/`.
`forgelm purge --check-policy --config config.yaml --output-dir <training.output_dir>` lists the artefacts in that directory that are past their horizon and exits 0 however many it finds (without `--output-dir` it scans the current directory, not `training.output_dir`). `config.yaml` can be your training config or a file that holds only the `retention:` block above. Nothing is deleted automatically and nothing is written to the audit log for it; deleting what is overdue is your storage layer's job. (`retention.enforce` is reserved: no command acts on it yet.) ForgeLM does not create `raw_documents/` or `ingestion_output/`, so `raw_documents_retention_days` has an effect only if you keep source documents under `<training.output_dir>/raw_documents/` or `ingestion_output/`.

## Data subject requests

Expand Down
2 changes: 1 addition & 1 deletion docs/usermanuals/tr/compliance/gdpr.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ retention:
ephemeral_artefact_retention_days: 365
```

`forgelm purge --check-policy --config config.yaml --output-dir <training.output_dir>` o dizinde ufku aşmış artefaktları listeler ve kaç tane bulursa bulsun 0 ile çıkar (`--output-dir` verilmezse `training.output_dir`'i değil, bulunulan dizini tarar). Hiçbir şey otomatik silinmez ve bunun için audit log'a bir şey yazılmaz; süresi geçeni silmek depolama katmanınızın işidir. (`retention.enforce` ayrılmıştır: henüz hiçbir komut onu kullanmaz.) ForgeLM `raw_documents/` veya `ingestion_output/` dizinlerini oluşturmaz; bu yüzden `raw_documents_retention_days`, yalnızca kaynak dokümanları `<training.output_dir>/raw_documents/` ya da `ingestion_output/` altında tutuyorsanız etkili olur.
`forgelm purge --check-policy --config config.yaml --output-dir <training.output_dir>` o dizinde ufku aşmış artefaktları listeler ve kaç tane bulursa bulsun 0 ile çıkar (`--output-dir` verilmezse `training.output_dir`'i değil, bulunulan dizini tarar). `config.yaml`, eğitim config'iniz ya da yalnızca yukarıdaki `retention:` bloğunu içeren bir dosya olabilir. Hiçbir şey otomatik silinmez ve bunun için audit log'a bir şey yazılmaz; süresi geçeni silmek depolama katmanınızın işidir. (`retention.enforce` ayrılmıştır: henüz hiçbir komut onu kullanmaz.) ForgeLM `raw_documents/` veya `ingestion_output/` dizinlerini oluşturmaz; bu yüzden `raw_documents_retention_days`, yalnızca kaynak dokümanları `<training.output_dir>/raw_documents/` ya da `ingestion_output/` altında tutuyorsanız etkili olur.

## Veri sahibi talepleri

Expand Down
34 changes: 31 additions & 3 deletions forgelm/cli/_config_load.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
"""Config loading + offline-flag application for training mode."""
"""Config loading + offline-flag application for training mode, and the ``purge --check-policy`` retention loader."""

from __future__ import annotations

import os
import sys
from typing import Optional

import yaml
from pydantic import ValidationError
from pydantic import ConfigDict, TypeAdapter, ValidationError

from .._strict_json import dumps_strict
from ..config import ConfigError, load_config
from ..config import ConfigError, RetentionConfig, load_config
from ._exit_codes import EXIT_CONFIG_ERROR
from ._logging import logger

Expand Down Expand Up @@ -73,6 +74,33 @@ def _load_config_or_exit(config_path: str, json_output: bool):
sys.exit(EXIT_CONFIG_ERROR)


def _load_retention_policy(config_path: Optional[str]) -> Optional[RetentionConfig]:
"""Return the ``retention:`` block that ``forgelm purge --check-policy`` reports against.

The file is either a full ForgeLM config or one whose only top-level key is ``retention``: a scheduled
retention report needs no training config (#1054). Any other top-level key makes it a full config, validated
as one, so a misspelt ``retension:`` fails instead of being ignored. ``retention: null`` returns ``None``, as
it does in a full config. Raises what ``load_config`` raises (``FileNotFoundError``, ``OSError``,
``ConfigError``), and like ``ForgeConfig`` leaves raw input values out of validation messages.
"""
if not config_path:
return None
if not os.path.exists(config_path):
raise FileNotFoundError(f"Configuration file not found: {config_path}")
with open(config_path, encoding="utf-8") as fh:
try:
data = yaml.safe_load(fh)
except yaml.YAMLError as exc:
raise ConfigError(f"Invalid YAML syntax in {config_path}: {exc}") from exc
if not (isinstance(data, dict) and set(data) == {"retention"}):
return load_config(config_path).retention
adapter = TypeAdapter(Optional[RetentionConfig], config=ConfigDict(hide_input_in_errors=True, title="retention"))
try:
return adapter.validate_python(data["retention"])
except ValidationError as exc:
raise ConfigError(f"Configuration validation failed:\n{exc}") from exc


def _disable_hub_telemetry() -> None:
"""Keep the ``forgelm`` CLI from sending Hugging Face's anonymous usage ping.

Expand Down
42 changes: 18 additions & 24 deletions forgelm/cli/subcommands/_purge.py
Original file line number Diff line number Diff line change
Expand Up @@ -1125,18 +1125,21 @@ def _run_purge_check_policy(args, output_format: str) -> None:
malformed YAML or a Pydantic validation error must surface as
``EXIT_CONFIG_ERROR`` rather than silently degrading to a
"no retention block" notice (which the operator would mistake
for "no violations").
for "no violations"). The file may hold only ``retention:``
(#1054); see ``_load_retention_policy``.
"""
import yaml as _yaml

from forgelm.config import ConfigError

from .._config_load import _load_retention_policy

try:
config_loaded = _maybe_load_config(getattr(args, "config", None), strict=True)
retention = _load_retention_policy(getattr(args, "config", None))
except (ConfigError, OSError, _yaml.YAMLError) as exc:
# OSError covers FileNotFoundError + permission denied; ConfigError
# wraps Pydantic ValidationError raised by ``load_config``;
# ``yaml.YAMLError`` mirrors the inner catch tuple in
# wraps Pydantic ValidationError and YAML parse errors;
# ``yaml.YAMLError`` mirrors the catch tuple in
# ``_maybe_load_config`` so a malformed YAML that bypasses the
# ConfigError wrap still surfaces a formatted operator-facing
# message instead of a stack trace. Any other class would be a
Expand All @@ -1146,7 +1149,7 @@ def _run_purge_check_policy(args, output_format: str) -> None:
f"--check-policy could not load --config: {exc}",
EXIT_CONFIG_ERROR,
)
if config_loaded is None or getattr(config_loaded, "retention", None) is None:
if retention is None:
msg = (
"No `retention:` block in the loaded config; nothing to enforce. "
"See `docs/guides/gdpr_erasure.md` for the schema."
Expand All @@ -1158,7 +1161,7 @@ def _run_purge_check_policy(args, output_format: str) -> None:
sys.exit(EXIT_SUCCESS)

output_dir = args.output_dir or "."
violations = _scan_retention_violations(config_loaded.retention, output_dir)
violations = _scan_retention_violations(retention, output_dir)
if output_format == "json":
print(dumps_strict({"success": True, "violations": violations, "count": len(violations)}, indent=2))
else:
Expand Down Expand Up @@ -1413,23 +1416,16 @@ def _resolve_artefact_age(
# ---------------------------------------------------------------------------


def _maybe_load_config(config_path: Optional[str], *, strict: bool = False):
"""Load the ``ForgeConfig`` from YAML.

``strict=False`` (default) is the best-effort mode used by the
row-id / run-id erasure paths: those subcommands work without a
config (the corpus-row erasure is intentionally config-agnostic),
so a missing / unreadable / invalid YAML falls back to ``None``.
When *config_path* was explicitly supplied the fallback is logged
at ``WARNING`` (not silent) so an operator relying on it for
external-copies detection sees that the load failed.
def _maybe_load_config(config_path: Optional[str]):
"""Load the ``ForgeConfig`` from YAML, best effort.

``strict=True`` is used by ``--check-policy`` where the operator
is *explicitly* asking for a retention-policy report against the
loaded config. In that mode a malformed YAML or a schema error
must surface as the original ``ConfigError`` / ``OSError`` so the
operator sees the validation failure instead of a misleading
"no `retention:` block" notice.
Used by the row-id erasure path, which works without a config (the
corpus-row erasure is intentionally config-agnostic), so a missing /
unreadable / invalid YAML falls back to ``None``. When *config_path*
was explicitly supplied the fallback is logged at ``WARNING`` (not
silent) so an operator relying on it for external-copies detection
sees that the load failed. ``--check-policy`` loads its config
strictly, through ``_load_retention_policy``.
"""
if not config_path:
return None
Expand All @@ -1446,8 +1442,6 @@ def _maybe_load_config(config_path: Optional[str], *, strict: bool = False):
try:
return load_config(config_path)
except (ConfigError, OSError, _yaml.YAMLError) as exc:
if strict:
raise
# Best-effort fallback: row-id / run-id paths run config-free.
# WARNING, not DEBUG: an operator who explicitly passed --config
# (e.g. to surface the external-copies webhook warning) must see
Expand Down
Loading
Loading