Skip to content

Report sharing between companies

A company that has submitted an emissions report can grant another company read access to it for a given reporting year — and a buyer can request access from a supplier. Access is per (owner company, grantee company, reporting year).

A supplier’s real reported figures are far better than a spend-based estimate, but the supplier owns that data and must consent. Sharing is what turns an “EEIO Estimate” row in a buyer’s supply chain table into a “Supplier reported” row — see [[supply-chain-emissions]]. Two directions are supported because in practice either party may start the conversation.

PathWhat it does
wp-content/themes/carboncalculator/page-share-carbon-emissions-report.phpThe page, /my-company-emissions/share-carbon-emissions-report/
wp-content/themes/carboncalculator/assets/js/share-report-modal.jsInvite / request / respond UI
wp-content/plugins/carboncalc/includes/ReportShare.phpAll share logic
wp-content/plugins/carboncalc/includes/Rest.php:159GET /report-shares — dashboard lists
wp-content/plugins/carboncalc/includes/Rest.php:165GET /report-shares/shareable-years
wp-content/plugins/carboncalc/includes/Rest.php:171GET /report-shares/companies — share targets
wp-content/plugins/carboncalc/includes/Rest.php:187POST /report-shares/invite (owner grants)
wp-content/plugins/carboncalc/includes/Rest.php:193POST /report-shares/request (grantee asks)
wp-content/plugins/carboncalc/includes/Rest.php:199POST /report-shares/{id}/respond
wp-content/plugins/carboncalc/includes/ReportShare.php:126grantee_has_active_access_to_report() — the read gate

cc_report_share on the Carbon blog (DDL at plugins/carboncalc/includes/Migrations.php:16):

ColumnMeaning
owner_company_idCompany whose report is shared
grantee_company_idCompany receiving access
reporting_year_idWhich year
statuspending | active | declined | revoked
initiatorgrantee (requested) or owner (invited)
created_by_lms_id, updated_by_lms_idAudit

UNIQUE KEY owner_grantee_year — exactly one row per triple, reused across state changes rather than inserting a new one.

Note it references companies, not reports. Access is granted to “whatever report that company has for that year”, resolved at read time.

stateDiagram-v2
  [*] --> pending: grantee requests
  [*] --> active: owner invites
  pending --> active: owner approves
  pending --> declined: owner declines
  pending --> declined: grantee cancels
  active --> revoked: owner revokes
  declined --> pending: grantee requests again
  revoked --> pending: grantee requests again
  revoked --> active: owner invites again

ReportShare::respond() (ReportShare.php:462) enforces who may do what: approve, decline and revoke require the actor to be the owner company; cancel requires the actor to be the grantee. A cancel is stored as declined, not a distinct status (ReportShare.php:544).

bulk_invite_as_owner() (:288) creates rows directly as active — an owner-initiated share needs no acceptance from the grantee.

  • Only submitted reports can be shared. Both invite and the shareable-years list check owner_has_submitted_report_for_year() (:214), and the error message names the year (submitted_report_required_error_message(), :567).
  • Share targets are CC Partner companies only. list_company_candidates() filters on is_partner = 1 (ReportShare.php:182), excludes the actor’s own company, and caps at 500.
  • A grantee cannot request from itself (:396).
  • Sharing is behind a feature flag: sharing_feature_error() (Rest.php:2632) short-circuits the endpoints when carboncalc_feature_sharing_enabled is off, and the “Share with clients” button is hidden on the dashboard.
  • Sharing requires an edit role: Share My Company Emissions data maps to Carbon Reporter or School Admin, and scs_cc_admin is explicitly excluded (plugins/carboncalc/includes/RolePermissions.php:594).

Rest::resolve_report_access_context() (Rest.php:291) is the only path in:

  1. If the id looks like a UUID and the report belongs to the caller’s company → owner mode.
  2. If the UUID matches any report and ReportShare::grantee_has_active_access_to_report() is true → grantee mode.
  3. If the id is numeric and belongs to the caller’s company → owner.
  4. Otherwise null → 404.

So a numeric id can only ever resolve to owner mode. Grantees must use the UUID; the comment at Rest.php:287 states this deliberately.

grantee_has_active_access_to_report() joins the report to the share on both owner_company_id = report.company_id and reporting_year_id = report.reporting_year_id, requiring status = 'active' (ReportShare.php:144). A share for 2025 gives no access to the same company’s 2026 report.

report_can_edit_row() returns false for anything but owner mode (Rest.php:229), so grantee access is read-only everywhere by construction.

OptionEffect
carboncalc_feature_sharing_enabledMaster switch; default off

Read via FeatureSettings::is_sharing_enabled(), which resolves against the Carbon subsite’s options table. No constants.

  • A share is company-to-company, not report-to-company. If the owner deletes and recreates their report for that year, the existing share silently applies to the new report.
  • Owner invites bypass consent — they land as active immediately. Only grantee-initiated requests pass through pending.
  • cancel is recorded as declined. Reporting on “how many suppliers refused” will over-count, because grantee cancellations are indistinguishable from owner declines. initiator is the only clue.
  • Revoking does not delete the row, so a revoked pair can be re-requested and the history of the original grant is overwritten in place — there is no audit trail beyond updated_by_lms_id and updated_at.
  • bulk_invite_as_owner() skips grantee/year pairs that are already active and counts nothing for them; the created/updated counts in the response will not add up to the number of pairs submitted.
  • The table is created twice in Migrations: once through the shared report_share_table_ddl() helper inside ensure_carbon_site_cc_tables() (:682) and once in the v14 gated block (:296). Both use the same helper, so keep DDL changes in that one function.
  • Sharing turned off mid-flight does not revoke existing shares; it only blocks the endpoints. Existing active rows still grant read access through resolve_report_access_context(), which does not check the flag.
  • New statuses: add the constant, include it in valid_statuses() (ReportShare.php:30), add the transition to respond(), and check every status = 'active' query — grantee_has_active_access_to_report() is the one that matters for security.
  • New share scopes (e.g. share a single report rather than a year) means changing the unique key and the join in grantee_has_active_access_to_report(). Do not add a second access path; keep resolve_report_access_context() as the only entry.
  • Notifications do not exist. A pending request is only visible when the owner opens the share page. If you add email, hook it in request_access() and respond(), not in the REST layer, so bulk invites are covered too.
  • Verify by hand with two companies: submit a report as A, invite B, confirm B can open the report by UUID but gets 404 on the numeric id and 403 on any write; then revoke and confirm B gets 404.

None. ReportShare::respond() is a pure state machine over a row and is the best candidate for a first test.

Related: [[supply-chain-emissions]], [[emissions-calculation-and-submission]], [[carbon-access-gates]].