Skip to content
Closed
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
59 changes: 59 additions & 0 deletions .github/workflows/nt009-evidence.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later

name: NT009 Source Evidence

on:
pull_request:
paths:
- "tools/src/Nt009Inspector.php"
- "tools/bin/domains.php"
- "tools/tests/Nt009InspectorTest.php"
- ".github/workflows/nt009-evidence.yml"
workflow_dispatch:

permissions:
contents: read

jobs:
optional-official-evidence:
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up PHP 8.2
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2
with:
php-version: "8.2"
extensions: dom, mbstring, zip, gd, xml, xmlreader, xmlwriter, simplexml, fileinfo
coverage: none
- name: Install isolated domain tooling
run: composer --working-dir=vendor-bin/domains install --prefer-dist --no-progress --no-interaction
- name: Verify original XLSX hashes and compare official NT009 data
run: |
php tools/bin/domains.php audit \
--download-dir="${{ runner.temp }}/nt009" \
--report="${{ runner.temp }}/nt009-comparison.json"
- name: Export exact official indicator rows for review
run: |
php -r '
require "vendor-bin/domains/vendor/autoload.php";
$r = new LibreCodeCoop\NfsePHP\Tools\DomainTableGenerator();
$rows = $r->indicatorRows(getenv("RUNNER_TEMP") . "/nt009/annex-vii-nt009.xlsx");
if (count($rows) !== 40) { throw new RuntimeException("Unexpected official VII cardinality"); }
echo "OFFICIAL_NT009_INDICATORS_BEGIN\n";
foreach ($rows as $row) { echo implode("\t", $row) . "\n"; }
echo "OFFICIAL_NT009_INDICATORS_END\n";
'
- name: Retain full evidence for manual review
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: nt009-official-source-evidence
path: |
${{ runner.temp }}/nt009-comparison.json
${{ runner.temp }}/nt009/*.xlsx
if-no-files-found: warn
retention-days: 30
45 changes: 45 additions & 0 deletions docs/NT009-SOURCE-AUDIT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<!--
SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
SPDX-License-Identifier: AGPL-3.0-or-later
-->

# NT009 v1.01: inspect published contracts without activating them

The official [RTC portal](https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/rtc)
publishes **Anexo VI v1.04.01** (DPS/NFS-e layout and fiscal business rules)
and **Anexo VII v1.03.00** (IBS/CBS operation indicators). The publication
does not by itself establish a deployed production schema or effective date.

A strictly read-only source review uses PHP/PhpSpreadsheet from the isolated
`vendor-bin/domains` Composer project:

```sh
composer domains:install
composer domains:audit -- --download-dir=/tmp/nfse-nt009 --report=/tmp/nt009-report.json
# Or use previously downloaded, independently verified official files:
composer domains:audit -- --annex-vi=/path/to/VI.xlsx --annex-vii=/path/to/VII.xlsx --report=/tmp/nt009-report.json
```

The audit first validates both workbook bytes against SHA-256 values independently
observed on **2026-10-05**, not government-issued signatures. It then reads
the layout worksheet rows with literal field names (ignoring, not evaluating,
spreadsheet helper formulas) and compares the complete
indicator-code/description/location triplets with the existing Anexo C v1.01
snapshot. Both known official indicator worksheet names (`INDOP` and `cIndOp Public`) are
supported; unknown sheet names fail explicitly. The NT009 sheet identifies A as the
six-digit indicator, C as the supply characteristic and D as the location in the DFe.
Its column J is a separate NFS-e IBS-incidence classification and must not be
substituted for the official DFe location text. The JSON report lists additions, removals and text changes, as well
as original cell coordinates for relevant NT009 layout entries.

**Interpretation boundary:** exported raw layout rows do *not* automatically
encode complex field conditions, business-rule activation or authorization by
the SEFIN API. The output is audit evidence, not a replacement XSD or a
compliance declaration. Neither Anexo VIII consultative correlations nor new
NT009 fields are activated in production from this tool. The existing
`DpsData`, `XmlBuilder`, `OfficialDomainCatalog` and XSD validators remain
unchanged until a separately reviewed implementation validates the full
normative conditions and deployment dates.

If official sources cannot be downloaded, the auxiliary evidence GitHub
workflow may be inconclusive; the offline PHPUnit suite remains deterministic.
63 changes: 62 additions & 1 deletion tools/bin/domains.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

use LibreCodeCoop\NfsePHP\Tools\AnnexReader;
use LibreCodeCoop\NfsePHP\Tools\DomainTableGenerator;
use LibreCodeCoop\NfsePHP\Tools\Nt009Inspector;
use LibreCodeCoop\NfsePHP\Tools\PortalIndexDiscovery;
use LibreCodeCoop\NfsePHP\Tools\SourceVerifier;

Expand Down Expand Up @@ -69,6 +70,38 @@
}
echo "Verified " . count($outputs) . " deterministic snapshots\n";
} elseif ($command === 'audit') {
if (isset($options['download-dir'])) {
$destination = $options['download-dir'];
if (!is_dir($destination) && !mkdir($destination, 0o700, true)) {
throw new RuntimeException('Cannot create official document download directory');
}
$manifest = json_decode(
(string) file_get_contents($root . '/resources/domains/sources.json'),
true,
512,
JSON_THROW_ON_ERROR
);
foreach (['annex-vi-nt009' => 'annex-vi', 'annex-vii-nt009' => 'annex-vii'] as $id => $name) {
$source = null;
foreach ($manifest['sources'] as $entry) {
if ($entry['id'] === $id) {
$source = $entry;
break;
}
}
if ($source === null || !str_starts_with(
$source['url'],
'https://www.gov.br/nfse/'
)) {
throw new RuntimeException('Missing or non-official source: ' . $id);
}
$file = $destination . '/' . $id . '.xlsx';
if (file_put_contents($file, SourceVerifier::fetchOfficial($source['url'])) === false) {
throw new RuntimeException('Failed to save official source: ' . $id);
}
$options[$name] = $file;
}
}
foreach (['annex-vi', 'annex-vii'] as $name) {
if (!isset($options[$name])) {
throw new InvalidArgumentException("Missing --{$name}=PATH");
Expand All @@ -85,6 +118,34 @@
echo "{$name}: {$hash}; sheets: " . implode(', ', $reader->sheetNames()) . "\n";
$reader->close();
}
$comparison = (new Nt009Inspector())->inspect(
$options['annex-vi'],
$options['annex-vii'],
$root . '/resources/domains/indicadores-operacao-ibscbs-v1.01.tsv'
);
$json = json_encode($comparison, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE
| JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n";
if (isset($options['report'])) {
if (file_put_contents($options['report'], $json) === false) {
throw new RuntimeException('Unable to save NT009 comparison');
}
}
echo 'NT009 Anexo VII: ' . $comparison['annex_vii_count']
. ' indicators; added=' . count($comparison['added_codes'])
. ', changed=' . count($comparison['changed_codes'])
. ', removed=' . count($comparison['removed_codes']) . "\n";
if (getenv('GITHUB_STEP_SUMMARY') !== false) {
file_put_contents(
(string) getenv('GITHUB_STEP_SUMMARY'),
"## NT009 source comparison\n\n" . 'Annex VII: '
. $comparison['annex_vii_count'] . ' indicators, added: '
. count($comparison['added_codes']) . ', changed: '
. count($comparison['changed_codes']) . ', removed: '
. count($comparison['removed_codes'])
. "\n\nA published annex does not establish production applicability.\n",
FILE_APPEND
);
}
} elseif ($command === 'watch') {
$manifest = $options['manifest'] ?? $root . '/resources/domains/sources.json';
$verifier = new SourceVerifier();
Expand Down Expand Up @@ -133,7 +194,7 @@
throw new InvalidArgumentException(
"Usage: php tools/bin/domains.php {generate|check|audit|watch} --name=PATH\n"
. "generate/check: --annex-a= --annex-b= --annex-c= --output= [--annex-vii= --expected-vii=N]\n"
. "audit: --annex-vi= --annex-vii=\nwatch: [--manifest= --report=]\n",
. "audit: --annex-vi= --annex-vii= [--report=] OR --download-dir= [--report=]\nwatch: [--manifest= --report=]\n",
);
}
} catch (Throwable $error) {
Expand Down
6 changes: 5 additions & 1 deletion tools/src/AnnexReader.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ public function sheetNames(): array
/**
* @return list<array<string, string>>
*/
public function rows(string $sheetName): array
public function rows(string $sheetName, bool $skipFormulaCells = false): array
{
$sheet = null;
foreach ($this->workbook->getWorksheetIterator() as $candidate) {
Expand All @@ -66,6 +66,10 @@ public function rows(string $sheetName): array
$name = Coordinate::stringFromColumnIndex($i);
$cell = $sheet->getCell($name . $r);
if ($cell->getDataType() === DataType::TYPE_FORMULA) {
if ($skipFormulaCells) {
// Spreadsheet helper formula: do not execute or trust its cached result.
continue;
}
throw new \UnexpectedValueException("Formula not allowed in {$sheetName}!{$name}{$r}");
}
$raw = $cell->getFormattedValue();
Expand Down
42 changes: 34 additions & 8 deletions tools/src/DomainTableGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,19 @@ public function generate(string $annexA, string $annexB, string $annexC, ?string
return $outputs;
}

/**
* @return list<list<string>>
*/
public function indicatorRows(string $workbookPath): array
{
$book = new AnnexReader($workbookPath);
try {
return $this->indicators($book);
} finally {
$book->close();
}
}

/**
* @param array<string, string> $outputs
*/
Expand Down Expand Up @@ -248,26 +261,39 @@ private function codeDescriptions(AnnexReader $book, string $sheet, int $length)
}

/**
* For the official Anexo C and VII "INDOP" sheet, column F is the supply
* characteristic, G is cIndOp and H is the location. Never rank free-text
* fields by length: that can silently misassign tax information.
* For the official Anexo C "INDOP" sheet, D is the supply
* characteristic, G is cIndOp and H is the location. NT009's public
* Annex VII sheet instead uses A/C/D; never infer columns by text length.
*
* @return list<list<string>>
*/
private function indicators(AnnexReader $book): array
{
$rows = [];
$seen = [];
foreach ($book->rows('INDOP') as $row) {
$rawCode = $row['G'] ?? '';
$names = $book->sheetNames();
if (in_array('INDOP', $names, true)) {
$sheet = 'INDOP';
$columns = ['code' => 'G', 'characteristic' => 'D', 'location' => 'H'];
} elseif (in_array('cIndOp Public', $names, true)) {
$sheet = 'cIndOp Public';
// Official NT009 v1.03.00: A=Código indOp, C=Característica,
// D=Local do fornecimento a ser identificado no DFe.
// J is NFSeLocIncidIBS (an additional classification), NOT the local description.
$columns = ['code' => 'A', 'characteristic' => 'C', 'location' => 'D'];
} else {
throw new \UnexpectedValueException('Missing indicator worksheet INDOP or cIndOp Public');
}
foreach ($book->rows($sheet) as $row) {
$rawCode = $row[$columns['code']] ?? '';
$code = self::digits($rawCode);
if (strlen($code) !== 6 || !preg_match('/^[0-9.]+$/D', $rawCode)) {
continue;
}
$characteristic = $row['F'] ?? '';
$location = $row['H'] ?? '';
$characteristic = $row[$columns['characteristic']] ?? '';
$location = $row[$columns['location']] ?? '';
if ($characteristic === '' || $location === '') {
throw new \UnexpectedValueException("Incomplete operation indicator {$code} in INDOP F/G/H");
throw new \UnexpectedValueException("Incomplete operation indicator {$code} in {$sheet}");
}
$this->appendCode($rows, $seen, [$code, $characteristic, $location]);
}
Expand Down
Loading